From ccd6b40981e19edd46e88faa11a4e13f261241fb Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 13 Aug 2026 21:36:03 -0400 Subject: [PATCH] feat(skills): complete private bundle sharing --- ...-skill-sharing-implementation-checklist.md | 61 ++- docs/agent-skill-sharing-installation-plan.md | 61 ++- .../runtime-auth-service.test.ts | 3 + .../claude-accounts/runtime-auth-service.ts | 4 +- src/main/claude-accounts/service.ts | 4 + src/main/index.ts | 8 +- .../skill-cloud-install-ipc-schemas.test.ts | 54 +++ .../ipc/skill-cloud-install-ipc-schemas.ts | 5 + src/main/ipc/skills.ts | 7 +- .../orca-runtime-skill-recovery.test.ts | 32 ++ src/main/runtime/orca-runtime.ts | 75 +++- src/main/runtime/rpc/methods/skills.test.ts | 1 + src/main/runtime/rpc/methods/skills.ts | 7 +- src/main/skills/discovery.test.ts | 11 +- src/main/skills/discovery.ts | 30 +- src/main/skills/skill-bundle-extraction.ts | 6 +- .../skill-bundle-install-request-service.ts | 22 +- .../skill-bundle-install-service.test.ts | 117 +++++ .../skills/skill-bundle-install-service.ts | 8 +- .../skills/skill-bundle-ssh-relay-service.ts | 13 +- .../skill-candidate-concurrency.test.ts | 21 + src/main/skills/skill-cloud-deadline.ts | 31 ++ .../skills/skill-cloud-direct-upload.test.ts | 105 +++++ src/main/skills/skill-cloud-direct-upload.ts | 57 ++- .../skills/skill-cloud-grant-installation.ts | 10 + src/main/skills/skill-cloud-request.ts | 43 +- src/main/skills/skill-cloud-service.test.ts | 161 ++++++- src/main/skills/skill-cloud-service.ts | 54 ++- src/main/skills/skill-discovery-sources.ts | 81 +++- src/main/skills/skill-discovery-target.ts | 11 +- src/main/skills/skill-discovery-wsl.ts | 5 +- ...ill-install-discovery-verification.test.ts | 25 ++ .../skill-install-discovery-verification.ts | 22 +- src/main/skills/skill-install-filesystem.ts | 1 + ...skill-install-historical-provider-roots.ts | 16 + .../skill-install-journal-transition.ts | 6 + .../skill-install-management-service.ts | 30 +- .../skills/skill-install-provenance.test.ts | 2 + src/main/skills/skill-install-provenance.ts | 5 + .../skills/skill-install-request-service.ts | 22 +- src/main/skills/skill-install-service.test.ts | 95 +++- src/main/skills/skill-install-service.ts | 134 ++---- .../skill-install-transaction-result.ts | 18 + src/main/skills/skill-install-transaction.ts | 36 +- .../skills/skill-operation-observability.ts | 3 +- .../skill-package-download-availability.ts | 53 +++ .../skills/skill-package-download.test.ts | 79 ++++ src/main/skills/skill-package-download.ts | 200 +++++---- .../skill-placement-artifact-recovery.ts | 129 ++++++ src/main/skills/skill-placement-copy.ts | 41 +- .../skill-placement-reconciliation.test.ts | 92 ++++ .../skills/skill-placement-reconciliation.ts | 86 ++-- .../skill-placement-recovery-journal.ts | 219 +++++++++ .../skill-placement-transaction-controller.ts | 150 +++++++ .../skill-placement-transaction.test.ts | 328 ++++++++++++++ .../skills/skill-placement-transaction.ts | 235 ++++++++++ .../skill-provider-destinations.test.ts | 167 +++++++ .../skills/skill-provider-destinations.ts | 59 ++- .../skill-provider-runtime-roots.test.ts | 52 +++ .../skills/skill-provider-runtime-roots.ts | 90 ++++ .../skill-remote-install-service.test.ts | 13 + .../skills/skill-remote-install-service.ts | 46 +- src/main/skills/skill-removable-placement.ts | 6 +- .../skills/skill-remove-transaction.test.ts | 32 +- src/main/skills/skill-remove-transaction.ts | 19 +- .../skills/skill-share-preparation-service.ts | 1 + .../skills/skill-ssh-relay-service.test.ts | 13 + src/main/skills/skill-ssh-relay-service.ts | 11 +- .../skill-transaction-startup-recovery.ts | 100 ++++- .../skill-wsl-install-filesystem.test.ts | 47 +- .../skills/skill-wsl-install-filesystem.ts | 21 +- src/relay/skill-install-handler.test.ts | 1 + src/relay/skill-install-handler.ts | 11 +- src/renderer/src/App.tsx | 6 +- src/renderer/src/assets/main.css | 7 + .../settings/OrcaAccountSettingsPane.tsx | 90 ++-- .../settings/OwnedSkillShareLinks.test.tsx | 63 --- .../settings/OwnedSkillShareLinks.tsx | 178 -------- .../src/components/settings/Settings.tsx | 2 +- .../settings/ShareSkillsSettingsPane.test.tsx | 19 +- .../settings/ShareSkillsSettingsPane.tsx | 43 +- .../settings/share-skills-settings-search.ts | 2 +- .../src/components/sidebar/SidebarNav.tsx | 43 +- .../skills/SkillBundleInstallFlow.tsx | 29 +- .../skills/SkillBundleInstallReview.tsx | 215 ++------- .../src/components/skills/SkillCard.tsx | 165 ------- .../SkillCloudManagementActions.test.tsx | 108 ----- .../skills/SkillCloudManagementActions.tsx | 232 ---------- .../skills/SkillDescriptionDisclosure.tsx | 48 ++ .../components/skills/SkillDetailDialog.tsx | 164 +++++++ .../skills/SkillDisclosureTrigger.tsx | 23 + .../skills/SkillInstallAgentPicker.tsx | 139 ++++++ .../skills/SkillInstallDialog.test.tsx | 161 ++++++- .../components/skills/SkillInstallDialog.tsx | 90 +++- .../skills/SkillInstallMachineSelect.tsx | 38 ++ .../SkillInstallManagementDialog.test.tsx | 194 ++++++-- .../skills/SkillInstallManagementDialog.tsx | 176 +++----- .../skills/SkillInstallManagementStatus.tsx | 31 ++ .../skills/SkillInstallReviewContent.tsx | 112 ++--- .../skills/SkillInstallTargetFields.test.tsx | 6 +- .../skills/SkillInstallTargetFields.tsx | 14 + .../skills/SkillManagedInstallDetails.tsx | 174 ------- .../skills/SkillManagedInstallList.tsx | 50 --- .../skills/SkillManagedInstallRow.tsx | 205 +++++++++ .../skills/SkillPackageChecklist.tsx | 136 ++++++ .../src/components/skills/SkillRow.tsx | 275 ++++++++++++ .../skills/SkillShareDialog.test.tsx | 120 ++++- .../components/skills/SkillShareDialog.tsx | 240 ++++++---- .../skills/SkillSharePackageSummary.tsx | 108 +++++ .../skills/SkillShareReleaseNotesField.tsx | 68 +++ .../skills/SkillShareReviewContent.tsx | 285 +++++------- .../skills/SkillShareSelectableCard.tsx | 35 -- .../skills/SkillShareSelectionList.tsx | 41 -- .../skills/SkillSharedLinkRow.test.tsx | 83 ++++ .../components/skills/SkillSharedLinkRow.tsx | 251 +++++++++++ .../skills/SkillSharedLinksView.tsx | 83 ++++ .../components/skills/SkillsFilterToolbar.tsx | 213 +++++++++ .../src/components/skills/SkillsList.tsx | 146 ++++++ .../src/components/skills/SkillsPage.test.tsx | 139 +++++- .../src/components/skills/SkillsPage.tsx | 424 +++++++----------- .../components/skills/SkillsPageHeader.tsx | 121 +++++ .../skills/SkillsSelectionHeader.tsx | 101 +++++ .../skills/SkillsSourcesPopover.tsx | 70 +++ .../components/skills/skill-agent-filter.ts | 58 +++ .../skills/skill-bundle-name.test.ts | 61 +++ .../components/skills/skill-bundle-name.ts | 34 ++ .../skills/skill-description-length.test.ts | 21 + .../skills/skill-description-length.ts | 8 + .../components/skills/skill-display-labels.ts | 71 ++- .../skills/skill-install-management-copy.ts | 73 +-- .../skill-install-provider-groups.test.ts | 63 +++ .../skills/skill-install-provider-groups.ts | 85 ++++ .../skills/skill-managed-install-labels.ts | 82 ++++ .../skill-package-checklist-items.test.ts | 111 +++++ .../skills/skill-package-checklist-items.ts | 50 +++ .../skills/skill-package-digest.test.ts | 15 + .../components/skills/skill-package-digest.ts | 8 + .../skill-share-preview-summary.test.ts | 60 +++ .../skills/skill-share-preview-summary.ts | 66 +++ .../skills/skill-share-selection.test.ts | 40 ++ ...nControls.tsx => skill-share-selection.ts} | 69 +-- .../skills/skill-source-inventory.test.ts | 76 ++++ .../skills/skill-source-inventory.ts | 46 ++ .../components/skills/skills-filter.test.ts | 22 +- .../src/components/skills/skills-filter.ts | 11 +- .../components/skills/skills-page-column.ts | 4 + .../components/skills/skills-page-states.tsx | 122 +++++ .../src/components/skills/skills-page-view.ts | 3 + .../skills/skills-subtitle-action.ts | 4 + .../skills/use-owned-skill-shares.ts | 97 ++++ .../skills/use-skill-discovery-host-label.ts | 24 + .../use-skill-install-detected-agents.ts | 46 ++ .../hooks/useSettingsNavigationMetadata.ts | 8 +- src/renderer/src/i18n/locales/en.json | 193 +++++++- src/renderer/src/store/slices/ui.ts | 13 + src/shared/constants.ts | 1 + src/shared/protocol-version.ts | 4 +- src/shared/skill-bundle-install-contract.ts | 2 + src/shared/skill-cloud-contract.ts | 1 + src/shared/skill-install-capability.ts | 3 + src/shared/skill-install-contract.ts | 6 + src/shared/skill-install-providers.ts | 115 +++++ src/shared/skill-sharing-contract.ts | 4 + src/shared/types.ts | 2 + 164 files changed, 8685 insertions(+), 2687 deletions(-) create mode 100644 src/main/ipc/skill-cloud-install-ipc-schemas.test.ts create mode 100644 src/main/runtime/orca-runtime-skill-recovery.test.ts create mode 100644 src/main/skills/skill-candidate-concurrency.test.ts create mode 100644 src/main/skills/skill-cloud-deadline.ts create mode 100644 src/main/skills/skill-install-historical-provider-roots.ts create mode 100644 src/main/skills/skill-package-download-availability.ts create mode 100644 src/main/skills/skill-placement-artifact-recovery.ts create mode 100644 src/main/skills/skill-placement-recovery-journal.ts create mode 100644 src/main/skills/skill-placement-transaction-controller.ts create mode 100644 src/main/skills/skill-placement-transaction.test.ts create mode 100644 src/main/skills/skill-placement-transaction.ts create mode 100644 src/main/skills/skill-provider-destinations.test.ts create mode 100644 src/main/skills/skill-provider-runtime-roots.test.ts create mode 100644 src/main/skills/skill-provider-runtime-roots.ts delete mode 100644 src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx delete mode 100644 src/renderer/src/components/settings/OwnedSkillShareLinks.tsx delete mode 100644 src/renderer/src/components/skills/SkillCard.tsx delete mode 100644 src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx delete mode 100644 src/renderer/src/components/skills/SkillCloudManagementActions.tsx create mode 100644 src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx create mode 100644 src/renderer/src/components/skills/SkillDetailDialog.tsx create mode 100644 src/renderer/src/components/skills/SkillDisclosureTrigger.tsx create mode 100644 src/renderer/src/components/skills/SkillInstallAgentPicker.tsx create mode 100644 src/renderer/src/components/skills/SkillInstallMachineSelect.tsx create mode 100644 src/renderer/src/components/skills/SkillInstallManagementStatus.tsx delete mode 100644 src/renderer/src/components/skills/SkillManagedInstallDetails.tsx delete mode 100644 src/renderer/src/components/skills/SkillManagedInstallList.tsx create mode 100644 src/renderer/src/components/skills/SkillManagedInstallRow.tsx create mode 100644 src/renderer/src/components/skills/SkillPackageChecklist.tsx create mode 100644 src/renderer/src/components/skills/SkillRow.tsx create mode 100644 src/renderer/src/components/skills/SkillSharePackageSummary.tsx create mode 100644 src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx delete mode 100644 src/renderer/src/components/skills/SkillShareSelectableCard.tsx delete mode 100644 src/renderer/src/components/skills/SkillShareSelectionList.tsx create mode 100644 src/renderer/src/components/skills/SkillSharedLinkRow.test.tsx create mode 100644 src/renderer/src/components/skills/SkillSharedLinkRow.tsx create mode 100644 src/renderer/src/components/skills/SkillSharedLinksView.tsx create mode 100644 src/renderer/src/components/skills/SkillsFilterToolbar.tsx create mode 100644 src/renderer/src/components/skills/SkillsList.tsx create mode 100644 src/renderer/src/components/skills/SkillsPageHeader.tsx create mode 100644 src/renderer/src/components/skills/SkillsSelectionHeader.tsx create mode 100644 src/renderer/src/components/skills/SkillsSourcesPopover.tsx create mode 100644 src/renderer/src/components/skills/skill-agent-filter.ts create mode 100644 src/renderer/src/components/skills/skill-bundle-name.test.ts create mode 100644 src/renderer/src/components/skills/skill-bundle-name.ts create mode 100644 src/renderer/src/components/skills/skill-description-length.test.ts create mode 100644 src/renderer/src/components/skills/skill-description-length.ts create mode 100644 src/renderer/src/components/skills/skill-install-provider-groups.test.ts create mode 100644 src/renderer/src/components/skills/skill-install-provider-groups.ts create mode 100644 src/renderer/src/components/skills/skill-managed-install-labels.ts create mode 100644 src/renderer/src/components/skills/skill-package-checklist-items.test.ts create mode 100644 src/renderer/src/components/skills/skill-package-checklist-items.ts create mode 100644 src/renderer/src/components/skills/skill-package-digest.test.ts create mode 100644 src/renderer/src/components/skills/skill-package-digest.ts create mode 100644 src/renderer/src/components/skills/skill-share-preview-summary.test.ts create mode 100644 src/renderer/src/components/skills/skill-share-preview-summary.ts create mode 100644 src/renderer/src/components/skills/skill-share-selection.test.ts rename src/renderer/src/components/skills/{SkillShareSelectionControls.tsx => skill-share-selection.ts} (64%) create mode 100644 src/renderer/src/components/skills/skill-source-inventory.test.ts create mode 100644 src/renderer/src/components/skills/skill-source-inventory.ts create mode 100644 src/renderer/src/components/skills/skills-page-column.ts create mode 100644 src/renderer/src/components/skills/skills-page-states.tsx create mode 100644 src/renderer/src/components/skills/skills-page-view.ts create mode 100644 src/renderer/src/components/skills/skills-subtitle-action.ts create mode 100644 src/renderer/src/components/skills/use-owned-skill-shares.ts create mode 100644 src/renderer/src/components/skills/use-skill-discovery-host-label.ts create mode 100644 src/renderer/src/components/skills/use-skill-install-detected-agents.ts create mode 100644 src/shared/skill-install-providers.ts diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 42af7bf2a9f..5a7ff368b35 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -9,13 +9,30 @@ Implementation baselines captured by this checklist update: - Orca implementation: `skills-share` through `607e31dd04`; no PR. - Orca Cloud: bundle smoke PR `#329` merged as `eddb144afe`; generation-aware recovery PR `#330` merged as `8045c85dad`; encrypted physical-host credential PR `#336` merged as `8fce3298ef`; - kill-switch discovery PR `#342` merged as `c2bef2ff20fb`; production remains untouched. + kill-switch discovery PR `#342` merged as `c2bef2ff20fb`. Windows device-name validation PR `#343` merged as `dbb14a658cbc`; explicit disabled production bootstrap PR `#352` merged as `2a23f6ace5`; publisher-identity omission PR `#355` merged as - `b3213bd34d1b224d8a3b11527eceaac883965400`; production remains untouched. + `b3213bd34d1b224d8a3b11527eceaac883965400`; production enablement PR `#357`, bounded OIDC smoke + PR `#358`, monitor IAM PR `#359`, and monitor release-ownership PRs `#360`/`#361` are merged + through `d8605edde72229c5d00d22c8bd6853a13c1a4aaa`. Validated so far: +- Current release hardening cancels bundle extraction after partial bytes exist and removes the + extraction directory, durable journal, and managed-install state. Four repeated focused runs and + the real SIGKILL recovery test passed. Signed GCS uploads use the earlier of signed-policy expiry + and a 15-minute cap, including their archive read stream, so slow valid uploads can finish while + stalled uploads remain bounded. +- Local and headless managed-skill operations now wait for the one startup transaction-recovery + promise before reading or changing install state. Codex uses its documented global + `~/.agents/skills` root, and provider removal derives allowed roots from the registry so aliases + remain removable after an agent is no longer detected. The combined focused suite passed 74 + tests across 12 files, including fake-timer slow-upload and controlled-recovery regressions. +- The reported desktop install failure was a strict main-process IPC schema rejecting the + renderer's additive `providers` selection before bundle verification. All four install schemas + now accept the bounded field, and focused schema/provider tests pass. The original production + link resolves as a valid one-skill bundle and returns a fresh same-version local download grant; + the signed URL and private metadata were not logged or printed during verification. - The final reliability hardening at `607e31dd04` adds a 60-second Cloud request deadline; cancellable, journaled bundle extraction; bounded package creation, verification, and preview fanout; atomic share-preparation admission and abandoned-archive cleanup; process-owned download @@ -288,12 +305,14 @@ Validated so far: Rollout gate: staging infrastructure, OIDC owner identity exchange, anonymous bearer lifecycle, owner management, browser-free desktop bundle lifecycle, privacy-safe logging, published-object recovery, bounded finalization load, and guarded rollback-capable deployment passed. Production -remains untouched. Native Windows, real Ubuntu 24.04 WSL filesystems, physical Ubuntu 20.04 and +infrastructure and the unlisted-link API are live with all four controls enabled after successful +authenticated candidate and canonical lifecycle smoke. Native Windows, real Ubuntu 24.04 WSL +filesystems, physical Ubuntu 20.04 and macOS ARM64 SSH, and paired non-Windows staging passed. On 2026-08-12, the owner accepted the remaining live WSL-to-staging journey as duplicate evidence after the combined 449-test physical Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL semantics as -identical to macOS. Supported Windows SSH and the quarantine lifecycle deletion remain. The shared -staging data plane is asleep. +identical to macOS. The user-driven signed-in desktop and real-host production journey, supported +Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep. Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md). @@ -313,7 +332,8 @@ does not mean the surrounding phase is complete. - [x] The portable archive root conforms to Agent Plugins 1.0.0 for skills-only packages and keeps Orca integrity metadata in `dev.orca.skill-sharing/manifest.json`. - [x] One canonical `.agents/skills/` copy is installed for each selected skill; - provider-specific placements are reconciled only for detected agents. + provider-specific placements are reconciled for explicitly selected agents, defaulting to + detected agents when no selection is supplied. - [x] Install, update, rollback, and removal preserve modified or unowned local content unless the user explicitly approves replacement. - [x] Package ingestion rejects unsafe archives before any destination mutation. @@ -1536,15 +1556,32 @@ disconnect boundaries remain separate gates below. ### Production infrastructure and launch -- [ ] Apply the approved production Terraform plan and verify no unrelated replacement. -- [ ] Run production database migrations before routing skill traffic. -- [ ] Deploy the API with all skill flags disabled. -- [ ] After every first-release gate passes, enable the feature for all accounts in one launch. +- [x] Apply the approved production Terraform plan and verify no unrelated replacement. The saved + skill-only plan added 40 resources with zero updates, replacements, or deletions; the + storage-monitor follow-up added one narrow IAM binding with zero changes or deletions. The + final hardening used two reviewed targeted phases: 13 additions plus one WIF update, followed + by exactly five broad-IAM deletions. A separate targeted apply made nine in-place + observability updates. No full plan or unrelated Relay drift was applied. +- [x] Run production database migrations before routing skill traffic. Disabled-route bootstrap + run `31648015091` completed the skill schema migration before any skill route was enabled. +- [x] Deploy the API with all skill flags disabled. Bootstrap revision `orca-cloud-api-00016-yem` + received 100% traffic and returned `404` for the disabled skill route while Artifact and Auth + health remained green. +- [x] After every first-release gate passes, enable the feature for all accounts in one launch. + Production run `31650178315` initially enabled upload, download, remote install, and sharing + together. Hardening run `31661421728` then passed candidate and canonical authenticated + artifact plus skill smoke and promoted `orca-cloud-api-00025-qup` at 100% traffic with digest + `sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. - [ ] Verify one complete share, local install, remote install, update, rollback, revoke, local - removal, Cloud deletion, upload expiry, and soft-delete recovery journey. + removal, Cloud deletion, upload expiry, and soft-delete recovery journey. Production CI has + verified the server-side share/version/grant/revoke/delete lifecycle; the signed-in desktop + and real-host production journey remains manual. - [ ] Verify upload, download, and remote-install kill switches independently. - [ ] Review error budgets, cost, authorization denials, saturation, orphan counts, and support - signals. + signals. The hardened production revision has zero post-deploy error logs. All eight skill + alerts route to the enabled dedicated `Orca skill sharing alerts` channel, and aggregate + monitor execution `orca-cloud-skill-storage-monitor-lfd26` passed on the serving API digest + with zero overdue objects. Sustained-usage review remains post-launch. ### Launch operations diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md index 80b3ede1993..bfda3cab054 100644 --- a/docs/agent-skill-sharing-installation-plan.md +++ b/docs/agent-skill-sharing-installation-plan.md @@ -53,22 +53,27 @@ that larger package-manager surface. ## Current execution status -The Orca implementation is on `skills-share` through `607e31dd04`; no Orca pull request exists. -Cloud bundle ingestion and bearer-link work merged through `stablyai/orca-cloud#320` as `0579cc1a71`; -the bundle desktop smoke update merged through `#329` as `eddb144afe`, generation-aware GCS -recovery merged through `#330` as `8045c85dad`, and the bounded finalization load gate plus cleanup -hardening merged through `#332`-`#335` as `bb8bf8b9ac`. The encrypted physical-host credential -handoff merged through `#336` as `8fce3298ef`. The isolated migration coexistence and database -restore drill merged through `#350` as `c22007384a`. -The explicit default-off production bootstrap merged through `#352` as `2a23f6ace5`; it performs -no deployment unless deliberately selected, and all production skill controls remain disabled. -The authenticated OIDC smoke landed in `#313`; the narrow Auth release-metadata convergence -follow-up landed in `#314`; the finalization and lifecycle fixes landed in `#317`. The final -anonymous bearer-link candidate and canonical smoke now pass in staging. Production remains -untouched. Local, Windows, WSL, paired-runtime, Docker-backed SSH, browser-free staging desktop, -physical Ubuntu 20.04 SSH, published-object recovery, and bounded finalization-load validation are -substantially complete; the implementation checklist records the exact evidence and remaining -physical-host and lifecycle gates. +The Orca implementation is on `skills-share` through `2a0b7b2acf`; no Orca pull request exists. +The local working tree also contains the install-IPC compatibility fix and the in-progress Skills +UI overhaul. Cloud bundle ingestion, bearer links, recovery, load testing, production bootstrap, +OIDC smoke, and monitor ownership merged through `stablyai/orca-cloud#361`. Final infrastructure +hardening merged in `#364` as `57c4958979fc32ed4753959cea45edd3a7c8775c`. + +Production is live. Two reviewed targeted Terraform phases added the dedicated app deploy identity, +restricted both GitHub WIF providers to exact workflows, added resource-scoped API/Auth/monitor and +Relay bindings, then removed five broad legacy bindings. A separate exact plan updated only the +bearer request-log exclusion and all eight skill alert policies; the alerts use the dedicated +`Orca skill sharing alerts` email channel. No full production plan was applied because it contains +unrelated Relay drift. + +Production deploy run `31661421728` promoted `orca-cloud-api-00025-qup` at 100% traffic from merged +`main`, using immutable digest +`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. Candidate and canonical +authenticated artifact and skill smoke passed, `/health` and `/ready` are green, and the direct +unlisted landing route returns only generic install copy plus the Orca deep link with `no-store`, +`no-referrer`, and restrictive CSP headers. Monitor execution +`orca-cloud-skill-storage-monitor-lfd26` passed on the same digest and emitted only aggregate +inventory. The implementation checklist records the remaining desktop and physical-host gates. The final reliability hardening gives every skill Cloud request a default deadline, makes bundle extraction cancellable and recoverable through the durable extraction journal, bounds package and @@ -266,7 +271,29 @@ candidate/canonical smoke passed. Its unrelated post-promotion storage-monitor i MIGs stable and reached at target zero, and the API at minimum scale zero while revision `00060-qay` retained 100% traffic. -Production remains untouched. +Production deployment completed on 2026-08-12. The exact skill-only Terraform apply created the +private bucket, database, secret, least-privilege identities, request-log exclusion, metrics, +alerts, and dashboard with 40 additions and zero updates, replacements, or deletions. Disabled-route +bootstrap run `31648015091` migrated the schema and promoted `orca-cloud-api-00016-yem` while all +four controls remained false. Cloud PR `#357` enabled all four controls in one launch. Cloud PR +`#358` replaced the missing long-lived production smoke secret with an exchange restricted to the +exact production `main` workflow, GitHub environment, dispatch event, repository/owner IDs, and +audience; issued principals expire in ten minutes and have no refresh credentials. Auth run +`31649381596` passed candidate and canonical health. API run `31650178315` passed candidate and +canonical authenticated artifact plus skill lifecycle smoke, promoted +`orca-cloud-api-00022-maq` to 100% traffic, and completed the aggregate monitor image update. All +four skill controls are true, the serving revision reported zero deployment-window errors, and +both API and Auth health are green. + +Cloud PR `#359` codified the deploy identity's `roles/iam.serviceAccountUser` grant only on the +read-only skill storage monitor identity. Its exact production apply added one resource with zero +changes or deletions, and the repeat targeted plan was zero-diff. Manual execution +`orca-cloud-skill-storage-monitor-bmm4q` completed successfully. Cloud PRs `#360` and `#361` keep +Terraform ownership of the monitor shape while assigning its immutable image and Cloud Run client +metadata to the release workflow; the exact production monitor plan is zero-diff after the live +image advance. The development Orca instance +`skill-sharing-manual` now points at `login.onorca.dev` and `cloud-api.onorca.dev`; sign-in and the +desktop/real-host production lifecycle remain user-driven validation. A final renderer lifecycle review fenced managed-install inventory by request generation so a slower previous machine cannot overwrite the newly selected machine's list. It also moved bundle diff --git a/src/main/claude-accounts/runtime-auth-service.test.ts b/src/main/claude-accounts/runtime-auth-service.test.ts index a78bc99ec1c..15ec69062f4 100644 --- a/src/main/claude-accounts/runtime-auth-service.test.ts +++ b/src/main/claude-accounts/runtime-auth-service.test.ts @@ -3526,6 +3526,9 @@ describe('ClaudeRuntimeAuthService', () => { const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') const service = new ClaudeRuntimeAuthService(store as never) + expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + ubuntuAuthPath + ) const preparation = await service.prepareForClaudeLaunch() expect(preparation).toMatchObject({ diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts index 58328222528..b655d454ac3 100644 --- a/src/main/claude-accounts/runtime-auth-service.ts +++ b/src/main/claude-accounts/runtime-auth-service.ts @@ -151,8 +151,8 @@ export class ClaudeRuntimeAuthService { }) } - getRuntimeConfigDir(): string { - return this.pathResolver.getRuntimePaths().configDir + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.getPreparation(target).configDir } private initializeLastSyncedState(): void { diff --git a/src/main/claude-accounts/service.ts b/src/main/claude-accounts/service.ts index 3e3a8ec5f47..853ad4a77f0 100644 --- a/src/main/claude-accounts/service.ts +++ b/src/main/claude-accounts/service.ts @@ -148,6 +148,10 @@ export class ClaudeAccountService { return this.cancelPendingClaudeLogin?.() ?? false } + getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string { + return this.runtimeAuth.getRuntimeConfigDir(target) + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) diff --git a/src/main/index.ts b/src/main/index.ts index 9c7d4950224..0d348d814d8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2360,7 +2360,10 @@ void app.whenReady().then(async () => { packaged: app.isPackaged, platform: process.platform }) - void recoverPendingSkillTransactions(join(app.getPath('userData'), 'skill-installs')) + const skillTransactionRecovery = recoverPendingSkillTransactions( + join(app.getPath('userData'), 'skill-installs') + ) + void skillTransactionRecovery .then((report) => { if (report.scanned || report.failures.length || report.truncated) { console.info('[skills] startup transaction recovery:', { @@ -2563,7 +2566,8 @@ void app.whenReady().then(async () => { }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, - orchestrationEnvironmentTransport + orchestrationEnvironmentTransport, + skillTransactionRecovery }) runtime = runtimeService runtimeService.prepareLegacyWorkerTerminalRecovery() diff --git a/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts b/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts new file mode 100644 index 00000000000..bb7671824d5 --- /dev/null +++ b/src/main/ipc/skill-cloud-install-ipc-schemas.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + skillCloudBundlePackageVersionInstallSchema, + skillCloudBundleShareInstallSchema, + skillCloudPackageVersionInstallSchema, + skillCloudShareInstallSchema +} from './skill-cloud-install-ipc-schemas' + +const destination = { scope: 'global' as const } +const providers = ['codex', 'claude'] + +describe('skill cloud install IPC schemas', () => { + it('accepts provider selection for every install entry point', () => { + expect( + skillCloudShareInstallSchema.parse({ shareId: 'share-1', destination, providers }) + ).toMatchObject({ providers }) + expect( + skillCloudBundleShareInstallSchema.parse({ + shareId: 'share-1', + selectedSkillIds: ['skill-1'], + destination, + providers + }) + ).toMatchObject({ providers }) + expect( + skillCloudPackageVersionInstallSchema.parse({ + packageId: 'package-1', + versionId: 'version-1', + destination, + providers + }) + ).toMatchObject({ providers }) + expect( + skillCloudBundlePackageVersionInstallSchema.parse({ + packageId: 'package-1', + versionId: 'version-1', + selectedSkillIds: ['skill-1'], + destination, + providers + }) + ).toMatchObject({ providers }) + }) + + it('bounds provider selection', () => { + expect(() => + skillCloudBundleShareInstallSchema.parse({ + shareId: 'share-1', + selectedSkillIds: ['skill-1'], + destination, + providers: Array.from({ length: 65 }, (_, index) => `provider-${index}`) + }) + ).toThrow() + }) +}) diff --git a/src/main/ipc/skill-cloud-install-ipc-schemas.ts b/src/main/ipc/skill-cloud-install-ipc-schemas.ts index 503d0ea3839..c0ffc59b9c1 100644 --- a/src/main/ipc/skill-cloud-install-ipc-schemas.ts +++ b/src/main/ipc/skill-cloud-install-ipc-schemas.ts @@ -4,10 +4,13 @@ import { SkillInstallDestinationSchema } from '../../shared/skill-install-contra export const skillCloudInstallEnvironmentIdSchema = z.string().min(1).max(128) +const skillCloudInstallProvidersSchema = z.array(z.string().min(1).max(64)).max(64) + const installDestinationFields = { operationId: z.string().min(1).max(128).optional(), environmentId: skillCloudInstallEnvironmentIdSchema.optional(), destination: SkillInstallDestinationSchema, + providers: skillCloudInstallProvidersSchema.optional(), conflictResolution: z .enum(['replace-unmodified', 'replace-and-discard-local', 'cancel']) .optional() @@ -29,6 +32,7 @@ export const skillCloudBundleShareInstallSchema = z environmentId: skillCloudInstallEnvironmentIdSchema.optional(), selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds, destination: SkillInstallDestinationSchema, + providers: skillCloudInstallProvidersSchema.optional(), conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional() }) .strict() @@ -49,6 +53,7 @@ export const skillCloudBundlePackageVersionInstallSchema = z environmentId: skillCloudInstallEnvironmentIdSchema.optional(), selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds, destination: SkillInstallDestinationSchema, + providers: skillCloudInstallProvidersSchema.optional(), conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional() }) .strict() diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 2a5c77e3c38..fe7647b605d 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -24,7 +24,12 @@ import { registerSkillCloudIpcHandlers } from './skill-cloud-ipc-handlers' export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeService): void { const discover = async (target?: SkillDiscoveryTarget): Promise => { const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined - return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos()) + const resolvedTarget = resolveSkillDiscoveryTarget(parsedTarget) + return discoverSkillsOnTarget( + resolvedTarget, + store.getRepos(), + await runtime?.resolveSkillDiscoveryProviderRoots(resolvedTarget) + ) } const scanInventory = (): Promise => // Why: the update command targets this machine's global homes. WSL and SSH diff --git a/src/main/runtime/orca-runtime-skill-recovery.test.ts b/src/main/runtime/orca-runtime-skill-recovery.test.ts new file mode 100644 index 00000000000..3f1f72841ce --- /dev/null +++ b/src/main/runtime/orca-runtime-skill-recovery.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } +})) + +describe('managed skill startup recovery', () => { + it('waits for startup recovery before reading local managed installs', async () => { + let finishRecovery!: () => void + const recovery = new Promise((resolve) => { + finishRecovery = resolve + }) + const runtime = new OrcaRuntimeService(null, undefined, { + skillTransactionRecovery: recovery + }) + const listing = runtime.listManagedSkillInstalls() + let settled = false + void listing.finally(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + + finishRecovery() + await expect(listing).resolves.toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 58ba26c55d7..87f8814d907 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -169,9 +169,15 @@ import { removeSharedSkillInstall } from '../skills/skill-install-management-service' import { listManagedSkillInstalls } from '../skills/skill-install-provenance' -import { getWslHome } from '../wsl' +import { getWslHome, toLinuxPath } from '../wsl' import { WslSkillInstallFilesystem } from '../skills/skill-wsl-install-filesystem' import { nativeSkillInstallFilesystem } from '../skills/skill-install-filesystem' +import type { SkillProviderRootOverrides } from '../skills/skill-provider-destinations' +import { + resolveEnvironmentSkillProviderRoots, + resolveWslGrokSkillProviderRoot, + withClaudeSkillProviderRoot +} from '../skills/skill-provider-runtime-roots' import type { SkillUploadBeginRequest, SkillUploadChunkRequest @@ -3302,6 +3308,7 @@ export class OrcaRuntimeService { private artifactService: ArtifactCloudService | null = null private skillCloudService: SkillCloudService | null = null private skillUploadSessions: SkillUploadSessionService | null = null + private readonly skillTransactionRecovery: Promise private readonly skillInstallOperations = new Map() private readonly skillInstallProgress = new Map() private readonly claudeAgentTeams = new ClaudeAgentTeamsService() @@ -3355,6 +3362,7 @@ export class OrcaRuntimeService { getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport + skillTransactionRecovery?: Promise } ) { this.store = store @@ -3367,6 +3375,7 @@ export class OrcaRuntimeService { this.store?.setMobileClientTabSelections?.(state) }) this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null + this.skillTransactionRecovery = deps?.skillTransactionRecovery ?? Promise.resolve() if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) @@ -4740,6 +4749,7 @@ export class OrcaRuntimeService { onProgress: reportProgress }) } + await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( @@ -4756,6 +4766,8 @@ export class OrcaRuntimeService { resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination), signal: controller.signal, onProgress: reportProgress }) @@ -4800,6 +4812,7 @@ export class OrcaRuntimeService { signal }) } + await this.skillTransactionRecovery const allowedDownloadOrigins = ['https://storage.googleapis.com'] if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { allowedDownloadOrigins.push( @@ -4816,6 +4829,8 @@ export class OrcaRuntimeService { resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination), signal }) } @@ -4838,10 +4853,13 @@ export class OrcaRuntimeService { workspace: sshTarget.workspace }) } + await this.skillTransactionRecovery return previewSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), stateDirectory: app.getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) }) } @@ -4881,11 +4899,14 @@ export class OrcaRuntimeService { })) }) } + await this.skillTransactionRecovery const runtimeId = this.getStatus().runtimeId return previewSharedSkillBundleInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), stateDirectory: app.getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) }) } @@ -4905,10 +4926,13 @@ export class OrcaRuntimeService { workspace: sshTarget.workspace }) } + await this.skillTransactionRecovery return removeSharedSkillInstall(request, { authority: this.skillInstallDestinationAuthority(runtimeId), stateDirectory: app.getPath('userData'), - detectProviders: detectInstalledAgentsWithShellPathHydration + detectProviders: detectInstalledAgentsWithShellPathHydration, + resolveProviderRootOverrides: (destination) => + this.resolveSkillProviderRootOverrides(destination) }) } @@ -4921,6 +4945,7 @@ export class OrcaRuntimeService { workspaces: await this.listSkillSshWorkspaces(connectionId) }) } + await this.skillTransactionRecovery const runtimeId = this.getStatus().runtimeId const [installs, worktrees] = await Promise.all([ listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), { @@ -4979,6 +5004,48 @@ export class OrcaRuntimeService { return Boolean(await this.resolveSkillSshTarget(destination)) } + async resolveSkillDiscoveryProviderRoots(target: { + kind: 'native-host' | 'wsl' + distro?: string + }): Promise { + const roots = await this.resolveSkillProviderRootOverrides({ + scope: 'global', + homeDirectory: homedir(), + ...(target.kind === 'wsl' && target.distro ? { wslDistro: target.distro } : {}) + }) + if (target.kind !== 'wsl') { + return roots + } + return Object.fromEntries( + Object.entries(roots).map(([provider, root]) => [provider, toLinuxPath(root)]) + ) + } + + private async resolveSkillProviderRootOverrides(destination: { + scope: 'global' | 'workspace' + homeDirectory: string + workspaceDirectory?: string + wslDistro?: string + }): Promise { + if (destination.scope !== 'global') { + return {} + } + const wslGrokRoot = destination.wslDistro + ? await resolveWslGrokSkillProviderRoot(destination.wslDistro) + : null + const roots: SkillProviderRootOverrides = destination.wslDistro + ? wslGrokRoot + ? { grok: wslGrokRoot } + : {} + : resolveEnvironmentSkillProviderRoots() + const claudeConfigDirectory = this.accountServices?.claudeAccounts.getRuntimeConfigDir( + destination.wslDistro + ? { runtime: 'wsl', wslDistro: destination.wslDistro } + : { runtime: 'host' } + ) + return withClaudeSkillProviderRoot(roots, claudeConfigDirectory) + } + private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority { return { environmentId: runtimeId, diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index a9197663e9f..c37c3f8b41e 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -30,6 +30,7 @@ function makeContext(overrides: { return { runtime: { listRepos: () => [], + resolveSkillDiscoveryProviderRoots: async () => ({}), resolveProjectRuntimeForWorktree: overrides.resolveProjectRuntimeForWorktree ?? (() => undefined) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 01cedf0a24a..fd29651f384 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -35,7 +35,12 @@ export const SKILL_METHODS: RpcMethod[] = [ ...params, projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId) } - return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos()) + const resolvedTarget = resolveSkillDiscoveryTarget(target) + return discoverSkillsOnTarget( + resolvedTarget, + runtime.listRepos(), + await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget) + ) } }), defineMethod({ diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index cbe33cbad42..092fab884ea 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -218,7 +218,16 @@ describe('skill discovery', () => { '/home/test/.omp/agent/skills', '/home/test/.gemini/skills', '/home/test/.gemini/antigravity/skills', - '/home/test/.cursor/skills' + '/home/test/.cursor/skills', + '/home/test/.factory/skills', + '/home/test/.continue/skills', + '/home/test/.trae-cn/skills', + '/home/test/.augment/skills', + '/workspace/current/.factory/skills', + '/workspace/current/.continue/skills', + '/workspace/current/.trae/skills', + '/workspace/current/.grok/skills', + '/workspace/current/.augment/skills' ]) ) // Why: these live outside ~/.agents/skills, so they must carry the shared diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 00edfc8e705..6adb71fa293 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -18,6 +18,8 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' export { buildSkillDiscoverySources } from './skill-discovery-sources' @@ -187,8 +189,8 @@ type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string } async function scanRoot(root: SkillScanRoot): Promise { const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 const skillFiles = await findSkillFiles(root.path, maxDepth) - const skills = await Promise.all( - skillFiles.map(async (skillFilePath): Promise => { + const skills = await runSkillCandidateTasks( + skillFiles.map((skillFilePath) => async (): Promise => { // Why: path identity belongs to the scanning host; canonicalizing before // returning prevents symlinked roots from becoming duplicate picker rows. const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath) @@ -225,6 +227,7 @@ export async function discoverSkills(args: { homeDir?: string cwd?: string includeCwd?: boolean + providerRootOverrides?: SkillProviderRootOverrides }): Promise { const homeDir = args.homeDir ?? homedir() const roots = [ @@ -236,21 +239,22 @@ export async function discoverSkills(args: { : []) ] const sources: SkillDiscoverySource[] = [] - const skillGroups = await Promise.all( - roots.map(async (root) => { + const rootResults = await runSkillCandidateTasks( + roots.map((root) => async () => { const exists = await pathExists(root.path) - sources.push({ - ...root, - providers: [...root.providers], - exists, - skippedReason: exists ? undefined : 'missing' - }) - if (!exists) { - return [] + return { + source: { + ...root, + providers: [...root.providers], + exists, + skippedReason: exists ? undefined : 'missing' + } satisfies SkillDiscoverySource, + skills: exists ? await scanRoot(root) : [] } - return scanRoot(root) }) ) + sources.push(...rootResults.map((result) => result.source)) + const skillGroups = rootResults.map((result) => result.skills) const seen = new Map() for (const skill of skillGroups.flat()) { // Why: overlapping repo/cwd roots and symlinked provider homes can reach diff --git a/src/main/skills/skill-bundle-extraction.ts b/src/main/skills/skill-bundle-extraction.ts index e215b2fa225..2c779693dda 100644 --- a/src/main/skills/skill-bundle-extraction.ts +++ b/src/main/skills/skill-bundle-extraction.ts @@ -235,7 +235,11 @@ export async function extractSkillBundleArchive(input: { throwIfCancelled(input.signal) return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity } } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)) + const failure = input.signal?.aborted + ? new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE, { cause: error }) + : error instanceof Error + ? error + : new Error(String(error)) archive.abort(failure) await archive.archiveIdentity.catch(() => undefined) if (destinationCreated) { diff --git a/src/main/skills/skill-bundle-install-request-service.ts b/src/main/skills/skill-bundle-install-request-service.ts index 183a9a39ae3..4630a29095e 100644 --- a/src/main/skills/skill-bundle-install-request-service.ts +++ b/src/main/skills/skill-bundle-install-request-service.ts @@ -11,8 +11,10 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati import { installSkillBundle } from './skill-bundle-install-service' import { downloadSkillPackageGrant } from './skill-package-download' import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' +import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers' import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem' import { startSkillBundleInstallOperation } from './skill-operation-observability' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' type StagedSkillBundle = { archivePath: string; cleanup(): Promise } @@ -26,6 +28,12 @@ export type SkillBundleInstallRequestDependencies = { onProgress?: (progress: SkillBundleInstallProgress) => void fetcher?: typeof fetch detectProviders: () => Promise + resolveProviderRootOverrides?: (destination: { + scope: 'global' | 'workspace' + homeDirectory: string + workspaceDirectory?: string + wslDistro?: string + }) => Promise | SkillProviderRootOverrides resolveStagedUpload?: ( uploadId: string, identity: SkillBundlePackageIdentity @@ -72,14 +80,19 @@ async function executeParsedSkillBundleInstallRequest( dependencies.authority ) ingress = await resolveIngress(request, dependencies) - const detectedProviders = destination.wslDistro - ? await detectSkillProvidersInWsl(destination.wslDistro) - : await dependencies.detectProviders() + const detectedProviders = selectedOrDetectedSkillProviders( + destination.wslDistro + ? await detectSkillProvidersInWsl(destination.wslDistro) + : await dependencies.detectProviders(), + request.providers + ) + const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination) const filesystem = destination.wslDistro ? createWslSkillInstallFilesystem({ distro: destination.wslDistro, homeDirectory: destination.homeDirectory, - workspaceDirectory: destination.workspaceDirectory + workspaceDirectory: destination.workspaceDirectory, + providerRootOverrides }) : undefined return await installSkillBundle({ @@ -97,6 +110,7 @@ async function executeParsedSkillBundleInstallRequest( workspaceDirectory: destination.workspaceDirectory, orcaStateDirectory: dependencies.stateDirectory, detectedProviders, + providerRootOverrides, destinationIdentity: destination.destinationIdentity, hostIdentity: dependencies.authority.environmentId, expectedArchiveSha256: request.package.archiveSha256, diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts index 83ce75e4425..aca545fe759 100644 --- a/src/main/skills/skill-bundle-install-service.test.ts +++ b/src/main/skills/skill-bundle-install-service.test.ts @@ -1,3 +1,4 @@ +import { readdirSync, statSync } from 'node:fs' import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -66,6 +67,77 @@ describe('skill bundle installation', () => { await expect(readdir(join(root, 'home', '.agents', 'skills'))).resolves.toEqual([]) }) + it('cancels during bundle extraction and removes its journal and partial bytes', async () => { + const root = await temporaryDirectory() + const source = await createSkill(join(root, 'sources'), 'alpha-skill') + const payloadBytes = 256 * 1024 + await writeFile(join(source, 'payload.bin'), Buffer.alloc(payloadBytes, 0x61)) + const bundle = await createSkillBundleArchive({ + sources: [{ sourceDirectory: source }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'package_mid_extract_cancel', + versionId: 'version_mid_extract_cancel', + bundleName: 'team-skills' + }) + const destinationRoot = join(root, 'home', '.agents', 'skills') + const controller = new AbortController() + let observedPartialBytes = false + const signal = new Proxy(controller.signal, { + get(target, property) { + if (property === 'aborted' && !target.aborted) { + const extraction = readdirSync(destinationRoot, { withFileTypes: true }).find( + (entry) => entry.isDirectory() && entry.name.startsWith('.orca-skill-extract-') + ) + if (extraction) { + const size = (() => { + try { + return statSync( + join(destinationRoot, extraction.name, 'skills', 'alpha-skill', 'payload.bin') + ).size + } catch { + return 0 + } + })() + if (size > 0 && size < payloadBytes) { + observedPartialBytes = true + controller.abort() + } + } + } + const value = Reflect.get(target, property, target) as unknown + return typeof value === 'function' ? value.bind(target) : value + } + }) + + await expect( + installSkillBundle({ + operationId: 'operation_mid_extract_cancel', + archivePath: bundle.archivePath, + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + selectedSkillIds: ['alpha-skill'], + expectedArchiveSha256: bundle.archiveSha256, + scope: 'global', + homeDirectory: join(root, 'home'), + orcaStateDirectory: join(root, 'state'), + detectedProviders: [], + destinationIdentity: 'local-global', + hostIdentity: 'host_1', + signal + }) + ).rejects.toMatchObject({ data: SKILL_INSTALL_CANCELLED_FAILURE }) + + expect(observedPartialBytes).toBe(true) + await expect(readdir(destinationRoot)).resolves.toEqual([]) + await expect( + readdir(join(root, 'state', 'skill-installs', 'extraction-journals')) + ).resolves.toEqual([]) + await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual( + [] + ) + }) + it('installs a selected subset and keeps an unowned conflict local', async () => { const root = await temporaryDirectory() const alpha = await createSkill(join(root, 'sources'), 'alpha-skill') @@ -142,4 +214,49 @@ describe('skill bundle installation', () => { }) ]) }) + + it('reports partial when a selected provider placement is unowned', async () => { + const root = await temporaryDirectory() + const alpha = await createSkill(join(root, 'sources'), 'alpha-skill') + const homeDirectory = join(root, 'home') + const providerPath = join(homeDirectory, '.claude', 'skills', 'alpha-skill') + await mkdir(providerPath, { recursive: true }) + await writeFile(join(providerPath, 'SKILL.md'), '# Unowned') + const bundle = await createSkillBundleArchive({ + sources: [{ sourceDirectory: alpha }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'package_provider_partial', + versionId: 'version_1', + bundleName: 'team-skills' + }) + + const result = await installSkillBundle({ + operationId: 'operation_provider_partial', + archivePath: bundle.archivePath, + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + selectedSkillIds: ['alpha-skill'], + expectedArchiveSha256: bundle.archiveSha256, + scope: 'global', + homeDirectory, + orcaStateDirectory: join(root, 'state'), + detectedProviders: ['claude'], + destinationIdentity: 'local-global', + hostIdentity: 'host_1' + }) + + expect(result).toMatchObject({ + status: 'partial', + skills: [ + { + name: 'alpha-skill', + status: 'installed', + placements: expect.arrayContaining([ + expect.objectContaining({ provider: 'claude', status: 'skipped' }) + ]) + } + ] + }) + }) }) diff --git a/src/main/skills/skill-bundle-install-service.ts b/src/main/skills/skill-bundle-install-service.ts index e51f419d2c0..b4021d24019 100644 --- a/src/main/skills/skill-bundle-install-service.ts +++ b/src/main/skills/skill-bundle-install-service.ts @@ -132,7 +132,13 @@ function bundleStatus( if (skills.every((skill) => skill.status === 'failed')) { return 'failed' } - return skills.some((skill) => ['kept-local', 'failed', 'cancelled'].includes(skill.status)) + return skills.some( + (skill) => + ['kept-local', 'failed', 'cancelled'].includes(skill.status) || + skill.placements.some( + (placement) => placement.status === 'failed' || placement.status === 'skipped' + ) + ) ? 'partial' : 'complete' } diff --git a/src/main/skills/skill-bundle-ssh-relay-service.ts b/src/main/skills/skill-bundle-ssh-relay-service.ts index 644a58bd0b5..0f5dfe04858 100644 --- a/src/main/skills/skill-bundle-ssh-relay-service.ts +++ b/src/main/skills/skill-bundle-ssh-relay-service.ts @@ -7,6 +7,7 @@ import { } from '../../shared/skill-bundle-install-contract' import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' @@ -41,6 +42,10 @@ export async function installSkillBundleOnSshHost(input: { }): Promise { const client = requireSkillSshRelayClient(input.provider) const supported = await skillSshRelayCapabilities(client) + const request = input.request + if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) { + throw new Error('skill-bundle-ssh-update-required') + } if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { recordSkillCapabilityAbsence({ capability: SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -54,7 +59,7 @@ export async function installSkillBundleOnSshHost(input: { read: async () => { const value = await client( SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, - { operationId: input.request.operationId }, + { operationId: request.operationId }, { timeoutMs: 2_000, signal: input.signal } ) if (value === null) { @@ -75,14 +80,14 @@ export async function installSkillBundleOnSshHost(input: { call: () => client( SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - { request: input.request, workspace: input.workspace }, + { request: request, workspace: input.workspace }, { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) }) ) } catch (error) { if ( - input.request.ingress.kind !== 'download-grant' || + request.ingress.kind !== 'download-grant' || !shouldUseSkillSshClientTransfer(error, input.requireHttps) ) { throw error @@ -106,7 +111,7 @@ export async function installSkillBundleOnSshHost(input: { SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, { request: { - ...input.request, + ...request, ingress: { kind: 'staged-upload', uploadId } }, workspace: input.workspace diff --git a/src/main/skills/skill-candidate-concurrency.test.ts b/src/main/skills/skill-candidate-concurrency.test.ts new file mode 100644 index 00000000000..c3fd515a4fa --- /dev/null +++ b/src/main/skills/skill-candidate-concurrency.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { runSkillCandidateTasks } from './skill-candidate-concurrency' + +describe('runSkillCandidateTasks', () => { + it('bounds concurrency and preserves task order', async () => { + let active = 0 + let maxActive = 0 + const results = await runSkillCandidateTasks( + Array.from({ length: 12 }, (_, index) => async () => { + active += 1 + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 12 - index)) + active -= 1 + return index + }) + ) + + expect(maxActive).toBe(4) + expect(results).toEqual(Array.from({ length: 12 }, (_, index) => index)) + }) +}) diff --git a/src/main/skills/skill-cloud-deadline.ts b/src/main/skills/skill-cloud-deadline.ts new file mode 100644 index 00000000000..8fd14ce3d82 --- /dev/null +++ b/src/main/skills/skill-cloud-deadline.ts @@ -0,0 +1,31 @@ +const DEFAULT_SKILL_CLOUD_TIMEOUT_MS = 60_000 + +export function createSkillCloudDeadline(input: { + signal?: AbortSignal + timeoutMs?: number + timeoutMessage: string +}): { + signal: AbortSignal + cleanup(): void +} { + const timeoutMs = input.timeoutMs ?? DEFAULT_SKILL_CLOUD_TIMEOUT_MS + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { + throw new Error(`${input.timeoutMessage}-invalid`) + } + const controller = new AbortController() + const forwardAbort = (): void => controller.abort(input.signal?.reason) + if (input.signal?.aborted) { + forwardAbort() + } else { + input.signal?.addEventListener('abort', forwardAbort, { once: true }) + } + const timeout = setTimeout(() => controller.abort(new Error(input.timeoutMessage)), timeoutMs) + timeout.unref() + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout) + input.signal?.removeEventListener('abort', forwardAbort) + } + } +} diff --git a/src/main/skills/skill-cloud-direct-upload.test.ts b/src/main/skills/skill-cloud-direct-upload.test.ts index 4ee0789c301..f065a05d1b3 100644 --- a/src/main/skills/skill-cloud-direct-upload.test.ts +++ b/src/main/skills/skill-cloud-direct-upload.test.ts @@ -7,10 +7,79 @@ import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload' const roots: string[] = [] afterEach(async () => { + vi.useRealTimers() await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) describe('uploadSkillPackageToSignedPolicy', () => { + it('allows a slow progressing upload to continue beyond one minute', async () => { + vi.useFakeTimers() + const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-')) + roots.push(root) + const archivePath = join(root, 'package.tar.gz') + const archive = Buffer.alloc(3 * 64 * 1024, 0x61) + await writeFile(archivePath, archive) + let markStarted!: () => void + const started = new Promise((resolve) => { + markStarted = resolve + }) + let chunksSeen = 0 + const chunkWaiters = new Set<() => void>() + const waitForChunk = async (count: number): Promise => { + if (chunksSeen >= count) { + return + } + await new Promise((resolve) => { + const check = (): void => { + if (chunksSeen >= count) { + chunkWaiters.delete(check) + resolve() + } + } + chunkWaiters.add(check) + }) + } + const fetcher = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => { + markStarted() + const body = init?.body as unknown as AsyncIterable + for await (const _chunk of body) { + chunksSeen += 1 + chunkWaiters.forEach((notify) => notify()) + await new Promise((resolve) => setTimeout(resolve, 30_000)) + } + return new Response(null, { status: 204 }) + }) + const progress = vi.fn() + const upload = uploadSkillPackageToSignedPolicy({ + policy: { + url: 'https://storage.googleapis.com/upload', + fields: {}, + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() + }, + archivePath, + expectedBytes: archive.length, + fetcher: fetcher as typeof fetch, + onProgress: progress + }) + + await started + await waitForChunk(1) + await vi.advanceTimersByTimeAsync(30_000) + await waitForChunk(2) + await vi.advanceTimersByTimeAsync(30_000) + await waitForChunk(3) + await vi.advanceTimersByTimeAsync(1_000) + expect(progress).toHaveBeenCalled() + expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(29_000) + await waitForChunk(4) + await vi.advanceTimersByTimeAsync(30_000) + await waitForChunk(5) + await vi.advanceTimersByTimeAsync(30_000) + + await expect(upload).resolves.toBeUndefined() + }) + it('streams exact bytes with policy fields and bounded progress', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-')) roots.push(root) @@ -67,5 +136,41 @@ describe('uploadSkillPackageToSignedPolicy', () => { expectedBytes: 4 }) ).rejects.toThrow('skill-cloud-upload-source-changed') + await expect( + uploadSkillPackageToSignedPolicy({ + policy: { + url: 'https://storage.test/upload', + fields: {}, + expiresAt: 'not-a-date' + }, + archivePath, + expectedBytes: 5 + }) + ).rejects.toThrow('skill-cloud-upload-policy-expiry-invalid') + }) + + it('bounds a stalled signed upload even without a caller-owned signal', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-')) + roots.push(root) + const archivePath = join(root, 'package.tar.gz') + await writeFile(archivePath, 'bytes') + const fetcher = vi.fn( + async (_url: URL | RequestInfo, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + }) + ) as typeof fetch + + const upload = uploadSkillPackageToSignedPolicy({ + policy: { url: 'https://storage.googleapis.com/upload', fields: {} }, + archivePath, + expectedBytes: 5, + fetcher, + timeoutMs: 25 + }) + const startedAt = Date.now() + + await expect(upload).rejects.toThrow('skill-cloud-upload-timeout') + expect(Date.now() - startedAt).toBeLessThan(1_000) }) }) diff --git a/src/main/skills/skill-cloud-direct-upload.ts b/src/main/skills/skill-cloud-direct-upload.ts index d733fb13803..d4ce7d78f67 100644 --- a/src/main/skills/skill-cloud-direct-upload.ts +++ b/src/main/skills/skill-cloud-direct-upload.ts @@ -2,10 +2,28 @@ import { randomBytes } from 'node:crypto' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' +import { createSkillCloudDeadline } from './skill-cloud-deadline' type SignedPostPolicy = { url: string fields: Record + expiresAt?: string +} + +const DEFAULT_UPLOAD_TIMEOUT_MS = 15 * 60_000 + +function resolveUploadTimeoutMs(policy: SignedPostPolicy, timeoutMs?: number): number { + if (timeoutMs !== undefined) { + return timeoutMs + } + if (!policy.expiresAt) { + return DEFAULT_UPLOAD_TIMEOUT_MS + } + const expiresAt = Date.parse(policy.expiresAt) + if (!Number.isFinite(expiresAt)) { + throw new Error('skill-cloud-upload-policy-expiry-invalid') + } + return Math.max(1, Math.min(DEFAULT_UPLOAD_TIMEOUT_MS, expiresAt - Date.now())) } function quoted(value: string): string { @@ -30,6 +48,7 @@ export async function uploadSkillPackageToSignedPolicy(input: { signal?: AbortSignal onProgress?: (bytesSent: number) => void fetcher?: typeof fetch + timeoutMs?: number }): Promise { const policyUrl = new URL(input.policy.url) if (policyUrl.protocol !== 'https:' || policyUrl.username || policyUrl.password) { @@ -53,13 +72,18 @@ export async function uploadSkillPackageToSignedPolicy(input: { fileHeader.length + archive.size + ending.length + const deadline = createSkillCloudDeadline({ + signal: input.signal, + timeoutMs: resolveUploadTimeoutMs(input.policy, input.timeoutMs), + timeoutMessage: 'skill-cloud-upload-timeout' + }) async function* body() { for (const field of fields) { yield field } yield fileHeader let bytesSent = 0 - for await (const chunk of createReadStream(input.archivePath, { signal: input.signal })) { + for await (const chunk of createReadStream(input.archivePath, { signal: deadline.signal })) { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) bytesSent += bytes.length if (bytesSent > input.expectedBytes) { @@ -73,19 +97,22 @@ export async function uploadSkillPackageToSignedPolicy(input: { } yield ending } - const request: RequestInit & { duplex: 'half' } = { - method: 'POST', - headers: { - 'content-type': `multipart/form-data; boundary=${boundary}`, - 'content-length': String(contentLength) - }, - body: Readable.from(body()) as unknown as BodyInit, - duplex: 'half', - redirect: 'error', - signal: input.signal - } - const response = await (input.fetcher ?? fetch)(policyUrl, request) - if (!response.ok && response.status !== 201 && response.status !== 204) { - throw new Error('skill-cloud-upload-failed') + try { + const response = await (input.fetcher ?? fetch)(policyUrl, { + method: 'POST', + headers: { + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(contentLength) + }, + body: Readable.from(body()) as unknown as BodyInit, + duplex: 'half', + redirect: 'error', + signal: deadline.signal + } as RequestInit & { duplex: 'half' }) + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error('skill-cloud-upload-failed') + } + } finally { + deadline.cleanup() } } diff --git a/src/main/skills/skill-cloud-grant-installation.ts b/src/main/skills/skill-cloud-grant-installation.ts index e7d74218c6e..add69270c5d 100644 --- a/src/main/skills/skill-cloud-grant-installation.ts +++ b/src/main/skills/skill-cloud-grant-installation.ts @@ -27,6 +27,7 @@ export type SkillCloudGrantInstallInput = { operationId: string environmentId?: string destination: SkillInstallDestination + providers?: string[] conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel' } @@ -35,6 +36,7 @@ export type SkillBundleCloudGrantInstallInput = { environmentId?: string selectedSkillIds: string[] destination: SkillInstallDestination + providers?: string[] conflictDecisions?: SkillBundleInstallRequest['conflictDecisions'] } @@ -90,6 +92,7 @@ export async function installSkillBundleCloudGrant( selectedSkillIds: input.selectedSkillIds, ingress: { kind: 'download-grant', ...grant.grant }, destination: input.destination, + ...(input.providers ? { providers: input.providers } : {}), conflictDecisions: input.conflictDecisions ?? [] } try { @@ -127,6 +130,9 @@ export async function installSkillBundleCloudGrant( } } catch (error) { const failure = skillInstallFailureFromError(error) + if (failure?.category === 'compatibility') { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } if (!failure) { throw error } @@ -155,6 +161,7 @@ export async function installSkillCloudGrant( expiresAt: grant.grant.expiresAt }, destination: input.destination, + ...(input.providers ? { providers: input.providers } : {}), conflictResolution: input.conflictResolution } try { @@ -188,6 +195,9 @@ export async function installSkillCloudGrant( } } catch (error) { const failure = skillInstallFailureFromError(error) + if (failure?.category === 'compatibility') { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } if (!failure) { throw error } diff --git a/src/main/skills/skill-cloud-request.ts b/src/main/skills/skill-cloud-request.ts index 184c2c58ee2..3798e29ebc4 100644 --- a/src/main/skills/skill-cloud-request.ts +++ b/src/main/skills/skill-cloud-request.ts @@ -1,6 +1,5 @@ import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config' - -const DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS = 60_000 +import { createSkillCloudDeadline } from './skill-cloud-deadline' export class SkillCloudRequestError extends Error { constructor( @@ -12,37 +11,6 @@ export class SkillCloudRequestError extends Error { } } -function requestSignal( - input: AbortSignal | undefined, - timeoutMs: number -): { - signal: AbortSignal - cleanup(): void -} { - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) { - throw new Error('skill-cloud-request-timeout-invalid') - } - const controller = new AbortController() - const forwardAbort = (): void => controller.abort(input?.reason) - if (input?.aborted) { - forwardAbort() - } else { - input?.addEventListener('abort', forwardAbort, { once: true }) - } - const timeout = setTimeout( - () => controller.abort(new Error('skill-cloud-request-timeout')), - timeoutMs - ) - timeout.unref() - return { - signal: controller.signal, - cleanup: () => { - clearTimeout(timeout) - input?.removeEventListener('abort', forwardAbort) - } - } -} - export async function skillCloudRequest(input: { apiUrl?: string authToken?: string @@ -59,10 +27,11 @@ export async function skillCloudRequest(input: { if (url.origin !== apiUrl || !url.pathname.startsWith('/v1/')) { throw new Error('skill-cloud-request-path-invalid') } - const deadline = requestSignal( - input.signal, - input.timeoutMs ?? DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS - ) + const deadline = createSkillCloudDeadline({ + signal: input.signal, + timeoutMs: input.timeoutMs, + timeoutMessage: 'skill-cloud-request-timeout' + }) try { const response = await (input.fetcher ?? fetch)(url, { method: input.method ?? 'GET', diff --git a/src/main/skills/skill-cloud-service.test.ts b/src/main/skills/skill-cloud-service.test.ts index 549c435ebe4..43d575a34ed 100644 --- a/src/main/skills/skill-cloud-service.test.ts +++ b/src/main/skills/skill-cloud-service.test.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SkillCloudVersion } from '../../shared/skill-cloud-contract' import { SkillCloudService } from './skill-cloud-service' const { packaged } = vi.hoisted(() => ({ packaged: { value: false } })) @@ -30,6 +31,41 @@ function userDataPath(): string { return path } +function publishedVersion(archiveSha256: string, compressedBytes: number): SkillCloudVersion { + return { + packageId: 'pkg_retry', + versionId: 'ver_retry', + name: 'retry-skill', + description: 'Retry publication', + packageDigest: 'a'.repeat(64), + archiveSha256, + compressedBytes, + createdAt: '2026-08-12T12:00:00.000Z', + releaseNotes: 'retry', + manifest: { + schemaVersion: 1, + packageId: 'pkg_retry', + versionId: 'ver_retry', + name: 'retry-skill', + description: 'Retry publication', + createdAt: '2026-08-12T12:00:00.000Z', + packageDigest: 'a'.repeat(64), + files: [] + } + } +} + +function publishRequest(archivePath: string, archiveSha256: string, compressedBytes: number) { + return { + apiUrl: 'http://127.0.0.1:8787', + archivePath, + archiveSha256, + compressedBytes, + packageId: 'pkg_retry', + releaseNotes: 'retry' + } +} + describe('SkillCloudService bearer links', () => { it('resolves and grants downloads without an Orca session', async () => { const requests: RequestInit[] = [] @@ -86,3 +122,126 @@ describe('SkillCloudService bearer links', () => { ).rejects.toThrow('available only in development builds') }) }) + +describe('SkillCloudService publication retries', () => { + it('reuses a reserved upload after its create response is lost', async () => { + vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token') + const root = userDataPath() + const archivePath = join(root, 'package.tar.gz') + const archive = Buffer.from('skill archive') + const archiveSha256 = 'b'.repeat(64) + const version = publishedVersion(archiveSha256, archive.byteLength) + writeFileSync(archivePath, archive) + let createAttempts = 0 + let uploads = 0 + const createKeys: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/v1/skill-packages/uploads')) { + createAttempts += 1 + createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '') + if (createAttempts === 1) { + throw new Error('create response lost') + } + return Response.json({ + upload: { + id: 'upl_retry', + policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' } + } + }) + } + if (url === 'https://storage.test/upload') { + uploads += 1 + return new Response(null, { status: 204 }) + } + if (url.endsWith('/v1/skill-packages/pkg_retry')) { + return new Response(null, { status: 404 }) + } + if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) { + return Response.json({ version }) + } + throw new Error(`Unexpected request: ${url}`) + }) + ) + const service = new SkillCloudService(root) + const request = publishRequest(archivePath, archiveSha256, archive.byteLength) + + await expect(service.publishVersion(request)).rejects.toThrow('create response lost') + await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version }) + + expect(createKeys).toHaveLength(2) + expect(new Set(createKeys).size).toBe(1) + expect(createKeys[0]).not.toBe('') + expect(uploads).toBe(1) + }) + + it('finds the finalized version when retrying after its response is lost', async () => { + vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token') + const root = userDataPath() + const archivePath = join(root, 'package.tar.gz') + const archive = Buffer.from('skill archive') + const archiveSha256 = 'c'.repeat(64) + const version = publishedVersion(archiveSha256, archive.byteLength) + writeFileSync(archivePath, archive) + let createAttempts = 0 + let uploads = 0 + let finalizations = 0 + const createKeys: string[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/v1/skill-packages/uploads')) { + createAttempts += 1 + createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '') + if (createAttempts === 2) { + return Response.json( + { code: 'skill_upload_not_pending', message: 'Upload is already finalized.' }, + { status: 409 } + ) + } + return Response.json({ + upload: { + id: 'upl_retry', + policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' } + } + }) + } + if (url === 'https://storage.test/upload') { + uploads += 1 + return new Response(null, { status: 204 }) + } + if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) { + finalizations += 1 + throw new Error('finalize response lost') + } + if (url.endsWith('/v1/skill-packages/pkg_retry')) { + return Response.json({ + package: { + id: 'pkg_retry', + name: 'retry-skill', + description: 'Retry publication', + createdAt: version.createdAt, + canManage: true, + versions: [version] + } + }) + } + throw new Error(`Unexpected request: ${url}`) + }) + ) + const service = new SkillCloudService(root) + const request = publishRequest(archivePath, archiveSha256, archive.byteLength) + + await expect(service.publishVersion(request)).rejects.toThrow('finalize response lost') + await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version }) + + expect(createKeys).toHaveLength(2) + expect(new Set(createKeys).size).toBe(1) + expect(createKeys[0]).not.toBe('') + expect(uploads).toBe(1) + expect(finalizations).toBe(1) + }) +}) diff --git a/src/main/skills/skill-cloud-service.ts b/src/main/skills/skill-cloud-service.ts index d675b6624e7..9f87d9266c3 100644 --- a/src/main/skills/skill-cloud-service.ts +++ b/src/main/skills/skill-cloud-service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import type { SkillCloudDownloadGrant, SkillCloudInstallTarget, @@ -19,6 +19,13 @@ import { startSkillPhaseOperation } from './skill-operation-observability' const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +function publishUploadIdempotencyKey(request: SkillCloudPublishRequest): string { + return ( + request.idempotencyKey ?? + createHash('sha256').update(`${request.packageId}\0${request.archiveSha256}`).digest('hex') + ) +} + function id(value: string): string { if (!ID_PATTERN.test(value)) { throw new Error('skill-cloud-id-invalid') @@ -46,23 +53,42 @@ export class SkillCloudService { request: SkillCloudPublishRequest ): Promise> { return this.withAuth(request, async (token, apiUrl) => { - const upload = await skillCloudRequest<{ + let upload: { upload: { id: string policy: { url: string; fields: Record; expiresAt: string } } - }>({ - apiUrl, - authToken: token, - path: '/v1/skill-packages/uploads', - method: 'POST', - body: { - expectedArchiveSha256: request.archiveSha256, - expectedCompressedBytes: request.compressedBytes - }, - idempotencyKey: randomUUID(), - signal: request.signal - }) + } + try { + upload = await skillCloudRequest({ + apiUrl, + authToken: token, + path: '/v1/skill-packages/uploads', + method: 'POST', + body: { + expectedArchiveSha256: request.archiveSha256, + expectedCompressedBytes: request.compressedBytes + }, + idempotencyKey: publishUploadIdempotencyKey(request), + signal: request.signal + }) + } catch (error) { + const existing = await skillCloudRequest<{ package: SkillCloudPackageDetails }>({ + apiUrl, + authToken: token, + path: `/v1/skill-packages/${id(request.packageId)}`, + signal: request.signal + }).catch(() => null) + const published = existing?.package.versions.find( + (version) => + version.archiveSha256 === request.archiveSha256 && + version.compressedBytes === request.compressedBytes + ) + if (published) { + return published + } + throw error + } const uploadOperation = startSkillPhaseOperation({ phase: 'upload', compressedBytes: request.compressedBytes diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index ad457d00e81..7cc8bf020e1 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -10,6 +10,8 @@ import type { import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' +import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots' export type SkillScanRoot = Omit type SkillDiscoveryPathApi = Pick @@ -67,11 +69,14 @@ export function buildSkillDiscoverySources( repos?: Repo[] includeCwd?: boolean pathApi?: SkillDiscoveryPathApi + providerRootOverrides?: SkillProviderRootOverrides } = {} ): SkillScanRoot[] { const pathApi = args.pathApi ?? { basename, join } const home = args.homeDir ?? homedir() const cwd = args.cwd ?? process.cwd() + const providerRootOverrides = + args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots()) const roots: SkillScanRoot[] = [ source( 'home-codex', @@ -92,7 +97,7 @@ export function buildSkillDiscoverySources( source( 'home-claude', 'Claude home', - pathApi.join(home, '.claude', 'skills'), + providerRootOverrides.claude ?? pathApi.join(home, '.claude', 'skills'), 'home', ['claude'], 'claude' @@ -110,7 +115,7 @@ export function buildSkillDiscoverySources( source( 'home-grok', 'Grok home', - pathApi.join(home, '.grok', 'skills'), + providerRootOverrides.grok ?? pathApi.join(home, '.grok', 'skills'), 'home', ['agent-skills'], 'grok' @@ -170,6 +175,38 @@ export function buildSkillDiscoverySources( 'home', ['agent-skills'], 'cursor' + ), + source( + 'home-droid', + 'Droid home', + pathApi.join(home, '.factory', 'skills'), + 'home', + ['agent-skills'], + 'droid' + ), + source( + 'home-continue', + 'Continue home', + pathApi.join(home, '.continue', 'skills'), + 'home', + ['agent-skills'], + 'continue' + ), + source( + 'home-trae', + 'Trae home', + pathApi.join(home, '.trae-cn', 'skills'), + 'home', + ['agent-skills'], + 'trae' + ), + source( + 'home-aug', + 'Augment home', + pathApi.join(home, '.augment', 'skills'), + 'home', + ['agent-skills'], + 'aug' ) ] @@ -204,6 +241,46 @@ export function buildSkillDiscoverySources( 'repo', ['claude'], 'claude' + ), + source( + `repo-droid-${stablePathId(repoPath)}`, + `${label} .factory`, + pathApi.join(repoPath, '.factory', 'skills'), + 'repo', + ['agent-skills'], + 'droid' + ), + source( + `repo-continue-${stablePathId(repoPath)}`, + `${label} .continue`, + pathApi.join(repoPath, '.continue', 'skills'), + 'repo', + ['agent-skills'], + 'continue' + ), + source( + `repo-trae-${stablePathId(repoPath)}`, + `${label} .trae`, + pathApi.join(repoPath, '.trae', 'skills'), + 'repo', + ['agent-skills'], + 'trae' + ), + source( + `repo-grok-${stablePathId(repoPath)}`, + `${label} .grok`, + pathApi.join(repoPath, '.grok', 'skills'), + 'repo', + ['agent-skills'], + 'grok' + ), + source( + `repo-aug-${stablePathId(repoPath)}`, + `${label} .augment`, + pathApi.join(repoPath, '.augment', 'skills'), + 'repo', + ['agent-skills'], + 'aug' ) ) } diff --git a/src/main/skills/skill-discovery-target.ts b/src/main/skills/skill-discovery-target.ts index 2be10232c8c..72dc5546e2e 100644 --- a/src/main/skills/skill-discovery-target.ts +++ b/src/main/skills/skill-discovery-target.ts @@ -3,6 +3,7 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/sk import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl' import { discoverSkills } from './discovery' import { discoverSkillsInWsl } from './skill-discovery-wsl' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' export type ResolvedSkillDiscoveryTarget = | { kind: 'native-host'; cwd: string | undefined } @@ -55,16 +56,18 @@ export function resolveSkillDiscoveryTarget( export async function discoverSkillsOnTarget( target: ResolvedSkillDiscoveryTarget, - repos: readonly Repo[] + repos: readonly Repo[], + providerRootOverrides?: SkillProviderRootOverrides ): Promise { if (target.kind === 'wsl') { return discoverSkillsInWsl({ distro: target.distro, homeDir: target.homeDir, - cwd: target.cwd + cwd: target.cwd, + providerRootOverrides }) } return target.cwd - ? discoverSkills({ repos: [], cwd: target.cwd }) - : discoverSkills({ repos: [...repos] }) + ? discoverSkills({ repos: [], cwd: target.cwd, providerRootOverrides }) + : discoverSkills({ repos: [...repos], providerRootOverrides }) } diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts index c4f553225ae..bfec78f24e3 100644 --- a/src/main/skills/skill-discovery-wsl.ts +++ b/src/main/skills/skill-discovery-wsl.ts @@ -16,6 +16,7 @@ import { type SkillScanRoot } from './skill-discovery-sources' import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' const MAX_MARKDOWN_BYTES = 256 * 1024 const MAX_PACKAGE_FILES = 200 @@ -180,6 +181,7 @@ export async function discoverSkillsInWsl(args: { distro: string homeDir: string cwd: string + providerRootOverrides?: SkillProviderRootOverrides }): Promise { // Plugin roots are resolved (in JS) from metadata this first wsl.exe call // reads, then fed to the scan's own wsl.exe call below — two sequential @@ -201,7 +203,8 @@ export async function discoverSkillsInWsl(args: { homeDir: args.homeDir, cwd: args.cwd, repos: [], - pathApi: pathPosix + pathApi: pathPosix, + providerRootOverrides: args.providerRootOverrides }), ...pluginRoots ] diff --git a/src/main/skills/skill-install-discovery-verification.test.ts b/src/main/skills/skill-install-discovery-verification.test.ts index 2fc007e9b7d..5081890a08b 100644 --- a/src/main/skills/skill-install-discovery-verification.test.ts +++ b/src/main/skills/skill-install-discovery-verification.test.ts @@ -98,6 +98,31 @@ describe('WSL install discovery verification', () => { }) }) + it('scans a managed WSL Claude config root used by the target session', async () => { + const managedRoot = '/home/alice/.local/share/orca/claude-accounts/account-1/auth/skills' + discoverSkillsInWslMock.mockResolvedValue( + discovery(['/home/alice/.agents/skills', managedRoot]) + ) + const result = await verifySkillInstallDiscovery({ + result: installResult([ + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.agents\\skills\\review', + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills\\review' + ]), + scope: 'global', + homeDirectory: '\\\\wsl.localhost\\Ubuntu\\home\\alice', + wslDistro: 'Ubuntu', + providerRootOverrides: { + claude: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills' + } + }) + + expect(result.status).toBe('installed') + expect(discoverSkillsInWslMock).toHaveBeenCalledWith( + expect.objectContaining({ providerRootOverrides: { claude: managedRoot } }) + ) + }) + it('fails verification without scanning a mismatched UNC distro', async () => { const result = await verifySkillInstallDiscovery({ result: installResult(['\\\\wsl.localhost\\Debian\\home\\alice\\.agents\\skills\\review']), diff --git a/src/main/skills/skill-install-discovery-verification.ts b/src/main/skills/skill-install-discovery-verification.ts index debdd783887..9263c660627 100644 --- a/src/main/skills/skill-install-discovery-verification.ts +++ b/src/main/skills/skill-install-discovery-verification.ts @@ -4,6 +4,7 @@ import type { SkillDiscoveryResult } from '../../shared/skills' import { parseWslUncPath } from '../../shared/wsl-paths' import { discoverSkills } from './discovery' import { discoverSkillsInWsl } from './skill-discovery-wsl' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' function toWslDiscoveryPath(path: string, distro: string): string { if (path.includes('\0')) { @@ -65,12 +66,23 @@ async function discoverInstalledSkill(input: { homeDirectory: string workspaceDirectory?: string wslDistro?: string + providerRootOverrides?: SkillProviderRootOverrides }): Promise { if (input.wslDistro) { + const wslDistro = input.wslDistro + const providerRootOverrides = input.providerRootOverrides + ? Object.fromEntries( + Object.entries(input.providerRootOverrides).map(([provider, root]) => [ + provider, + toWslDiscoveryPath(root, wslDistro) + ]) + ) + : undefined return discoverSkillsInWsl({ - distro: input.wslDistro, - homeDir: toWslDiscoveryPath(input.homeDirectory, input.wslDistro), - cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, input.wslDistro) + distro: wslDistro, + homeDir: toWslDiscoveryPath(input.homeDirectory, wslDistro), + cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, wslDistro), + ...(providerRootOverrides ? { providerRootOverrides } : {}) }) } return discoverSkills({ @@ -78,7 +90,8 @@ async function discoverInstalledSkill(input: { repos: [], ...(input.scope === 'workspace' && input.workspaceDirectory ? { cwd: input.workspaceDirectory } - : { includeCwd: false }) + : { includeCwd: false }), + ...(input.providerRootOverrides ? { providerRootOverrides: input.providerRootOverrides } : {}) }) } @@ -88,6 +101,7 @@ export async function verifySkillInstallDiscovery(input: { homeDirectory: string workspaceDirectory?: string wslDistro?: string + providerRootOverrides?: SkillProviderRootOverrides discover?: () => Promise }): Promise { const discovery = await (input.discover?.() ?? discoverInstalledSkill(input)).catch(() => null) diff --git a/src/main/skills/skill-install-filesystem.ts b/src/main/skills/skill-install-filesystem.ts index 2c5169aca9b..67ab5d6d36f 100644 --- a/src/main/skills/skill-install-filesystem.ts +++ b/src/main/skills/skill-install-filesystem.ts @@ -10,6 +10,7 @@ import { export type SkillInstalledFileMode = { path: string; executable: boolean } export type SkillInstallFilesystem = { + authorizeRoots?(paths: readonly string[]): void prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise observeSkill( path: string, diff --git a/src/main/skills/skill-install-historical-provider-roots.ts b/src/main/skills/skill-install-historical-provider-roots.ts new file mode 100644 index 00000000000..4865a3cfce7 --- /dev/null +++ b/src/main/skills/skill-install-historical-provider-roots.ts @@ -0,0 +1,16 @@ +import { dirname } from 'node:path' +import { isSkillInstallProviderId } from '../../shared/skill-install-providers' +import type { SkillInstallReceiptV1 } from './skill-install-provenance' + +export function historicalSuccessfulProviderRoots(receipt: SkillInstallReceiptV1 | null): string[] { + return ( + receipt?.placements.flatMap((placement) => + isSkillInstallProviderId(placement.provider) && + placement.topology !== 'canonical-copy' && + placement.status !== 'failed' && + placement.status !== 'skipped' + ? [dirname(placement.path)] + : [] + ) ?? [] + ) +} diff --git a/src/main/skills/skill-install-journal-transition.ts b/src/main/skills/skill-install-journal-transition.ts index 64a4b551a96..0768501f4f1 100644 --- a/src/main/skills/skill-install-journal-transition.ts +++ b/src/main/skills/skill-install-journal-transition.ts @@ -1,5 +1,6 @@ import { writeSkillStateFile } from './skill-install-provenance' import type { SkillInstallJournalV1 } from './skill-install-recovery' +import type { SkillInstallReceiptV1 } from './skill-install-provenance' export type SkillInstallJournalBoundary = 'before' | 'after' @@ -8,6 +9,11 @@ export type SkillInstallTransactionDependencies = { phase: SkillInstallJournalV1['phase'], boundary: SkillInstallJournalBoundary ) => Promise + placementTransaction?: { + prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise + commit(receipt: SkillInstallReceiptV1): Promise + finish(receipt: SkillInstallReceiptV1): Promise + } } export async function persistSkillInstallJournalTransition( diff --git a/src/main/skills/skill-install-management-service.ts b/src/main/skills/skill-install-management-service.ts index 1547eb031a5..7f496e9a98f 100644 --- a/src/main/skills/skill-install-management-service.ts +++ b/src/main/skills/skill-install-management-service.ts @@ -15,15 +15,22 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati import { resolveSkillInstallDestination } from './skill-install-destinations' import { inspectSkillCanonicalState } from './skill-install-planner' import { readSkillInstallReceipt } from './skill-install-provenance' -import { resolveSkillProviderDestinations } from './skill-provider-destinations' +import { + resolveSkillProviderDestinations, + type SkillProviderRootOverrides +} from './skill-provider-destinations' import { removeLocalSharedSkill } from './skill-remove-transaction' import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem' +import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' type ManagementDependencies = { authority: SkillInstallDestinationAuthority stateDirectory: string detectProviders(): Promise + resolveProviderRootOverrides?: ( + destination: PreviewContext['destination'] + ) => Promise | SkillProviderRootOverrides } type PreviewContext = { @@ -31,6 +38,7 @@ type PreviewContext = { filesystem: ReturnType | undefined installStateDirectory: string detectedProviders: readonly string[] + providerRootOverrides?: SkillProviderRootOverrides } const BUNDLE_PREVIEW_CONCURRENCY = 8 @@ -55,19 +63,22 @@ async function preparePreviewContext( destinationRequest, dependencies.authority ) + const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination) return { destination, filesystem: destination.wslDistro ? createWslSkillInstallFilesystem({ distro: destination.wslDistro, homeDirectory: destination.homeDirectory, - workspaceDirectory: destination.workspaceDirectory + workspaceDirectory: destination.workspaceDirectory, + providerRootOverrides }) : undefined, installStateDirectory: join(dependencies.stateDirectory, 'skill-installs'), detectedProviders: destination.wslDistro ? await detectSkillProvidersInWsl(destination.wslDistro) - : await dependencies.detectProviders() + : await dependencies.detectProviders(), + providerRootOverrides } } @@ -96,7 +107,8 @@ async function previewWithContext( scope: context.destination.scope, homeDirectory: context.destination.homeDirectory, workspaceDirectory: context.destination.workspaceDirectory, - detectedProviders: context.detectedProviders + detectedProviders: context.detectedProviders, + providerRootOverrides: context.providerRootOverrides }) return { name: request.name, @@ -179,21 +191,21 @@ export async function removeSharedSkillInstall( request.destination, dependencies.authority ) - const detectedProviders = destination.wslDistro - ? await detectSkillProvidersInWsl(destination.wslDistro) - : await dependencies.detectProviders() + const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination) const filesystem = destination.wslDistro ? createWslSkillInstallFilesystem({ distro: destination.wslDistro, homeDirectory: destination.homeDirectory, - workspaceDirectory: destination.workspaceDirectory + workspaceDirectory: destination.workspaceDirectory, + providerRootOverrides }) : undefined const providerRoots = resolveSkillProviderDestinations({ scope: destination.scope, homeDirectory: destination.homeDirectory, workspaceDirectory: destination.workspaceDirectory, - detectedProviders + detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id), + providerRootOverrides }).map((provider) => provider.rootPath) return removeLocalSharedSkill({ operationId: request.operationId, diff --git a/src/main/skills/skill-install-provenance.test.ts b/src/main/skills/skill-install-provenance.test.ts index 61b67551217..6060702dba1 100644 --- a/src/main/skills/skill-install-provenance.test.ts +++ b/src/main/skills/skill-install-provenance.test.ts @@ -26,6 +26,7 @@ describe('managed skill install receipts', () => { destinationIdentity: 'global:runtime-1', canonicalPath: join(root, 'missing', 'example'), placements: [], + providers: ['codex', 'claude'], installedAt: '2026-08-11T00:00:00.000Z', hostIdentity: 'runtime-1' }) @@ -35,6 +36,7 @@ describe('managed skill install receipts', () => { name: 'example', packageId: 'package-1', versionId: 'version-1', + providers: ['codex', 'claude'], state: 'missing' }) ]) diff --git a/src/main/skills/skill-install-provenance.ts b/src/main/skills/skill-install-provenance.ts index 928f0cb9958..42b6c07f545 100644 --- a/src/main/skills/skill-install-provenance.ts +++ b/src/main/skills/skill-install-provenance.ts @@ -17,6 +17,7 @@ export type SkillInstallReceiptV1 = { destinationIdentity: string canonicalPath: string placements: SkillPlacementResult[] + providers?: string[] previousVersionId?: string installedAt: string hostIdentity: string @@ -67,6 +68,9 @@ function isReceipt(value: unknown): value is SkillInstallReceiptV1 { typeof receipt.destinationIdentity === 'string' && typeof receipt.canonicalPath === 'string' && Array.isArray(receipt.placements) && + (receipt.providers === undefined || + (Array.isArray(receipt.providers) && + receipt.providers.every((provider) => typeof provider === 'string'))) && typeof receipt.installedAt === 'string' && typeof receipt.hostIdentity === 'string' && (receipt.fileModes === undefined || @@ -110,6 +114,7 @@ export async function listManagedSkillInstalls( scope: parsed.scope, destinationIdentity: parsed.destinationIdentity, installedAt: parsed.installedAt, + ...(parsed.providers ? { providers: parsed.providers } : {}), state: observed ? observed.observedDigest === parsed.packageDigest ? 'unchanged' diff --git a/src/main/skills/skill-install-request-service.ts b/src/main/skills/skill-install-request-service.ts index dcd617a0923..192bebc3709 100644 --- a/src/main/skills/skill-install-request-service.ts +++ b/src/main/skills/skill-install-request-service.ts @@ -11,9 +11,11 @@ import { import { installSharedSkill } from './skill-install-service' import { downloadSkillPackageGrant } from './skill-package-download' import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' +import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers' import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem' import { skillInstallFailureFromError } from './skill-install-operation-error' import { startSkillInstallOperation } from './skill-operation-observability' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' type StagedSkillPackage = { archivePath: string @@ -29,6 +31,12 @@ export type SkillInstallRequestDependencies = { signal?: AbortSignal fetcher?: typeof fetch detectProviders: () => Promise + resolveProviderRootOverrides?: (destination: { + scope: 'global' | 'workspace' + homeDirectory: string + workspaceDirectory?: string + wslDistro?: string + }) => Promise | SkillProviderRootOverrides resolveStagedUpload?: ( uploadId: string, identity: SkillInstallRequest['package'] @@ -91,14 +99,19 @@ async function executeParsedSkillInstallRequest( let ingress: StagedSkillPackage | null = null try { ingress = await resolveIngress(request, dependencies) - const detectedProviders = destination.wslDistro - ? await detectSkillProvidersInWsl(destination.wslDistro) - : await dependencies.detectProviders() + const detectedProviders = selectedOrDetectedSkillProviders( + destination.wslDistro + ? await detectSkillProvidersInWsl(destination.wslDistro) + : await dependencies.detectProviders(), + request.providers + ) + const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination) const filesystem = destination.wslDistro ? createWslSkillInstallFilesystem({ distro: destination.wslDistro, homeDirectory: destination.homeDirectory, - workspaceDirectory: destination.workspaceDirectory + workspaceDirectory: destination.workspaceDirectory, + providerRootOverrides }) : undefined return await installSharedSkill({ @@ -109,6 +122,7 @@ async function executeParsedSkillInstallRequest( workspaceDirectory: destination.workspaceDirectory, orcaStateDirectory: dependencies.stateDirectory, detectedProviders, + providerRootOverrides, destinationIdentity: destination.destinationIdentity, hostIdentity: dependencies.authority.environmentId, expectedArchiveSha256: request.package.archiveSha256, diff --git a/src/main/skills/skill-install-service.test.ts b/src/main/skills/skill-install-service.test.ts index aed3879ab93..ae47e88b6b8 100644 --- a/src/main/skills/skill-install-service.test.ts +++ b/src/main/skills/skill-install-service.test.ts @@ -1,7 +1,7 @@ -import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createSkillPackageArchive } from './skill-package-creation' import { installSharedSkill, @@ -56,7 +56,7 @@ afterEach(async () => { }) describe('skill install service', () => { - it('installs one canonical copy and aliases Claude to it', async () => { + it('installs one canonical Codex copy and aliases the other agent home to it', async () => { const { root, input } = await fixture() const result = await installSharedSkill(input) const canonical = join(root, 'home', '.agents', 'skills', 'test-skill') @@ -67,6 +67,27 @@ describe('skill install service', () => { expect(await realpath(claude)).toBe(await realpath(canonical)) }) + it('authorizes a historical provider root before relocating it', async () => { + const { root, input } = await fixture() + const oldRoot = join(root, 'old-claude', 'skills') + await installSharedSkill({ + ...input, + detectedProviders: ['claude'], + providerRootOverrides: { claude: oldRoot }, + filesystem: { ...nativeSkillInstallFilesystem } + }) + const authorizeRoots = vi.fn() + + await installSharedSkill({ + ...input, + detectedProviders: ['claude'], + providerRootOverrides: { claude: join(root, 'new-claude', 'skills') }, + filesystem: { ...nativeSkillInstallFilesystem, authorizeRoots } + }) + + expect(authorizeRoots).toHaveBeenCalledWith([oldRoot]) + }) + it('leaves an unowned provider copy untouched and reports partial coverage', async () => { const { root, input } = await fixture() const claude = join(root, 'home', '.claude', 'skills', 'test-skill') @@ -84,6 +105,51 @@ describe('skill install service', () => { expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toBe('unowned') }) + it('never removes a byte-identical provider copy that was recorded as unowned', async () => { + const { root, input } = await fixture() + const sourceSkill = join(root, 'source') + const claude = join(root, 'home', '.claude', 'skills', 'test-skill') + await mkdir(join(root, 'home', '.claude', 'skills'), { recursive: true }) + await cp(sourceSkill, claude, { recursive: true }) + const installed = await installSharedSkill(input) + expect(installed.placements).toContainEqual( + expect.objectContaining({ provider: 'claude', status: 'skipped' }) + ) + + const removed = await removeSharedSkill({ + operationId: 'remove_identical_unowned', + skillName: 'test-skill', + scope: 'global', + homeDirectory: input.homeDirectory, + orcaStateDirectory: input.orcaStateDirectory, + detectedProviders: input.detectedProviders + }) + + expect(removed.status).toBe('partial') + expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toContain('# Test') + }) + + it('keeps an unchanged canonical install partial when a provider placement is unowned', async () => { + const { root, input } = await fixture() + await installSharedSkill({ ...input, detectedProviders: ['codex'] }) + const claude = join(root, 'home', '.claude', 'skills', 'test-skill') + await mkdir(claude, { recursive: true }) + await writeFile(join(claude, 'SKILL.md'), 'unowned') + + const result = await installSharedSkill(input) + + expect(result).toMatchObject({ + status: 'partial', + placements: expect.arrayContaining([ + expect.objectContaining({ + provider: 'claude', + status: 'skipped', + errorCategory: 'skill-placement-unowned' + }) + ]) + }) + }) + it('does not report success when normal skill discovery cannot observe the canonical copy', async () => { const { root, input } = await fixture() @@ -102,7 +168,7 @@ describe('skill install service', () => { ).toContain('# Test') }) - it('removes the canonical skill and its owned provider alias', async () => { + it('removes owned placements even after the provider is no longer detected', async () => { const { root, input } = await fixture() await installSharedSkill(input) @@ -112,7 +178,7 @@ describe('skill install service', () => { scope: 'global', homeDirectory: input.homeDirectory, orcaStateDirectory: input.orcaStateDirectory, - detectedProviders: input.detectedProviders + detectedProviders: [] }) expect(result.status).toBe('removed') @@ -128,6 +194,24 @@ describe('skill install service', () => { }) }) + it('removes an owned placement when its provider is deselected', async () => { + const { root, input } = await fixture() + await installSharedSkill(input) + const claude = join(root, 'home', '.claude', 'skills', 'test-skill') + + const updated = await installSharedSkill({ + ...input, + operationId: 'operation_without_claude', + detectedProviders: ['codex'] + }) + + expect(updated.status).toBe('unchanged') + await expect(lstat(claude)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(updated.placements).toEqual([ + expect.objectContaining({ provider: 'agent-skills', topology: 'canonical-copy' }) + ]) + }) + it('preserves modified canonical and provider content during removal', async () => { const { root, input } = await fixture() await installSharedSkill(input) @@ -234,7 +318,6 @@ describe('skill install service', () => { }) ]) ) - expect(interrupted.placements.some((placement) => placement.provider === 'codex')).toBe(false) expect(await readFile(join(canonical, 'SKILL.md'), 'utf8')).toContain('# Test') const retried = await installSharedSkill({ ...input, operationId: 'operation_retry' }) diff --git a/src/main/skills/skill-install-service.ts b/src/main/skills/skill-install-service.ts index eacf1cefbf0..1e62d42eb4f 100644 --- a/src/main/skills/skill-install-service.ts +++ b/src/main/skills/skill-install-service.ts @@ -1,6 +1,5 @@ import { join } from 'node:path' -import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract' -import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance' +import type { SkillInstallResult } from '../../shared/skill-install-contract' import { installLocalExtractedSkillPackage, installLocalSkillPackage, @@ -8,13 +7,16 @@ import { type LocalSkillInstallInput, previewLocalSkillPackage } from './skill-install-transaction' -import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation' -import { resolveSkillProviderDestinations } from './skill-provider-destinations' +import { + resolveSkillProviderDestinations, + type SkillProviderRootOverrides +} from './skill-provider-destinations' import { removeLocalSharedSkill } from './skill-remove-transaction' import type { SkillInstallFilesystem } from './skill-install-filesystem' import { verifySkillInstallDiscovery } from './skill-install-discovery-verification' import type { SkillDiscoveryResult } from '../../shared/skills' -import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest' +import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' +import { createSkillPlacementTransaction } from './skill-placement-transaction-controller' export type SkillInstallServiceInput = Omit< LocalSkillInstallInput, @@ -25,6 +27,7 @@ export type SkillInstallServiceInput = Omit< workspaceDirectory?: string orcaStateDirectory: string detectedProviders: readonly string[] + providerRootOverrides?: SkillProviderRootOverrides filesystem?: SkillInstallFilesystem wslDistro?: string discover?: () => Promise @@ -39,6 +42,7 @@ export type SkillRemoveServiceInput = { workspaceDirectory?: string orcaStateDirectory: string detectedProviders: readonly string[] + providerRootOverrides?: SkillProviderRootOverrides conflictResolution?: 'replace-and-discard-local' | 'cancel' filesystem?: SkillInstallFilesystem } @@ -76,32 +80,29 @@ export function skillInstallLocalInput(input: SkillInstallServiceInput): LocalSk } } -function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult { - return { - provider: 'agent-skills', - path: result.canonicalPath!, - topology: 'canonical-copy', - status: result.status === 'unchanged' ? 'unchanged' : 'installed' - } +function placementTransaction(input: SkillInstallServiceInput, request: LocalSkillInstallInput) { + return createSkillPlacementTransaction({ + stateDirectory: request.stateDirectory, + scope: input.scope, + homeDirectory: input.homeDirectory, + workspaceDirectory: input.workspaceDirectory, + detectedProviders: input.detectedProviders, + providerRootOverrides: input.providerRootOverrides, + filesystem: input.filesystem, + wslDistro: input.wslDistro, + signal: input.signal + }) } export async function installSharedSkill( input: SkillInstallServiceInput ): Promise { const request = skillInstallLocalInput(input) - const preview = await previewLocalSkillPackage(request) - const previousReceipt = await readSkillInstallReceipt( - request.stateDirectory, - preview.canonicalPath - ) - const result = await installLocalSkillPackage(request) - return completeSharedSkillInstall({ - input, - request, - manifest: preview.manifest, - previousReceipt, - result + await previewLocalSkillPackage(request) + const result = await installLocalSkillPackage(request, { + placementTransaction: placementTransaction(input, request) }) + return completeSharedSkillInstall(input, result) } export async function installSharedExtractedSkill( @@ -109,26 +110,16 @@ export async function installSharedExtractedSkill( extracted: LocalExtractedSkillPackage ): Promise { const request = skillInstallLocalInput(input) - const canonicalPath = join(request.destinationRoot, extracted.manifest.name) - const previousReceipt = await readSkillInstallReceipt(request.stateDirectory, canonicalPath) - const result = await installLocalExtractedSkillPackage(request, extracted) - return completeSharedSkillInstall({ - input, - request, - manifest: extracted.manifest, - previousReceipt, - result + const result = await installLocalExtractedSkillPackage(request, extracted, { + placementTransaction: placementTransaction(input, request) }) + return completeSharedSkillInstall(input, result) } -async function completeSharedSkillInstall(input: { - input: SkillInstallServiceInput - request: LocalSkillInstallInput - manifest: SkillPackageManifestV1 - previousReceipt: Awaited> +async function completeSharedSkillInstall( + input: SkillInstallServiceInput, result: SkillInstallResult -}): Promise { - const { result } = input +): Promise { if ( !result.canonicalPath || result.status === 'conflict' || @@ -137,61 +128,17 @@ async function completeSharedSkillInstall(input: { ) { return result } - const placements: SkillPlacementResult[] = [canonicalPlacement(result)] - const destinations = resolveSkillProviderDestinations({ - scope: input.input.scope, - homeDirectory: input.input.homeDirectory, - workspaceDirectory: input.input.workspaceDirectory, - detectedProviders: input.input.detectedProviders - }) - for (const destination of destinations) { - if (destination.readsCanonicalRoot) { - continue - } - if (input.input.signal?.aborted) { - placements.push({ - provider: destination.provider, - path: join(destination.rootPath, result.name), - topology: 'independent-copy', - status: 'skipped', - errorCategory: 'skill-placement-cancelled', - failure: { - category: 'cancelled', - code: 'skill-placement-cancelled', - retryable: true - } - }) - continue - } - const placement = await reconcileSkillProviderPlacement({ - canonicalPath: result.canonicalPath, - skillName: result.name, - destination, - previousReceipt: input.previousReceipt, - packageDigest: result.packageDigest, - fileModes: input.manifest.files, - filesystem: input.input.filesystem, - ...(input.input.wslDistro ? { targetPlatform: 'linux' as const } : {}) - }) - if (placement) { - placements.push(placement) - } - } - const receipt = await readSkillInstallReceipt(input.request.stateDirectory, result.canonicalPath) - if (!receipt) { - throw new Error('skill-install-receipt-missing') - } - await writeSkillInstallReceipt(input.request.stateDirectory, { ...receipt, placements }) - const incomplete = placements.some( + const incomplete = result.placements.some( (placement) => placement.status === 'failed' || placement.status === 'skipped' ) return verifySkillInstallDiscovery({ - result: { ...result, status: incomplete ? 'partial' : result.status, placements }, - scope: input.input.scope, - homeDirectory: input.input.homeDirectory, - workspaceDirectory: input.input.workspaceDirectory, - wslDistro: input.input.wslDistro, - discover: input.input.discover + result: { ...result, status: incomplete ? 'partial' : result.status }, + scope: input.scope, + homeDirectory: input.homeDirectory, + workspaceDirectory: input.workspaceDirectory, + wslDistro: input.wslDistro, + providerRootOverrides: input.providerRootOverrides, + discover: input.discover }) } @@ -205,7 +152,8 @@ export async function removeSharedSkill( scope: input.scope, homeDirectory: input.homeDirectory, workspaceDirectory: input.workspaceDirectory, - detectedProviders: input.detectedProviders + detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id), + providerRootOverrides: input.providerRootOverrides }) return removeLocalSharedSkill({ operationId: input.operationId, diff --git a/src/main/skills/skill-install-transaction-result.ts b/src/main/skills/skill-install-transaction-result.ts index 2b580491298..7df4cc96ce7 100644 --- a/src/main/skills/skill-install-transaction-result.ts +++ b/src/main/skills/skill-install-transaction-result.ts @@ -68,6 +68,24 @@ export function skillInstallReplacementAllowed( ) } +export function skillInstallUnchangedResult( + input: LocalSkillInstallInput, + manifest: SkillPackageManifestV1, + canonicalPath: string, + receipt: SkillInstallReceiptV1 +): SkillInstallResult { + return { + operationId: input.operationId, + status: 'unchanged', + name: manifest.name, + packageDigest: manifest.packageDigest, + canonicalPath, + placements: receipt.placements.map((placement) => + placement.status === 'installed' ? { ...placement, status: 'unchanged' } : placement + ) + } +} + export function createSkillInstallReceipt(input: { request: LocalSkillInstallInput manifest: SkillPackageManifestV1 diff --git a/src/main/skills/skill-install-transaction.ts b/src/main/skills/skill-install-transaction.ts index 1410a6d585f..d3464490585 100644 --- a/src/main/skills/skill-install-transaction.ts +++ b/src/main/skills/skill-install-transaction.ts @@ -34,15 +34,12 @@ import { createSkillInstallReceipt, skillInstallConflictResult, skillInstallFailureResult, - skillInstallReplacementAllowed + skillInstallReplacementAllowed, + skillInstallUnchangedResult } from './skill-install-transaction-result' import { settleObservedSkillTransactionRecovery } from './skill-transaction-recovery-observability' - -export { recoverSkillInstallTransaction } from './skill-install-recovery' -export type { - SkillInstallJournalBoundary, - SkillInstallTransactionDependencies -} from './skill-install-journal-transition' +import { recoverSkillPlacementTransaction } from './skill-placement-transaction' +import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots' export type LocalSkillInstallInput = { operationId: string @@ -168,8 +165,11 @@ export async function installLocalExtractedSkillPackage( path: skillInstallLockPath(input.stateDirectory, canonicalPath), timeoutMs: input.lockTimeoutMs }) + const historicalReceipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath) + filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt)) await recoverSkillRemovalTransaction(input.stateDirectory, canonicalPath, filesystem) await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem) + await recoverSkillPlacementTransaction(input.stateDirectory, canonicalPath, filesystem) throwIfCancelled(input.signal) const previous = await readSkillInstallReceipt(input.stateDirectory, canonicalPath) const state = await inspectSkillCanonicalState({ @@ -178,7 +178,7 @@ export async function installLocalExtractedSkillPackage( receipt: previous, filesystem }) - const receipt = createSkillInstallReceipt({ + let receipt = createSkillInstallReceipt({ request: input, manifest: extracted.manifest, archiveSha256: extracted.archiveSha256, @@ -186,23 +186,17 @@ export async function installLocalExtractedSkillPackage( previous }) if (state.kind === 'unchanged') { + await dependencies.placementTransaction?.prepare(previous, receipt) throwIfCancelled(input.signal) + receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt await writeSkillInstallReceipt(input.stateDirectory, receipt) - return { - operationId: input.operationId, - status: 'unchanged', - name: extracted.manifest.name, - packageDigest: extracted.manifest.packageDigest, - canonicalPath, - placements: receipt.placements.map((placement) => ({ - ...placement, - status: 'unchanged' - })) - } + await dependencies.placementTransaction?.finish(receipt) + return skillInstallUnchangedResult(input, extracted.manifest, canonicalPath, receipt) } if (!skillInstallReplacementAllowed(state, input)) { return skillInstallConflictResult(input.operationId, extracted.manifest, state) } + await dependencies.placementTransaction?.prepare(previous, receipt) throwIfCancelled(input.signal) const transactionId = randomUUID() const stagingPath = join( @@ -271,8 +265,12 @@ export async function installLocalExtractedSkillPackage( throw new Error('skill-install-committed-digest-mismatch') } await writeSkillInstallReceipt(input.stateDirectory, receipt) + receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt + await writeSkillInstallReceipt(input.stateDirectory, receipt) + journal.receipt = receipt journal.phase = 'receipt-published' await persistSkillInstallJournalTransition(statePath, journal, dependencies) + await dependencies.placementTransaction?.finish(receipt) await cleanSkillInstallJournalFiles(journal, filesystem) journal.phase = 'complete' await persistSkillInstallJournalTransition(statePath, journal, dependencies) diff --git a/src/main/skills/skill-operation-observability.ts b/src/main/skills/skill-operation-observability.ts index c0cddfda155..6fd18dbe6b0 100644 --- a/src/main/skills/skill-operation-observability.ts +++ b/src/main/skills/skill-operation-observability.ts @@ -4,6 +4,7 @@ import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract' +import type { SkillInstallProviderId } from '../../shared/skill-install-providers' import type { SkillBundleInstallRequest, SkillBundleInstallResult @@ -33,7 +34,7 @@ type SkillPhaseStart = { destination?: 'startup' | 'transaction' | 'provider-placement' | 'remote-runtime' | 'global-ssh' compressedBytes?: number skillCount?: number - provider?: 'codex' | 'claude' + provider?: SkillInstallProviderId } type SkillPhaseOutcome = { diff --git a/src/main/skills/skill-package-download-availability.ts b/src/main/skills/skill-package-download-availability.ts new file mode 100644 index 00000000000..db6a92ab862 --- /dev/null +++ b/src/main/skills/skill-package-download-availability.ts @@ -0,0 +1,53 @@ +import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure' +import { SkillInstallOperationError } from './skill-install-operation-error' + +const MAX_TIMEOUT_DELAY_MS = 2_147_483_647 + +export function throwIfSkillDownloadUnavailable(input: { + signal?: AbortSignal + now: () => number + expiresAt: number +}): void { + if (input.signal?.aborted) { + throw new SkillInstallOperationError({ + ...SKILL_INSTALL_CANCELLED_FAILURE, + code: 'skill-download-cancelled' + }) + } + if (input.now() >= input.expiresAt) { + throw new Error('skill-download-grant-expired') + } +} + +export function isSkillDownloadGrantExpiredAbort(signal: AbortSignal | undefined): boolean { + return signal?.reason instanceof Error && signal.reason.message === 'skill-download-grant-expired' +} + +export function createSkillDownloadAvailabilitySignal(input: { + signal?: AbortSignal + now: () => number + expiresAt: number +}): { + signal: AbortSignal + cleanup(): void +} { + const controller = new AbortController() + const abortFromCaller = (): void => controller.abort(input.signal?.reason) + if (input.signal?.aborted) { + abortFromCaller() + } else { + input.signal?.addEventListener('abort', abortFromCaller, { once: true }) + } + const timeout = setTimeout( + () => controller.abort(new Error('skill-download-grant-expired')), + Math.min(MAX_TIMEOUT_DELAY_MS, Math.max(0, input.expiresAt - input.now())) + ) + timeout.unref() + return { + signal: controller.signal, + cleanup: () => { + clearTimeout(timeout) + input.signal?.removeEventListener('abort', abortFromCaller) + } + } +} diff --git a/src/main/skills/skill-package-download.test.ts b/src/main/skills/skill-package-download.test.ts index 20dce93a8a4..0d869c6bdb2 100644 --- a/src/main/skills/skill-package-download.test.ts +++ b/src/main/skills/skill-package-download.test.ts @@ -183,6 +183,54 @@ describe('downloadSkillPackageGrant', () => { expect(expired.fetcher).not.toHaveBeenCalled() }) + it('physically aborts a fetch stalled past grant expiry', async () => { + vi.useFakeTimers() + const stalled = await input({ + expiresAt: new Date(Date.now() + 100).toISOString(), + fetcher: vi.fn( + async (_input: URL | RequestInfo, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true + }) + }) + ) as typeof fetch + }) + + const download = downloadSkillPackageGrant(stalled) + const expectation = expect(download).rejects.toThrow('skill-download-grant-expired') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(100) + await expectation + vi.useRealTimers() + }) + + it('does not expire a far-future stalled fetch immediately when the timer delay overflows Node limits', async () => { + vi.useFakeTimers() + const caller = new AbortController() + const stalled = await input({ + signal: caller.signal, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 50).toISOString(), + fetcher: vi.fn( + async (_input: URL | RequestInfo, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true + }) + }) + ) as typeof fetch + }) + + const download = downloadSkillPackageGrant(stalled) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10) + await expect(Promise.race([download, Promise.resolve('pending')])).resolves.toBe('pending') + + caller.abort() + await expect(download).rejects.toThrow('skill-download-cancelled') + vi.useRealTimers() + }) + it('deletes partial bytes when a streaming download is cancelled', async () => { const controller = new AbortController() let sent = false @@ -207,4 +255,35 @@ describe('downloadSkillPackageGrant', () => { await expect(downloadSkillPackageGrant(cancelled)).rejects.toThrow('skill-download-cancelled') expect(await remainingDownloadFiles(cancelled.temporaryRoot)).toEqual([]) }) + + it('physically aborts a body read stalled past grant expiry and deletes partial bytes', async () => { + vi.useFakeTimers() + const stalled = await input({ + expiresAt: new Date(Date.now() + 100).toISOString(), + fetcher: vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => { + let controller!: ReadableStreamDefaultController + init?.signal?.addEventListener( + 'abort', + () => controller.error(init.signal?.reason ?? new Error('aborted')), + { once: true } + ) + return response( + new ReadableStream({ + start(streamController) { + controller = streamController + controller.enqueue(bytes.subarray(0, 4)) + } + }) + ) + }) as typeof fetch + }) + + const download = downloadSkillPackageGrant(stalled) + const expectation = expect(download).rejects.toThrow('skill-download-grant-expired') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(100) + await expectation + expect(await remainingDownloadFiles(stalled.temporaryRoot)).toEqual([]) + vi.useRealTimers() + }) }) diff --git a/src/main/skills/skill-package-download.ts b/src/main/skills/skill-package-download.ts index 9fe58c8530d..595b568eeff 100644 --- a/src/main/skills/skill-package-download.ts +++ b/src/main/skills/skill-package-download.ts @@ -5,8 +5,11 @@ import { SKILL_PACKAGE_CONTENT_TYPE, SKILL_PACKAGE_MAX_COMPRESSED_BYTES } from '../../shared/skill-package-manifest' -import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure' -import { SkillInstallOperationError } from './skill-install-operation-error' +import { + createSkillDownloadAvailabilitySignal, + isSkillDownloadGrantExpiredAbort, + throwIfSkillDownloadUnavailable +} from './skill-package-download-availability' import { startSkillPhaseOperation } from './skill-operation-observability' const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]) @@ -63,40 +66,37 @@ function validateUrl( return url } -function throwIfUnavailable(input: SkillPackageDownloadInput, expiresAt: number): void { - if (input.signal?.aborted) { - throw new SkillInstallOperationError({ - ...SKILL_INSTALL_CANCELLED_FAILURE, - code: 'skill-download-cancelled' - }) - } - if (input.now!() >= expiresAt) { - throw new Error('skill-download-grant-expired') - } -} - async function fetchWithoutCredentialRedirect( input: SkillPackageDownloadInput, allowedOrigins: ReadonlySet, - expiresAt: number + expiresAt: number, + signal: AbortSignal ): Promise { let url = validateUrl(input.url, allowedOrigins, input.requireHttps) for (let redirectCount = 0; ; redirectCount += 1) { - throwIfUnavailable(input, expiresAt) + throwIfSkillDownloadUnavailable({ + signal: input.signal, + now: input.now!, + expiresAt + }) let response: Response try { response = await input.fetcher!(url, { method: 'GET', redirect: 'manual', - signal: input.signal + signal }) } catch { if (input.signal?.aborted) { - throw new SkillInstallOperationError({ - ...SKILL_INSTALL_CANCELLED_FAILURE, - code: 'skill-download-cancelled' + throwIfSkillDownloadUnavailable({ + signal: input.signal, + now: input.now!, + expiresAt }) } + if (isSkillDownloadGrantExpiredAbort(signal)) { + throw new Error('skill-download-grant-expired') + } throw new Error('skill-download-transport-failed') } if (!REDIRECT_STATUSES.has(response.status)) { @@ -181,80 +181,102 @@ async function downloadSkillPackageGrantUnobserved( (origin) => validateUrl(origin, new Set([new URL(origin).origin]), false).origin ) ) - throwIfUnavailable(input, expiresAt) - const response = await fetchWithoutCredentialRedirect(input, allowedOrigins, expiresAt) - if (!response.ok || !response.body) { - throw new Error('skill-download-transport-failed') - } - if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) { - throw new Error('skill-download-content-type-invalid') - } - const contentLength = response.headers.get('content-length') - if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) { - throw new Error('skill-download-size-mismatch') - } - - const processRoot = await prepareTemporaryRoot(input.temporaryRoot) - const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-')) - const archivePath = join(temporaryDirectory, 'package.tar.gz') + throwIfSkillDownloadUnavailable({ signal: input.signal, now: input.now!, expiresAt }) + const availability = createSkillDownloadAvailabilitySignal({ + signal: input.signal, + now: input.now!, + expiresAt + }) try { - const handle = await open(archivePath, 'wx', 0o600) - const reader = response.body.getReader() - const hash = createHash('sha256') - let compressedBytes = 0 - try { - for (;;) { - throwIfUnavailable(input, expiresAt) - const chunk = await reader.read() - if (chunk.done) { - break - } - compressedBytes += chunk.value.byteLength - if ( - compressedBytes > input.expectedCompressedBytes || - compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES - ) { - throw new Error('skill-download-size-limit') - } - hash.update(chunk.value) - let offset = 0 - while (offset < chunk.value.byteLength) { - const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset) - if (written.bytesWritten === 0) { - throw new Error('skill-download-write-failed') - } - offset += written.bytesWritten - } - } - await handle.sync() - } catch (error) { - await reader.cancel().catch(() => undefined) - if (input.signal?.aborted) { - throw new SkillInstallOperationError({ - ...SKILL_INSTALL_CANCELLED_FAILURE, - code: 'skill-download-cancelled' - }) - } - throw error - } finally { - await handle.close() + const response = await fetchWithoutCredentialRedirect( + input, + allowedOrigins, + expiresAt, + availability.signal + ) + if (!response.ok || !response.body) { + throw new Error('skill-download-transport-failed') } - if (compressedBytes !== input.expectedCompressedBytes) { + if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) { + throw new Error('skill-download-content-type-invalid') + } + const contentLength = response.headers.get('content-length') + if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) { throw new Error('skill-download-size-mismatch') } - const archiveSha256 = hash.digest('hex') - if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) { - throw new Error('skill-download-archive-digest-mismatch') + + const processRoot = await prepareTemporaryRoot(input.temporaryRoot) + const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-')) + const archivePath = join(temporaryDirectory, 'package.tar.gz') + try { + const handle = await open(archivePath, 'wx', 0o600) + const reader = response.body.getReader() + const hash = createHash('sha256') + let compressedBytes = 0 + try { + for (;;) { + throwIfSkillDownloadUnavailable({ + signal: input.signal, + now: input.now!, + expiresAt + }) + const chunk = await reader.read() + if (chunk.done) { + break + } + compressedBytes += chunk.value.byteLength + if ( + compressedBytes > input.expectedCompressedBytes || + compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES + ) { + throw new Error('skill-download-size-limit') + } + hash.update(chunk.value) + let offset = 0 + while (offset < chunk.value.byteLength) { + const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset) + if (written.bytesWritten === 0) { + throw new Error('skill-download-write-failed') + } + offset += written.bytesWritten + } + } + await handle.sync() + } catch (error) { + await reader.cancel().catch(() => undefined) + if (input.signal?.aborted) { + throwIfSkillDownloadUnavailable({ + signal: input.signal, + now: input.now!, + expiresAt + }) + } + if (isSkillDownloadGrantExpiredAbort(availability.signal)) { + throw new Error('skill-download-grant-expired') + } + throw error + } finally { + await handle.close() + } + if (compressedBytes !== input.expectedCompressedBytes) { + throw new Error('skill-download-size-mismatch') + } + const archiveSha256 = hash.digest('hex') + if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) { + throw new Error('skill-download-archive-digest-mismatch') + } + return { + archivePath, + archiveSha256, + compressedBytes, + cleanup: () => rm(temporaryDirectory, { recursive: true, force: true }) + } + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }) + throw error } - return { - archivePath, - archiveSha256, - compressedBytes, - cleanup: () => rm(temporaryDirectory, { recursive: true, force: true }) - } - } catch (error) { - await rm(temporaryDirectory, { recursive: true, force: true }) - throw error + } finally { + availability.cleanup() } } diff --git a/src/main/skills/skill-placement-artifact-recovery.ts b/src/main/skills/skill-placement-artifact-recovery.ts new file mode 100644 index 00000000000..94a40900207 --- /dev/null +++ b/src/main/skills/skill-placement-artifact-recovery.ts @@ -0,0 +1,129 @@ +import { lstat, readlink } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import type { SkillInstalledFileMode, SkillInstallFilesystem } from './skill-install-filesystem' +import type { + SkillPlacementJournalActionV1, + SkillPlacementJournalV1 +} from './skill-placement-recovery-journal' + +function normalizedPath(path: string): string { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export async function placementPathExists(path: string): Promise { + return Boolean(await lstat(path).catch(() => null)) +} + +export async function placementIsAlias( + canonicalPath: string, + destinationPath: string, + filesystem: SkillInstallFilesystem +): Promise { + if (filesystem.aliasTargets) { + return filesystem.aliasTargets(canonicalPath, destinationPath).catch(() => false) + } + return Boolean((await lstat(destinationPath).catch(() => null))?.isSymbolicLink()) +} + +export async function placementMatchesDigest( + path: string, + digest: string, + journal: SkillPlacementJournalV1, + filesystem: SkillInstallFilesystem, + fileModes: readonly SkillInstalledFileMode[] = journal.fileModes +): Promise { + return ( + (await filesystem.observeSkill(path, fileModes).catch(() => null))?.observedDigest === digest + ) +} + +export async function placementBackupMatchesPrevious( + journal: SkillPlacementJournalV1, + action: SkillPlacementJournalActionV1, + filesystem: SkillInstallFilesystem +): Promise { + const previous = journal.previousReceipt?.placements.find( + (placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath) + ) + if (!previous || !journal.previousReceipt) { + return false + } + if (previous.topology !== 'provider-alias') { + return placementMatchesDigest( + action.backupPath, + journal.previousReceipt.packageDigest, + journal, + filesystem, + journal.previousReceipt.fileModes ?? [] + ) + } + if (filesystem.aliasTargets) { + return filesystem.aliasTargets(journal.canonicalPath, action.backupPath).catch(() => false) + } + const target = await readlink(action.backupPath).catch(() => null) + return Boolean( + target && + normalizedPath(resolve(dirname(action.backupPath), target)) === + normalizedPath(journal.canonicalPath) + ) +} + +export async function settleDesiredPlacementArtifacts( + journal: SkillPlacementJournalV1, + action: SkillPlacementJournalActionV1, + filesystem: SkillInstallFilesystem +): Promise { + const [destinationExists, stagingExists, backupExists] = await Promise.all([ + placementPathExists(action.destinationPath), + placementPathExists(action.stagingPath), + placementPathExists(action.backupPath) + ]) + if (backupExists) { + if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) { + throw new Error('skill-placement-recovery-conflict') + } + if (destinationExists) { + if ( + !(await placementMatchesDigest( + action.destinationPath, + journal.packageDigest, + journal, + filesystem + )) + ) { + throw new Error('skill-placement-recovery-conflict') + } + await filesystem.remove(action.backupPath) + } else if ( + stagingExists && + (await placementMatchesDigest(action.stagingPath, journal.packageDigest, journal, filesystem)) + ) { + await filesystem.rename(action.stagingPath, action.destinationPath) + await filesystem.remove(action.backupPath) + return true + } else { + if (stagingExists) { + await filesystem.remove(action.stagingPath) + } + await filesystem.rename(action.backupPath, action.destinationPath) + } + } + if (await placementPathExists(action.stagingPath)) { + if ( + !(await placementMatchesDigest( + action.stagingPath, + journal.packageDigest, + journal, + filesystem + )) + ) { + await filesystem.remove(action.stagingPath) + return true + } + await ((await placementPathExists(action.destinationPath)) + ? filesystem.remove(action.stagingPath) + : filesystem.rename(action.stagingPath, action.destinationPath)) + } + return stagingExists || backupExists +} diff --git a/src/main/skills/skill-placement-copy.ts b/src/main/skills/skill-placement-copy.ts index 08ad2601537..3e7c17182f4 100644 --- a/src/main/skills/skill-placement-copy.ts +++ b/src/main/skills/skill-placement-copy.ts @@ -29,12 +29,20 @@ export async function replaceOwnedSkillPlacementCopy( canonicalPath: string, destinationPath: string, filesystem: SkillInstallFilesystem, - fileModes?: readonly SkillInstalledFileMode[] + fileModes?: readonly SkillInstalledFileMode[], + transaction?: { replacementPath: string; backupPath: string; retainBackup: boolean } ): Promise { - const replacement = `${destinationPath}.orca-copy-${randomUUID()}` - const backup = `${destinationPath}.orca-backup-${randomUUID()}` + const replacement = transaction?.replacementPath ?? `${destinationPath}.orca-copy-${randomUUID()}` + const backup = transaction?.backupPath ?? `${destinationPath}.orca-backup-${randomUUID()}` try { - await createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes) + await (transaction + ? createSkillPlacementCopyAtMissingDestination( + canonicalPath, + replacement, + filesystem, + fileModes + ) + : createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes)) await filesystem.rename(destinationPath, backup) try { await filesystem.rename(replacement, destinationPath) @@ -42,7 +50,9 @@ export async function replaceOwnedSkillPlacementCopy( await filesystem.rename(backup, destinationPath) throw error } - await filesystem.remove(backup) + if (!transaction?.retainBackup) { + await filesystem.remove(backup) + } } finally { await filesystem.remove(replacement) } @@ -52,14 +62,17 @@ export async function createSkillPlacementCopyAtMissingDestination( canonicalPath: string, destinationPath: string, filesystem: SkillInstallFilesystem, - fileModes?: readonly SkillInstalledFileMode[] + fileModes?: readonly SkillInstalledFileMode[], + stagingPath?: string ): Promise { await mkdir(dirname(destinationPath), { recursive: true }) - await mkdir(destinationPath, { mode: 0o700 }) + const copyPath = stagingPath ?? destinationPath + await mkdir(copyPath, { mode: 0o700 }) let complete = false + let placed = false try { for (const entry of await readdir(canonicalPath)) { - await cp(join(canonicalPath, entry), join(destinationPath, entry), { + await cp(join(canonicalPath, entry), join(copyPath, entry), { recursive: true, verbatimSymlinks: true, force: false, @@ -68,15 +81,23 @@ export async function createSkillPlacementCopyAtMissingDestination( } const [source, copied] = await Promise.all([ filesystem.observeSkill(canonicalPath, fileModes), - filesystem.observeSkill(destinationPath, fileModes) + filesystem.observeSkill(copyPath, fileModes) ]) if (source.observedDigest !== copied.observedDigest) { throw new Error('skill-placement-copy-digest-mismatch') } + if (stagingPath) { + await filesystem.rename(stagingPath, destinationPath) + placed = true + await filesystem.observeSkill(destinationPath, fileModes) + } complete = true } finally { if (!complete) { - await filesystem.remove(destinationPath) + await filesystem.remove(copyPath) + if (placed) { + await filesystem.remove(destinationPath) + } } } } diff --git a/src/main/skills/skill-placement-reconciliation.test.ts b/src/main/skills/skill-placement-reconciliation.test.ts index 0c9d96f3f9d..25bc43bd0d7 100644 --- a/src/main/skills/skill-placement-reconciliation.test.ts +++ b/src/main/skills/skill-placement-reconciliation.test.ts @@ -12,10 +12,29 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ })) import { nativeSkillInstallFilesystem } from './skill-install-filesystem' +import type { SkillInstallReceiptV1 } from './skill-install-provenance' import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation' const temporaryDirectories: string[] = [] +function receipt(canonicalPath: string, packageDigest: string): SkillInstallReceiptV1 { + return { + schemaVersion: 1, + packageId: 'package_1', + versionId: 'version_1', + packageDigest, + archiveSha256: 'a'.repeat(64), + scope: 'global', + destinationIdentity: 'global:test', + canonicalPath, + placements: [], + installedAt: new Date().toISOString(), + hostIdentity: 'test', + fileModes: [], + providers: [] + } +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) @@ -143,4 +162,77 @@ describe('skill provider placement reconciliation', () => { expect(result).toMatchObject({ topology: 'provider-alias', status: 'unchanged' }) expect(createAlias).not.toHaveBeenCalled() }) + + it('updates an owned copy even when host alias inspection returns false', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-')) + temporaryDirectories.push(root) + const canonicalPath = join(root, 'canonical', 'private-skill') + const providerRoot = join(root, 'provider') + const destinationPath = join(providerRoot, 'private-skill') + await mkdir(canonicalPath, { recursive: true }) + await mkdir(destinationPath, { recursive: true }) + await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill') + await writeFile(join(destinationPath, 'SKILL.md'), 'old skill') + const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath)) + .observedDigest + const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath)) + .observedDigest + const previous = receipt(canonicalPath, previousDigest) + previous.placements = [ + { + provider: 'claude', + path: destinationPath, + topology: 'independent-copy', + status: 'installed' + } + ] + + const result = await reconcileSkillProviderPlacement({ + canonicalPath, + skillName: 'private-skill', + destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false }, + previousReceipt: previous, + packageDigest: nextDigest, + filesystem: { ...nativeSkillInstallFilesystem, aliasTargets: async () => false } + }) + + expect(result).toMatchObject({ topology: 'independent-copy', status: 'installed' }) + expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('new skill') + }) + + it('does not update a byte-identical copy previously recorded as skipped', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-')) + temporaryDirectories.push(root) + const canonicalPath = join(root, 'canonical', 'private-skill') + const providerRoot = join(root, 'provider') + const destinationPath = join(providerRoot, 'private-skill') + await mkdir(canonicalPath, { recursive: true }) + await mkdir(destinationPath, { recursive: true }) + await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill') + await writeFile(join(destinationPath, 'SKILL.md'), 'old skill') + const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath)) + .observedDigest + const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath)) + .observedDigest + const previous = receipt(canonicalPath, previousDigest) + previous.placements = [ + { + provider: 'claude', + path: destinationPath, + topology: 'independent-copy', + status: 'skipped' + } + ] + + const result = await reconcileSkillProviderPlacement({ + canonicalPath, + skillName: 'private-skill', + destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false }, + previousReceipt: previous, + packageDigest: nextDigest + }) + + expect(result).toMatchObject({ status: 'skipped', errorCategory: 'skill-placement-unowned' }) + expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('old skill') + }) }) diff --git a/src/main/skills/skill-placement-reconciliation.ts b/src/main/skills/skill-placement-reconciliation.ts index 746f7d646df..7116a01411c 100644 --- a/src/main/skills/skill-placement-reconciliation.ts +++ b/src/main/skills/skill-placement-reconciliation.ts @@ -40,7 +40,10 @@ function previousPlacement( return ( receipt?.placements.find( (placement) => - placement.provider === provider && normalizedPath(placement.path) === normalizedPath(path) + placement.provider === provider && + placement.status !== 'failed' && + placement.status !== 'skipped' && + normalizedPath(placement.path) === normalizedPath(path) ) ?? null ) } @@ -74,12 +77,54 @@ async function reconcileExistingPlacement(input: { filesystem: SkillInstallFilesystem fileModes?: readonly SkillInstalledFileMode[] observation: PlacementObservation + transaction?: { stagingPath: string; backupPath: string } }): Promise { const previous = previousPlacement( input.previousReceipt, input.destination.provider, input.destinationPath ) + if (previous?.topology === 'independent-copy') { + const observed = await input.filesystem + .observeSkill(input.destinationPath, input.previousReceipt?.fileModes) + .catch(() => null) + if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) { + return { + provider: input.destination.provider, + path: input.destinationPath, + topology: 'independent-copy', + status: 'skipped', + ...placementFailure('skill-placement-modified-copy') + } + } + if (observed.observedDigest === input.packageDigest) { + return { + provider: input.destination.provider, + path: input.destinationPath, + topology: 'independent-copy', + status: 'unchanged' + } + } + await replaceOwnedSkillPlacementCopy( + input.canonicalPath, + input.destinationPath, + input.filesystem, + input.fileModes, + input.transaction + ? { + replacementPath: input.transaction.stagingPath, + backupPath: input.transaction.backupPath, + retainBackup: true + } + : undefined + ) + return { + provider: input.destination.provider, + path: input.destinationPath, + topology: 'independent-copy', + status: 'installed' + } + } if (input.filesystem.aliasTargets) { return (await input.filesystem.aliasTargets(input.canonicalPath, input.destinationPath)) ? { @@ -130,40 +175,6 @@ async function reconcileExistingPlacement(input: { ...placementFailure('skill-placement-unowned-link') } } - if (stat.isDirectory() && previous?.topology === 'independent-copy') { - const observed = await input.filesystem - .observeSkill(input.destinationPath, input.previousReceipt?.fileModes) - .catch(() => null) - if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) { - return { - provider: input.destination.provider, - path: input.destinationPath, - topology: 'independent-copy', - status: 'skipped', - ...placementFailure('skill-placement-modified-copy') - } - } - if (observed.observedDigest === input.packageDigest) { - return { - provider: input.destination.provider, - path: input.destinationPath, - topology: 'independent-copy', - status: 'unchanged' - } - } - await replaceOwnedSkillPlacementCopy( - input.canonicalPath, - input.destinationPath, - input.filesystem, - input.fileModes - ) - return { - provider: input.destination.provider, - path: input.destinationPath, - topology: 'independent-copy', - status: 'installed' - } - } return { provider: input.destination.provider, path: input.destinationPath, @@ -180,6 +191,7 @@ async function createMissingPlacement(input: { filesystem: SkillInstallFilesystem fileModes?: readonly SkillInstalledFileMode[] observation: PlacementObservation + transaction?: { stagingPath: string; backupPath: string } }): Promise { try { await createProviderAlias(input.canonicalPath, input.destinationPath, input.filesystem) @@ -196,7 +208,8 @@ async function createMissingPlacement(input: { input.canonicalPath, input.destinationPath, input.filesystem, - input.fileModes + input.fileModes, + input.transaction?.stagingPath ) return { provider: input.destination.provider, @@ -225,6 +238,7 @@ export async function reconcileSkillProviderPlacement(input: { filesystem?: SkillInstallFilesystem fileModes?: readonly SkillInstalledFileMode[] targetPlatform?: 'darwin' | 'linux' | 'win32' | 'other' + transaction?: { stagingPath: string; backupPath: string } }): Promise { if (input.destination.readsCanonicalRoot) { return null diff --git a/src/main/skills/skill-placement-recovery-journal.ts b/src/main/skills/skill-placement-recovery-journal.ts new file mode 100644 index 00000000000..fb83973218f --- /dev/null +++ b/src/main/skills/skill-placement-recovery-journal.ts @@ -0,0 +1,219 @@ +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { isSkillInstallProviderId } from '../../shared/skill-install-providers' +import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' +import type { SkillInstalledFileMode } from './skill-install-filesystem' +import { skillInstallStateKey, type SkillInstallReceiptV1 } from './skill-install-provenance' +import { + resolveSkillProviderDestinations, + type SkillProviderRootOverrides +} from './skill-provider-destinations' + +const JOURNAL_MAX_BYTES = 4 * 1024 * 1024 + +export type SkillPlacementJournalActionV1 = { + provider: string + destinationPath: string + rootPath: string + desired: boolean + stagingPath: string + backupPath: string +} + +export type SkillPlacementJournalV1 = { + schemaVersion: 1 + operation: 'place' + canonicalPath: string + packageDigest: string + fileModes: SkillInstalledFileMode[] + receipt: SkillInstallReceiptV1 + previousReceipt: SkillInstallReceiptV1 | null + providers: string[] + providerRootOverrides?: SkillProviderRootOverrides + actions: SkillPlacementJournalActionV1[] + wslDistro?: string +} + +export function skillPlacementJournalPath(stateDirectory: string, canonicalPath: string): string { + return join(stateDirectory, 'placement-journals', `${skillInstallStateKey(canonicalPath)}.json`) +} + +function normalizedPath(path: string): string { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +function scopeRoot(canonicalPath: string): string | null { + const skills = dirname(canonicalPath) + const agents = dirname(skills) + return basename(skills) === 'skills' && basename(agents) === '.agents' ? dirname(agents) : null +} + +function validFileModes(value: unknown): value is SkillInstalledFileMode[] { + if (!Array.isArray(value)) { + return false + } + const paths = value.map((entry) => + entry && typeof entry === 'object' && 'path' in entry ? (entry as { path: unknown }).path : null + ) + return ( + paths.every( + (path) => + typeof path === 'string' && + path.length > 0 && + path.length <= 4096 && + !path.includes('\0') && + !path.startsWith('/') && + !path.split(/[\\/]/u).includes('..') + ) && + new Set(paths).size === paths.length && + value.every( + (entry) => + entry && + typeof entry === 'object' && + typeof (entry as { executable?: unknown }).executable === 'boolean' + ) + ) +} + +function validProviderRootOverrides(value: unknown): value is SkillProviderRootOverrides { + if (value === undefined) { + return true + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + return Object.entries(value).every( + ([provider, root]) => + (provider === 'claude' || provider === 'grok') && + typeof root === 'string' && + root.length > 0 && + root.length <= 32_768 && + !root.includes('\0') && + isAbsolute(root) + ) +} + +function isJournal(value: unknown, canonicalPath: string): value is SkillPlacementJournalV1 { + if (!value || typeof value !== 'object') { + return false + } + const journal = value as Partial + if ( + journal.schemaVersion !== 1 || + journal.operation !== 'place' || + journal.canonicalPath !== canonicalPath || + !journal.receipt || + journal.receipt.canonicalPath !== canonicalPath || + journal.packageDigest !== journal.receipt.packageDigest || + !/^[a-f0-9]{64}$/u.test(journal.packageDigest ?? '') || + typeof journal.receipt.packageId !== 'string' || + typeof journal.receipt.versionId !== 'string' || + (journal.receipt.scope !== 'global' && journal.receipt.scope !== 'workspace') || + !Array.isArray(journal.receipt.placements) || + !validFileModes(journal.fileModes) || + !Array.isArray(journal.providers) || + !journal.providers.every(isSkillInstallProviderId) || + new Set(journal.providers).size !== journal.providers.length || + !validProviderRootOverrides(journal.providerRootOverrides) || + (journal.providerRootOverrides !== undefined && journal.receipt.scope !== 'global') || + !Array.isArray(journal.actions) || + !journal.actions.every( + (action) => + action && + typeof action.provider === 'string' && + typeof action.destinationPath === 'string' && + typeof action.rootPath === 'string' && + typeof action.desired === 'boolean' && + typeof action.stagingPath === 'string' && + typeof action.backupPath === 'string' + ) || + (journal.previousReceipt !== null && + journal.previousReceipt?.canonicalPath !== canonicalPath) || + (journal.wslDistro !== undefined && typeof journal.wslDistro !== 'string') + ) { + return false + } + const root = scopeRoot(canonicalPath) + if (!root) { + return false + } + const destinations = resolveSkillProviderDestinations({ + scope: journal.receipt.scope, + homeDirectory: root, + ...(journal.receipt.scope === 'workspace' ? { workspaceDirectory: root } : {}), + detectedProviders: journal.providers, + providerRootOverrides: journal.providerRootOverrides + }).filter((destination) => !destination.readsCanonicalRoot) + const desiredByProvider = new Map( + destinations.map((destination) => [destination.provider, destination]) + ) + const normalizedActionPaths = journal.actions.flatMap((action) => [ + normalizedPath(action.destinationPath), + normalizedPath(action.stagingPath), + normalizedPath(action.backupPath) + ]) + return ( + new Set(normalizedActionPaths).size === normalizedActionPaths.length && + journal.actions.every((action) => { + const desiredDestination = desiredByProvider.get(action.provider as never) + const previousPlacement = journal.previousReceipt?.placements.find( + (placement) => + placement.provider === action.provider && + normalizedPath(placement.path) === normalizedPath(action.destinationPath) + ) + const destination = action.desired + ? desiredDestination + : previousPlacement + ? { + provider: action.provider, + rootPath: dirname(previousPlacement.path), + readsCanonicalRoot: false + } + : undefined + const name = basename(canonicalPath) + return Boolean( + destination && + action.rootPath === destination.rootPath && + (action.desired + ? Boolean(desiredDestination) + : Boolean( + previousPlacement && + (!desiredDestination || + normalizedPath(desiredDestination.rootPath) !== normalizedPath(action.rootPath)) + )) && + normalizedPath(action.destinationPath) === + normalizedPath(join(destination.rootPath, name)) && + dirname(action.stagingPath) === dirname(action.destinationPath) && + dirname(action.backupPath) === dirname(action.destinationPath) && + basename(action.stagingPath).startsWith(`.${name}.orca-placement-staging-`) && + basename(action.backupPath).startsWith(`.${name}.orca-placement-backup-`) && + action.stagingPath !== action.backupPath + ) + }) + ) +} + +export async function readSkillPlacementRecoveryJournal( + stateDirectory: string, + canonicalPath: string +): Promise { + try { + const value: unknown = JSON.parse( + ( + await readNodeFileWithinLimit( + skillPlacementJournalPath(stateDirectory, canonicalPath), + JOURNAL_MAX_BYTES + ) + ).buffer.toString('utf8') + ) + if (!isJournal(value, canonicalPath)) { + throw new Error('skill-placement-journal-invalid') + } + return value + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} diff --git a/src/main/skills/skill-placement-transaction-controller.ts b/src/main/skills/skill-placement-transaction-controller.ts new file mode 100644 index 00000000000..6bd83938290 --- /dev/null +++ b/src/main/skills/skill-placement-transaction-controller.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto' +import { basename, dirname, join, resolve } from 'node:path' +import { isSkillInstallProviderId } from '../../shared/skill-install-providers' +import { + nativeSkillInstallFilesystem, + type SkillInstallFilesystem +} from './skill-install-filesystem' +import { writeSkillStateFile, type SkillInstallReceiptV1 } from './skill-install-provenance' +import { + skillPlacementJournalPath, + type SkillPlacementJournalV1 +} from './skill-placement-recovery-journal' +import { + finishSkillPlacementTransaction, + recoverSkillPlacementTransaction +} from './skill-placement-transaction' +import { + resolveSkillProviderDestinations, + type SkillProviderDestination, + type SkillProviderRootOverrides +} from './skill-provider-destinations' + +function normalizedPath(path: string): string { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +export function createSkillPlacementTransaction(input: { + stateDirectory: string + scope: 'global' | 'workspace' + homeDirectory: string + workspaceDirectory?: string + detectedProviders: readonly string[] + providerRootOverrides?: SkillProviderRootOverrides + filesystem?: SkillInstallFilesystem + wslDistro?: string + signal?: AbortSignal +}): { + prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise + commit(receipt: SkillInstallReceiptV1): Promise + finish(receipt: SkillInstallReceiptV1): Promise +} { + const filesystem = input.filesystem ?? nativeSkillInstallFilesystem + const providerRootOverrides = + input.scope === 'global' && + input.providerRootOverrides && + Object.keys(input.providerRootOverrides).length > 0 + ? input.providerRootOverrides + : undefined + let canonicalPath: string | null = null + return { + async prepare(previous, receipt) { + canonicalPath = receipt.canonicalPath + const selected = new Set(input.detectedProviders.filter(isSkillInstallProviderId)) + const destinations = resolveSkillProviderDestinations({ + scope: input.scope, + homeDirectory: input.homeDirectory, + workspaceDirectory: input.workspaceDirectory, + detectedProviders: [...selected], + providerRootOverrides + }).filter((destination) => !destination.readsCanonicalRoot) + const desiredPathByProvider = new Map( + destinations.map((destination) => [ + destination.provider, + normalizedPath(destination.rootPath) + ]) + ) + for (const placement of previous?.placements ?? []) { + if ( + !isSkillInstallProviderId(placement.provider) || + placement.topology === 'canonical-copy' || + placement.status === 'failed' || + placement.status === 'skipped' + ) { + continue + } + const claimedBy = destinations.find( + (destination) => + normalizedPath(destination.rootPath) === normalizedPath(dirname(placement.path)) + ) + if (claimedBy && claimedBy.provider !== placement.provider) { + throw new Error('skill-install-provider-root-ownership-conflict') + } + } + const previousDestinationKeys = new Set() + const previousDestinations: SkillProviderDestination[] = + previous?.placements.flatMap((placement) => { + if (!isSkillInstallProviderId(placement.provider)) { + return [] + } + if (placement.status === 'failed' || placement.status === 'skipped') { + return [] + } + const rootPath = dirname(placement.path) + const key = `${placement.provider}:${normalizedPath(rootPath)}` + if ( + placement.topology === 'canonical-copy' || + desiredPathByProvider.get(placement.provider) === normalizedPath(rootPath) || + previousDestinationKeys.has(key) + ) { + return [] + } + previousDestinationKeys.add(key) + return [{ provider: placement.provider, readsCanonicalRoot: false, rootPath }] + }) ?? [] + const id = randomUUID() + const name = basename(receipt.canonicalPath) + const journal: SkillPlacementJournalV1 = { + schemaVersion: 1, + operation: 'place', + canonicalPath: receipt.canonicalPath, + packageDigest: receipt.packageDigest, + fileModes: receipt.fileModes ?? [], + receipt: { ...receipt, providers: [...selected] }, + previousReceipt: previous, + providers: [...selected], + actions: [...destinations, ...previousDestinations].map((destination) => ({ + provider: destination.provider, + destinationPath: join(destination.rootPath, name), + rootPath: destination.rootPath, + desired: destinations.includes(destination), + stagingPath: join(destination.rootPath, `.${name}.orca-placement-staging-${id}`), + backupPath: join(destination.rootPath, `.${name}.orca-placement-backup-${id}`) + })), + ...(providerRootOverrides ? { providerRootOverrides } : {}), + ...(input.wslDistro ? { wslDistro: input.wslDistro } : {}) + } + await writeSkillStateFile( + skillPlacementJournalPath(input.stateDirectory, receipt.canonicalPath), + journal + ) + }, + async commit(receipt) { + if (canonicalPath !== receipt.canonicalPath) { + throw new Error('skill-placement-transaction-not-prepared') + } + return ( + (await recoverSkillPlacementTransaction( + input.stateDirectory, + receipt.canonicalPath, + filesystem, + { finalize: false, signal: input.signal } + )) ?? receipt + ) + }, + async finish(receipt) { + await finishSkillPlacementTransaction(input.stateDirectory, receipt.canonicalPath, filesystem) + } + } +} diff --git a/src/main/skills/skill-placement-transaction.test.ts b/src/main/skills/skill-placement-transaction.test.ts new file mode 100644 index 00000000000..33054469ea7 --- /dev/null +++ b/src/main/skills/skill-placement-transaction.test.ts @@ -0,0 +1,328 @@ +import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { SkillInstallReceiptV1 } from './skill-install-provenance' +import { readSkillInstallReceipt } from './skill-install-provenance' +import { nativeSkillInstallFilesystem } from './skill-install-filesystem' +import { createSkillPlacementTransaction } from './skill-placement-transaction-controller' +import { + readSkillPlacementRecoveryJournal, + skillPlacementJournalPath +} from './skill-placement-recovery-journal' +import { recoverSkillPlacementTransaction } from './skill-placement-transaction' + +const roots: string[] = [] + +async function skill(root: string, body: string): Promise<{ path: string; digest: string }> { + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'SKILL.md'), body) + return { + path: root, + digest: (await nativeSkillInstallFilesystem.observeSkill(root)).observedDigest + } +} + +function receipt( + canonicalPath: string, + packageDigest: string, + versionId: string, + placements: SkillInstallReceiptV1['placements'] +): SkillInstallReceiptV1 { + return { + schemaVersion: 1, + packageId: 'package_1', + versionId, + packageDigest, + archiveSha256: 'a'.repeat(64), + scope: 'global', + destinationIdentity: 'global:test', + canonicalPath, + placements, + installedAt: new Date().toISOString(), + hostIdentity: 'test', + fileModes: [] + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('skill placement transaction recovery', () => { + it('does not persist empty global root overrides for a workspace transaction', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-workspace-root-')) + roots.push(root) + const workspace = join(root, 'workspace') + const canonical = await skill(join(workspace, '.agents', 'skills', 'alpha'), '# Skill') + const stateDirectory = join(root, 'state') + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'workspace', + homeDirectory: join(root, 'home'), + workspaceDirectory: workspace, + detectedProviders: ['claude'], + providerRootOverrides: {} + }) + await transaction.prepare(null, receipt(canonical.path, canonical.digest, 'version_1', [])) + + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path) + expect(journal).not.toBeNull() + expect(journal).not.toHaveProperty('providerRootOverrides') + }) + + it('rebuilds a partial staged copy and publishes its placement receipt', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-partial-')) + roots.push(root) + const canonical = await skill(join(root, 'home', '.agents', 'skills', 'alpha'), '# New') + const stateDirectory = join(root, 'state') + const next = receipt(canonical.path, canonical.digest, 'version_1', []) + const filesystem = { + ...nativeSkillInstallFilesystem, + createAlias: async () => { + throw new Error('aliases-disabled') + } + } + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: join(root, 'home'), + detectedProviders: ['claude'], + filesystem + }) + await transaction.prepare(null, next) + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path) + if (!journal?.actions[0]) { + throw new Error('missing placement action') + } + await mkdir(journal.actions[0].stagingPath, { recursive: true }) + await writeFile(join(journal.actions[0].stagingPath, 'partial'), 'partial') + + const recovered = await recoverSkillPlacementTransaction( + stateDirectory, + canonical.path, + filesystem + ) + + expect(recovered?.placements).toEqual([ + expect.objectContaining({ provider: 'agent-skills' }), + expect.objectContaining({ provider: 'claude', topology: 'independent-copy' }) + ]) + expect( + await readFile(join(root, 'home', '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8') + ).toBe('# New') + await expect( + lstat(skillPlacementJournalPath(stateDirectory, canonical.path)) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('rolls a copy replacement forward after the old placement moved to backup', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-replace-')) + roots.push(root) + const home = join(root, 'home') + const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old') + const providerPath = join(home, '.claude', 'skills', 'alpha') + await cp(canonical.path, providerPath, { recursive: true }) + const previous = receipt(canonical.path, canonical.digest, 'version_1', [ + { + provider: 'agent-skills', + path: canonical.path, + topology: 'canonical-copy', + status: 'installed' + }, + { + provider: 'claude', + path: providerPath, + topology: 'independent-copy', + status: 'installed' + } + ]) + await writeFile(join(canonical.path, 'SKILL.md'), '# New') + const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest + const next = receipt(canonical.path, digest, 'version_2', []) + const stateDirectory = join(root, 'state') + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: home, + detectedProviders: ['claude'] + }) + await transaction.prepare(previous, next) + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path) + if (!journal?.actions[0]) { + throw new Error('missing placement action') + } + await cp(canonical.path, journal.actions[0].stagingPath, { recursive: true }) + await rename(providerPath, journal.actions[0].backupPath) + + const recovered = await recoverSkillPlacementTransaction(stateDirectory, canonical.path) + + expect(recovered?.placements).toContainEqual( + expect.objectContaining({ provider: 'claude', topology: 'independent-copy' }) + ) + expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# New') + expect((await readSkillInstallReceipt(stateDirectory, canonical.path))?.versionId).toBe( + 'version_2' + ) + await expect(lstat(journal.actions[0].backupPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('restores the previous copy when placement and rollback renames both fail', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-rollback-')) + roots.push(root) + const home = join(root, 'home') + const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old') + const providerPath = join(home, '.claude', 'skills', 'alpha') + await cp(canonical.path, providerPath, { recursive: true }) + const previous = receipt(canonical.path, canonical.digest, 'version_1', [ + { + provider: 'claude', + path: providerPath, + topology: 'independent-copy', + status: 'installed' + } + ]) + await writeFile(join(canonical.path, 'SKILL.md'), '# New') + const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest + const stateDirectory = join(root, 'state') + let failRenames = true + const filesystem = { + ...nativeSkillInstallFilesystem, + createAlias: async () => { + throw new Error('aliases-disabled') + }, + rename: async (source: string, target: string): Promise => { + if (failRenames && source.includes('.orca-placement-staging-')) { + throw new Error('injected-placement-rename-failure') + } + if (failRenames && source.includes('.orca-placement-backup-')) { + throw new Error('injected-rollback-rename-failure') + } + await nativeSkillInstallFilesystem.rename(source, target) + } + } + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: home, + detectedProviders: ['claude'], + filesystem + }) + await transaction.prepare(previous, receipt(canonical.path, digest, 'version_2', [])) + + const failed = await transaction.commit(receipt(canonical.path, digest, 'version_2', [])) + expect(failed.placements).toContainEqual( + expect.objectContaining({ provider: 'claude', status: 'failed' }) + ) + failRenames = false + await transaction.finish(failed) + + expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# Old') + }) + + it('moves an owned placement when Claude changes to a custom config root', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-custom-root-')) + roots.push(root) + const home = join(root, 'home') + const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill') + const previousPath = join(home, '.claude', 'skills', 'alpha') + await cp(canonical.path, previousPath, { recursive: true }) + const previous = receipt(canonical.path, canonical.digest, 'version_1', [ + { + provider: 'claude', + path: previousPath, + topology: 'independent-copy', + status: 'installed' + } + ]) + const next = receipt(canonical.path, canonical.digest, 'version_2', []) + const stateDirectory = join(root, 'state') + const customRoot = join(root, 'managed-claude', 'skills') + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: home, + detectedProviders: ['claude'], + providerRootOverrides: { claude: customRoot } + }) + await transaction.prepare(previous, next) + + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path) + expect(journal?.actions).toEqual([ + expect.objectContaining({ rootPath: customRoot, desired: true }), + expect.objectContaining({ rootPath: join(home, '.claude', 'skills'), desired: false }) + ]) + + const committed = await transaction.commit(next) + await transaction.finish(committed) + + await expect(lstat(previousPath)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readFile(join(customRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Skill') + }) + + it('rejects a historical root claimed by another selected provider before journaling', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-cross-provider-root-')) + roots.push(root) + const home = join(root, 'home') + const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill') + const sharedRoot = join(root, 'shared', 'skills') + const previousPath = join(sharedRoot, 'alpha') + await cp(canonical.path, previousPath, { recursive: true }) + const previous = receipt(canonical.path, canonical.digest, 'version_1', [ + { + provider: 'claude', + path: previousPath, + topology: 'independent-copy', + status: 'installed' + } + ]) + const next = receipt(canonical.path, canonical.digest, 'version_2', []) + const stateDirectory = join(root, 'state') + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: home, + detectedProviders: ['claude', 'grok'], + providerRootOverrides: { + claude: join(root, 'new-claude', 'skills'), + grok: sharedRoot + } + }) + await expect(transaction.prepare(previous, next)).rejects.toThrow( + 'skill-install-provider-root-ownership-conflict' + ) + await expect( + lstat(skillPlacementJournalPath(stateDirectory, canonical.path)) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('does not treat a skipped historical placement as ownership', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-placement-skipped-owner-')) + roots.push(root) + const home = join(root, 'home') + const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill') + const sharedRoot = join(root, 'shared', 'skills') + const previous = receipt(canonical.path, canonical.digest, 'version_1', [ + { + provider: 'claude', + path: join(sharedRoot, 'alpha'), + topology: 'independent-copy', + status: 'skipped' + } + ]) + const next = receipt(canonical.path, canonical.digest, 'version_2', []) + const stateDirectory = join(root, 'state') + const transaction = createSkillPlacementTransaction({ + stateDirectory, + scope: 'global', + homeDirectory: home, + detectedProviders: ['grok'], + providerRootOverrides: { grok: sharedRoot } + }) + + await expect(transaction.prepare(previous, next)).resolves.toBeUndefined() + await expect( + readSkillPlacementRecoveryJournal(stateDirectory, canonical.path) + ).resolves.not.toBeNull() + }) +}) diff --git a/src/main/skills/skill-placement-transaction.ts b/src/main/skills/skill-placement-transaction.ts new file mode 100644 index 00000000000..cbd3ff9568f --- /dev/null +++ b/src/main/skills/skill-placement-transaction.ts @@ -0,0 +1,235 @@ +import { rm } from 'node:fs/promises' +import { basename, resolve } from 'node:path' +import type { SkillPlacementResult } from '../../shared/skill-install-contract' +import { + nativeSkillInstallFilesystem, + type SkillInstallFilesystem +} from './skill-install-filesystem' +import { + writeSkillInstallReceipt, + writeSkillStateFile, + type SkillInstallReceiptV1 +} from './skill-install-provenance' +import { isRemovableSkillPlacement } from './skill-removable-placement' +import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation' +import { + readSkillPlacementRecoveryJournal, + skillPlacementJournalPath, + type SkillPlacementJournalActionV1, + type SkillPlacementJournalV1 +} from './skill-placement-recovery-journal' +import { + placementBackupMatchesPrevious, + placementIsAlias, + placementMatchesDigest, + placementPathExists, + settleDesiredPlacementArtifacts +} from './skill-placement-artifact-recovery' + +function normalizedPath(path: string): string { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} + +async function settleDeselectedAction( + journal: SkillPlacementJournalV1, + action: SkillPlacementJournalActionV1, + filesystem: SkillInstallFilesystem, + allowedProviderRoots: readonly string[] +): Promise { + if (await placementPathExists(action.backupPath)) { + if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) { + throw new Error('skill-placement-recovery-conflict') + } + await filesystem.remove(action.backupPath) + } + const previous = journal.previousReceipt?.placements.find( + (placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath) + ) + if (!previous || !(await placementPathExists(action.destinationPath))) { + return null + } + const removable = await isRemovableSkillPlacement({ + placement: previous, + receipt: journal.previousReceipt!, + allowedProviderRoots, + filesystem + }) + if (removable) { + await filesystem.rename(action.destinationPath, action.backupPath) + return null + } + return { + ...previous, + status: 'skipped', + errorCategory: 'skill-removal-placement-modified-or-unowned', + failure: { + category: 'conflict', + code: 'skill-removal-placement-modified-or-unowned', + retryable: false + } + } +} + +export async function recoverSkillPlacementTransaction( + stateDirectory: string, + canonicalPath: string, + filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem, + options: { finalize?: boolean; signal?: AbortSignal } = {} +): Promise { + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath) + if (!journal) { + return null + } + if (!(await placementMatchesDigest(canonicalPath, journal.packageDigest, journal, filesystem))) { + if ( + journal.previousReceipt && + (await placementMatchesDigest( + canonicalPath, + journal.previousReceipt.packageDigest, + journal, + filesystem, + journal.previousReceipt.fileModes ?? [] + )) + ) { + await Promise.all( + journal.actions.flatMap((action) => [ + filesystem.remove(action.stagingPath), + filesystem.remove(action.backupPath) + ]) + ) + await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true }) + return journal.previousReceipt + } + if ( + !journal.previousReceipt && + !(await placementPathExists(canonicalPath)) && + !( + await Promise.all( + journal.actions.flatMap((action) => [ + placementPathExists(action.stagingPath), + placementPathExists(action.backupPath) + ]) + ) + ).some(Boolean) + ) { + await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true }) + return null + } + throw new Error('skill-placement-canonical-mismatch') + } + const allowedProviderRoots = journal.actions.map((action) => action.rootPath) + const placements: SkillPlacementResult[] = [ + { + provider: 'agent-skills', + path: canonicalPath, + topology: 'canonical-copy', + status: 'installed' + } + ] + for (const action of journal.actions) { + if (!action.desired) { + const skipped = await settleDeselectedAction( + journal, + action, + filesystem, + allowedProviderRoots + ) + if (skipped) { + placements.push(skipped) + } + continue + } + if (options.signal?.aborted) { + placements.push({ + provider: action.provider, + path: action.destinationPath, + topology: 'independent-copy', + status: 'skipped', + errorCategory: 'skill-placement-cancelled', + failure: { + category: 'cancelled', + code: 'skill-placement-cancelled', + retryable: true + } + }) + continue + } + const recoveredArtifacts = await settleDesiredPlacementArtifacts(journal, action, filesystem) + if ( + recoveredArtifacts && + (await placementPathExists(action.destinationPath)) && + (await placementMatchesDigest( + action.destinationPath, + journal.packageDigest, + journal, + filesystem + )) + ) { + placements.push({ + provider: action.provider, + path: action.destinationPath, + topology: (await placementIsAlias(canonicalPath, action.destinationPath, filesystem)) + ? 'provider-alias' + : 'independent-copy', + status: 'unchanged' + }) + continue + } + const destination = { + provider: action.provider as never, + rootPath: action.rootPath, + readsCanonicalRoot: false + } + const placement = await reconcileSkillProviderPlacement({ + canonicalPath, + skillName: basename(canonicalPath), + destination, + previousReceipt: journal.previousReceipt, + packageDigest: journal.packageDigest, + fileModes: journal.fileModes, + filesystem, + transaction: { stagingPath: action.stagingPath, backupPath: action.backupPath }, + ...(journal.wslDistro ? { targetPlatform: 'linux' as const } : {}) + }) + if (placement) { + placements.push(placement) + } + } + const receipt = { ...journal.receipt, placements, providers: journal.providers } + await writeSkillInstallReceipt(stateDirectory, receipt) + await writeSkillStateFile(skillPlacementJournalPath(stateDirectory, canonicalPath), { + ...journal, + receipt + }) + if (options.finalize !== false) { + await finishSkillPlacementTransaction(stateDirectory, canonicalPath, filesystem) + } + return receipt +} + +export async function finishSkillPlacementTransaction( + stateDirectory: string, + canonicalPath: string, + filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem +): Promise { + const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath) + if (!journal) { + return + } + await Promise.all( + journal.actions.map(async (action) => { + if (action.desired) { + await settleDesiredPlacementArtifacts(journal, action, filesystem) + } + await filesystem.remove(action.stagingPath) + if (await placementPathExists(action.backupPath)) { + if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) { + throw new Error('skill-placement-recovery-conflict') + } + await filesystem.remove(action.backupPath) + } + }) + ) + await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true }) +} diff --git a/src/main/skills/skill-provider-destinations.test.ts b/src/main/skills/skill-provider-destinations.test.ts new file mode 100644 index 00000000000..b755c4068e8 --- /dev/null +++ b/src/main/skills/skill-provider-destinations.test.ts @@ -0,0 +1,167 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers' +import { resolveSkillProviderDestinations } from './skill-provider-destinations' + +const HOME = join('/home', 'dev') +const WORKSPACE = join('/repos', 'orca') + +describe('resolveSkillProviderDestinations', () => { + it('gives an agent that reads the canonical root no placement of its own', () => { + const destinations = resolveSkillProviderDestinations({ + scope: 'workspace', + homeDirectory: HOME, + workspaceDirectory: WORKSPACE, + detectedProviders: ['codex'] + }) + expect(destinations).toEqual([ + { + provider: 'codex', + readsCanonicalRoot: true, + rootPath: join(WORKSPACE, '.agents', 'skills') + } + ]) + }) + + // Why: Codex and Cursor read a project's .agents/skills but keep their own + // home directory, so the same agent needs a placement at one scope only. + it('resolves scope-specific roots for an agent that is canonical in a workspace only', () => { + const workspace = resolveSkillProviderDestinations({ + scope: 'workspace', + homeDirectory: HOME, + workspaceDirectory: WORKSPACE, + detectedProviders: ['cursor'] + }) + const global = resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['cursor'] + }) + expect(workspace[0]).toMatchObject({ readsCanonicalRoot: true }) + expect(global[0]).toMatchObject({ + readsCanonicalRoot: false, + rootPath: join(HOME, '.cursor', 'skills') + }) + }) + + it('places every detected agent that owns a directory', () => { + const destinations = resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['codex', 'claude', 'droid', 'grok', 'aug', 'continue', 'trae'] + }) + expect(destinations.map((destination) => destination.rootPath)).toEqual([ + join(HOME, '.agents', 'skills'), + join(HOME, '.claude', 'skills'), + join(HOME, '.factory', 'skills'), + join(HOME, '.continue', 'skills'), + join(HOME, '.trae-cn', 'skills'), + join(HOME, '.grok', 'skills'), + join(HOME, '.augment', 'skills') + ]) + }) + + it('uses the canonical root for Codex at global scope', () => { + expect( + resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['codex'] + }) + ).toEqual([ + { provider: 'codex', readsCanonicalRoot: true, rootPath: join(HOME, '.agents', 'skills') } + ]) + }) + + it('honors custom global config roots without changing workspace roots', () => { + const customClaudeRoot = join('/srv', 'claude', 'skills') + expect( + resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['claude'], + providerRootOverrides: { claude: customClaudeRoot } + }) + ).toEqual([{ provider: 'claude', readsCanonicalRoot: false, rootPath: customClaudeRoot }]) + expect( + resolveSkillProviderDestinations({ + scope: 'workspace', + homeDirectory: HOME, + workspaceDirectory: WORKSPACE, + detectedProviders: ['claude'], + providerRootOverrides: { claude: customClaudeRoot } + })[0]?.rootPath + ).toBe(join(WORKSPACE, '.claude', 'skills')) + }) + + it('treats a custom root equal to canonical as canonical', () => { + expect( + resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['claude'], + providerRootOverrides: { claude: join(HOME, '.agents', 'skills') } + }) + ).toEqual([ + { + provider: 'claude', + readsCanonicalRoot: true, + rootPath: join(HOME, '.agents', 'skills') + } + ]) + }) + + it('rejects two providers claiming one noncanonical root', () => { + const sharedRoot = join(HOME, '.custom-agent', 'skills') + expect(() => + resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['claude', 'grok'], + providerRootOverrides: { claude: sharedRoot, grok: sharedRoot } + }) + ).toThrow('skill-install-provider-root-collision') + }) + + it('ignores agents Orca cannot place skills for', () => { + expect( + resolveSkillProviderDestinations({ + scope: 'global', + homeDirectory: HOME, + detectedProviders: ['opencode', 'goose', 'not-an-agent'] + }) + ).toEqual([]) + }) + + it('requires a workspace directory for workspace scope', () => { + expect(() => + resolveSkillProviderDestinations({ + scope: 'workspace', + homeDirectory: HOME, + detectedProviders: ['claude'] + }) + ).toThrow('skill-install-workspace-required') + }) +}) + +describe('selectedOrDetectedSkillProviders', () => { + // Why: removal passes no selection so it can still clean roots an earlier + // install wrote, even if the user has since narrowed their agents. + it('keeps every detected agent when nothing was selected', () => { + expect(selectedOrDetectedSkillProviders(['claude', 'codex'], undefined)).toEqual([ + 'claude', + 'codex' + ]) + }) + + it('honors selected agents even when they are not currently detected', () => { + expect(selectedOrDetectedSkillProviders(['claude', 'codex'], ['claude', 'cursor'])).toEqual([ + 'claude', + 'cursor' + ]) + }) + + it('places nothing extra when the choice is empty', () => { + expect(selectedOrDetectedSkillProviders(['claude', 'codex'], [])).toEqual([]) + }) +}) diff --git a/src/main/skills/skill-provider-destinations.ts b/src/main/skills/skill-provider-destinations.ts index 9a37ec70216..361b0932f10 100644 --- a/src/main/skills/skill-provider-destinations.ts +++ b/src/main/skills/skill-provider-destinations.ts @@ -1,6 +1,11 @@ -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { + isSkillInstallProviderId, + SKILL_INSTALL_PROVIDERS, + type SkillInstallProviderId +} from '../../shared/skill-install-providers' -export type OrcaSkillProviderId = 'codex' | 'claude' +export type OrcaSkillProviderId = SkillInstallProviderId export type SkillProviderDestination = { provider: OrcaSkillProviderId @@ -8,37 +13,49 @@ export type SkillProviderDestination = { rootPath: string } -const SUPPORTED_PROVIDER_IDS = new Set(['codex', 'claude']) +export type SkillProviderRootOverrides = Partial> + +function normalizedPath(path: string): string { + const normalized = resolve(path) + return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized +} export function resolveSkillProviderDestinations(input: { scope: 'global' | 'workspace' homeDirectory: string workspaceDirectory?: string detectedProviders: readonly string[] + providerRootOverrides?: SkillProviderRootOverrides }): SkillProviderDestination[] { - const detected = new Set( - input.detectedProviders.filter((provider): provider is OrcaSkillProviderId => - SUPPORTED_PROVIDER_IDS.has(provider as OrcaSkillProviderId) - ) - ) + const detected = new Set(input.detectedProviders.filter(isSkillInstallProviderId)) const scopeRoot = input.scope === 'global' ? input.homeDirectory : input.workspaceDirectory if (!scopeRoot) { throw new Error('skill-install-workspace-required') } const destinations: SkillProviderDestination[] = [] - if (detected.has('codex')) { - destinations.push({ - provider: 'codex', - readsCanonicalRoot: true, - rootPath: join(scopeRoot, '.agents', 'skills') - }) - } - if (detected.has('claude')) { - destinations.push({ - provider: 'claude', - readsCanonicalRoot: false, - rootPath: join(scopeRoot, '.claude', 'skills') - }) + const canonicalRoot = join(scopeRoot, '.agents', 'skills') + const claimedPlacementRoots = new Set() + for (const provider of SKILL_INSTALL_PROVIDERS) { + if (!detected.has(provider.id)) { + continue + } + const segments = input.scope === 'global' ? provider.globalSegments : provider.workspaceSegments + const overriddenRoot = + input.scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined + const rootPath = overriddenRoot + ? overriddenRoot + : segments + ? join(scopeRoot, ...segments) + : canonicalRoot + const readsCanonicalRoot = normalizedPath(rootPath) === normalizedPath(canonicalRoot) + if (!readsCanonicalRoot) { + const normalizedRoot = normalizedPath(rootPath) + if (claimedPlacementRoots.has(normalizedRoot)) { + throw new Error('skill-install-provider-root-collision') + } + claimedPlacementRoots.add(normalizedRoot) + } + destinations.push({ provider: provider.id, readsCanonicalRoot, rootPath }) } return destinations } diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts new file mode 100644 index 00000000000..07d9d422dac --- /dev/null +++ b/src/main/skills/skill-provider-runtime-roots.test.ts @@ -0,0 +1,52 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + resolveEnvironmentSkillProviderRoots, + resolveWslGrokSkillProviderRoot, + withClaudeSkillProviderRoot +} from './skill-provider-runtime-roots' + +describe('skill provider runtime roots', () => { + it('maps Claude and Grok config homes to their global skill roots', () => { + expect( + resolveEnvironmentSkillProviderRoots({ + CLAUDE_CONFIG_DIR: join('/srv', 'claude'), + GROK_HOME: join('/srv', 'grok') + }) + ).toEqual({ + claude: join('/srv', 'claude', 'skills'), + grok: join('/srv', 'grok', 'skills') + }) + }) + + it('rejects relative config roots and lets a target-specific Claude root win', () => { + const roots = resolveEnvironmentSkillProviderRoots({ + CLAUDE_CONFIG_DIR: '../claude', + GROK_HOME: '../grok' + }) + expect(roots).toEqual({}) + expect(withClaudeSkillProviderRoot(roots, join('/managed', 'claude'))).toEqual({ + claude: join('/managed', 'claude', 'skills') + }) + }) + + it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => { + await expect( + resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n') + ).resolves.toBe('\\\\wsl.localhost\\Ubuntu-24.04\\srv\\grok\\skills') + }) + + it('ignores unsafe or missing WSL GROK_HOME values', async () => { + await expect( + resolveWslGrokSkillProviderRoot('Ubuntu', async () => '../grok') + ).resolves.toBeNull() + await expect( + resolveWslGrokSkillProviderRoot('Ubuntu', async () => '/srv/grok\0other') + ).resolves.toBeNull() + await expect( + resolveWslGrokSkillProviderRoot('Ubuntu', async () => { + throw new Error('probe failed') + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts new file mode 100644 index 00000000000..8f2a4db2196 --- /dev/null +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -0,0 +1,90 @@ +import { execFile } from 'node:child_process' +import { isAbsolute, join, resolve } from 'node:path' +import { toWindowsWslPath } from '../../shared/wsl-paths' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' + +const PROVIDER_ROOT_MAX_LENGTH = 32_768 +const WSL_ENV_PROBE_TIMEOUT_MS = 8_000 +const WSL_ENV_PROBE_MAX_BYTES = 4_097 +const WSL_GROK_HOME_SCRIPT = [ + 'entry=$(getent passwd "$(id -u)" 2>/dev/null || true)', + 'login_shell=${entry##*:}', + 'case "$login_shell" in /*) ;; *) login_shell=/bin/sh ;; esac', + `exec "$login_shell" -lc 'printf %s "\${GROK_HOME:-}" | head -c ${WSL_ENV_PROBE_MAX_BYTES}'` +].join('\n') + +function normalizedRoot(value: string | undefined): string | null { + const candidate = value?.trim() + if ( + !candidate || + candidate.length > PROVIDER_ROOT_MAX_LENGTH || + candidate.includes('\0') || + !isAbsolute(candidate) + ) { + return null + } + return resolve(candidate) +} + +export function resolveEnvironmentSkillProviderRoots( + env: NodeJS.ProcessEnv = process.env +): SkillProviderRootOverrides { + const claudeConfig = normalizedRoot(env.CLAUDE_CONFIG_DIR) + const grokHome = normalizedRoot(env.GROK_HOME) + return { + ...(claudeConfig ? { claude: join(claudeConfig, 'skills') } : {}), + ...(grokHome ? { grok: join(grokHome, 'skills') } : {}) + } +} + +export function withClaudeSkillProviderRoot( + roots: SkillProviderRootOverrides, + claudeConfigDirectory: string | null | undefined +): SkillProviderRootOverrides { + const configDirectory = normalizedRoot(claudeConfigDirectory ?? undefined) + return configDirectory ? { ...roots, claude: join(configDirectory, 'skills') } : roots +} + +type WslEnvironmentProbe = (distro: string) => Promise + +function probeWslGrokHome(distro: string): Promise { + return new Promise((resolveProbe, reject) => { + execFile( + 'wsl.exe', + ['-d', distro, '--exec', 'sh', '-c', WSL_GROK_HOME_SCRIPT], + { + encoding: 'utf8', + timeout: WSL_ENV_PROBE_TIMEOUT_MS, + maxBuffer: WSL_ENV_PROBE_MAX_BYTES, + windowsHide: true + }, + (error, stdout) => (error ? reject(error) : resolveProbe(stdout)) + ) + }) +} + +export async function resolveWslGrokSkillProviderRoot( + distro: string, + probe: WslEnvironmentProbe = probeWslGrokHome +): Promise { + try { + const value = await probe(distro) + const candidate = value.trim() + if ( + !candidate || + candidate.length >= WSL_ENV_PROBE_MAX_BYTES || + !candidate.startsWith('/') || + candidate.includes('\\') || + Array.from(candidate).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) + ) { + return null + } + const grokHome = candidate.replace(/\/+$/u, '') || '/' + return toWindowsWslPath(`${grokHome === '/' ? '' : grokHome}/skills`, distro) + } catch { + return null + } +} diff --git a/src/main/skills/skill-remote-install-service.test.ts b/src/main/skills/skill-remote-install-service.test.ts index 29da1ab8911..0bd3552a3d5 100644 --- a/src/main/skills/skill-remote-install-service.test.ts +++ b/src/main/skills/skill-remote-install-service.test.ts @@ -81,6 +81,19 @@ describe('installSkillOnRemoteRuntime', () => { mocks.transferSkillPackageToRuntime.mockReset() }) + it('requires a host update before sending an explicit provider choice', async () => { + await expect( + installSkillOnRemoteRuntime({ + userDataPath: '/state', + environmentId: 'environment-1', + request: { ...request, providers: ['claude'] }, + capabilities: ['skills.install.v1'], + requireHttps: true + }) + ).rejects.toThrow('skill-install-remote-update-required') + expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled() + }) + it('uses direct remote download when the runtime can reach storage', async () => { mocks.callRuntimeEnvironment.mockResolvedValue(success(result)) diff --git a/src/main/skills/skill-remote-install-service.ts b/src/main/skills/skill-remote-install-service.ts index 6b719adf3c6..6451f868a7f 100644 --- a/src/main/skills/skill-remote-install-service.ts +++ b/src/main/skills/skill-remote-install-service.ts @@ -15,6 +15,7 @@ import { import { SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' @@ -120,14 +121,21 @@ export async function installSkillOnRemoteRuntime(input: { requireHttps: boolean signal?: AbortSignal }): Promise { - if (input.request.ingress.kind !== 'download-grant') { + const request = input.request + if ( + request.providers !== undefined && + !input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY) + ) { + throw new Error('skill-install-remote-update-required') + } + if (request.ingress.kind !== 'download-grant') { throw new Error('skill-install-remote-ingress-invalid') } - const grant = input.request.ingress + const grant = request.ingress const direct = await retrySkillTransferRpc({ signal: input.signal, retryable: retryableRemoteInstallTransportError, - call: () => install(input.userDataPath, input.environmentId, input.request, input.signal) + call: () => install(input.userDataPath, input.environmentId, request, input.signal) }) if (!isDirectDownloadUnavailable(direct, input.requireHttps)) { if (direct.ok !== true) { @@ -150,8 +158,8 @@ export async function installSkillOnRemoteRuntime(input: { const transfer = await transferSkillPackageToRuntime({ userDataPath: input.userDataPath, environmentId: input.environmentId, - transferId: input.request.operationId, - package: input.request.package, + transferId: request.operationId, + package: request.package, grant, requireHttps: input.requireHttps, signal: input.signal @@ -161,7 +169,7 @@ export async function installSkillOnRemoteRuntime(input: { input.userDataPath, input.environmentId, { - ...input.request, + ...request, ingress: { kind: 'staged-upload', uploadId: transfer.uploadId } }, input.signal @@ -186,7 +194,14 @@ export async function installSkillBundleOnRemoteRuntime(input: { signal?: AbortSignal onProgress?: (progress: SkillBundleInstallProgress) => void }): Promise { - if (input.request.ingress.kind !== 'download-grant') { + const request = input.request + if ( + request.providers !== undefined && + !input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY) + ) { + throw new Error('skill-bundle-remote-update-required') + } + if (request.ingress.kind !== 'download-grant') { throw new Error('skill-bundle-remote-ingress-invalid') } if (!input.capabilities.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { @@ -200,21 +215,16 @@ export async function installSkillBundleOnRemoteRuntime(input: { input.onProgress && input.capabilities.includes(SKILL_INSTALL_PROGRESS_CAPABILITY) ? startSkillInstallProgressPolling({ read: () => - readBundleInstallProgress( - input.userDataPath, - input.environmentId, - input.request.operationId - ), + readBundleInstallProgress(input.userDataPath, input.environmentId, request.operationId), onProgress: input.onProgress }) : null try { - const grant = input.request.ingress + const grant = request.ingress const direct = await retrySkillTransferRpc({ signal: input.signal, retryable: retryableRemoteInstallTransportError, - call: () => - installBundle(input.userDataPath, input.environmentId, input.request, input.signal) + call: () => installBundle(input.userDataPath, input.environmentId, request, input.signal) }) if (!isDirectDownloadUnavailable(direct, input.requireHttps)) { if (direct.ok !== true) { @@ -236,8 +246,8 @@ export async function installSkillBundleOnRemoteRuntime(input: { const transfer = await transferSkillPackageToRuntime({ userDataPath: input.userDataPath, environmentId: input.environmentId, - transferId: input.request.operationId, - package: input.request.package, + transferId: request.operationId, + package: request.package, grant, requireHttps: input.requireHttps, signal: input.signal @@ -247,7 +257,7 @@ export async function installSkillBundleOnRemoteRuntime(input: { input.userDataPath, input.environmentId, { - ...input.request, + ...request, ingress: { kind: 'staged-upload', uploadId: transfer.uploadId } }, input.signal diff --git a/src/main/skills/skill-removable-placement.ts b/src/main/skills/skill-removable-placement.ts index 827282763d8..a4b5b66590b 100644 --- a/src/main/skills/skill-removable-placement.ts +++ b/src/main/skills/skill-removable-placement.ts @@ -32,7 +32,11 @@ export async function isRemovableSkillPlacement(input: { allowedProviderRoots: readonly string[] filesystem: SkillInstallFilesystem }): Promise { - if (input.placement.topology === 'canonical-copy') { + if ( + input.placement.topology === 'canonical-copy' || + input.placement.status === 'failed' || + input.placement.status === 'skipped' + ) { return false } if ( diff --git a/src/main/skills/skill-remove-transaction.test.ts b/src/main/skills/skill-remove-transaction.test.ts index 96bb88f4ea4..fc6b5699cc5 100644 --- a/src/main/skills/skill-remove-transaction.test.ts +++ b/src/main/skills/skill-remove-transaction.test.ts @@ -1,10 +1,11 @@ -import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { createSkillPackageArchive } from './skill-package-creation' import { readSkillInstallReceipt, + writeSkillInstallReceipt, writeSkillStateFile, type SkillInstallReceiptV1 } from './skill-install-provenance' @@ -154,6 +155,35 @@ describe('skill removal recovery', () => { expect(await readFile(join(backupPath, 'local.md'), 'utf8')).toBe('changed') }) + it('removes an owned placement after its provider config root changes', async () => { + const fixture = await installedFixture() + const oldProviderRoot = join(fixture.root, 'old-claude-config', 'skills') + const oldPlacementPath = join(oldProviderRoot, basename(fixture.canonicalPath)) + await cp(fixture.canonicalPath, oldPlacementPath, { recursive: true }) + await writeSkillInstallReceipt(fixture.stateDirectory, { + ...fixture.receipt, + placements: [ + ...fixture.receipt.placements, + { + provider: 'claude', + path: oldPlacementPath, + topology: 'independent-copy', + status: 'installed' + } + ] + }) + + await expect( + removeLocalSharedSkill({ + operationId: 'remove-custom-root', + canonicalPath: fixture.canonicalPath, + stateDirectory: fixture.stateDirectory, + allowedProviderRoots: [join(fixture.root, 'new-claude-config', 'skills')] + }) + ).resolves.toMatchObject({ status: 'removed' }) + await expect(lstat(oldPlacementPath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it.each( ['prepared', 'moving', 'receipt-removed'].flatMap((phase) => ['before', 'after'].map((boundary) => [phase, boundary] as const) diff --git a/src/main/skills/skill-remove-transaction.ts b/src/main/skills/skill-remove-transaction.ts index 1fc383aaaa0..439e6886477 100644 --- a/src/main/skills/skill-remove-transaction.ts +++ b/src/main/skills/skill-remove-transaction.ts @@ -21,6 +21,9 @@ import { type RemovalMove, type SkillRemovalJournalV1 } from './skill-remove-recovery' +import { recoverSkillPlacementTransaction } from './skill-placement-transaction' +import { recoverSkillInstallTransaction } from './skill-install-recovery' +import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots' export { recoverSkillRemovalTransaction, @@ -105,7 +108,14 @@ export async function removeLocalSharedSkill( path: skillInstallLockPath(input.stateDirectory, input.canonicalPath) }) try { + const historicalReceipt = await readSkillInstallReceipt( + input.stateDirectory, + input.canonicalPath + ) + filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt)) await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem) + await recoverSkillInstallTransaction(input.stateDirectory, input.canonicalPath, filesystem) + await recoverSkillPlacementTransaction(input.stateDirectory, input.canonicalPath, filesystem) const receipt = await readSkillInstallReceipt(input.stateDirectory, input.canonicalPath) const state = await inspectCanonicalRemoval(input, receipt) if (!receipt) { @@ -118,6 +128,11 @@ export async function removeLocalSharedSkill( return conflictResult(input, receipt, 'modified') } + const allowedProviderRoots = [ + ...input.allowedProviderRoots, + ...historicalSuccessfulProviderRoots(receipt) + ] + const removedPlacements: SkillPlacementResult[] = [] const skippedPlacements: SkillPlacementResult[] = [] const moves: RemovalMove[] = [] @@ -129,7 +144,7 @@ export async function removeLocalSharedSkill( await isRemovableSkillPlacement({ placement, receipt, - allowedProviderRoots: input.allowedProviderRoots, + allowedProviderRoots, filesystem }) ) { @@ -188,7 +203,7 @@ export async function removeLocalSharedSkill( movedCount: 0, moves, receipt, - allowedProviderRoots: [...input.allowedProviderRoots] + allowedProviderRoots: [...new Set(allowedProviderRoots)] } const statePath = skillRemovalJournalPath(input.stateDirectory, input.canonicalPath) await persistRemovalJournal(statePath, journal, dependencies) diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts index 053901af24e..f974a95385b 100644 --- a/src/main/skills/skill-share-preparation-service.ts +++ b/src/main/skills/skill-share-preparation-service.ts @@ -133,6 +133,7 @@ export class SkillSharePreparationService { archiveSha256: preparation.created.archiveSha256, compressedBytes: preparation.created.compressedBytes, packageId: preparation.created.manifest.packageId, + idempotencyKey: input.preparationId, releaseNotes: input.releaseNotes, signal: controller.signal, onProgress: (progress) => diff --git a/src/main/skills/skill-ssh-relay-service.test.ts b/src/main/skills/skill-ssh-relay-service.test.ts index a2e1e9ae6fa..c23d092362b 100644 --- a/src/main/skills/skill-ssh-relay-service.test.ts +++ b/src/main/skills/skill-ssh-relay-service.test.ts @@ -49,6 +49,19 @@ function request(bytes: Buffer) { } describe('installSkillOnSshHost', () => { + it('requires a relay update before sending an explicit provider choice', async () => { + const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] })) + await expect( + installSkillOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + userDataPath: await userDataPath(), + request: { ...request(Buffer.from('archive')), providers: ['claude'] }, + requireHttps: true + }) + ).rejects.toThrow('skill-install-ssh-update-required') + expect(requestHostRpc).toHaveBeenCalledOnce() + }) + it('uses client-mediated upload only after direct host download fails', async () => { const bytes = Buffer.from('private skill archive') let received = 0 diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts index 91360eec830..a2ab25bdf2f 100644 --- a/src/main/skills/skill-ssh-relay-service.ts +++ b/src/main/skills/skill-ssh-relay-service.ts @@ -1,5 +1,6 @@ import { SKILL_INSTALL_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' @@ -51,6 +52,10 @@ export async function installSkillOnSshHost(input: { }): Promise { const client = requireSkillSshRelayClient(input.provider) const supported = await skillSshRelayCapabilities(client) + const request = input.request + if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) { + throw new Error('skill-install-ssh-update-required') + } if (!supported.includes(SKILL_INSTALL_CAPABILITY)) { recordSkillCapabilityAbsence({ capability: SKILL_INSTALL_CAPABILITY, @@ -66,14 +71,14 @@ export async function installSkillOnSshHost(input: { call: () => client( SKILL_SSH_RELAY_INSTALL_METHOD, - { request: input.request, workspace: input.workspace }, + { request: request, workspace: input.workspace }, { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) }) ) } catch (error) { if ( - input.request.ingress.kind !== 'download-grant' || + request.ingress.kind !== 'download-grant' || !shouldUseSkillSshClientTransfer(error, input.requireHttps) ) { throw error @@ -97,7 +102,7 @@ export async function installSkillOnSshHost(input: { SKILL_SSH_RELAY_INSTALL_METHOD, { request: { - ...input.request, + ...request, ingress: { kind: 'staged-upload', uploadId } }, workspace: input.workspace diff --git a/src/main/skills/skill-transaction-startup-recovery.ts b/src/main/skills/skill-transaction-startup-recovery.ts index 012cecb5e7c..ab8928f9fd9 100644 --- a/src/main/skills/skill-transaction-startup-recovery.ts +++ b/src/main/skills/skill-transaction-startup-recovery.ts @@ -18,6 +18,8 @@ import { } from './skill-remove-recovery' import { startSkillPhaseOperation } from './skill-operation-observability' import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem' +import { readSkillPlacementRecoveryJournal } from './skill-placement-recovery-journal' +import { recoverSkillPlacementTransaction } from './skill-placement-transaction' const MAX_PENDING_TRANSACTION_JOURNALS = 64 const MAX_TRANSACTION_JOURNAL_BYTES = 4 * 1024 * 1024 @@ -27,6 +29,7 @@ type PendingTransaction = { journalKey: string install: boolean removal: boolean + placement: boolean } export type SkillTransactionStartupRecoveryReport = { @@ -46,7 +49,7 @@ function failureCode(error: unknown): string { async function scanJournalDirectory( stateDirectory: string, - directoryName: 'journals' | 'removal-journals' + directoryName: 'journals' | 'removal-journals' | 'placement-journals' ): Promise<{ candidates: { canonicalPath: string; journalKey: string }[] failures: { journalKey: string; code: string }[] @@ -106,13 +109,14 @@ function pendingTransactions( const pending = new Map() const add = ( candidate: { canonicalPath: string; journalKey: string }, - kind: 'install' | 'removal' + kind: 'install' | 'removal' | 'placement' ): void => { const current = pending.get(candidate.canonicalPath) ?? { canonicalPath: candidate.canonicalPath, journalKey: candidate.journalKey, install: false, - removal: false + removal: false, + placement: false } current[kind] = true pending.set(candidate.canonicalPath, current) @@ -125,36 +129,65 @@ function pendingTransactions( async function recoverPendingSkillTransactionsUnobserved( stateDirectory: string ): Promise { - const [installs, removals, extractions, locks] = await Promise.all([ + const [installs, removals, placements, extractions, locks] = await Promise.all([ scanJournalDirectory(stateDirectory, 'journals'), scanJournalDirectory(stateDirectory, 'removal-journals'), + scanJournalDirectory(stateDirectory, 'placement-journals'), recoverPendingSkillExtractions(stateDirectory), reclaimDeadSkillInstallLocks(stateDirectory) ]) const report: SkillTransactionStartupRecoveryReport = { - scanned: installs.candidates.length + removals.candidates.length + extractions.scanned, + scanned: + installs.candidates.length + + removals.candidates.length + + placements.candidates.length + + extractions.scanned, recovered: extractions.recovered, orphanedExtractionsRecovered: extractions.recovered, orphanedLocksReclaimed: locks.reclaimed, - failures: [...installs.failures, ...removals.failures, ...extractions.failures], - truncated: installs.truncated || removals.truncated || extractions.truncated || locks.truncated + failures: [ + ...installs.failures, + ...removals.failures, + ...placements.failures, + ...extractions.failures + ], + truncated: + installs.truncated || + removals.truncated || + placements.truncated || + extractions.truncated || + locks.truncated } - for (const pending of pendingTransactions(installs.candidates, removals.candidates)) { + const pending = pendingTransactions(installs.candidates, removals.candidates) + for (const placement of placements.candidates) { + const current = pending.find((candidate) => candidate.canonicalPath === placement.canonicalPath) + if (current) { + current.placement = true + } else { + pending.push({ ...placement, install: false, removal: false, placement: true }) + } + } + for (const pendingTransaction of pending) { let releaseLock: (() => Promise) | null = null try { releaseLock = await acquireSkillInstallLock({ - path: skillInstallLockPath(stateDirectory, pending.canonicalPath) + path: skillInstallLockPath(stateDirectory, pendingTransaction.canonicalPath) }) - const installJournal = pending.install - ? await readSkillInstallRecoveryJournal(stateDirectory, pending.canonicalPath) + const installJournal = pendingTransaction.install + ? await readSkillInstallRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath) : null - const removalJournal = pending.removal - ? await readSkillRemovalRecoveryJournal(stateDirectory, pending.canonicalPath) + const removalJournal = pendingTransaction.removal + ? await readSkillRemovalRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath) + : null + const placementJournal = pendingTransaction.placement + ? await readSkillPlacementRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath) : null const distros = new Set( - [installJournal?.receipt.wslDistro, removalJournal?.receipt.wslDistro].filter( - (distro): distro is string => Boolean(distro) - ) + [ + installJournal?.receipt.wslDistro, + removalJournal?.receipt.wslDistro, + placementJournal?.wslDistro + ].filter((distro): distro is string => Boolean(distro)) ) if (distros.size > 1 || (distros.size && process.platform !== 'win32')) { throw new Error('skill-transaction-wsl-recovery-unavailable') @@ -162,23 +195,46 @@ async function recoverPendingSkillTransactionsUnobserved( const distro = [...distros][0] const filesystem = distro ? new WslSkillInstallFilesystem(distro, [ - dirname(pending.canonicalPath), - ...(removalJournal?.allowedProviderRoots ?? []) + dirname(pendingTransaction.canonicalPath), + ...(removalJournal?.allowedProviderRoots ?? []), + ...(placementJournal?.actions.map((action) => action.rootPath) ?? []) ]) : undefined if (removalJournal) { - await recoverSkillRemovalTransaction(stateDirectory, pending.canonicalPath, filesystem) + await recoverSkillRemovalTransaction( + stateDirectory, + pendingTransaction.canonicalPath, + filesystem + ) report.recovered += 1 } if (installJournal) { - await recoverSkillInstallTransaction(stateDirectory, pending.canonicalPath, filesystem) + await recoverSkillInstallTransaction( + stateDirectory, + pendingTransaction.canonicalPath, + filesystem + ) + report.recovered += 1 + } + if (placementJournal) { + await recoverSkillPlacementTransaction( + stateDirectory, + pendingTransaction.canonicalPath, + filesystem + ) report.recovered += 1 } } catch (error) { - report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) }) + report.failures.push({ + journalKey: pendingTransaction.journalKey, + code: failureCode(error) + }) } finally { await releaseLock?.().catch((error) => { - report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) }) + report.failures.push({ + journalKey: pendingTransaction.journalKey, + code: failureCode(error) + }) }) } } diff --git a/src/main/skills/skill-wsl-install-filesystem.test.ts b/src/main/skills/skill-wsl-install-filesystem.test.ts index cf259e1f27b..a85370223e5 100644 --- a/src/main/skills/skill-wsl-install-filesystem.test.ts +++ b/src/main/skills/skill-wsl-install-filesystem.test.ts @@ -9,7 +9,10 @@ vi.mock('node:child_process', () => ({ }) })) -import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem' +import { + createWslSkillInstallFilesystem, + WslSkillInstallFilesystem +} from './skill-wsl-install-filesystem' const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.agents\\skills' @@ -18,6 +21,32 @@ beforeEach(() => { }) describe('WslSkillInstallFilesystem', () => { + it('allows every global provider destination inside the selected distro', async () => { + const home = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin' + const filesystem = createWslSkillInstallFilesystem({ + distro: 'Ubuntu-24.04', + homeDirectory: home + }) + + for (const directory of [ + '.claude', + '.cursor', + '.gemini', + '.factory', + '.continue', + '.trae-cn', + '.grok', + '.augment' + ]) { + await filesystem.createAlias( + `${home}\\.agents\\skills\\private-skill`, + `${home}\\${directory}\\skills\\private-skill` + ) + } + + expect(execFileAsyncMock).toHaveBeenCalledTimes(8) + }) + it('applies and verifies manifest modes through bounded guest argv batches', async () => { const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT]) const manifest = { @@ -94,4 +123,20 @@ describe('WslSkillInstallFilesystem', () => { ).rejects.toThrow('skill-install-wsl-path-invalid') expect(execFileAsyncMock).not.toHaveBeenCalled() }) + + it('authorizes a historical provider root before update or removal', async () => { + const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT]) + const historicalRoot = + '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\claude-accounts\\old\\auth\\skills' + filesystem.authorizeRoots([historicalRoot]) + + await filesystem.remove(`${historicalRoot}\\private-skill`) + + expect(execFileAsyncMock).toHaveBeenCalledOnce() + expect(execFileAsyncMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining([ + '/home/jin/.local/share/orca/claude-accounts/old/auth/skills/private-skill' + ]) + ) + }) }) diff --git a/src/main/skills/skill-wsl-install-filesystem.ts b/src/main/skills/skill-wsl-install-filesystem.ts index 30f37527fc7..90fa71ce66a 100644 --- a/src/main/skills/skill-wsl-install-filesystem.ts +++ b/src/main/skills/skill-wsl-install-filesystem.ts @@ -9,6 +9,8 @@ import { type ObservedSkillPackage } from './skill-package-identity' import type { SkillInstallFilesystem, SkillInstalledFileMode } from './skill-install-filesystem' +import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers' +import type { SkillProviderRootOverrides } from './skill-provider-destinations' const execFileAsync = promisify(execFile) const BATCH_FILE_COUNT = 8 @@ -74,6 +76,15 @@ export class WslSkillInstallFilesystem implements SkillInstallFilesystem { this.guestAllowedRoots = allowedRoots.map((path) => this.toGuestPath(path)) } + authorizeRoots(paths: readonly string[]): void { + for (const path of paths) { + const guestRoot = this.toGuestPath(path) + if (!this.guestAllowedRoots.includes(guestRoot)) { + this.guestAllowedRoots.push(guestRoot) + } + } + } + async prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise { const guestRoot = this.requireAllowed(path) if (isWindowsBackedGuestPath(guestRoot)) { @@ -223,10 +234,18 @@ export function createWslSkillInstallFilesystem(input: { distro: string homeDirectory: string workspaceDirectory?: string + providerRootOverrides?: SkillProviderRootOverrides }): WslSkillInstallFilesystem { const scopeRoot = input.workspaceDirectory ?? input.homeDirectory + const scope = input.workspaceDirectory ? 'workspace' : 'global' + const providerRoots = SKILL_INSTALL_PROVIDERS.flatMap((provider) => { + const segments = scope === 'global' ? provider.globalSegments : provider.workspaceSegments + const overriddenRoot = + scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined + return overriddenRoot ? [overriddenRoot] : segments ? [join(scopeRoot, ...segments)] : [] + }) return new WslSkillInstallFilesystem(input.distro, [ join(scopeRoot, '.agents', 'skills'), - join(scopeRoot, '.claude', 'skills') + ...providerRoots ]) } diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts index 419055254c2..8bb46cd3d1e 100644 --- a/src/relay/skill-install-handler.test.ts +++ b/src/relay/skill-install-handler.test.ts @@ -113,6 +113,7 @@ describe('SkillInstallHandler', () => { it('advertises separately gateable install, upload, and management capabilities', () => { expect(SKILL_RELAY_CAPABILITIES).toEqual([ 'skills.install.v1', + 'skills.install-providers.v1', 'skills.install.bundle.v1', 'skills.install-progress.v1', 'skills.upload.v1', diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts index dfcd8d8a052..7c1abe68526 100644 --- a/src/relay/skill-install-handler.ts +++ b/src/relay/skill-install-handler.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_UPLOAD_CAPABILITY @@ -54,11 +55,13 @@ import { skillInstallFailureFromError } from '../main/skills/skill-install-operation-error' import { recoverPendingSkillTransactions } from '../main/skills/skill-transaction-startup-recovery' +import { resolveEnvironmentSkillProviderRoots } from '../main/skills/skill-provider-runtime-roots' const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host' export const SKILL_RELAY_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, + SKILL_INSTALL_PROVIDERS_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY, @@ -102,6 +105,7 @@ export class SkillInstallHandler { requireHttps: true, resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), detectProviders: this.detectProviders, + resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(), signal: context.signal }) ) @@ -117,6 +121,7 @@ export class SkillInstallHandler { requireHttps: true, resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), detectProviders: this.detectProviders, + resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(), signal: context.signal, onProgress: (progress) => this.installProgress.set(input.request.operationId, progress) }) @@ -135,7 +140,8 @@ export class SkillInstallHandler { previewSharedSkillInstall(input.request, { authority: this.authority(input.workspace), stateDirectory: this.stateDirectory, - detectProviders: this.detectProviders + detectProviders: this.detectProviders, + resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() }) ) }) @@ -145,7 +151,8 @@ export class SkillInstallHandler { removeSharedSkillInstall(input.request, { authority: this.authority(input.workspace), stateDirectory: this.stateDirectory, - detectProviders: this.detectProviders + detectProviders: this.detectProviders, + resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots() }) ) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5f9b6567270..4b314e97fff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1531,11 +1531,9 @@ function App(): React.JSX.Element { const hasTabBar = tabCount >= 2 const showTitlebarExpandButton = workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded // Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there. + // Skills is reached from the sidebar like Artifacts, so it keeps the sidebar visible to navigate away from. const showSidebar = - activeView !== 'settings' && - activeView !== 'activity' && - activeView !== 'space' && - activeView !== 'skills' + activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space' // Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it). const stackedSidebarOpen = !workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 099478745e4..57d2e207f69 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1679,6 +1679,13 @@ html.native-shell .app-layout { animation: collapsible-up 180ms ease-out; } +@media (prefers-reduced-motion: reduce) { + .collapsible-height-content[data-state='open'], + .collapsible-height-content[data-state='closed'] { + animation: none; + } +} + @keyframes collapsible-down { from { height: 0; diff --git a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx index 4a9ce103447..cf6aec97946 100644 --- a/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx +++ b/src/renderer/src/components/settings/OrcaAccountSettingsPane.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react' -import { Check, CircleUserRound, Files, Smartphone } from 'lucide-react' +import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { OrcaProfileSignOutConfirmDialog } from '../orca-profiles/OrcaProfileSignOutConfirmDialog' @@ -34,6 +35,28 @@ function accountStatusCopy( return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…') } +function AccountBenefit({ + icon: Icon, + title, + description, + className +}: { + icon: typeof Files + title: string + description: string + className?: string +}): React.JSX.Element { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} + export function OrcaAccountSettingsPane(): React.JSX.Element { const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const connecting = useAppStore((state) => state.orcaProfileConnecting) @@ -120,38 +143,41 @@ export function OrcaAccountSettingsPane(): React.JSX.Element { 'Included with your account' )}

-
-
- -
-

- {translate( - 'auto.components.settings.orcaAccount.artifactsTitle', - 'Artifact sharing' - )} -

-

- {translate( - 'auto.components.settings.orcaAccount.artifactsDescription', - 'Publish HTML and Markdown files, then manage every shared link from Orca.' - )} -

-
-
-
- -
-

- {translate('auto.components.settings.orcaAccount.relayTitle', 'Orca Relay')} -

-

- {translate( - 'auto.components.settings.orcaAccount.relayDescription', - 'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.' - )} -

-
+
+
+ +
+ {/* Why: a third column would squeeze all three; a full-width row + below keeps the pair's divider and reads as one list. */} +
diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx deleted file mode 100644 index 26ae6acd50b..00000000000 --- a/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// @vitest-environment happy-dom - -import '@testing-library/jest-dom/vitest' -import { cleanup, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { OwnedSkillShareLinks } from './OwnedSkillShareLinks' - -vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback -})) - -vi.mock('sonner', () => ({ toast: { success: vi.fn() } })) - -afterEach(() => { - cleanup() - Reflect.deleteProperty(window, 'api') -}) - -describe('OwnedSkillShareLinks', () => { - it('copies and revokes owner-only shared links', async () => { - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - const revokeShare = vi.fn().mockResolvedValue({ status: 'ok', value: undefined }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { - ui: { writeClipboardText }, - skills: { - listOwnedShares: vi.fn().mockResolvedValue({ - status: 'ok', - value: [ - { - id: 'share_1', - url: 'https://share.onorca.dev/skills/share/share_1', - packageId: 'package_1', - name: 'team-skills', - description: 'Team bundle', - createdAt: '2026-08-12T00:00:00.000Z' - } - ] - }), - revokeShare - } - } - }) - const user = userEvent.setup() - render() - - await screen.findByText('team-skills') - await user.click(screen.getByRole('button', { name: 'Copy link' })) - expect(writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/skills/share/share_1') - - const unshare = screen.getByRole('button', { name: 'Unshare' }) - unshare.focus() - await user.click(unshare) - expect(revokeShare).not.toHaveBeenCalled() - const confirm = screen.getByRole('button', { name: 'Confirm unshare' }) - expect(confirm).toHaveFocus() - await user.click(confirm) - await waitFor(() => expect(revokeShare).toHaveBeenCalledWith('share_1')) - expect(screen.queryByText('team-skills')).not.toBeInTheDocument() - }) -}) diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx deleted file mode 100644 index 1bf3c89a0fd..00000000000 --- a/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Clipboard, Link2Off, Loader2, RefreshCw } from 'lucide-react' -import { toast } from 'sonner' -import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' - -function inventoryError(status: string): string { - return status === 'reconnect-required' - ? translate( - 'auto.components.settings.shareSkills.linksReconnect', - 'Sign in again to manage shared links.' - ) - : translate( - 'auto.components.settings.shareSkills.linksUnavailable', - 'Shared links are unavailable right now.' - ) -} - -export function OwnedSkillShareLinks(): React.JSX.Element { - const [shares, setShares] = useState([]) - const [loading, setLoading] = useState(true) - const [busyShareId, setBusyShareId] = useState(null) - const [confirmShareId, setConfirmShareId] = useState(null) - const [error, setError] = useState(null) - const generation = useRef(0) - - const load = useCallback(async (): Promise => { - const current = ++generation.current - setLoading(true) - setError(null) - try { - const operation = await window.api.skills.listOwnedShares() - if (generation.current !== current) { - return - } - if (operation.status !== 'ok') { - setError(inventoryError(operation.status)) - return - } - setShares(operation.value) - } catch { - if (generation.current === current) { - setError( - translate( - 'auto.components.settings.shareSkills.linksUnavailable', - 'Shared links are unavailable right now.' - ) - ) - } - } finally { - if (generation.current === current) { - setLoading(false) - } - } - }, []) - - useEffect(() => { - void load() - return () => { - generation.current += 1 - } - }, [load]) - - const copy = async (share: SkillCloudOwnedShare): Promise => { - await window.api.ui.writeClipboardText(share.url) - toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied')) - } - - const unshare = async (share: SkillCloudOwnedShare): Promise => { - if (confirmShareId !== share.id) { - setConfirmShareId(share.id) - return - } - setBusyShareId(share.id) - setError(null) - try { - const operation = await window.api.skills.revokeShare(share.id) - if (operation.status !== 'ok') { - setError(inventoryError(operation.status)) - return - } - setShares((current) => current.filter((candidate) => candidate.id !== share.id)) - setConfirmShareId(null) - toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked')) - } catch { - setError( - translate( - 'auto.components.settings.shareSkills.revokeFailed', - 'Orca could not revoke this link.' - ) - ) - } finally { - setBusyShareId(null) - } - } - - return ( -
-
-
-

- {translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')} -

-

- {translate( - 'auto.components.settings.shareSkills.activeLinksDescription', - 'Only people with a link can open it. Unshare a link to block future inspection and installs.' - )} -

-
- -
- - {!loading && shares.length === 0 && !error ? ( -

- {translate( - 'auto.components.settings.shareSkills.noActiveLinks', - 'No active links. Publish a skill bundle from Skills to create one.' - )} -

- ) : null} - - {shares.length > 0 ? ( -
    - {shares.map((share) => ( -
  • -
    -

    {share.name}

    -

    {share.url}

    -
    -
    - - -
    -
  • - ))} -
- ) : null} - - {error ? ( -

- {error} -

- ) : null} -
- ) -} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 161048e58c9..8b35219db49 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -1463,7 +1463,7 @@ function Settings(): React.JSX.Element { badge="Beta" description={translate( 'auto.components.settings.shareSkills.description', - 'Share one or many skills through unlisted, revocable links.' + 'Share your skills with an unlisted link. Anyone who has it can install them.' )} searchEntries={getSectionSearchEntries('share-skills')} > diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx index 091a4e1ddf6..74cfffcb157 100644 --- a/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx +++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { TooltipProvider } from '@/components/ui/tooltip' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -65,7 +66,11 @@ describe('ShareSkillsSettingsPane', () => { it('explains unlisted multi-skill links and opens Skills', async () => { const user = userEvent.setup() - render() + render( + + + + ) expect(screen.getByText('Unlisted skill links')).toBeInTheDocument() expect(screen.getByText('Select one or more skills')).toBeInTheDocument() @@ -82,7 +87,11 @@ describe('ShareSkillsSettingsPane', () => { it('offers owner sign-in while explaining recipients stay signed out', async () => { const user = userEvent.setup() mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } - render() + render( + + + + ) expect(screen.getByText('Sign in to share skills')).toBeInTheDocument() expect(screen.getByText(/Recipients do not need an account/)).toBeInTheDocument() @@ -93,7 +102,11 @@ describe('ShareSkillsSettingsPane', () => { it('does not offer desktop publishing from the web client', () => { mocks.state.isWebClient = true mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } - render() + render( + + + + ) expect(screen.getByText(/available in the Orca desktop app/)).toBeInTheDocument() expect(screen.queryByRole('button', { name: /Open Skills/ })).not.toBeInTheDocument() diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx index e6e0d1a39d2..70741b35fb6 100644 --- a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx +++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx @@ -1,15 +1,18 @@ import { useEffect } from 'react' -import { ArrowRight, Share2 } from 'lucide-react' +import { ArrowRight, BookOpen } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' -import { OwnedSkillShareLinks } from './OwnedSkillShareLinks' +import { SettingsSwitchRow } from './SettingsFormControls' type HowToStep = { key: string; title: string; description: string } export function ShareSkillsSettingsPane(): React.JSX.Element { const openSkillsPage = useAppStore((state) => state.openSkillsPage) + const openSkillsSharedLinks = useAppStore((state) => state.openSkillsSharedLinks) + const settings = useAppStore((state) => state.settings) + const updateSettings = useAppStore((state) => state.updateSettings) const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const connecting = useAppStore((state) => state.orcaProfileConnecting) const connect = useAppStore((state) => state.connectCurrentOrcaProfile) @@ -78,6 +81,18 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {

+ {!isWebClient ? ( + void updateSettings({ showSkillsButton: !settings?.showSkillsButton })} + /> + ) : null} + {!signedIn ? (
@@ -116,7 +131,27 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
) : null} - {signedIn && !isWebClient ? : null} + {/* Why: the link inventory grows without bound and belongs with the page + that publishes it; settings keeps the preference and the way over. */} + {signedIn && !isWebClient ? ( +
+
+

+ {translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')} +

+

+ {translate( + 'auto.components.settings.shareSkills.activeLinksDescription', + 'Only people with a link can open it. Unshare a link to block future inspection and installs.' + )} +

+
+ +
+ ) : null}
@@ -153,7 +188,7 @@ export function ShareSkillsSettingsPane(): React.JSX.Element { onClick={openSkillsPage} > - + diff --git a/src/renderer/src/components/settings/share-skills-settings-search.ts b/src/renderer/src/components/settings/share-skills-settings-search.ts index b4d82271eeb..8eb7f224563 100644 --- a/src/renderer/src/components/settings/share-skills-settings-search.ts +++ b/src/renderer/src/components/settings/share-skills-settings-search.ts @@ -7,7 +7,7 @@ export const getShareSkillsSettingsSearchEntries = createLocalizedCatalog(() => title: translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links'), description: translate( 'auto.components.settings.shareSkills.searchDescription', - 'Share one or more skills behind an unlisted, revocable link.' + 'Share your skills with an unlisted link. Anyone who has it can install them.' ), keywords: [ ...translateSearchKeyword('auto.components.settings.shareSkills.keywordSkills', 'skills'), diff --git a/src/renderer/src/components/sidebar/SidebarNav.tsx b/src/renderer/src/components/sidebar/SidebarNav.tsx index 98333ba6287..d8165ef534b 100644 --- a/src/renderer/src/components/sidebar/SidebarNav.tsx +++ b/src/renderer/src/components/sidebar/SidebarNav.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Bell, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react' +import { Bell, BookOpen, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' @@ -49,6 +49,12 @@ export function shouldShowArtifactsButton( return settings?.showArtifactsButton === true } +export function shouldShowSkillsButton( + settings: Pick | null | undefined +): boolean { + return settings?.showSkillsButton === true +} + const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry')) const SidebarNav = React.memo(function SidebarNav() { @@ -60,6 +66,7 @@ const SidebarNav = React.memo(function SidebarNav() { const openActivityPage = useAppStore((s) => s.openActivityPage) const openMobilePage = useAppStore((s) => s.openMobilePage) const openArtifactsPage = useAppStore((s) => s.openArtifactsPage) + const openSkillsPage = useAppStore((s) => s.openSkillsPage) const openModal = useAppStore((s) => s.openModal) const updateSettings = useAppStore((s) => s.updateSettings) const activeView = useAppStore((s) => s.activeView) @@ -73,10 +80,12 @@ const SidebarNav = React.memo(function SidebarNav() { const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings)) const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings)) const showArtifactsButton = useAppStore((s) => shouldShowArtifactsButton(s.settings)) + const showSkillsButton = useAppStore((s) => shouldShowSkillsButton(s.settings)) const automationsActive = activeView === 'automations' const activityActive = activeView === 'activity' const mobileActive = activeView === 'mobile' const artifactsActive = activeView === 'artifacts' + const skillsActive = activeView === 'skills' const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge') const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton) const hideAutomationsButton = React.useCallback(() => { @@ -88,6 +97,9 @@ const SidebarNav = React.memo(function SidebarNav() { const hideArtifactsButton = React.useCallback(() => { void updateSettings({ showArtifactsButton: false }) }, [updateSettings]) + const hideSkillsButton = React.useCallback(() => { + void updateSettings({ showSkillsButton: false }) + }, [updateSettings]) return (
) : null} + {showSkillsButton ? ( + + + + + + + ) : null} {showAutomationsButton ? ( diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx index 2f9292f3873..70e9c3dcaa2 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -14,6 +14,10 @@ import type { SkillInstallDestination } from '../../../../shared/skill-install-c import { SkillBundleInstallOutcome } from './SkillBundleInstallOutcome' import { SkillBundleInstallReview } from './SkillBundleInstallReview' import { SkillInstallTargetFields } from './SkillInstallTargetFields' +import { defaultSelectedSkillProviders } from './skill-install-provider-groups' +import { installSkillsActionLabel, retrySkillsActionLabel } from './skill-display-labels' +import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents' +import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers' import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices' import { useSkillInstallProgress } from './skill-install-progress-state' import { translate } from '@/i18n/i18n' @@ -56,6 +60,9 @@ export function SkillBundleInstallFlow(props: { const [result, setResult] = useState(null) const [environmentId, setEnvironmentId] = useState('local') const [scope, setScope] = useState<'global' | 'workspace'>('global') + // Why: null means "follow detection"; storing the derived default instead + // would freeze the picker on whichever machine was selected first. + const [providerChoice, setProviderChoice] = useState | null>(null) const [workspace, setWorkspace] = useState('') const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>( null @@ -63,6 +70,11 @@ export function SkillBundleInstallFlow(props: { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const installProgress = useSkillInstallProgress() + const detectedAgents = useSkillInstallDetectedAgents({ + environmentId, + wslDistro: executionTarget?.distro ?? null + }) + const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents) const workspaceChoices = useMemo( () => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }), @@ -155,6 +167,7 @@ export function SkillBundleInstallFlow(props: { ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }), selectedSkillIds: [...requestedIds], destination: target, + providers: [...providers], conflictDecisions: checked.skills .filter((skill) => CONFLICT_STATES.has(skill.currentState)) .map((skill) => ({ @@ -271,6 +284,10 @@ export function SkillBundleInstallFlow(props: { setExecutionTarget(value) resetPreview() }} + providers={providers} + detectedAgents={detectedAgents} + onProvidersChange={setProviderChoice} + busy={busy} runtimeEnvironments={runtimeEnvironments} runtimeStatus={runtimeStatus} sshConnections={sshConnections} @@ -314,16 +331,8 @@ export function SkillBundleInstallFlow(props: { {busy ? translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e03', 'Installing…') : result - ? translate( - 'auto.components.skills.SkillBundleInstallFlow.01c5a13e04', - 'Retry {{value0}} skills', - { value0: retryIds.size } - ) - : translate( - 'auto.components.skills.SkillBundleInstallFlow.01c5a13e05', - 'Install {{value0}} skills', - { value0: selectedSkillIds.size } - )} + ? retrySkillsActionLabel(retryIds.size) + : installSkillsActionLabel(selectedSkillIds.size)} ) : null} diff --git a/src/renderer/src/components/skills/SkillBundleInstallReview.tsx b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx index 15a7679f3b8..ee3ceb2d90a 100644 --- a/src/renderer/src/components/skills/SkillBundleInstallReview.tsx +++ b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react' import { AlertTriangle } from 'lucide-react' -import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract' import type { SkillBundleInstallPreview } from '../../../../shared/skill-bundle-install-contract' +import { SkillPackageChecklist } from './SkillPackageChecklist' +import { checklistItemsFromVersion } from './skill-package-checklist-items' import { translate } from '@/i18n/i18n' type BundleVersion = SkillCloudVersion & { @@ -12,14 +13,26 @@ type BundleVersion = SkillCloudVersion & { const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision']) -function skillCounts(skill: BundleVersion['manifest']['skills'][number]): { - scripts: number - executables: number -} { - return { - scripts: skill.files.filter((file) => file.path.startsWith('scripts/')).length, - executables: skill.files.filter((file) => file.executable).length +// Why: the row already names the skill, so the note only has to say what +// installing it would do to the copy already on the machine. +function destinationNotes( + preview: SkillBundleInstallPreview | null +): ReadonlyMap | undefined { + if (!preview) { + return undefined } + return new Map( + preview.skills.map((skill) => [ + skill.id, + CONFLICT_STATES.has(skill.currentState) + ? translate('auto.components.skills.install.rowNeedsDecision', 'Needs a decision') + : skill.currentState === 'unchanged' + ? translate('auto.components.skills.install.rowInstalled', 'Already installed') + : skill.currentState === 'clean-update' + ? translate('auto.components.skills.install.rowUpdate', 'Update') + : translate('auto.components.skills.install.rowNew', 'New') + ]) + ) } export function SkillBundleInstallReview(props: { @@ -34,104 +47,42 @@ export function SkillBundleInstallReview(props: { children: ReactNode }): React.JSX.Element { const { manifest } = props.version - const selectedSkills = manifest.skills.filter((skill) => props.selectedSkillIds.has(skill.id)) - const selectedFiles = selectedSkills.flatMap((skill) => skill.files) const conflicts = props.destinationPreview?.skills.filter((skill) => CONFLICT_STATES.has(skill.currentState)) ?? [] - const previewSkills = props.destinationPreview?.skills ?? [] const allSelected = props.selectedSkillIds.size === manifest.skills.length return ( -
+
-
-
-

{manifest.bundleName}

-

- {manifest.description} + {manifest.skills.length > 1 ? ( +

+

+ {translate( + 'auto.components.skills.install.chooseSkills', + 'Choose what to install from this link.' + )}

-
- - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e01', - 'Immutable version' - )} - -
-
- - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e02', - '{{value0}} skills', - { value0: selectedSkills.length } - )} - - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e03', - '{{value0}} files', - { value0: selectedFiles.length } - )} - - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e04', - '{{value0}} scripts', - { - value0: selectedFiles.filter((file) => file.path.startsWith('scripts/')).length - } - )} - - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e05', - '{{value0}} executable', - { value0: selectedFiles.filter((file) => file.executable).length } - )} - -
- {props.destinationPreview ? ( -
- + - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e15', - '{{value0}} unchanged', - { - value0: previewSkills.filter((skill) => skill.currentState === 'unchanged').length - } - )} - - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e16', - '{{value0}} updates', - { - value0: previewSkills.filter((skill) => skill.currentState === 'clean-update') - .length - } - )} - - - {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e17', - '{{value0}} conflicts', - { value0: conflicts.length } - )} - +
) : null} -

- {translate('auto.components.skills.SkillBundleInstallReview.01c5a11e06', 'SHA-256')}{' '} - {manifest.bundleDigest} -

+ {props.version.releaseNotes.trim() ? (

{translate( @@ -143,84 +94,12 @@ export function SkillBundleInstallReview(props: { ) : null}

{translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e0a', - 'Skills contain instructions and may include scripts. Treat them as code from their author.' + 'auto.components.skills.install.trustNote', + 'Skills are instructions and code from their author. Install what you trust.' )}

-
-
-
-

- {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e0b', - 'Skills to install' - )} -

-

- {translate( - 'auto.components.skills.SkillBundleInstallReview.01c5a11e0c', - 'Choose any subset from this bundle.' - )} -

-
- -
-
- {manifest.skills.map((skill) => { - const counts = skillCounts(skill) - const state = props.destinationPreview?.skills.find((item) => item.id === skill.id) - return ( - - ) - })} -
-
- {props.children} {conflicts.length ? ( diff --git a/src/renderer/src/components/skills/SkillCard.tsx b/src/renderer/src/components/skills/SkillCard.tsx deleted file mode 100644 index 9ac197c8ec2..00000000000 --- a/src/renderer/src/components/skills/SkillCard.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useId } from 'react' -import { BookOpen, Clock, FolderOpen, Share2 } from 'lucide-react' -import { toast } from 'sonner' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Checkbox } from '@/components/ui/checkbox' -import { translate } from '@/i18n/i18n' -import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills' -import { pluralize, sourceLabels } from './skill-display-labels' - -const providerLabels: Record = { - codex: 'Codex', - claude: 'Claude', - 'agent-skills': 'Agent Skills' -} - -const dateFormatter = new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' -}) - -function formatUpdatedAt(value: number | null): string { - return value ? dateFormatter.format(new Date(value)) : 'Unknown' -} - -export function SkillCard({ - skill, - onShare, - selected = false, - selectionMode = false, - selectionDisabled = false, - selectionDisabledReason, - onSelectionChange -}: { - skill: DiscoveredSkill - onShare?: () => void - selected?: boolean - selectionMode?: boolean - selectionDisabled?: boolean - selectionDisabledReason?: string | null - onSelectionChange?: (selected: boolean) => void -}): React.JSX.Element { - const selectionReasonId = useId() - const revealSkill = async (): Promise => { - const result = await window.api.shell.openInFileManager(skill.skillFilePath) - if (!result.ok) { - toast.error( - translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file') - ) - } - } - - return ( - - -
- {selectionMode ? ( - onSelectionChange?.(value === true)} - /> - ) : null} -
- -
-
-
-

{skill.name}

- - {skill.installed - ? translate('auto.components.skills.SkillsPage.0c74e7ff34', 'Installed') - : translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')} - - - {sourceLabels[skill.sourceKind]} - -
- {skill.description ? ( -

- {skill.description} -

- ) : ( -

- {translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')} -

- )} -
-
- {onShare ? ( - - - - - - {translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill')} - - - ) : null} - - - - - - {translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')} - - -
-
- - {selectionMode && selectionDisabledReason ? ( -

- {selectionDisabledReason} -

- ) : null} - -
-
- {skill.skillFilePath} -
-
- {skill.providers.map((provider) => ( - - {providerLabels[provider]} - - ))} -
-
- {skill.sourceLabel} - {pluralize(skill.fileCount, 'file')} - - - {formatUpdatedAt(skill.updatedAt)} - -
-
-
-
- ) -} diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx deleted file mode 100644 index 9e7b0aa2605..00000000000 --- a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract' -import { SkillCloudManagementActions } from './SkillCloudManagementActions' - -const details: SkillCloudPackageDetails = { - id: 'pkg_1', - name: 'private-skill', - description: 'Private skill', - createdAt: '2026-08-11T00:00:00.000Z', - canManage: true, - versions: [], - management: { - shares: [ - { - id: 'share_1', - url: 'https://share.test/skills/share/share_1', - pinnedVersionId: 'ver_1', - createdAt: '2026-08-11T00:00:00.000Z' - } - ] - } -} - -function setup() { - const skills = { - revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }), - deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }), - deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }) - } - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - const onChanged = vi.fn().mockResolvedValue(undefined) - const onPackageDeleted = vi.fn() - Object.defineProperty(window, 'api', { - configurable: true, - value: { - skills, - ui: { writeClipboardText } - } - }) - render( - - ) - return { skills, onChanged, onPackageDeleted, writeClipboardText } -} - -afterEach(() => { - cleanup() - vi.restoreAllMocks() - Reflect.deleteProperty(window, 'api') -}) - -describe('SkillCloudManagementActions', () => { - it('requires confirmation before revoking an active link', async () => { - const { skills } = setup() - expect(screen.getByText(/leaves installed copies unchanged/)).toBeTruthy() - expect(screen.queryByText('Access')).toBeNull() - const unshare = screen.getByRole('button', { name: 'Unshare' }) - unshare.focus() - fireEvent.click(unshare) - expect(skills.revokeShare).not.toHaveBeenCalled() - const confirm = screen.getByRole('button', { name: 'Confirm unshare' }) - expect(document.activeElement).toBe(confirm) - fireEvent.click(confirm) - - await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('share_1')) - }) - - it('copies an active unlisted link', async () => { - const { writeClipboardText } = setup() - fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) - await waitFor(() => - expect(writeClipboardText).toHaveBeenCalledWith('https://share.test/skills/share/share_1') - ) - }) - - it('requires confirmation before deleting the selected immutable version', async () => { - const { skills } = setup() - fireEvent.click(screen.getByRole('button', { name: 'Delete selected Cloud version' })) - expect(skills.deletePackageVersion).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Confirm version deletion' })) - - await waitFor(() => - expect(skills.deletePackageVersion).toHaveBeenCalledWith({ - packageId: 'pkg_1', - versionId: 'ver_1' - }) - ) - }) - - it('requires confirmation before deleting the Cloud package', async () => { - const { skills, onChanged, onPackageDeleted } = setup() - fireEvent.click(screen.getByRole('button', { name: 'Delete Cloud package' })) - expect(skills.deletePackage).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button', { name: 'Confirm package deletion' })) - - await waitFor(() => expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1')) - expect(onPackageDeleted).toHaveBeenCalledOnce() - expect(onChanged).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.tsx deleted file mode 100644 index 839e954b294..00000000000 --- a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import { useState } from 'react' -import { Clipboard, Link2Off, Trash2 } from 'lucide-react' -import { toast } from 'sonner' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import type { - SkillCloudOperation, - SkillCloudPackageDetails -} from '../../../../shared/skill-cloud-contract' -import { translate } from '@/i18n/i18n' - -type SkillCloudManagement = NonNullable -type SkillCloudManagementActionsProps = { - details: SkillCloudPackageDetails - selectedVersionId: string - onChanged: () => Promise - onPackageDeleted: () => void -} - -function operationMessage(operation: SkillCloudOperation): string | null { - if (operation.status === 'ok') { - return null - } - return operation.status === 'reconnect-required' - ? 'Reconnect your Orca account and try again.' - : operation.message -} - -export function SkillCloudManagementActions( - props: SkillCloudManagementActionsProps -): React.JSX.Element | null { - const management = props.details.management - if (!props.details.canManage || !management) { - return null - } - const stateKey = JSON.stringify([props.details.id, management.shares]) - return -} - -function SkillCloudManagementForm({ - details, - selectedVersionId, - onChanged, - onPackageDeleted, - management -}: SkillCloudManagementActionsProps & { - management: SkillCloudManagement -}): React.JSX.Element { - const [busyAction, setBusyAction] = useState(null) - const [confirmation, setConfirmation] = useState(null) - const [message, setMessage] = useState(null) - - const copyShareLink = async (url?: string): Promise => { - if (!url) { - return - } - await window.api.ui.writeClipboardText(url) - toast.success( - translate( - 'auto.components.skills.SkillCloudManagementActions.linkCopied', - 'Share link copied' - ) - ) - } - - const run = async ( - action: string, - operation: () => Promise>, - after: () => Promise | void = onChanged - ): Promise => { - setBusyAction(action) - setMessage(null) - try { - const result = await operation() - const failure = operationMessage(result) - if (failure) { - setMessage(failure) - return - } - setConfirmation(null) - try { - await after() - } catch { - setMessage('Cloud change completed, but Orca could not refresh package details.') - } - } catch { - setMessage('Orca could not complete this Cloud change.') - } finally { - setBusyAction(null) - } - } - - return ( -
-
-

- {translate( - 'auto.components.skills.SkillCloudManagementActions.8cac3b9362', - 'Active links' - )} -

-

- {translate( - 'auto.components.skills.SkillCloudManagementActions.activeLinkBearerDescription', - 'Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.' - )} -

- {management.shares.length === 0 ? ( -

- {translate( - 'auto.components.skills.SkillCloudManagementActions.9e6bd31487', - 'No active share links.' - )} -

- ) : ( - management.shares.map((share) => ( -
- - {share.url ?? share.id} - - {share.url ? ( - - ) : null} - -
- )) - )} -
- -
- - -
- {message ? ( -

- {message} -

- ) : null} -
- ) -} diff --git a/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx b/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx new file mode 100644 index 00000000000..26e3d1b6b57 --- /dev/null +++ b/src/renderer/src/components/skills/SkillDescriptionDisclosure.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { isLongSkillDescription } from './skill-description-length' + +/** + * A skill description doubles as its trigger documentation, so it can run for + * paragraphs. Show the opening lines and let the reader ask for the rest. + */ +export function SkillDescriptionDisclosure({ + description, + className +}: { + description: string | null | undefined + className?: string +}): React.JSX.Element | null { + const [expanded, setExpanded] = useState(false) + if (!description) { + return null + } + const long = isLongSkillDescription(description) + return ( +
+

+ {description} +

+ {long ? ( + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/skills/SkillDetailDialog.tsx b/src/renderer/src/components/skills/SkillDetailDialog.tsx new file mode 100644 index 00000000000..7466d71b81b --- /dev/null +++ b/src/renderer/src/components/skills/SkillDetailDialog.tsx @@ -0,0 +1,164 @@ +import { ClipboardCopy, FolderOpen, Share2 } from 'lucide-react' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { fileCountLabel, sourceKindLabel } from './skill-display-labels' +import { skillAgentLabel } from './skill-agent-filter' + +const updatedFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' +}) + +function DetailRow({ + label, + children +}: { + label: string + children: React.ReactNode +}): React.JSX.Element { + return ( + <> +
{label}
+
{children}
+ + ) +} + +/** Agent names come from the roots that reached the file, so a skill shared by + * several agents lists all of them rather than the coarse provider tag. */ +function agentNames( + skill: DiscoveredSkill, + agentByRootPath: ReadonlyMap +): string[] { + const roots = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] + const labels = roots + .map((root) => agentByRootPath.get(root)) + .filter((agent): agent is string => Boolean(agent)) + .map(skillAgentLabel) + return [...new Set(labels)].sort((left, right) => left.localeCompare(right)) +} + +export function SkillDetailDialog({ + skill, + agentByRootPath, + shareable, + onOpenChange, + onShare +}: { + skill: DiscoveredSkill | null + agentByRootPath: ReadonlyMap + shareable: boolean + onOpenChange: (open: boolean) => void + onShare: () => void +}): React.JSX.Element | null { + if (!skill) { + return null + } + + const copyPath = async (): Promise => { + await window.api.ui.writeClipboardText(skill.skillFilePath) + toast.success(translate('auto.components.skills.SkillRow.pathCopied', 'Path copied')) + } + + const revealSkill = async (): Promise => { + const result = await window.api.shell.openInFileManager(skill.skillFilePath) + if (!result.ok) { + toast.error( + translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file') + ) + } + } + + const agents = agentNames(skill, agentByRootPath) + + return ( + + + {/* Why: pr-10 keeps the title clear of the dialog's own close button. */} + +
+ {skill.name} + {!skill.installed ? ( + + {translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')} + + ) : null} +
+ + {sourceKindLabel(skill.sourceKind)} + {' · '} + {skill.sourceLabel} + +
+ +
+

+ {skill.description ?? + translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')} +

+
+ {agents.length > 0 ? ( + + {agents.join(', ')} + + ) : null} + + {fileCountLabel(skill.fileCount)} + + + {skill.updatedAt + ? updatedFormatter.format(new Date(skill.updatedAt)) + : translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')} + + + {/* Why: a labelled button beats an icon + tooltip here — the + tooltip would cover the very path it names. */} + + {skill.skillFilePath} + + + +
+
+ + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx b/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx new file mode 100644 index 00000000000..75b47afa83f --- /dev/null +++ b/src/renderer/src/components/skills/SkillDisclosureTrigger.tsx @@ -0,0 +1,23 @@ +import { ChevronRight } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { CollapsibleTrigger } from '@/components/ui/collapsible' + +/** Radix stamps `data-state` on the trigger, so the chevron follows the section + * without a second copy of its open state in React. */ +export function SkillDisclosureTrigger({ label }: { label: string }): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx b/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx new file mode 100644 index 00000000000..76e49896b9b --- /dev/null +++ b/src/renderer/src/components/skills/SkillInstallAgentPicker.tsx @@ -0,0 +1,139 @@ +import { ChevronRight } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Label } from '@/components/ui/label' +import { translate } from '@/i18n/i18n' +import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers' +import { + groupSkillInstallProviders, + toggledSkillProviderSelection +} from './skill-install-provider-groups' + +function AgentRow({ + name, + directory, + checked, + disabled, + note, + onCheckedChange +}: { + name: string + directory: string + checked: boolean + disabled: boolean + note: string | null + onCheckedChange?: (checked: boolean) => void +}): React.JSX.Element { + return ( +
  • + +
  • + ) +} + +/** + * Mirrors the `skills` CLI: one list, where agents that read the canonical + * `.agents/skills` root at this scope are shown already on and locked, because + * the canonical copy is written whatever the user picks. + */ +export function SkillInstallAgentPicker({ + scope, + selected, + detectedAgents, + busy, + onChange +}: { + scope: 'global' | 'workspace' + selected: ReadonlySet + detectedAgents: readonly string[] | null + busy: boolean + onChange: (next: Set) => void +}): React.JSX.Element { + const { canonical, selectable } = groupSkillInstallProviders(scope, detectedAgents) + const installed = detectedAgents ? new Set(detectedAgents) : null + const canonicalRoot = scope === 'global' ? '~/.agents/skills' : '.agents/skills' + const chosen = [ + ...canonical.map((provider) => provider.displayName), + ...selectable + .filter((choice) => selected.has(choice.provider.id)) + .map((choice) => choice.provider.displayName) + ] + return ( + +
    + + {/* Why: a full-width row with the chevron trailing, so this disclosure + and the skill rows above it open the same way. */} + + + {chosen.length === 0 + ? translate('auto.components.skills.install.agentsNone', 'No agents selected') + : translate( + 'auto.components.skills.install.agentsSelected', + 'Installing for: {{value0}}', + { value0: chosen.join(', ') } + )} + + + +
    + +
      + {canonical.map((provider) => ( + + ))} + {selectable.map(({ provider, directory }) => ( + + onChange(toggledSkillProviderSelection(selected, provider.id, checked)) + } + /> + ))} +
    + {canonical.length > 0 ? ( +

    + {translate( + 'auto.components.skills.install.agentsCanonicalNote', + '{{value0}} read {{value1}}, which every install writes.', + { + value0: canonical.map((provider) => provider.displayName).join(', '), + value1: canonicalRoot + } + )} +

    + ) : null} +
    +
    + ) +} diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx index 9e90251b539..b6bd43b5707 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx @@ -80,6 +80,13 @@ function bundleVersion(): SkillCloudVersion { } } +function detectionApi(agents: string[]) { + return { + detectAgents: vi.fn().mockResolvedValue(agents), + detectRemoteAgents: vi.fn().mockResolvedValue(agents) + } +} + function installApi(previewInstall: ReturnType) { let progressListener: ((progress: SkillInstallProgress) => void) | null = null return { @@ -154,7 +161,7 @@ describe('SkillInstallDialog', () => { render( undefined} />) await inspectSkill(sharedVersion.description) - expect(screen.getByRole('heading', { name: longName })).toBeTruthy() + expect(screen.getByRole('button', { name: new RegExp(longName) })).toBeTruthy() expect(screen.queryByText(/Published by Orca user/)).toBeNull() expect( screen.getByText( @@ -164,6 +171,131 @@ describe('SkillInstallDialog', () => { ).toBeTruthy() }) + // Why: the picker is the only place a recipient can say "not Claude"; the + // request has to carry that choice or the host installs everywhere it can. + it('installs only for the agents left checked', async () => { + const skills = installApi( + vi.fn().mockResolvedValue({ + status: 'ok', + value: { + name: 'private-skill', + packageDigest: DIGEST, + destinationIdentity: 'global:local', + currentState: 'missing', + providers: [] + } + }) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['claude', 'codex', 'droid']) } + }) + render( undefined} />) + await inspectSkill() + + fireEvent.click(await screen.findByRole('button', { name: /Installing for:/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Claude Code/ })) + fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) + + await waitFor(() => expect(skills.installShare).toHaveBeenCalled()) + const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }] + expect(request.providers).not.toContain('claude') + expect(request.providers).toContain('codex') + expect(request.providers).toContain('droid') + }) + + // Why: checking agents the machine does not have would write placements no + // agent reads, and make the user opt out of tools they never installed. + it('starts from the agents the target machine has', async () => { + const skills = installApi( + vi.fn().mockResolvedValue({ + status: 'ok', + value: { + name: 'private-skill', + packageDigest: DIGEST, + destinationIdentity: 'global:local', + currentState: 'missing', + providers: [] + } + }) + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) + render( undefined} />) + await inspectSkill() + await screen.findByRole('button', { name: 'Installing for: Codex' }) + + fireEvent.click(screen.getByRole('button', { name: 'Install skill' })) + await waitFor(() => expect(skills.installShare).toHaveBeenCalled()) + const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }] + expect(request.providers).toEqual(['codex']) + }) + + // Why: a skill description doubles as its trigger documentation and can run + // for paragraphs; the row shows one line until the reader opens it. + it('unclamps a long description when the row is opened', async () => { + const sharedVersion = version() + sharedVersion.description = `Use the public CLI. ${'trigger words.'.repeat(20)}` + const skills = installApi(vi.fn()) + skills.resolveShare.mockResolvedValue({ + status: 'ok', + value: { id: 'share_1', version: sharedVersion } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) + render( undefined} />) + await inspectSkill(sharedVersion.description) + + const description = screen.getByText(sharedVersion.description) + expect(description.className).toContain('line-clamp-1') + expect(description.className).toContain('group-data-[state=open]/row:line-clamp-none') + }) + + // Why: "view the full skill contents" is the point of the row — the file list + // is the only place a recipient sees what lands on their machine. + it('lists the files a skill would write when its row is expanded', async () => { + const skills = installApi(vi.fn()) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) + render( undefined} />) + await inspectSkill() + + expect(screen.queryByText('SKILL.md')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /private-skill/ })) + expect(screen.getByText('SKILL.md')).toBeTruthy() + }) + + // Why: a recipient cannot compare a digest against anything, and the badge + // language ("Immutable version") explained nothing. Both left the review. + it.each([ + ['skill', () => version(), 'a'], + ['bundle', () => bundleVersion(), 'c'] + ])('keeps package plumbing out of the %s review', async (_kind, build, char) => { + const skills = installApi(vi.fn()) + skills.resolveShare.mockResolvedValue({ + status: 'ok', + value: { id: 'share_1', version: build() } + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) + render( undefined} />) + await inspectSkill('skills' in build().manifest ? 'alpha description' : build().description) + + expect(screen.queryByText(/SHA-256/)).toBeNull() + expect(screen.queryByText(char.repeat(64))).toBeNull() + expect(screen.queryByText('Immutable version')).toBeNull() + expect(screen.queryByText('0 scripts')).toBeNull() + expect(screen.getAllByText(/No scripts or executables/).length).toBeGreaterThan(0) + }) + it('focuses the link first and programmatically names destination controls', async () => { const user = userEvent.setup() const onOpenChange = vi.fn() @@ -179,16 +311,26 @@ describe('SkillInstallDialog', () => { } }) ) - Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) render() expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Orca skill link' })) + // Why: the submit sits in the footer beside Close, so Enter in the field is + // the keyboard path rather than tabbing past the back-out action. + const footerButtons = screen + .getAllByRole('button') + .filter((button) => button.getAttribute('data-slot') !== 'dialog-close') + expect(footerButtons.map((button) => button.textContent?.trim())).toEqual([ + 'Close', + 'Inspect skill' + ]) await user.type( screen.getByRole('textbox', { name: 'Orca skill link' }), 'https://app.orca.dev/skills/share/share_1' ) - await user.tab() - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Inspect skill' })) await user.keyboard('{Enter}') await screen.findByText('A private skill') expect(screen.getByRole('combobox', { name: 'Machine' })).toBeTruthy() @@ -251,12 +393,15 @@ describe('SkillInstallDialog', () => { } }) }) - Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills, preflight: detectionApi(['codex']) } + }) render( undefined} />) - await inspectSkill('Two private skills') + await inspectSkill('alpha description') fireEvent.click(screen.getByRole('checkbox', { name: /alpha/ })) - fireEvent.click(screen.getByRole('button', { name: 'Install 1 skills' })) + fireEvent.click(screen.getByRole('button', { name: 'Install 1 skill' })) await screen.findByText('Skills installed and verified.') expect(skills.installBundleShare).toHaveBeenCalledWith( @@ -298,7 +443,7 @@ describe('SkillInstallDialog', () => { }) Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) render( undefined} />) - await inspectSkill('Two private skills') + await inspectSkill('alpha description') fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' })) await screen.findByText('Local copies need a decision') diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index 072518a2fb1..e972acc99f1 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -1,10 +1,9 @@ -import { useMemo, useState } from 'react' -import { Download, Loader2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Download, Loader2, ShieldCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle @@ -20,6 +19,9 @@ import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices' import { isSkillBundleVersion, type ResolvedSkillShare } from './skill-share-version-summary' import { SkillBundleInstallFlow } from './SkillBundleInstallFlow' import { SkillInstallTargetFields } from './SkillInstallTargetFields' +import { defaultSelectedSkillProviders } from './skill-install-provider-groups' +import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents' +import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers' import { SkillInstallOutcome, SkillInstallReview, @@ -49,16 +51,25 @@ export function SkillInstallDialog({ const [preview, setPreview] = useState(null) const [environmentId, setEnvironmentId] = useState('local') const [scope, setScope] = useState<'global' | 'workspace'>('global') + // Why: null means "follow detection"; storing the derived default instead + // would freeze the picker on whichever machine was selected first. + const [providerChoice, setProviderChoice] = useState | null>(null) const [workspace, setWorkspace] = useState('') const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>( null ) const [busy, setBusy] = useState(false) const [bundleBusy, setBundleBusy] = useState(false) + const autoResolved = useRef(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) const [destinationPreview, setDestinationPreview] = useState(null) const installProgress = useSkillInstallProgress() + const detectedAgents = useSkillInstallDetectedAgents({ + environmentId, + wslDistro: executionTarget?.distro ?? null + }) + const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents) const workspaceChoices = useMemo( () => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }), @@ -74,8 +85,8 @@ export function SkillInstallDialog({ [sshConnectionStates, sshTargetLabels] ) - const inspect = async (): Promise => { - const shareId = parseSkillShareId(link) + const resolveLink = useCallback(async (value: string): Promise => { + const shareId = parseSkillShareId(value) if (!shareId) { setError('Enter an Orca skill share link.') return @@ -100,7 +111,23 @@ export function SkillInstallDialog({ } finally { setBusy(false) } - } + }, []) + + const inspect = (): Promise => resolveLink(link) + + /** True from the moment a prefilled link opens the dialog until it resolves + * or fails, so the paste step never appears for a caller-supplied link. */ + const resolvingInitialLink = Boolean(initialLink) && !preview && !error + + // Why: arriving with a link already in hand means the paste step is busywork, + // so the dialog opens on the review screen instead of asking for a click. + useEffect(() => { + if (!open || !initialLink || autoResolved.current) { + return + } + autoResolved.current = true + void resolveLink(initialLink) + }, [initialLink, open, resolveLink]) const install = async (discardLocal = false): Promise => { if (!preview) { @@ -163,6 +190,7 @@ export function SkillInstallDialog({ operationId, ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }), destination, + providers: [...providers], ...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {}) }) if (operation.status === 'unsupported') { @@ -219,7 +247,15 @@ export function SkillInstallDialog({ return ( !next && !busy && !bundleBusy && close()}> - + {/* Why: DialogContent is a grid, so a child that cannot wrap (a digest, a + path, a long name) raises the grid's minimum width and the whole dialog + scrolls sideways with its content clipped. min-w-0 lets children reflow + at the dialog width; overflow-x-hidden makes sideways scroll impossible + even if some future child still refuses to shrink. */} + {bundleVersion @@ -232,18 +268,20 @@ export function SkillInstallDialog({ 'Install shared skill' )} - - {translate( - 'auto.components.skills.SkillInstallDialog.4a00d133c5', - 'Orca verifies access and package identity before changing the selected machine.' - )} - + {/* Why: the title plus what is on screen already says this; the old + line was package-identity jargon a recipient cannot act on. */} - {!preview ? ( + {resolvingInitialLink ? ( + // Why: the paste form would flash for the length of the round-trip + // before being replaced by a review the caller already asked for. +

    + + {translate('auto.components.skills.SkillInstallDialog.opening', 'Opening this link…')} +

    + ) : !preview ? ( void inspect()} /> @@ -285,6 +323,10 @@ export function SkillInstallDialog({ setExecutionTarget(value) setDestinationPreview(null) }} + providers={providers} + detectedAgents={detectedAgents} + onProvidersChange={setProviderChoice} + busy={busy} runtimeEnvironments={runtimeEnvironments} runtimeStatus={runtimeStatus} sshConnections={sshConnections} @@ -308,6 +350,24 @@ export function SkillInstallDialog({ + {!preview && !resolvingInitialLink ? ( + + ) : null} {busy && installProgress.activeOperationId ? ( + + onLinkChange(event.target.value)} + placeholder={translate( + 'auto.components.skills.SkillInstallReviewContent.66cff7a804', + 'https://app.orca.dev/skills/share/…' + )} + className="font-mono text-xs" + autoFocus + /> +

    + {translate( + 'auto.components.skills.install.linkHint', + 'Opening a link never installs anything — you review it first.' + )} +

    ) } @@ -136,7 +126,6 @@ export function SkillInstallReview({ children: ReactNode }): React.JSX.Element { const version = preview.version - const versionSummary = summarizeSkillShareVersion(version) const hasConflict = result?.status === 'conflict' || (destinationPreview && @@ -146,38 +135,11 @@ export function SkillInstallReview({ return (
    -
    -
    -

    {version.name}

    -

    - {version.description} -

    -
    - - {translate( - 'auto.components.skills.SkillInstallReviewContent.8f9833d509', - 'Immutable version' - )} - -
    -
    - - {versionSummary.fileCount}{' '} - {translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')} - - - {versionSummary.scriptCount}{' '} - {translate('auto.components.skills.SkillInstallReviewContent.87137bcb8d', 'scripts')} - - - {versionSummary.executableCount}{' '} - {translate('auto.components.skills.SkillInstallReviewContent.3d8421ca2f', 'executable')} - -
    -

    - {translate('auto.components.skills.SkillInstallReviewContent.f72ee4022a', 'SHA-256')}{' '} - {version.packageDigest} -

    + {version.releaseNotes.trim() ? (

    {translate( @@ -189,8 +151,8 @@ export function SkillInstallReview({ ) : null}

    {translate( - 'auto.components.skills.SkillInstallReviewContent.98ed90e523', - 'A skill contains instructions and may include scripts. Treat it as code from its author.' + 'auto.components.skills.install.trustNote', + 'Skills are instructions and code from their author. Install what you trust.' )}

    diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx index 3292cc17950..a5d6d682f44 100644 --- a/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallTargetFields.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { SkillInstallTargetFields } from './SkillInstallTargetFields' +import { defaultSelectedSkillProviders } from './skill-install-provider-groups' const callbacks = { onEnvironmentChange: vi.fn(), @@ -34,7 +35,10 @@ describe('SkillInstallTargetFields', () => { runtimeEnvironments: [], runtimeStatus: new Map(), sshConnections: [], - workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }] + workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }], + providers: defaultSelectedSkillProviders(null), + detectedAgents: null, + onProvidersChange: vi.fn() } const view = render() diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx index 4d070e93550..f92f7459895 100644 --- a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx +++ b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx @@ -8,6 +8,8 @@ import { SelectValue } from '@/components/ui/select' import { SKILL_INSTALL_CAPABILITY } from '../../../../shared/skill-install-capability' +import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers' +import { SkillInstallAgentPicker } from './SkillInstallAgentPicker' import type { SkillInstallWorkspaceChoice } from './skill-install-workspace-choices' import { translate } from '@/i18n/i18n' @@ -25,6 +27,10 @@ export function SkillInstallTargetFields(props: { sshConnections: { id: string; label: string; connected: boolean }[] workspaceChoices: SkillInstallWorkspaceChoice[] requiredCapability?: string + providers: ReadonlySet + detectedAgents: readonly string[] | null + onProvidersChange(next: Set): void + busy?: boolean }): React.JSX.Element { const [wslDistros, setWslDistros] = useState([]) const fieldId = useId() @@ -227,6 +233,14 @@ export function SkillInstallTargetFields(props: { ) : null}
    ) : null} + + ) } diff --git a/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx b/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx deleted file mode 100644 index bc660e7ebc7..00000000000 --- a/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { AlertTriangle, Loader2, MonitorUp, RotateCcw, Trash2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' -import type { SkillInstallResult } from '../../../../shared/skill-install-contract' -import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract' -import { SkillCloudManagementActions } from './SkillCloudManagementActions' -import type { SkillManagedInstallGroup } from './skill-managed-install-groups' -import type { skillInstallManagementCopy } from './skill-install-management-copy' - -type Copy = ReturnType - -export function SkillManagedInstallDetails({ - selected, - details, - versionId, - busy, - confirmRemove, - result, - bundleResult, - installActive, - destructiveConflict, - copy, - onVersionChange, - onInstall, - onCancelInstall, - onInstallAnotherMachine, - onRemove, - onCloudChanged, - onPackageDeleted -}: { - selected: SkillManagedInstallGroup - details: SkillCloudPackageDetails - versionId: string - busy: boolean - confirmRemove: boolean - result: SkillInstallResult | null - bundleResult: SkillBundleInstallResult | null - installActive: boolean - destructiveConflict: boolean - copy: Copy - onVersionChange: (versionId: string) => void - onInstall: (discardLocal?: boolean) => void - onCancelInstall: () => void - onInstallAnotherMachine: (shareId: string) => void - onRemove: (discardLocal?: boolean) => void - onCloudChanged: () => Promise - onPackageDeleted: () => void -}): React.JSX.Element { - const selectedVersion = details.versions.find((version) => version.versionId === versionId) - const bundleManifest = - selectedVersion?.manifest && 'skills' in selectedVersion.manifest - ? selectedVersion.manifest - : null - const activeShare = details.management?.shares[0] - return ( -
    -
    -

    - {bundleManifest?.bundleName ?? selected.installs[0]?.name} -

    -

    - {copy.installedVersion} {selected.versionId} -

    - {selected.bundleDigest ? ( -
    - {selected.installs.map((install) => ( -

    - {install.name} - {install.state} -

    - ))} -
    - ) : null} -
    - - {destructiveConflict ? ( -
    -

    - {copy.modified} -

    -

    {copy.preserveModified}

    - - -
    - ) : null} -
    - - {installActive ? ( - - ) : null} - {activeShare ? ( - - ) : null} - -
    - - {bundleResult ? ( -

    - {copy.bundleResult( - bundleResult.skills.filter((skill) => skill.status === 'installed').length, - bundleResult.skills.filter((skill) => skill.status === 'updated').length, - bundleResult.skills.filter((skill) => skill.status === 'kept-local').length - )} -

    - ) : null} -
    - ) -} diff --git a/src/renderer/src/components/skills/SkillManagedInstallList.tsx b/src/renderer/src/components/skills/SkillManagedInstallList.tsx deleted file mode 100644 index 9276d9a548f..00000000000 --- a/src/renderer/src/components/skills/SkillManagedInstallList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { translate } from '@/i18n/i18n' -import { groupInstallState, type SkillManagedInstallGroup } from './skill-managed-install-groups' - -export function SkillManagedInstallList({ - groups, - selectedKey, - onSelect -}: { - groups: SkillManagedInstallGroup[] - selectedKey: string - onSelect: (group: SkillManagedInstallGroup) => void -}): React.JSX.Element { - return ( -
    - {groups.map((group) => { - const state = groupInstallState(group) - const isBundle = Boolean(group.bundleDigest) - return ( - - ) - })} -
    - ) -} diff --git a/src/renderer/src/components/skills/SkillManagedInstallRow.tsx b/src/renderer/src/components/skills/SkillManagedInstallRow.tsx new file mode 100644 index 00000000000..c2330467f55 --- /dev/null +++ b/src/renderer/src/components/skills/SkillManagedInstallRow.tsx @@ -0,0 +1,205 @@ +import { ChevronRight, Loader2, MonitorUp, RotateCcw, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' +import type { SkillInstallResult } from '../../../../shared/skill-install-contract' +import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract' +import type { SkillManagedInstallGroup } from './skill-managed-install-groups' +import { + managedInstalledOnLabel, + managedInstallScopeLabel, + managedInstallStateNote, + managedInstallTitle, + managedSkillStateNote, + managedVersionLabel +} from './skill-managed-install-labels' + +function SkillLines({ group }: { group: SkillManagedInstallGroup }): React.JSX.Element { + return ( +
      + {group.installs.map((install) => { + const note = managedSkillStateNote(install.state) + return ( +
    • + {install.name} + {note ? {note} : null} +
    • + ) + })} +
    + ) +} + +export function SkillManagedInstallRow({ + group, + open, + details, + versionId, + busy, + confirmRemove, + installActive, + editedWarning, + bundleResult, + result, + onOpenChange, + onVersionChange, + onInstall, + onCancelInstall, + onSendToMachine, + onRemove +}: { + group: SkillManagedInstallGroup + open: boolean + details: SkillCloudPackageDetails | null + versionId: string + busy: boolean + confirmRemove: boolean + installActive: boolean + editedWarning: boolean + bundleResult: SkillBundleInstallResult | null + result: SkillInstallResult | null + onOpenChange: (open: boolean) => void + onVersionChange: (versionId: string) => void + onInstall: (discardLocal?: boolean) => void + onCancelInstall: () => void + onSendToMachine: (shareId: string) => void + onRemove: (discardLocal?: boolean) => void +}): React.JSX.Element { + const stateNote = managedInstallStateNote(group) + const installedOn = managedInstalledOnLabel(group) + const activeShare = details?.management?.shares[0] + const switching = Boolean(versionId) && versionId !== group.versionId + const retrying = result?.status === 'partial' || bundleResult?.status === 'partial' + + return ( +
  • + + + + {managedInstallTitle(group)} + + {group.installs.map((install) => install.name).join(', ')} + {' · '} + {managedInstallScopeLabel(group)} + + + {stateNote ? ( + {stateNote} + ) : null} + {installedOn ? ( + {installedOn} + ) : null} + + + + {group.installs.length > 1 ? : null} + {details ? ( + + ) : null} + {editedWarning ? ( +

    + {translate( + 'auto.components.skills.managedInstall.editedWarning', + 'Your edits are kept unless you choose to discard them.' + )} +

    + ) : null} +
    + {details ? ( + + ) : null} + {installActive ? ( + + ) : null} + {activeShare ? ( + + ) : null} + + {editedWarning ? ( + + ) : null} + {editedWarning ? ( + + ) : null} +
    +
    +
    +
  • + ) +} diff --git a/src/renderer/src/components/skills/SkillPackageChecklist.tsx b/src/renderer/src/components/skills/SkillPackageChecklist.tsx new file mode 100644 index 00000000000..e3ee36f96ae --- /dev/null +++ b/src/renderer/src/components/skills/SkillPackageChecklist.tsx @@ -0,0 +1,136 @@ +import { AlertTriangle, ChevronRight } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { byteLabel } from './skill-share-preview-summary' +import { + checklistItemSummary, + isScriptFile, + type SkillChecklistFile, + type SkillChecklistItem +} from './skill-package-checklist-items' + +function FileRow({ file }: { file: SkillChecklistFile }): React.JSX.Element { + const tag = file.executable + ? translate('auto.components.skills.share.fileExecutable', 'executable') + : isScriptFile(file) + ? translate('auto.components.skills.share.fileScript', 'script') + : null + return ( +
  • + + {file.path} + + {tag ? {tag} : null} + {byteLabel(file.size)} +
  • + ) +} + +function ChecklistRow({ + item, + selected, + note, + busy, + onSelectedChange +}: { + item: SkillChecklistItem + selected: boolean | null + note: string | null + busy: boolean + onSelectedChange?: (selected: boolean) => void +}): React.JSX.Element { + const summary = checklistItemSummary(item.files) + const description = + item.description || + translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.') + return ( +
  • + {/* Why: the description stays on the row and simply unclamps when the row + opens, so nothing is repeated and nothing is hidden behind a click. */} + +
    + {selected !== null ? ( + onSelectedChange?.(checked === true)} + /> + ) : null} + + + {item.name} + + {description} + + + {note ? ( + {note} + ) : null} + + {summary.risky ? : null} + {summary.label} + + + +
    + +
      + {item.files.map((file) => ( + + ))} +
    +
    +
    +
  • + ) +} + +/** + * What the link actually contains: one row per skill, each expandable to its + * description and file list. Selection is offered only when there is a choice + * to make — a single-skill package installs as one unit. + */ +export function SkillPackageChecklist({ + items, + selectedIds, + notes, + busy, + onSelectedChange +}: { + items: readonly SkillChecklistItem[] + selectedIds: ReadonlySet | null + notes?: ReadonlyMap + busy: boolean + onSelectedChange?: (skillId: string, selected: boolean) => void +}): React.JSX.Element { + return ( +
      + {items.map((item) => ( + onSelectedChange?.(item.id, selected)} + /> + ))} +
    + ) +} diff --git a/src/renderer/src/components/skills/SkillRow.tsx b/src/renderer/src/components/skills/SkillRow.tsx new file mode 100644 index 00000000000..e4d9b63cb26 --- /dev/null +++ b/src/renderer/src/components/skills/SkillRow.tsx @@ -0,0 +1,275 @@ +import { useId, useRef } from 'react' +import { ClipboardCopy, FolderOpen, Info, MoreHorizontal, Share2 } from 'lucide-react' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills' +import { sourceKindLabel } from './skill-display-labels' + +const providerLabels: Record = { + codex: 'Codex', + claude: 'Claude', + 'agent-skills': 'Agent Skills' +} + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric' +}) + +function formatUpdatedAt(value: number | null): string { + return value + ? dateFormatter.format(new Date(value)) + : translate('auto.components.skills.SkillRow.updatedUnknown', 'No date') +} + +type SkillRowAction = { + key: string + label: string + icon: React.JSX.Element + disabled?: boolean + onSelect: () => void +} + +export function SkillRow({ + skill, + selectionMode, + selected, + selectable, + shareable, + disabledReason, + focusable, + onOpenDetail, + onSelectionChange, + onShare, + onFocus, + onKeyDown +}: { + skill: DiscoveredSkill + selectionMode: boolean + selected: boolean + selectable: boolean + shareable: boolean + /** Row-specific explanation only; page-wide causes are stated once above the + * list instead of once per row. */ + disabledReason: string | null + focusable: boolean + onOpenDetail: () => void + onSelectionChange: (selected: boolean, range: boolean) => void + onShare: () => void + onFocus: () => void + onKeyDown: (event: React.KeyboardEvent) => void +}): React.JSX.Element { + const reasonId = useId() + // Why: Radix's checkbox callback carries no event, so the modifier has to be + // captured from the pointer press that produced it. + const rangeRef = useRef(false) + const selectionBlocked = selectionMode && !selectable + const showReason = selectionBlocked && disabledReason !== null + + const revealSkill = async (): Promise => { + const result = await window.api.shell.openInFileManager(skill.skillFilePath) + if (!result.ok) { + toast.error( + translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file') + ) + } + } + + const copyPath = async (): Promise => { + await window.api.ui.writeClipboardText(skill.skillFilePath) + toast.success(translate('auto.components.skills.SkillRow.pathCopied', 'Path copied')) + } + + const actions: SkillRowAction[] = [ + { + key: 'details', + label: translate('auto.components.skills.SkillRow.viewDetails', 'View details'), + icon: , + onSelect: onOpenDetail + }, + { + key: 'share', + label: translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill'), + icon: , + disabled: !shareable, + onSelect: onShare + }, + { + key: 'reveal', + label: translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file'), + icon: , + onSelect: () => void revealSkill() + }, + { + key: 'copy-path', + label: translate('auto.components.skills.SkillRow.copyPath', 'Copy path'), + icon: , + onSelect: () => void copyPath() + } + ] + + const activate = (): void => { + if (selectionMode) { + if (!selectionBlocked) { + onSelectionChange(!selected, rangeRef.current) + } + return + } + onOpenDetail() + } + + return ( + + +
    { + rangeRef.current = event.shiftKey + }} + onClick={activate} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + activate() + return + } + onKeyDown(event) + }} + className={cn( + 'group flex w-full cursor-pointer items-start gap-3 border-b border-border/50 px-2 py-2.5 text-left transition-colors last:border-b-0 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + selected && 'bg-accent', + selectionBlocked && 'opacity-60' + )} + > + {selectionMode ? ( + e.stopPropagation()} + > + onSelectionChange(value === true, rangeRef.current)} + /> + + ) : null} + {/* Why: metadata is its own grid column, so the description truncates + where that column starts instead of at the window edge. */} +
    +
    + + {skill.name} + + {!skill.installed ? ( + + {translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')} + + ) : null} + {showReason ? ( + + + + {translate('auto.components.skills.SkillRow.notShareable', 'Not shareable')} + + + + {disabledReason} + + + ) : null} +
    + + {sourceKindLabel(skill.sourceKind)} + + · + + + {skill.providers.map((provider) => providerLabels[provider]).join(', ')} + + · + {formatUpdatedAt(skill.updatedAt)} + +

    + {skill.description ?? + translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')} +

    + {showReason ? ( + + {disabledReason} + + ) : null} +
    +
    event.stopPropagation()} + > + + + + + + {actions.map((action) => ( + + {action.icon} + {action.label} + + ))} + + +
    +
    +
    + + {actions.map((action) => ( + + {action.icon} + {action.label} + + ))} + +
    + ) +} diff --git a/src/renderer/src/components/skills/SkillShareDialog.test.tsx b/src/renderer/src/components/skills/SkillShareDialog.test.tsx index b4fde4fb49b..9e3a6b52dea 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.test.tsx @@ -50,6 +50,14 @@ const secondSkill: DiscoveredSkill = { skillFilePath: '/home/skills/second-skill/SKILL.md' } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + function managedInstall(destinationIdentity: string): ManagedSkillInstall { return { name: 'private-skill', @@ -67,12 +75,19 @@ function managedInstall(destinationIdentity: string): ManagedSkillInstall { function setup( installs: ManagedSkillInstall[], organization = false, - selectedSkills: DiscoveredSkill[] = [skill] + selectedSkills: DiscoveredSkill[] = [skill], + previewOverrides: Partial = {}, + preparation?: Promise, + authStatus: Promise = Promise.resolve({ + cloud: { email: 'owner@example.com', ...(organization ? { activeOrgId: 'org_1' } : {}) } + }) ) { let progressListener: ((progress: SkillShareProgress) => void) | null = null const skills = { listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installs }), - prepareShare: vi.fn().mockResolvedValue(preview), + prepareShare: vi + .fn() + .mockReturnValue(preparation ?? Promise.resolve({ ...preview, ...previewOverrides })), publishShare: vi.fn(), cancelShare: vi.fn().mockResolvedValue(undefined), releaseShare: vi.fn().mockResolvedValue(undefined), @@ -86,12 +101,7 @@ function setup( value: { skills, orcaProfiles: { - authStatus: vi.fn().mockResolvedValue({ - cloud: { - email: 'owner@example.com', - ...(organization ? { activeOrgId: 'org_1', activeOrgName: 'Orca' } : {}) - } - }), + authStatus: vi.fn().mockReturnValue(authStatus), orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster: { members: [] } @@ -99,10 +109,13 @@ function setup( } } }) - render( undefined} />) + const view = render( + undefined} /> + ) return { skills, - emitProgress: (progress: SkillShareProgress) => progressListener?.(progress) + emitProgress: (progress: SkillShareProgress) => progressListener?.(progress), + unmount: view.unmount } } @@ -113,9 +126,60 @@ afterEach(() => { }) describe('SkillShareDialog', () => { + it('releases a preparation that resolves after the dialog unmounts', async () => { + const preparation = deferred() + const { skills, unmount } = setup([], false, [skill], {}, preparation.promise) + await waitFor(() => expect(skills.prepareShare).toHaveBeenCalledOnce()) + + unmount() + preparation.resolve(preview) + + await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId)) + }) + + it('releases a prepared archive when account inspection fails', async () => { + const { skills } = setup( + [], + false, + [skill], + {}, + undefined, + Promise.reject(new Error('auth unavailable')) + ) + + await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId)) + expect((await screen.findByRole('alert')).textContent).toContain( + 'Could not prepare this skill for sharing.' + ) + }) + + it('releases a prepared archive when the dialog unmounts', async () => { + const { skills, unmount } = setup([]) + await screen.findByRole('heading', { name: 'Share skill' }) + + unmount() + + await waitFor(() => expect(skills.releaseShare).toHaveBeenCalledWith(preview.preparationId)) + }) + + it('closes even when preparation cleanup fails', async () => { + const onOpenChange = vi.fn() + const { skills } = setup([]) + skills.releaseShare.mockRejectedValue(new Error('busy')) + await screen.findByRole('heading', { name: 'Share skill' }) + + cleanup() + render() + await screen.findByRole('heading', { name: 'Share skill' }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + it('accepts multiline release notes up to the published limit', async () => { setup([], false, [skill, secondSkill]) await screen.findByRole('heading', { name: 'Share skill bundle' }) + fireEvent.click(screen.getByRole('button', { name: 'Add release notes (optional)' })) const notes = screen.getByRole('textbox', { name: 'Release notes' }) as HTMLTextAreaElement const value = `Release summary\n${'x'.repeat(9_984)}` @@ -124,6 +188,40 @@ describe('SkillShareDialog', () => { expect(notes.value).toBe(value) }) + // Why: "what changed in this version?" has no answer on a first publish, so + // the field only opens itself when there is a previous version to compare to. + it('opens release notes only when publishing a new version', async () => { + const { skills } = setup([managedInstall('global:local')]) + await screen.findByRole('heading', { name: 'Publish new skill version' }) + expect(screen.getByRole('textbox', { name: 'Release notes' })).toBeTruthy() + expect(skills.prepareShare).toHaveBeenCalled() + + cleanup() + setup([]) + await screen.findByRole('heading', { name: 'Share skill' }) + expect(screen.queryByRole('textbox', { name: 'Release notes' })).toBeNull() + }) + + it('names the files that can run instead of counting zeros', async () => { + setup([], false, [skill], { + scriptPaths: ['scripts/setup.sh'], + executablePaths: ['bin/tool'] + }) + await screen.findByRole('heading', { name: 'Share skill' }) + expect(screen.getByText('1 script, 1 executable')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Review files that can run' })) + expect(screen.getByText('scripts/setup.sh')).toBeTruthy() + expect(screen.getByText('bin/tool')).toBeTruthy() + }) + + it('states that nothing in the package can run when no file can', async () => { + setup([]) + await screen.findByRole('heading', { name: 'Share skill' }) + expect(screen.getByText('No scripts or executables')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Review files that can run' })).toBeNull() + }) + it('publishes a new immutable version for one exact managed-install match', async () => { const { skills } = setup([managedInstall('global:local')]) @@ -166,7 +264,7 @@ describe('SkillShareDialog', () => { expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy() expect(skills.prepareShare).toHaveBeenCalledWith({ skillIds: [skill.id, secondSkill.id], - bundleName: 'shared-skills', + bundleName: 'private-skill-and-1-more', packageId: 'pkg_1' }) }) diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx index 1c94ed314e2..145f9066095 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.tsx @@ -13,9 +13,13 @@ import type { DiscoveredSkill } from '../../../../shared/skills' import { SkillShareDialogHeader, SkillSharePreparationReview, - SkillSharePublishedLink + SkillSharePublishProgress, + SkillSharePublishedLink, + skillShareDialogHasDescription } from './SkillShareReviewContent' +import { useAppStore } from '@/store' import { matchingManagedSkillShareInstall } from './skill-share-package-selection' +import { derivedBundleName } from './skill-bundle-name' type SkillShareDialogProps = { skills?: DiscoveredSkill[] @@ -44,7 +48,9 @@ export function SkillShareDialog({ }: SkillShareDialogProps): React.JSX.Element { const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills]) const [preview, setPreview] = useState(null) - const [author, setAuthor] = useState('') + // Why: the dialog never shows who is publishing — it only needs to know that a + // Cloud account exists, since publishing fails without one. + const [hasCloudAccount, setHasCloudAccount] = useState(false) const [releaseNotes, setReleaseNotes] = useState('') const [progress, setProgress] = useState(null) const [shareUrl, setShareUrl] = useState(null) @@ -55,12 +61,17 @@ export function SkillShareDialog({ const [publishingNewVersion, setPublishingNewVersion] = useState(false) const generation = useRef(0) const cancellationRequested = useRef(false) + const publishButtonRef = useRef(null) + const openSettingsPage = useAppStore((state) => state.openSettingsPage) + const openSettingsTarget = useAppStore((state) => state.openSettingsTarget) useEffect(() => { if (!open || selectedSkills.length === 0) { return } const current = ++generation.current + let disposed = false + let retainedPreparationId: string | null = null setPreview(null) setShareUrl(null) setProgress(null) @@ -77,27 +88,34 @@ export function SkillShareDialog({ } catch (cause) { console.warn('[skills] managed install lookup failed during share:', cause) } - const [nextPreview, auth] = await Promise.all([ - window.api.skills.prepareShare({ - skillIds: selectedSkills.map((skill) => skill.id), - bundleName: selectedSkills.length === 1 ? selectedSkills[0].name : 'shared-skills', - ...(managedInstall ? { packageId: managedInstall.packageId } : {}) - }), - window.api.orcaProfiles.authStatus() - ]) + const nextPreview = await window.api.skills.prepareShare({ + skillIds: selectedSkills.map((skill) => skill.id), + bundleName: derivedBundleName(selectedSkills), + ...(managedInstall ? { packageId: managedInstall.packageId } : {}) + }) + retainedPreparationId = nextPreview.preparationId + const auth = await window.api.orcaProfiles.authStatus() return { nextPreview, auth, managedInstall } })() .then(async ({ nextPreview, auth, managedInstall }) => { - if (generation.current !== current) { + if (disposed || generation.current !== current) { await window.api.skills.releaseShare(nextPreview.preparationId) return } + retainedPreparationId = nextPreview.preparationId setPreview(nextPreview) setPublishingNewVersion(managedInstall !== null) const cloud = auth.cloud - setAuthor(cloud?.displayName || cloud?.email || '') + setHasCloudAccount(Boolean(cloud?.displayName || cloud?.email)) }) - .catch((cause) => { + .catch(async (cause) => { + const preparationId = retainedPreparationId + retainedPreparationId = null + if (preparationId) { + await window.api.skills.releaseShare(preparationId).catch((releaseCause) => { + console.warn('[skills] failed preparation cleanup:', releaseCause) + }) + } console.warn('[skills] share preparation failed:', cause) if (generation.current === current) { setError( @@ -109,10 +127,23 @@ export function SkillShareDialog({ } }) .finally(() => { - if (generation.current === current) { + if (!disposed && generation.current === current) { setPreparing(false) } }) + return () => { + disposed = true + if (generation.current === current) { + generation.current += 1 + } + if (retainedPreparationId) { + const preparationId = retainedPreparationId + retainedPreparationId = null + void window.api.skills.releaseShare(preparationId).catch((cause) => { + console.warn('[skills] share preparation cleanup failed:', cause) + }) + } + } }, [open, selectedSkills]) useEffect(() => { @@ -126,6 +157,14 @@ export function SkillShareDialog({ }) }, [preview]) + // Why: nothing has to be typed to publish, so the primary action takes focus + // as soon as there is something to publish — Enter then completes the flow. + useEffect(() => { + if (preview && !shareUrl) { + publishButtonRef.current?.focus() + } + }, [preview, shareUrl]) + const progressPercent = useMemo(() => { if (!progress || progress.totalBytes === 0) { return 0 @@ -135,10 +174,13 @@ export function SkillShareDialog({ const close = async (): Promise => { generation.current += 1 - if (preview && !shareUrl) { - await window.api.skills.releaseShare(preview.preparationId) - } onOpenChange(false) + if (!preview || shareUrl) { + return + } + await window.api.skills.releaseShare(preview.preparationId).catch((cause) => { + console.warn('[skills] share preparation cleanup failed:', cause) + }) } const publish = async (): Promise => { @@ -203,79 +245,111 @@ export function SkillShareDialog({ return ( !next && !publishing && void close()}> - + {/* Why: the review scrolls on its own so progress, errors, and the primary + action stay visible on a short window instead of below the fold. */} + - {preparing ? ( -
    - - {translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')} -
    - ) : preview && !shareUrl ? ( - - ) : shareUrl ? ( - void copyLink()} /> - ) : null} - - {error ? ( -

    - {error} -

    - ) : null} - - - {!shareUrl ? ( - publishing ? ( - - ) : ( - - ) +
    + {preparing ? ( +
    + + {translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')} +
    + ) : preview && !shareUrl ? ( + void publish()} + /> + ) : shareUrl && preview ? ( + void copyLink()} + onManageLinks={() => { + openSettingsTarget({ pane: 'share-skills', repoId: null }) + openSettingsPage() + void close() + }} + /> ) : null} - +
    + +
    + {publishing ? ( + + ) : null} + {error ? ( +

    + {error} +

    + ) : null} + + + {!shareUrl ? ( + publishing ? ( + + ) : ( + + ) + ) : null} + +
    ) diff --git a/src/renderer/src/components/skills/SkillSharePackageSummary.tsx b/src/renderer/src/components/skills/SkillSharePackageSummary.tsx new file mode 100644 index 00000000000..5f187cf93be --- /dev/null +++ b/src/renderer/src/components/skills/SkillSharePackageSummary.tsx @@ -0,0 +1,108 @@ +import { AlertTriangle, FileCode2 } from 'lucide-react' +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { SkillSharePreview } from '../../../../shared/skill-sharing-contract' +import { fileCountLabel, skillCountLabel } from './skill-display-labels' +import { byteLabel, sensitiveShareFiles, summarizeShareRisk } from './skill-share-preview-summary' +import { SkillDescriptionDisclosure } from './SkillDescriptionDisclosure' +import { SkillDisclosureTrigger } from './SkillDisclosureTrigger' + +function SensitiveFileList({ preview }: { preview: SkillSharePreview }): React.JSX.Element { + return ( +
      + {sensitiveShareFiles(preview).map((file) => ( +
    • + + {file.path} + + + {file.executable + ? translate('auto.components.skills.share.fileExecutable', 'executable') + : translate('auto.components.skills.share.fileScript', 'script')} + +
    • + ))} +
    + ) +} + +function BundleSkillList({ preview }: { preview: SkillSharePreview }): React.JSX.Element { + return ( +
    + {(preview.skills ?? []).map((skill) => ( +
    +
    +

    {skill.name}

    +

    {skill.description}

    +
    + + {fileCountLabel(skill.fileCount)} · {byteLabel(skill.totalBytes)} + +
    + ))} +
    + ) +} + +export function SkillSharePackageSummary({ + preview +}: { + preview: SkillSharePreview +}): React.JSX.Element { + const skillCount = preview.skillCount ?? preview.skills?.length ?? 1 + const bundle = skillCount > 1 + const risk = summarizeShareRisk(preview) + return ( +
    +
    +
    + +
    +
    + {/* Why: a bundle's package name is synthesized ("shared-skills"), so the + count is the only honest heading; the list below names the members. */} +

    + {bundle ? skillCountLabel(skillCount) : preview.name} +

    + {bundle ? null : } +

    + {fileCountLabel(preview.fileCount)} + · + {byteLabel(preview.totalBytes)} + · + + {risk.risky ? : null} + {risk.label} + +

    +
    +
    + + {risk.risky ? ( + + + + + + + ) : null} + + {bundle && (preview.skills?.length ?? 0) > 0 ? ( + + + + + + + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx b/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx new file mode 100644 index 00000000000..7b2a540e8c8 --- /dev/null +++ b/src/renderer/src/components/skills/SkillShareReleaseNotesField.tsx @@ -0,0 +1,68 @@ +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible' +import { Label } from '@/components/ui/label' +import { translate } from '@/i18n/i18n' +import { SkillDisclosureTrigger } from './SkillDisclosureTrigger' + +/** + * Collapsed on a first publish, where "what changed" has no answer yet, and open + * for a new version, where the notes are the reason to read the dialog. + */ +export function SkillShareReleaseNotesField({ + value, + newVersion, + onChange, + onSubmit +}: { + value: string + newVersion: boolean + onChange: (value: string) => void + onSubmit: () => void +}): React.JSX.Element { + const isMac = navigator.userAgent.includes('Mac') + return ( + + + + +