diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index 5b31b73547c..025767402ac 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -7,8 +7,8 @@ Last updated: 2026-08-11. Implementation baselines captured by this checklist update: - Orca implementation: `6ba6107dba` on `skills-share` (pushed; no PR). -- Orca Cloud: `4e91cf9` on `main` (skill OIDC smoke in `stablyai/orca-cloud#313` and - release-metadata convergence in `#314`). +- Orca Cloud: `be00db10` on `main` (opaque audience IDs in `stablyai/orca-cloud#317`, with the + skill OIDC smoke and release-metadata convergence from `#313` and `#314`). Validated so far: @@ -73,6 +73,16 @@ Validated so far: lifecycle; skill upload/finalize/download; two immutable versions; recipient and outsider authorization; local and remote grants; rollback selection; expiry; revocation; package deletion; and signed-object cleanup. +- Cloud PR `#317` passed both required checks and merged as `be00db10`. Deploy run `31546194596` + promoted revision `orca-cloud-api-staging-00048-xom` at 100% traffic with + `authenticatedSmoke: true` and `skillSmoke: true`; canonical health and the immutable image + digest independently matched after promotion. +- The signed-in macOS desktop staging journey reached upload, finalize, durable share creation, + and global install after replacing an invalid colon in the audience-derived idempotency key. + The retry test now requires a stable path-safe key, and the live test records the package ID + before publication so a failed share can still clean up its finalized package. Its update phase + uncovered same-name discovery ambiguity; the harness now selects the exact workspace source + directory instead of the first same-named global skill. A final full live rerun remains open. - The serving API has all four controls `true`, zero revision errors, and privacy-safe structured request logs. The log field inventory contains no principals, credentials, signed URLs, object paths, or private contents; a sensitive-value scan returned zero matches. @@ -80,9 +90,9 @@ Validated so far: `31536141357`, merged as `4e91cf9`, and passed `main` run `31536329459`. The targeted Auth/API plan then reported zero intended changes. Known SQL and artifact-bucket drift was excluded and never applied. -- Guarded sleep run `31536547916` stopped all three Relay cells. Read-only status run - `31537525219` verified Cloud SQL policy `NEVER`, every MIG target at zero, and both managed Cloud - Run active-revision minimums at zero. +- Guarded sleep run `31546908228` stopped all three Relay cells after the physical staging work. + Independent GCP reads verified Cloud SQL policy `NEVER`, every MIG target at zero, and both + managed Cloud Run minimums at zero. Rollout gate: staging infrastructure, OIDC identity exchange, route registration, and the authenticated artifact/skill lifecycle smoke passed. The shared staging data plane is back in its @@ -97,14 +107,16 @@ does not mean the surrounding phase is complete. ## Definition of done -- [ ] A user can package a private local skill, choose its audience, and receive a durable Orca - share URL. -- [ ] An authorized recipient can inspect and install an immutable version globally or into a Git - worktree or plain folder workspace. +- [ ] A user can select one or many private local skills, publish one immutable Skill Bundle, and + receive one durable Orca share URL. +- [ ] An authorized recipient can inspect a bundle, choose all or a subset of its skills, and + install them globally or into a Git worktree or plain folder workspace. - [ ] Installation works on local macOS, Linux, native Windows, WSL, paired Orca runtimes, and supported SSH targets. -- [x] One canonical `.agents/skills/` copy is installed; provider-specific placements - are reconciled only for detected agents. +- [ ] 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. - [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. @@ -114,6 +126,8 @@ does not mean the surrounding phase is complete. - [x] Mixed-version clients and remote hosts fail safely through capability negotiation. - [ ] Cloud resources are Terraform-owned, monitored, recoverable, and protected by independent upload, download, and remote-install kill switches. +- [ ] The existing Skills page supports multi-select sharing, selective installation, per-skill + conflicts/results, and installed/shared bundle management. ## 0. Confirm scope and ownership @@ -125,6 +139,13 @@ does not mean the surrounding phase is complete. - [x] Decide Orca will not depend on the upstream CLI or an unsupported programmatic API. - [x] Decide V1 accepts Orca package sources only; Git, npm, and community registries remain with existing tools. +- [x] Record Agent Plugins 1.0.0 commit `bd383552095128f6effe895b9257cfd580a6d179` + as the portable-format reference. +- [x] Decide to implement from the specification without copying its CC BY prose or vendoring its + Apache-licensed schemas. +- [x] Treat the archive as a Skill Bundle, not an Orca plugin product or executable plugin runtime. +- [x] Limit V1 bundles to skills; exclude MCP servers, hooks, processes, connectors, and + permissions. - [ ] Review the architecture decision record covering the reference-only boundary with engineering and legal owners. - [x] Add a review check that flags any proposed copied or mechanically translated upstream @@ -151,6 +172,14 @@ does not mean the surrounding phase is complete. unshare/delete—while retaining skill-specific version, ACL, trust, and install semantics. - [x] Confirm V1 has no checked-in workspace desired-state lockfile and no automatic fleet-wide installation. +- [x] Confirm one skill is a one-item bundle and a bundle may contain large selections such as 30 + skills behind one durable link. +- [x] Keep bundle identity, access, versions, transport, update/rollback, revocation, and deletion + at bundle scope. +- [x] Keep installation selection, conflicts, provenance, modification protection, and outcomes at + skill scope. +- [x] Keep loose-skill installation as the universal/default path and treat provider-native plugin + adapters as optional compatibility outputs. ### Existing-code and provider research @@ -171,11 +200,34 @@ does not mean the surrounding phase is complete. ## 1. Define package and install contracts +### Bundle contract replacement + +- [ ] Replace the unpublished single-skill envelope with root `plugin.json`, `skills//`, and + `dev.orca.skill-sharing/manifest.json`; do not add an outer `bundle/` directory. +- [x] Define `SkillBundleManifestV1` with stable package/version identity, bundle metadata, ordered + skill entries, per-skill identity and file lists, globally unique archive paths, and a bundle + digest. +- [x] Validate Agent Plugins 1.0.0 `plugin.json` fields from independently authored local rules; + never fetch a schema during loading. +- [ ] Create fresh staging roots and reject unknown, malformed, or conflicting imported + `dev.orca.skill-sharing` namespaces without overwriting them. +- [x] Keep the detailed manifest inside the archive for export, remote/SSH transport, and offline + validation; keep GCS metadata compact and PostgreSQL searchable. +- [x] Add selected skill IDs and per-skill conflict choices to preview/install requests. +- [x] Return aggregate status plus ordered installed, unchanged, kept-local, and failed per-skill + outcomes; support retrying failed items only. +- [x] Add `skills.install.bundle.v1` and additive bundle RPC methods so mixed-version peers fail + before transfer. +- [x] Decide to dispose of the unpublished staging-only single-skill records before changing the + Cloud schema; there + is no production compatibility commitment to the unpublished single-skill format. + ### Shared package manifest - [x] Add `src/shared/skill-package-manifest.ts`. -- [x] Define `SkillPackageManifestV1` with schema version, stable package ID, immutable version ID, - name, description, creation time, file identities, and package digest. +- [x] Define the original single-skill `SkillPackageManifestV1` prototype with schema version, + stable package ID, immutable version ID, name, description, creation time, file identities, + and package digest. - [x] Normalize manifest paths to `/` while keeping filesystem conversion host-owned. - [x] Define the canonical digest algorithm over normalized paths, file identity, executable state, and classification. @@ -246,6 +298,12 @@ does not mean the surrounding phase is complete. - [x] Include only `skill/` contents in the eventual installed directory. - [x] Bind the user-visible share preview to the final staged digest. - [x] Clean staging files on success, cancellation, source drift, and error. +- [ ] Accept a non-empty ordered selection of skill directories and support at least 30 within the + global entry/file/byte limits. +- [ ] Reject duplicate normalized skill names and preserve selection across search/filter changes + in the caller. +- [x] Generate deterministic `plugin.json`, `skills/`, and Orca extension metadata. +- [ ] Bind the share review to every staged skill digest and the final bundle digest. ### Bounded extraction @@ -258,6 +316,9 @@ does not mean the surrounding phase is complete. - [x] Convert manifest paths with the destination runtime's path APIs. - [x] Re-observe extracted `skill/` and compare every file identity and package digest. - [x] Delete partial extraction bytes on cancellation or failure. +- [x] Validate the portable root, Orca extension namespace, bundle manifest, and every selected + `skills/` subtree before destination mutation. +- [ ] Extract selected skill subtrees without requiring installation of unselected skills. ### Package tests @@ -381,6 +442,12 @@ does not mean the surrounding phase is complete. installations. - [x] Detect an agent before creating its provider-specific configuration root. - [x] Avoid modifying roots for agents that consume `.agents/skills` directly. +- [ ] Keep loose-skill placement as the universal/default installation path. +- [ ] Use the portable root directly for compatible Cursor installations. +- [ ] Generate validated `.claude-plugin/plugin.json` and `.codex-plugin/plugin.json` adapters only + for detected clients where native plugin installation is useful. +- [ ] Fall back to loose skills for unsupported plugin clients, including the current Codex IDE + extension, and retain bundle/version provenance for every installed skill. ### Placement reconciliation @@ -551,6 +618,14 @@ does not mean the surrounding phase is complete. tenant-scoped archive and final GCS key/generation constraints. - [x] Add active-share, ACL-principal, and pending-upload expiration indexes. - [x] Add foreign keys preventing blob-reference deletion while a published version uses it. +- [x] Accept and stream-validate both the pre-release single-skill envelope and the new Agent + Plugins skills-only bundle envelope during the desktop migration. +- [x] Store the detailed bundle manifest in the existing immutable version catalog and derive the + searchable bundle name and digest without changing object or ACL semantics. +- [ ] Adapt package/version records to bundle-level identity and add ordered per-version skill rows + with identity, digest, counts, warnings, and archive roots. +- [ ] Keep detailed file identity in the in-archive manifest rather than duplicating it into GCS + custom metadata. - [ ] Test forward migration, rollback strategy, backup restoration, and migration compatibility during mixed API versions. @@ -646,6 +721,16 @@ does not mean the surrounding phase is complete. - [x] Return a copyable durable Orca URL after publication. - [x] Add access editing, version publishing, unshare, and package deletion actions. - [x] Make clear that unsharing blocks future installs but does not remove installed copies. +- [ ] Add **Share skills**, **Install from link**, and unified **Manage** to the existing Skills page + header. The first two are present; management still covers installed packages only. +- [x] Add selection mode whose selection survives search/filter changes and an explicit **Select + all results** action; never select all implicitly. +- [x] Keep the per-card share action as a one-skill bundle shortcut. +- [ ] Disable ineligible skills with visible reasons and block duplicate skill names before review. +- [x] Review bundle name, skill/file/byte counts, script/executable warnings, expandable per-skill + details, audience, and release notes. +- [ ] Show named preparation, upload, verification, and link-publication progress and return one + durable link for the bundle. ### Install experience @@ -660,6 +745,16 @@ does not mean the surrounding phase is complete. - [x] Render installed, unchanged, updated, partial, conflict, unsupported, cancelled, and failed results with actionable recovery. - [x] Add incomplete-coverage retry. +- [ ] Open deep links in inspection mode without starting installation. +- [x] Show publisher, organization, immutable version, skill count, scripts/executables, and + release notes before selection. +- [x] Let the recipient select all or a subset, then choose local, paired runtime, WSL, or SSH and + global or workspace scope. +- [x] Preview new, unchanged, update, and conflict counts and resolve all per-skill conflicts in one + surface with **Keep local** as the default. +- [ ] Label the primary action **Install N skills** and show aggregate progress plus current skill. + The label and aggregate phases are implemented; current-skill progress is not yet emitted. +- [x] Group results into installed, unchanged, kept local, and failed; retry failed items only. ### Skills page lifecycle @@ -668,6 +763,13 @@ does not mean the surrounding phase is complete. - [x] Show whether a package came from Orca Cloud and its accessible version history. - [x] Refresh discovery and installation state after local or remote actions. - [x] Keep Cloud deletion, share revocation, and local removal clearly separate. +- [ ] Add installed-bundle update, rollback, install-on-another-machine, inspect, and remove actions. + Grouped inspection, selective update/rollback, and one-surface safe removal are implemented; + install-on-another-machine remains. +- [ ] Add shared-bundle copy-link, access, revoke, publish-version, and Cloud-delete actions. Access, + revoke, version/package deletion are bundle-compatible; copy-link and publish-version remain. +- [x] Protect locally modified skills during bundle update and removal, including when only part of + a bundle remains managed. Keep-local is the default and destructive replacement is explicit. ### UX validation @@ -715,6 +817,8 @@ does not mean the surrounding phase is complete. ### Mixed versions +- [x] Advertise additive bundle installation separately on paired and SSH hosts; never send the + bundle method to an older peer that lacks `skills.install.bundle.v1`. - [x] Hide or disable remote install when `skills.install.v1` is absent. - [x] Show a specific host-update-required message instead of attempting the RPC. - [x] Test new client/old server and old client/new server. @@ -872,6 +976,8 @@ This does not substitute for physical SSH macOS/Windows or the supported Linux f - [x] If unsupported, offer a package download or explicit command; never install into the desktop's home as fallback. - [x] Propagate cancellation and clean partial SSH transfers and staging. +- [x] Route selective bundle installs through an additive SSH method with direct download, + client-mediated fallback, per-skill results, and old-host capability fencing. - [ ] Test SSH-only macOS, Linux at the supported floor, and Windows where the existing provider supports it. - [x] Test Git worktree and folder-workspace scope over SSH through the disposable Docker Linux @@ -1018,6 +1124,11 @@ still require the listed split metrics, latency panels, and alerts. - [ ] Run upload/finalize/download tests for expiry, denial, oversize, corruption, deduplication, revocation, deletion, and cleanup. - [ ] Run desktop-to-local, paired-runtime, `windows 2`, WSL, and SSH journeys in staging. + The macOS desktop journey has live upload, finalize, share, and first-install evidence; its + exact-source update, rollback, revocation, removal, and deletion rerun remains. +- [ ] Give the live staging E2E an owner-private persisted test profile or a short-lived + non-interactive test credential so reruns do not open PKCE login tabs or copy a user's + primary Orca session. - [x] Verify staging skill request logs contain only route, method, status, duration, and standard process/request metadata, with zero credential or private-content matches. - [ ] Verify logs, metrics, traces, diagnostics, and support bundles contain no grants or private @@ -1027,23 +1138,22 @@ still require the listed split metrics, latency panels, and alerts. - [ ] Exercise at least one quarantine lifecycle deletion and published-object soft-delete recovery. -### Production infrastructure and internal rollout +### 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. -- [ ] Enable internal accounts only. +- [ ] After every first-release gate passes, enable the feature for all accounts in one launch. - [ ] Verify one complete share, local install, remote install, update, rollback, revoke, local removal, Cloud deletion, upload expiry, and soft-delete recovery journey. - [ ] Verify upload, download, and remote-install kill switches independently. - [ ] Review error budgets, cost, authorization denials, saturation, orphan counts, and support signals. -### Gradual availability +### Launch operations -- [ ] Expand by account/organization cohort with rollback criteria and an on-call owner. - [ ] Keep upload grants, download grants, and remote installation independently controllable. -- [ ] Pause expansion on unexplained digest mismatch, archive containment failure, data leak, +- [ ] Disable the affected operation on unexplained digest mismatch, archive containment failure, data leak, unrecoverable local mutation, or cross-tenant authorization defect. - [ ] Publish user documentation for sharing, access, install destinations, updates, rollback, removal, retention, and trust. diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md index dc9a26338f3..919556fd013 100644 --- a/docs/agent-skill-sharing-installation-plan.md +++ b/docs/agent-skill-sharing-installation-plan.md @@ -15,7 +15,8 @@ Provider registry: ## Decision summary -Orca will own a focused skill packaging and installation pipeline for private sharing. +Orca will own a focused Skill Bundle packaging and installation pipeline for private sharing. A +bundle contains one or many skills; sharing one skill is the same flow with one selected item. The community [`vercel-labs/skills`](https://github.com/vercel-labs/skills) project will remain a behavioral reference, not a runtime dependency or source donor. Its npm package @@ -24,14 +25,22 @@ not provide the transaction, provenance, or private-package semantics Orca needs Orca will: -1. Package a local skill into an immutable, content-addressed artifact. +1. Package one or many local skills into one immutable, content-addressed artifact and durable + link. 2. Store private artifacts behind Orca Cloud authorization and durable share records. 3. Resolve a share into a short-lived download grant after checking access. 4. Execute installation on the machine that will use the skill. 5. Validate, stage, commit, and verify the package without silently overwriting local work. -6. Install once into `.agents/skills` and add aliases only for detected agents that need - provider-specific paths. -7. Record Orca-owned provenance independently from the community CLI's lockfiles. +6. Let the recipient install all or a selected subset, with conflicts and results reported per + skill. +7. Install each selected skill once into `.agents/skills` and add aliases only for detected agents + that need provider-specific paths. +8. Record bundle/version provenance independently from the community CLI's lockfiles. + +The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not +present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors, +and permissions are out of scope. Loose-skill installation remains the universal/default delivery +path, with provider-native plugin adapters generated only where they improve compatibility. The first release will support Orca package sources only. Arbitrary Git, npm, and community registry sources remain delegated to existing tools until there is a product need to own @@ -39,14 +48,32 @@ that larger package-manager surface. ## Current execution status -Orca implementation through `6ba6107dba` is pushed on `skills-share` without an Orca pull request. -Orca Cloud implementation is merged on `main` through `4e91cf9`. The authenticated OIDC smoke -landed in `stablyai/orca-cloud#313`; the narrow Auth release-metadata convergence follow-up landed -in `#314`. Staging infrastructure and skill routes are deployed and validated; production remains -untouched. Local, Windows, WSL, paired-runtime, and +The last pushed Orca baseline is `41a7b45c4e`; current Skill Bundle work remains uncommitted on +`skills-share` and no Orca pull request exists. Orca Cloud implementation is merged on `main` +through `be00db10`. The authenticated OIDC smoke landed in `stablyai/orca-cloud#313`; the narrow +Auth release-metadata convergence follow-up landed in `#314`; the finalization and lifecycle fixes +landed in `#317`. Staging infrastructure and skill routes are deployed and validated; production +remains untouched. Local, Windows, WSL, paired-runtime, and Docker-backed SSH validation are substantially complete; the implementation checklist records the exact evidence and remaining physical-host and failure-recovery gates. +The local redesign now has an independently implemented Agent Plugins 1.0.0 skills-only bundle +manifest, deterministic one-or-many archive creation, bounded bundle extraction, additive bundle +install contracts, per-skill conflict/results orchestration, and bundle provenance on installed +skill receipts. The recipient dialog now recognizes bundle versions, defaults to all skills, +supports subset selection, previews aggregate destination state, keeps conflicts local by default, +and groups per-skill outcomes with failed-only retry. Local and paired-runtime bundle grants use the +additive bundle RPC with staged-upload fallback. SSH now uses a separately advertised additive +bundle relay method with the same direct-download and staged-upload fallback; older hosts are +rejected before transfer. The original single-skill path remains active for legacy shares while +bundle management migrates. + +Cloud bundle validation is pushed on isolated branch `feat/skill-bundles` at `d7d1026`. It accepts +both envelopes during migration, stores the detailed bundle manifest with the immutable version, +and leaves object storage, ACLs, shares, and download grants unchanged. The full API suite passes +136 tests with one skipped. It is not merged or deployed until the desktop publisher emits the new +format and the staging smoke is updated. + The latest resilience pass adds bounded convergence after a lost final install response for both paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload. SSH now carries only schema-validated structured skill failures through optional JSON-RPC @@ -84,6 +111,15 @@ are end-user install journeys, load, quarantine lifecycle deletion, and soft-del The upstream assessment used `vercel-labs/skills` commit `c6f69c631292444cc541ac6d91e2226b0ff247da`. +The portable bundle assessment used +[`agentplugins/agent-plugins-spec`](https://github.com/agentplugins/agent-plugins-spec) commit +`bd383552095128f6effe895b9257cfd580a6d179`, specifically `spec/1.0.0.md`. The local reviewed clone +is `/Users/jinwoo/refs/misc/agent-plugins-spec`; the example repository is +`/Users/jinwoo/refs/misc/agent-plugins-example`. Orca independently implements the reviewed +behavior. The specification prose is CC BY 4.0 and its schemas/software are Apache 2.0, so Orca +will not copy the prose or vendor the schemas unless the corresponding notices are deliberately +handled. + Useful upstream behavior: - A canonical `.agents/skills/` location. @@ -108,7 +144,8 @@ attribution. Revisit this decision if implementation work ever proposes copying ## Goals -- Share a private skill with a teammate through a durable, intuitive link. +- Share one or many private skills with a teammate through one durable, intuitive link. +- Select and review large bundles, including groups of 30 skills, without repetitive dialogs. - Install on the local computer or any compatible connected Orca runtime. - Support global scope and folder-workspace/project scope. - Work on macOS, Linux, and Windows. @@ -122,6 +159,7 @@ attribution. Revisit this decision if implementation work ever proposes copying - Support immutable versions, updates, removal, and eventual team-wide reconciliation. - Remain compatible with independently updated desktop clients and remote Orca servers. - Treat ordinary folder workspaces as first-class; Git is not required. +- Preserve enough portable metadata for export, SSH/remote transfer, and offline validation. ## Non-goals for the first release @@ -132,6 +170,9 @@ attribution. Revisit this decision if implementation work ever proposes copying - Merging local modifications with a newer shared package version. - Treating installation as a security sandbox. A skill contains instructions and scripts and must be presented to the user as code from its author. +- Shipping a full plugin runtime, MCP servers, hooks, processes, connectors, or portable + permissions. +- Requiring a provider-native plugin installer; unsupported agents still receive loose skills. - End-to-end encryption from Orca Cloud operators in the initial trust model. Private means access-controlled to authorized Orca users and organizations. Application-level package encryption can be added later without changing package identity. @@ -140,19 +181,26 @@ attribution. Revisit this decision if implementation work ever proposes copying ### Sharing -The user opens an installed or workspace skill and chooses **Share skill**. +The existing Skills page has three header actions: **Share skills**, **Install from link**, and +**Manage**. **Share skills** enters selection mode across installed and workspace skills. Search +and filter changes retain selections; **Select all results** is explicit and Orca never silently +selects everything. The existing per-card share action remains a one-skill shortcut. -Orca shows: +Ineligible skills are disabled with a visible reason. Duplicate skill names must be resolved before +publication because they would collide in both the archive and destination roots. -- Skill name and description. -- Package file count and total size. -- Included scripts and executable files. +The review dialog shows: + +- Bundle name and selected skill list. +- Skill, file, and byte counts. +- Included scripts and executable files, with an expandable review for each skill. - Current author identity and organization. - Access choice: organization or selected people. - Optional version label and release notes. -After confirmation, Orca validates and packages the exact bytes shown in the preview, uploads -them, and returns a durable URL such as: +After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is +named **Preparing skills**, **Uploading**, **Verifying**, and **Publishing link**. Completion returns +one durable URL such as: ```text https://app.orca.dev/skills/share/ @@ -165,32 +213,33 @@ copied. ### Installing -Opening a share shows: +Opening a deep link launches Orca and inspects the bundle without installing it. Preview identifies +the publisher, organization, immutable version, release notes, skill count, scripts, and executable +files. The recipient can select all or a subset of the contained skills. -- Author and organization. -- Skill description, version, file summary, and package digest. -- Whether an installed skill with the same name already exists. -- Destination machine. -- Global or workspace scope. -- Detected agents that will receive coverage. -- Any conflict that requires a decision. +The destination picker supports the local machine, paired runtimes, WSL, and SSH, in global or +workspace scope. Before commit, the preview groups selected skills as new, unchanged, updates, or +conflicts and shows detected-agent coverage. Conflicts are handled per skill in one review surface, +not one dialog per conflict. **Keep local** is the default; replacement is explicit. The primary +action says **Install N skills**. -The default action installs on the current machine globally. Connected machines and folder -workspaces are selectable. The action reports one of: +Installation shows aggregate progress plus the current skill. Results are grouped as installed, +unchanged, kept local, and failed; failed items can be retried without repeating successful work. +An incompatible selected runtime receives an update-required result before transfer. -- Installed. -- Already installed. -- Updated. -- Installed with incomplete agent coverage. -- Needs a conflict decision. -- Unsupported because the selected runtime must be updated. +**Manage** separates: + +- Installed bundles: update, rollback, install on another machine, inspect skills, and remove. +- Shared bundles: copy link, change access, revoke, publish version, and delete the Cloud package. + +Bundle update or removal never silently destroys locally modified skills. The UI must follow `docs/STYLEGUIDE.md` and use existing design tokens and shadcn primitives. ## Target architecture ```text -Skill folder +Selected skill folders | v Package builder -- validates and creates immutable artifact @@ -206,10 +255,10 @@ Destination runtime | +-- package ingress +-- package inspection - +-- install planning + +-- per-skill selection and install planning +-- staged transaction +-- provider placement reconciliation - +-- provenance publication + +-- bundle and per-skill provenance publication +-- post-install discovery ``` @@ -220,59 +269,89 @@ the bytes. ## Package format Use a versioned tar archive so executable modes can be represented without a platform-specific -side channel. The archive contains an envelope rather than placing Orca metadata inside the -installed skill: +side channel. The extracted archive root is an Agent Plugins 1.0.0-compatible, skills-only plugin: ```text -manifest.json -skill/ - SKILL.md - scripts/ - references/ - assets/ +plugin.json +skills/ +├── skill-a/ +│ └── SKILL.md +├── skill-b/ +│ └── SKILL.md +└── ... +dev.orca.skill-sharing/ +└── manifest.json ``` Conceptual manifest: ```ts -type SkillPackageManifestV1 = { +type SkillBundleManifestV1 = { schemaVersion: 1 packageId: string versionId: string - name: string + bundleName: string description: string createdAt: string - files: Array<{ - path: string - size: number - executable: boolean - sha256: string + skills: Array<{ + id: string + name: string + description: string + digest: string + files: Array<{ + path: string + size: number + executable: boolean + sha256: string + }> }> - packageDigest: string + bundleDigest: string } ``` Rules: - `packageId` is stable across versions; `versionId` identifies one immutable publication. -- `packageDigest` is derived from normalized paths, executable state, classification, and file - identity using Orca's existing skill-package identity rules. -- `skill/SKILL.md` is required and must parse successfully. -- The installed folder receives only the contents of `skill/`. +- `plugin.json` uses the canonical Agent Plugins 1.0.0 `$schema`, a valid portable plugin name, + bundle version, description, and optional standard metadata. Orca validates from locally owned + rules and never fetches the schema while loading. +- Every immediate `skills//SKILL.md` is required and must parse successfully. +- Each skill digest uses normalized relative paths, executable state, classification, and file + identity. The bundle digest commits to the ordered skill identities and their globally unique + archive paths. +- The installed folders receive only the selected children of `skills/`. +- Bundle identity, access, immutable versions, upload/download, update/rollback, and Cloud deletion + are bundle-level. Conflict handling, provenance, selection, local-modification protection, and + results are skill-level. - Paths use `/` in the manifest and are converted with the executing host's path API. - LF and CRLF non-executable text share one normalized package identity, while each immutable archive retains and hashes its exact source bytes; executable and binary files always use exact bytes for identity. - Names are normalized once during publication. Installation never silently renames a conflict. +- The archive has no outer `bundle/` wrapper, so its root remains directly consumable by agents + that implement Agent Plugins. +- Orca-specific integrity and version data lives in `dev.orca.skill-sharing/manifest.json` rather + than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote + transfer, and offline validation and can describe every skill and file. +- GCS custom metadata stays compact and operational. PostgreSQL remains the searchable package, + share, and version catalog. +- Orca constructs a fresh staging root. If imported content already contains + `dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown, + malformed, or conflicting contents; it never overwrites the namespace silently. - V1 rejects symlinks and special files. A later package-builder feature may safely dereference internal links after proving they remain within the source root. - Existing limits remain the starting contract: depth 16, 2,048 entries, 512 files, 4 MiB per file, and 32 MiB total extracted bytes. Compressed download size also receives an explicit cap. - Archive and manifest schema changes are additive or introduced as a new schema version. -The package builder observes the source, copies it into a private staging directory, observes -the staged copy again, and only packages it if the two identities agree. The preview shown to -the user is bound to that final digest. +The package builder observes every selected source, copies it into a fresh private staging root, +observes the staged copies again, and only packages them if every identity agrees. The preview +shown to the user is bound to that final bundle digest. + +The original unpublished `manifest.json + skill/` staging format has no production compatibility +commitment. Orca will replace it before launch and migrate or discard staging-only records. Remote +bundle RPCs use a new `skills.install.bundle.v1` capability and additive methods so older peers do +not interpret bundle requests as single-skill requests. ## GCP deployment plan @@ -439,10 +518,13 @@ No long-lived GCP key is shipped in Orca or stored on a remote runtime. Create migrations for: -- `skill_packages`: stable package identity, owner organization, slug/name, creator, timestamps, +- `skill_packages`: stable bundle identity, owner organization, slug/name, creator, timestamps, and deletion state. - `skill_package_versions`: immutable version, package digest, archive SHA-256, GCS object key and - generation, byte counts, manifest JSON, release notes, creator, and publication state. + generation, byte and skill counts, compact searchable metadata, release notes, creator, and + publication state. +- `skill_package_version_skills`: ordered per-skill identity, name, description, digest, file and + byte counts, warnings, and archive root for preview and selective installation. - `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities, expiration, finalization state, and failure category. - `skill_package_acl`: organization or user principal, permission, creator, and timestamps. @@ -566,16 +648,16 @@ policies, signed URLs, or ACL membership. startup applies PostgreSQL migrations without registering the skill routes. 7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill route boundary before changing any control. -8. Enable route registration for the staging test cohort and run upload/finalize/download tests, +8. Enable route registration for the staging identity and run upload/finalize/download tests, including expired policies, ACL denial, oversized uploads, corrupt archives, deduplication, revocation, and object cleanup. 9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private contents. 10. Apply the reviewed production Terraform plan. -11. Run migrations, deploy the API, and enable internal accounts only. -12. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey. -13. Expand the feature flag gradually, retaining separate kill switches for upload grants, - download grants, and remote installation. +11. Run migrations and deploy the API with availability disabled. +12. After every release gate passes, enable the feature for all accounts in one launch. +13. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey, + retaining separate kill switches for upload grants, download grants, and remote installation. Production verification uses read-only commands such as: @@ -661,14 +743,30 @@ Policy: Provider releases and official documentation are reviewed periodically. Orca's registry changes only through normal review and platform tests; no upstream synchronization script owns it. +The portable root does not imply every agent can install it as a native plugin. Cursor can consume +the root `plugin.json` package directly. Claude expects `.claude-plugin/plugin.json`; Codex and +ChatGPT expect `.codex-plugin/plugin.json`; the Codex IDE extension does not currently support +plugins. Orca therefore installs loose skills by default and may generate provider-native adapters +for detected compatible clients: + +- Cursor: use the portable package directly. +- Claude: generate `.claude-plugin/plugin.json` from validated bundle metadata. +- Codex/ChatGPT: generate `.codex-plugin/plugin.json` from validated bundle metadata. +- Other or unsupported agents: install the selected loose skills. + +Native plugin installation never becomes the only path. Provenance connects every loose installed +skill to its source bundle and immutable version. + ## Installer components Create narrow modules with explicit responsibilities: ### Shared contracts -- `src/shared/skill-package-manifest.ts`: package manifest schema and canonical validation. -- `src/shared/skill-install-contract.ts`: request, preview, conflict, placement, and result types. +- `src/shared/skill-package-manifest.ts`: bundle manifest, portable plugin manifest, and canonical + validation. +- `src/shared/skill-install-contract.ts`: bundle selection, per-skill preview, conflict, placement, + and result types. - `src/shared/skill-install-capability.ts`: runtime capability name and compatibility message. ### Main/runtime implementation @@ -716,27 +814,29 @@ neutral. ### Renderer -- Share preview and access dialog. +- Multi-select bundle share review and access dialog. - Share completion dialog with copyable durable link. - Access editing, active-link revocation, immutable-version deletion, and Cloud-package deletion with explicit confirmation and copy explaining that installed copies remain local. -- Install preview with destination, scope, coverage, and conflict state. -- Install progress and structured outcome. -- Installed version, update, removal, and incomplete-coverage actions on the Skills page. +- Selective install preview with destination, scope, coverage, and per-skill conflict state. +- Aggregate install progress and grouped per-skill outcomes. +- Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on + the Skills page. ## Install request and result contract Conceptual request: ```ts -type SkillInstallRequest = { +type SkillBundleInstallRequest = { operationId: string package: { packageId: string versionId: string - packageDigest: string + bundleDigest: string compressedBytes: number } + selectedSkillIds: string[] ingress: | { kind: 'download-grant'; url: string; expiresAt: string } | { kind: 'staged-upload'; uploadId: string } @@ -744,7 +844,10 @@ type SkillInstallRequest = { destination: | { scope: 'global'; environmentId?: string } | { scope: 'workspace'; worktreeId?: string; folderWorkspaceId?: string } - conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel' + conflictResolutions?: Record< + string, + 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local' + > } ``` @@ -754,24 +857,30 @@ arbitrary remote RPC path. Conceptual result: ```ts -type SkillInstallResult = { +type SkillBundleInstallResult = { operationId: string - status: 'installed' | 'updated' | 'unchanged' | 'conflict' | 'partial' | 'failed' - name: string - packageDigest: string - canonicalPath?: string - placements: Array<{ - provider: string - path: string - topology: 'canonical-copy' | 'provider-alias' | 'independent-copy' - status: 'installed' | 'unchanged' | 'skipped' | 'failed' + packageId: string + versionId: string + bundleDigest: string + status: 'complete' | 'partial' | 'failed' + skills: Array<{ + skillId: string + name: string + status: 'installed' | 'updated' | 'unchanged' | 'kept-local' | 'failed' + canonicalPath?: string + placements: Array<{ + provider: string + path: string + topology: 'canonical-copy' | 'provider-alias' | 'independent-copy' + status: 'installed' | 'unchanged' | 'skipped' | 'failed' + errorCategory?: string + }> + conflict?: { + kind: 'modified' | 'unowned' | 'external-link' | 'name-collision' + existingDigest?: string + } errorCategory?: string }> - conflict?: { - kind: 'modified' | 'unowned' | 'external-link' | 'name-collision' - existingDigest?: string - } - errorCategory?: string } ``` @@ -781,15 +890,15 @@ The response never includes a download grant or credentials. ### 1. Admission -- Validate all request fields and package identifiers. +- Validate all request fields, package identifiers, selected skill IDs, and per-skill decisions. - Resolve the destination through host-owned runtime state. - Confirm the requested scope is writable and contained in an allowed home or workspace root. - Reject an expired grant before network access. -- Deduplicate retries using `operationId` and the package/destination identity. +- Deduplicate retries using `operationId`, package/version, selected skill set, and destination. ### 2. Lock -- Acquire a cross-process lock scoped to canonical destination and skill name. +- Acquire locks for selected canonical destinations in deterministic skill-name order. - Use an atomic lock-directory or exclusive-file creation supported on all target platforms. - Record a random owner token and start time. - Recover a stale lock only after validating its journal and owner liveness policy. diff --git a/src/main/ipc/skill-cloud-install-ipc-schemas.ts b/src/main/ipc/skill-cloud-install-ipc-schemas.ts new file mode 100644 index 00000000000..503d0ea3839 --- /dev/null +++ b/src/main/ipc/skill-cloud-install-ipc-schemas.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { SkillBundleInstallRequestSchema } from '../../shared/skill-bundle-install-contract' +import { SkillInstallDestinationSchema } from '../../shared/skill-install-contract' + +export const skillCloudInstallEnvironmentIdSchema = z.string().min(1).max(128) + +const installDestinationFields = { + operationId: z.string().min(1).max(128).optional(), + environmentId: skillCloudInstallEnvironmentIdSchema.optional(), + destination: SkillInstallDestinationSchema, + conflictResolution: z + .enum(['replace-unmodified', 'replace-and-discard-local', 'cancel']) + .optional() +} as const + +export const skillCloudShareInstallSchema = z + .object({ + shareId: z.string().min(1).max(128), + versionId: z.string().min(1).max(128).optional(), + ...installDestinationFields + }) + .strict() + +export const skillCloudBundleShareInstallSchema = z + .object({ + shareId: z.string().min(1).max(128), + versionId: z.string().min(1).max(128).optional(), + operationId: z.string().min(1).max(128).optional(), + environmentId: skillCloudInstallEnvironmentIdSchema.optional(), + selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds, + destination: SkillInstallDestinationSchema, + conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional() + }) + .strict() + +export const skillCloudPackageVersionInstallSchema = z + .object({ + packageId: z.string().min(1).max(128), + versionId: z.string().min(1).max(128), + ...installDestinationFields + }) + .strict() + +export const skillCloudBundlePackageVersionInstallSchema = z + .object({ + packageId: z.string().min(1).max(128), + versionId: z.string().min(1).max(128), + operationId: z.string().min(1).max(128).optional(), + environmentId: skillCloudInstallEnvironmentIdSchema.optional(), + selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds, + destination: SkillInstallDestinationSchema, + conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional() + }) + .strict() diff --git a/src/main/ipc/skill-cloud-ipc-handlers.ts b/src/main/ipc/skill-cloud-ipc-handlers.ts index cc7f9b69e41..46868cb1c22 100644 --- a/src/main/ipc/skill-cloud-ipc-handlers.ts +++ b/src/main/ipc/skill-cloud-ipc-handlers.ts @@ -3,24 +3,35 @@ import { join } from 'node:path' import { app, BrowserWindow, ipcMain } from 'electron' import { z } from 'zod' import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability' -import { SkillInstallDestinationSchema } from '../../shared/skill-install-contract' import type { SkillInstallProgress } from '../../shared/skill-sharing-contract' import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills' import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { installSkillCloudGrant } from '../skills/skill-cloud-grant-installation' +import { + installSkillBundleCloudGrant, + installSkillCloudGrant +} from '../skills/skill-cloud-grant-installation' import { SkillRemoteInstallCancellation } from '../skills/skill-remote-install-cancellation' import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target' import { SkillSharePreparationService } from '../skills/skill-share-preparation-service' -import { supportsSkillRuntimeInstall } from '../skills/skill-runtime-capability' +import { + supportsSkillRuntimeBundleInstall, + supportsSkillRuntimeInstall +} from '../skills/skill-runtime-capability' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers' - -const environmentIdSchema = z.string().min(1).max(128) +import { + skillCloudBundlePackageVersionInstallSchema, + skillCloudBundleShareInstallSchema, + skillCloudInstallEnvironmentIdSchema, + skillCloudPackageVersionInstallSchema, + skillCloudShareInstallSchema +} from './skill-cloud-install-ipc-schemas' const sharePrepareSchema = z .object({ - skillId: z.string().min(1).max(4096), + skillIds: z.array(z.string().min(1).max(4096)).min(1).max(512), + bundleName: z.string().regex(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/), target: SkillDiscoveryTargetSchema.optional(), packageId: z.string().min(1).max(128).optional() }) @@ -30,40 +41,15 @@ const sharePublishSchema = z .object({ preparationId: z.string().uuid(), releaseNotes: z.string().max(10_000), - userIds: z.array(z.string().min(1).max(128)).max(100), + userIds: z.array(z.string().min(1).max(255)).max(100), shareWithOrganization: z.boolean() }) .strict() -const installDestinationFields = { - operationId: z.string().min(1).max(128).optional(), - environmentId: environmentIdSchema.optional(), - destination: SkillInstallDestinationSchema, - conflictResolution: z - .enum(['replace-unmodified', 'replace-and-discard-local', 'cancel']) - .optional() -} as const - -const shareInstallSchema = z - .object({ - shareId: z.string().min(1).max(128), - versionId: z.string().min(1).max(128).optional(), - ...installDestinationFields - }) - .strict() - -const packageVersionInstallSchema = z - .object({ - packageId: z.string().min(1).max(128), - versionId: z.string().min(1).max(128), - ...installDestinationFields - }) - .strict() - const replaceAccessSchema = z .object({ packageId: z.string().min(1).max(128), - userIds: z.array(z.string().min(1).max(128)).max(100), + userIds: z.array(z.string().min(1).max(255)).max(100), shareWithOrganization: z.boolean() }) .strict() @@ -81,17 +67,24 @@ function registerSharingHandlers( ): void { const preparations = new SkillSharePreparationService( join(app.getPath('userData'), 'skill-share-preparations'), - { publish: (request) => runtime.publishSkillPackage(request) } + { + publishVersion: (request) => runtime.publishSkillPackageVersion(request), + createShare: (packageId, request) => runtime.createSkillPackageShare(packageId, request) + } ) ipcMain.handle('skills:prepareShare', async (_event, value: unknown) => { const input = sharePrepareSchema.parse(value) const result = await discover(input.target) - const skill = result.skills.find((candidate) => candidate.id === input.skillId) - if (!skill) { + const requested = new Set(input.skillIds) + const skills = result.skills.filter((candidate) => requested.has(candidate.id)) + if (skills.length !== requested.size) { throw new Error('skill-share-source-not-found') } return preparations.prepare({ - sourceDirectory: skill.directoryPath, + sources: skills.map((skill) => ({ id: skill.name, sourceDirectory: skill.directoryPath })), + bundleName: input.bundleName, + description: + skills.length === 1 ? (skills[0].description ?? '') : `${skills.length} shared skills`, packageId: input.packageId }) }) @@ -117,7 +110,9 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { const remoteInstallCancellation = new SkillRemoteInstallCancellation() const installAuthorizedGrant = async ( grant: SkillCloudDownloadGrant, - input: z.infer | z.infer + input: + | z.infer + | z.infer ) => { if (!input.environmentId || input.environmentId.startsWith('ssh:')) { return installSkillCloudGrant(runtime, grant, { @@ -133,6 +128,26 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { remoteInstallCancellation.finish(operationId, signal) } } + const installAuthorizedBundleGrant = async ( + grant: SkillCloudDownloadGrant, + input: + | z.infer + | z.infer + ) => { + if (!input.environmentId || input.environmentId.startsWith('ssh:')) { + return installSkillBundleCloudGrant(runtime, grant, { + ...input, + operationId: input.operationId ?? randomUUID() + }) + } + const operationId = input.operationId ?? randomUUID() + const signal = remoteInstallCancellation.begin(operationId) + try { + return await installSkillBundleCloudGrant(runtime, grant, { ...input, operationId }, signal) + } finally { + remoteInstallCancellation.finish(operationId, signal) + } + } ipcMain.handle('skills:resolveShare', (_event, shareId: unknown) => runtime.resolveSkillShare(z.string().min(1).max(128).parse(shareId), {}) ) @@ -148,7 +163,7 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { } } ipcMain.handle('skills:installShare', async (event, value: unknown) => { - const parsed = shareInstallSchema.parse(value) + const parsed = skillCloudShareInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( @@ -167,8 +182,29 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { } return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant }) + ipcMain.handle('skills:installBundleShare', async (event, value: unknown) => { + const parsed = skillCloudBundleShareInstallSchema.parse(value) + const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } + sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + if ( + input.environmentId && + !input.environmentId.startsWith('ssh:') && + !(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), input.environmentId)) + ) { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } + const installTarget = await classifySkillCloudInstallTarget(runtime, input) + const grant = await runtime.createSkillDownloadGrant(input.shareId, { + versionId: input.versionId, + installTarget + }) + if (grant.status === 'ok') { + sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + } + return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant + }) ipcMain.handle('skills:installPackageVersion', async (event, value: unknown) => { - const parsed = packageVersionInstallSchema.parse(value) + const parsed = skillCloudPackageVersionInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( @@ -188,11 +224,33 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { } return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant }) + ipcMain.handle('skills:installBundlePackageVersion', async (event, value: unknown) => { + const parsed = skillCloudBundlePackageVersionInstallSchema.parse(value) + const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } + sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + if ( + input.environmentId && + !input.environmentId.startsWith('ssh:') && + !(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), input.environmentId)) + ) { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } + const installTarget = await classifySkillCloudInstallTarget(runtime, input) + const grant = await runtime.createSkillPackageVersionDownloadGrant( + input.packageId, + input.versionId, + { installTarget } + ) + if (grant.status === 'ok') { + sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + } + return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant + }) ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => { const input = z .object({ operationId: z.string().min(1).max(128), - environmentId: environmentIdSchema.optional() + environmentId: skillCloudInstallEnvironmentIdSchema.optional() }) .strict() .parse(value) diff --git a/src/main/ipc/skill-install-management-ipc-handlers.ts b/src/main/ipc/skill-install-management-ipc-handlers.ts index 17e8ce3fda1..98787fbc3cd 100644 --- a/src/main/ipc/skill-install-management-ipc-handlers.ts +++ b/src/main/ipc/skill-install-management-ipc-handlers.ts @@ -2,6 +2,10 @@ import { randomUUID } from 'node:crypto' import { app, ipcMain } from 'electron' import { z } from 'zod' import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability' +import { + SkillBundleInstallPreviewRequestSchema, + SkillBundleInstallPreviewSchema +} from '../../shared/skill-bundle-install-contract' import { ManagedSkillInstallListSchema, SkillInstallDestinationSchema, @@ -10,7 +14,10 @@ import { SkillPackageIdentitySchema } from '../../shared/skill-install-contract' import type { OrcaRuntimeService } from '../runtime/orca-runtime' -import { supportsSkillRuntimeManagement } from '../skills/skill-runtime-capability' +import { + supportsSkillRuntimeBundleInstall, + supportsSkillRuntimeManagement +} from '../skills/skill-runtime-capability' import { listWslDistrosAsync } from '../wsl' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -33,6 +40,52 @@ const removeSchema = z }) .strict() +type BundlePreviewInput = z.infer & { + environmentId?: string +} + +async function previewBundleInstall(runtime: OrcaRuntimeService, input: BundlePreviewInput) { + const previews = await Promise.all( + input.selectedSkills.map(async (skill) => { + const request = { + package: { + packageId: input.package.packageId, + versionId: input.package.versionId, + packageDigest: skill.digest, + archiveSha256: input.package.archiveSha256, + compressedBytes: input.package.compressedBytes + }, + name: skill.name, + destination: input.destination + } + if (!input.environmentId) { + return runtime.previewSharedSkillInstallRequest(request) + } + const response = await callRuntimeEnvironment( + app.getPath('userData'), + input.environmentId, + 'skills.previewInstall', + request, + 30_000 + ) + if (response.ok !== true) { + throw new Error(`skill-bundle-preview-remote-${response.error.code}`) + } + return SkillInstallPreviewSchema.parse(response.result) + }) + ) + return SkillBundleInstallPreviewSchema.parse({ + packageId: input.package.packageId, + versionId: input.package.versionId, + bundleDigest: input.package.bundleDigest, + destinationIdentity: previews[0]?.destinationIdentity ?? '', + skills: input.selectedSkills.map((skill, index) => ({ + ...skill, + currentState: previews[index].currentState + })) + }) +} + export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => { const environmentId = environmentIdSchema.optional().parse(environmentIdValue) @@ -75,6 +128,25 @@ export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeSe } return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) } }) + ipcMain.handle('skills:previewBundleInstall', async (_event, value: unknown) => { + const parsed = z + .object({ + environmentId: environmentIdSchema.optional(), + package: SkillBundleInstallPreviewRequestSchema.shape.package, + selectedSkills: SkillBundleInstallPreviewRequestSchema.shape.selectedSkills, + destination: SkillInstallDestinationSchema + }) + .strict() + .parse(value) + if ( + parsed.environmentId && + (!(await supportsSkillRuntimeManagement(app.getPath('userData'), parsed.environmentId)) || + !(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), parsed.environmentId))) + ) { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } + return { status: 'ok' as const, value: await previewBundleInstall(runtime, parsed) } + }) ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => { const input = removeSchema.parse(value) const request = { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2d952d92dcd..444bd1b3901 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -152,7 +152,12 @@ import type { ManagedSkillInstall, SkillRemoveRequest } from '../../shared/skill-install-contract' +import type { + SkillBundleInstallRequest, + SkillBundleInstallResult +} from '../../shared/skill-bundle-install-contract' import { executeSkillInstallRequest } from '../skills/skill-install-request-service' +import { executeSkillBundleInstallRequest } from '../skills/skill-bundle-install-request-service' import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations' import { previewSharedSkillInstall, @@ -168,6 +173,7 @@ import type { } from '../../shared/skill-upload-session-contract' import { SkillUploadSessionService } from '../skills/skill-upload-session-service' import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' +import { installSkillBundleOnSshHost } from '../skills/skill-bundle-ssh-relay-service' import { installSkillOnSshHost, listSkillInstallsOnSshHost, @@ -4581,6 +4587,24 @@ export class OrcaRuntimeService { return this.requireSkillCloudService().publish(request) } + publishSkillPackageVersion( + request: SkillCloudPublishRequest + ): Promise> { + return this.requireSkillCloudService().publishVersion(request) + } + + createSkillPackageShare( + packageId: string, + request: SkillCloudOptions & { + pinnedVersionId?: string + userIds: string[] + shareWithOrganization: boolean + idempotencyKey?: string + } + ) { + return this.requireSkillCloudService().createShare(packageId, request) + } + resolveSkillShare( shareId: string, options: SkillCloudOptions @@ -4672,6 +4696,66 @@ export class OrcaRuntimeService { } } + async installSharedSkillBundleRequest( + request: SkillBundleInstallRequest, + signal?: AbortSignal + ): Promise { + if (this.skillInstallOperations.has(request.operationId)) { + throw new Error('skill-install-operation-in-progress') + } + const controller = new AbortController() + const abort = (): void => controller.abort() + if (signal?.aborted) { + abort() + } else { + signal?.addEventListener('abort', abort, { once: true }) + } + this.skillInstallOperations.set(request.operationId, controller) + try { + const runtimeId = this.getStatus().runtimeId + const sshTarget = await this.resolveSkillSshTarget(request.destination) + if (sshTarget) { + return installSkillBundleOnSshHost({ + provider: sshTarget.provider, + userDataPath: app.getPath('userData'), + request: { + ...request, + destination: + request.destination.scope === 'global' + ? { scope: 'global', executionTarget: { kind: 'host' } } + : request.destination + }, + workspace: sshTarget.workspace, + requireHttps: app.isPackaged, + signal: controller.signal + }) + } + const allowedDownloadOrigins = ['https://storage.googleapis.com'] + if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + allowedDownloadOrigins.push( + ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + ) + } + return await executeSkillBundleInstallRequest(request, { + authority: this.skillInstallDestinationAuthority(runtimeId), + stateDirectory: app.getPath('userData'), + allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)], + requireHttps: app.isPackaged, + resolveStagedUpload: (uploadId, identity) => + this.requireSkillUploadSessions().take(uploadId, identity), + detectProviders: detectInstalledAgentsWithShellPathHydration, + signal: controller.signal + }) + } finally { + signal?.removeEventListener('abort', abort) + if (this.skillInstallOperations.get(request.operationId) === controller) { + this.skillInstallOperations.delete(request.operationId) + } + } + } + cancelSharedSkillInstall(operationId: string): boolean { const operation = this.skillInstallOperations.get(operationId) operation?.abort() diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index f0799160f6e..e7dadbe9c07 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -87,6 +87,34 @@ describe('skills.discover RPC', () => { }) describe('skills.install RPC', () => { + it('routes one bundle request without changing the single-skill method', async () => { + const installSharedSkillBundleRequest = vi.fn(async () => ({ status: 'complete' })) + const request = { + operationId: 'operation_1', + package: { + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100 + }, + selectedSkillIds: ['alpha'], + ingress: { + kind: 'download-grant' as const, + url: 'https://storage.googleapis.com/package', + expiresAt: '2026-08-11T12:00:00.000Z' + }, + destination: { scope: 'global' as const }, + conflictDecisions: [] + } + + await method('skills.installBundle').handler(request, { + runtime: { installSharedSkillBundleRequest } + } as unknown as RpcContext) + + expect(installSharedSkillBundleRequest).toHaveBeenCalledWith(request, undefined) + }) + it('delegates installation to the executing runtime service', async () => { const installSharedSkillRequest = vi.fn(async () => ({ status: 'installed' })) const runtime = { diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 945c98d77da..e4427205fbc 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -6,6 +6,7 @@ import { SkillInstallRequestSchema, SkillRemoveRequestSchema } from '../../../../shared/skill-install-contract' +import { SkillBundleInstallRequestSchema } from '../../../../shared/skill-bundle-install-contract' import { SkillUploadBeginRequestSchema, SkillUploadChunkRequestSchema, @@ -53,6 +54,12 @@ export const SKILL_METHODS: RpcMethod[] = [ return result } }), + defineMethod({ + name: 'skills.installBundle', + params: SkillBundleInstallRequestSchema, + handler: (params, { runtime, signal }) => + runtime.installSharedSkillBundleRequest(params, signal) + }), defineMethod({ name: 'skills.cancelInstall', params: z.object({ operationId: z.string().min(1).max(128) }).strict(), diff --git a/src/main/skills/skill-bundle-creation.test.ts b/src/main/skills/skill-bundle-creation.test.ts new file mode 100644 index 00000000000..c6ee801dc86 --- /dev/null +++ b/src/main/skills/skill-bundle-creation.test.ts @@ -0,0 +1,113 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AGENT_PLUGIN_SCHEMA_V1 } from '../../shared/skill-bundle-manifest' +import { createSkillBundleArchive } from './skill-bundle-creation' +import { extractSkillBundleArchive } from './skill-bundle-extraction' + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-test-')) + temporaryDirectories.push(directory) + return directory +} + +async function createSkill(root: string, name: string, description: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n` + ) + await writeFile(join(directory, 'notes.txt'), `${name} notes\n`) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle creation and extraction', () => { + it('round trips multiple skills through an Agent Plugins compatible root', async () => { + const root = await temporaryDirectory() + const alpha = await createSkill(root, 'alpha-skill', 'Alpha') + const beta = await createSkill(root, 'beta-skill', 'Beta') + const created = await createSkillBundleArchive({ + sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'package_1', + versionId: 'version_1', + bundleName: 'team-skills', + description: 'Team skills', + createdAt: '2026-08-11T12:00:00.000Z' + }) + + const extracted = await extractSkillBundleArchive({ + archivePath: created.archivePath, + destinationDirectory: join(root, 'extracted'), + expectedBundleDigest: created.manifest.bundleDigest + }) + + expect(extracted.pluginManifest).toEqual({ + $schema: AGENT_PLUGIN_SCHEMA_V1, + name: 'team-skills', + version: 'version_1', + description: 'Team skills' + }) + expect(extracted.manifest.skills.map((skill) => skill.name)).toEqual([ + 'alpha-skill', + 'beta-skill' + ]) + expect(await readFile(join(extracted.skillsDirectory, 'beta-skill', 'notes.txt'), 'utf8')).toBe( + 'beta-skill notes\n' + ) + }) + + it('creates deterministic archives regardless of source selection order', async () => { + const root = await temporaryDirectory() + const alpha = await createSkill(root, 'alpha-skill', 'Alpha') + const beta = await createSkill(root, 'beta-skill', 'Beta') + const publication = { + packageId: 'package_1', + versionId: 'version_1', + bundleName: 'team-skills', + createdAt: '2026-08-11T12:00:00.000Z' + } + const first = await createSkillBundleArchive({ + ...publication, + sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }], + archivePath: join(root, 'first.tar.gz') + }) + const second = await createSkillBundleArchive({ + ...publication, + sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }], + archivePath: join(root, 'second.tar.gz') + }) + + expect(first.archiveSha256).toBe(second.archiveSha256) + expect(await readFile(first.archivePath)).toEqual(await readFile(second.archivePath)) + }) + + it('rejects duplicate names and source drift without publishing an archive', async () => { + const root = await temporaryDirectory() + const first = await createSkill(root, 'same-skill', 'First') + const secondRoot = join(root, 'other') + const second = await createSkill(secondRoot, 'same-skill', 'Second') + const archivePath = join(root, 'duplicate.tar.gz') + + await expect( + createSkillBundleArchive({ + sources: [{ sourceDirectory: first }, { sourceDirectory: second }], + archivePath, + packageId: 'package_1', + versionId: 'version_1', + bundleName: 'team-skills' + }) + ).rejects.toThrow('skill-bundle-skill-collision') + await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/src/main/skills/skill-bundle-creation.ts b/src/main/skills/skill-bundle-creation.ts new file mode 100644 index 00000000000..a3e7d85e879 --- /dev/null +++ b/src/main/skills/skill-bundle-creation.ts @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' +import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + AGENT_PLUGIN_MANIFEST_PATH, + AGENT_PLUGIN_SCHEMA_V1, + ORCA_SKILL_BUNDLE_MANIFEST_PATH, + computeSkillBundleDigest, + parseAgentPluginManifest, + parseSkillBundleManifest, + type AgentPluginManifestV1, + type SkillBundleEntry, + type SkillBundleManifestV1 +} from '../../shared/skill-bundle-manifest' +import { + computeSkillPackageDigest, + validateSkillPackageName +} from '../../shared/skill-package-manifest' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry' +import { extractSkillBundleArchive } from './skill-bundle-extraction' +import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity' +import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar' + +export type SkillBundleSource = { id?: string; sourceDirectory: string } + +export type CreatedSkillBundle = { + pluginManifest: AgentPluginManifestV1 + manifest: SkillBundleManifestV1 + archivePath: string + archiveSha256: string + compressedBytes: number +} + +export type SkillBundleCreationDependencies = { afterSourcesObserved?: () => Promise } + +function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean { + return ( + left.files.length === right.files.length && + left.files.every((file, index) => { + const other = right.files[index] + return ( + file.path === other.path && + file.size === other.size && + file.executable === other.executable && + file.classification === other.classification && + file.exactSha256 === other.exactSha256 && + file.identitySha256 === other.identitySha256 + ) + }) + ) +} + +function bundleEntry(input: { + id: string + name: string + description: string + observed: ObservedSkillPackage +}): SkillBundleEntry { + const files = input.observed.files.map((file) => ({ + path: file.path, + size: file.size, + executable: file.executable, + classification: file.classification, + sha256: file.exactSha256, + identitySha256: file.identitySha256 + })) + return { + id: input.id, + name: input.name, + description: input.description, + digest: computeSkillPackageDigest(files), + files + } +} + +async function stageSkill(input: { + source: SkillBundleSource + sourceObservation: ObservedSkillPackage + skillsRoot: string + name: string + description: string +}): Promise { + const stagedDirectory = join(input.skillsRoot, input.name) + await cp(input.source.sourceDirectory, stagedDirectory, { + recursive: true, + verbatimSymlinks: true, + force: false, + errorOnExist: true + }) + const stagedObservation = await observeSkillPackage(stagedDirectory) + if (!observationsMatch(input.sourceObservation, stagedObservation)) { + throw new Error('skill-package-source-changed-during-staging') + } + const stagedSummary = summarizeSkillMarkdown( + await readFile(join(stagedDirectory, 'SKILL.md'), 'utf8') + ) + if ( + stagedSummary.name !== input.name || + (stagedSummary.description ?? '') !== input.description + ) { + throw new Error('skill-package-source-changed-during-staging') + } + return bundleEntry({ + id: input.source.id ?? input.name, + name: input.name, + description: input.description, + observed: stagedObservation + }) +} + +export async function createSkillBundleArchive( + input: { + sources: readonly SkillBundleSource[] + archivePath: string + packageId: string + versionId: string + bundleName: string + description?: string + createdAt?: string + }, + dependencies: SkillBundleCreationDependencies = {} +): Promise { + if (input.sources.length === 0) { + throw new Error('skill-bundle-empty') + } + const sourceObservations = await Promise.all( + input.sources.map((source) => observeSkillPackage(source.sourceDirectory)) + ) + const summaries = await Promise.all( + input.sources.map(async (source) => { + const summary = summarizeSkillMarkdown( + await readFile(join(source.sourceDirectory, 'SKILL.md'), 'utf8') + ) + if (!summary.name) { + throw new Error('skill-package-skill-name-required') + } + validateSkillPackageName(summary.name) + return { name: summary.name, description: summary.description ?? '' } + }) + ) + const foldedNames = new Set() + for (const summary of summaries) { + const foldedName = summary.name.toLocaleLowerCase('en-US') + if (foldedNames.has(foldedName)) { + throw new Error('skill-bundle-skill-collision') + } + foldedNames.add(foldedName) + } + await dependencies.afterSourcesObserved?.() + const workDirectory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-')) + const skillsRoot = join(workDirectory, 'skills') + const verificationDirectory = join(workDirectory, 'verification') + const temporaryArchive = `${input.archivePath}.${process.pid}.${randomUUID()}.tmp` + try { + await mkdir(skillsRoot, { recursive: true }) + const skills: SkillBundleEntry[] = [] + for (const [index, source] of input.sources.entries()) { + const skill = await stageSkill({ + source, + sourceObservation: sourceObservations[index], + skillsRoot, + ...summaries[index] + }) + skills.push(skill) + } + skills.sort((left, right) => left.name.localeCompare(right.name, 'en-US')) + const manifest = parseSkillBundleManifest({ + schemaVersion: 1, + packageId: input.packageId, + versionId: input.versionId, + bundleName: input.bundleName, + description: input.description ?? '', + createdAt: input.createdAt ?? new Date().toISOString(), + skills, + bundleDigest: computeSkillBundleDigest(skills) + }) + const pluginManifest = parseAgentPluginManifest({ + $schema: AGENT_PLUGIN_SCHEMA_V1, + name: manifest.bundleName, + version: manifest.versionId, + description: manifest.description + }) + const pluginBytes = Buffer.from(JSON.stringify(pluginManifest), 'utf8') + const manifestBytes = Buffer.from(JSON.stringify(manifest), 'utf8') + const entries: SkillTarWriteEntry[] = [ + { + path: AGENT_PLUGIN_MANIFEST_PATH, + size: pluginBytes.length, + executable: false, + bytes: pluginBytes + }, + { + path: ORCA_SKILL_BUNDLE_MANIFEST_PATH, + size: manifestBytes.length, + executable: false, + bytes: manifestBytes + }, + ...manifest.skills.flatMap((skill) => + skill.files.map((file) => ({ + path: `skills/${skill.name}/${file.path}`, + size: file.size, + executable: file.executable, + sourcePath: join(skillsRoot, skill.name, ...file.path.split('/')) + })) + ) + ] + await mkdir(dirname(input.archivePath), { recursive: true }) + const archiveIdentity = await writeSkillTarGzip(temporaryArchive, entries) + await extractSkillBundleArchive({ + archivePath: temporaryArchive, + destinationDirectory: verificationDirectory, + expectedArchiveSha256: archiveIdentity.archiveSha256, + expectedBundleDigest: manifest.bundleDigest, + expectedPackageId: manifest.packageId, + expectedVersionId: manifest.versionId + }) + await renameSkillPathWithWindowsRetry(temporaryArchive, input.archivePath) + return { pluginManifest, manifest, archivePath: input.archivePath, ...archiveIdentity } + } finally { + await rm(workDirectory, { recursive: true, force: true }) + await rm(temporaryArchive, { force: true }).catch(() => undefined) + } +} diff --git a/src/main/skills/skill-bundle-extraction.ts b/src/main/skills/skill-bundle-extraction.ts new file mode 100644 index 00000000000..d8e8db28019 --- /dev/null +++ b/src/main/skills/skill-bundle-extraction.ts @@ -0,0 +1,205 @@ +import { createHash } from 'node:crypto' +import { mkdir, open, readFile, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + AGENT_PLUGIN_MANIFEST_PATH, + ORCA_SKILL_BUNDLE_MANIFEST_PATH, + parseAgentPluginManifest, + parseSkillBundleManifest, + type AgentPluginManifestV1, + type SkillBundleManifestV1 +} from '../../shared/skill-bundle-manifest' +import { + SKILL_PACKAGE_MAX_MANIFEST_BYTES, + type SkillPackageFile +} from '../../shared/skill-package-manifest' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import { observeSkillPackage } from './skill-package-identity' +import { + openSkillTarGzip, + parseSkillTarHeader, + SKILL_TAR_BLOCK_BYTES, + type TarByteReader +} from './skill-package-tar' + +export type SkillBundleExtractionResult = { + pluginManifest: AgentPluginManifestV1 + manifest: SkillBundleManifestV1 + skillsDirectory: string + archiveSha256: string + compressedBytes: number +} + +async function consumePadding(reader: TarByteReader, size: number): Promise { + const padding = (SKILL_TAR_BLOCK_BYTES - (size % SKILL_TAR_BLOCK_BYTES)) % SKILL_TAR_BLOCK_BYTES + if (padding > 0 && !(await reader.readExact(padding)).every((byte) => byte === 0)) { + throw new Error('skill-package-tar-padding-invalid') + } +} + +async function readJsonEntry(reader: TarByteReader, expectedPath: string): Promise { + const header = parseSkillTarHeader(await reader.readExact(SKILL_TAR_BLOCK_BYTES)) + if ( + !header || + header.path !== expectedPath || + header.executable || + header.size > SKILL_PACKAGE_MAX_MANIFEST_BYTES + ) { + throw new Error('skill-bundle-manifest-envelope-invalid') + } + const bytes = await reader.readExact(header.size) + await consumePadding(reader, header.size) + try { + return JSON.parse(bytes.toString('utf8')) + } catch { + throw new Error('skill-bundle-manifest-invalid') + } +} + +async function extractFile( + reader: TarByteReader, + destination: string, + expected: SkillPackageFile +): Promise { + await mkdir(dirname(destination), { recursive: true, mode: 0o700 }) + const handle = await open(destination, 'wx', expected.executable ? 0o700 : 0o600) + const hash = createHash('sha256') + let offset = 0 + try { + while (offset < expected.size) { + const bytes = await reader.readExact(Math.min(64 * 1024, expected.size - offset)) + hash.update(bytes) + let written = 0 + while (written < bytes.length) { + const result = await handle.write(bytes, written, bytes.length - written, offset + written) + if (result.bytesWritten === 0) { + throw new Error('skill-package-extraction-write-failed') + } + written += result.bytesWritten + } + offset += bytes.length + } + } finally { + await handle.close() + } + if (hash.digest('hex') !== expected.sha256) { + throw new Error('skill-package-file-digest-mismatch') + } + await consumePadding(reader, expected.size) +} + +async function requireArchiveEnd(reader: TarByteReader): Promise { + for (let index = 0; index < 2; index += 1) { + if (!(await reader.readExact(SKILL_TAR_BLOCK_BYTES)).every((byte) => byte === 0)) { + throw new Error('skill-package-tar-trailing-entry') + } + } + for (;;) { + const block = await reader.readExactOrNull(SKILL_TAR_BLOCK_BYTES) + if (!block) { + return + } + if (!block.every((byte) => byte === 0)) { + throw new Error('skill-package-tar-trailing-data') + } + } +} + +async function verifySkill(input: { + directory: string + manifest: SkillBundleManifestV1['skills'][number] +}): Promise { + const observed = await observeSkillPackage(input.directory) + if ( + observed.observedDigest !== input.manifest.digest || + observed.files.length !== input.manifest.files.length || + observed.files.some((file, index) => { + const expected = input.manifest.files[index] + return ( + file.path !== expected.path || + file.exactSha256 !== expected.sha256 || + file.identitySha256 !== expected.identitySha256 || + file.executable !== expected.executable + ) + }) + ) { + throw new Error('skill-bundle-extracted-identity-mismatch') + } + const summary = summarizeSkillMarkdown(await readFile(join(input.directory, 'SKILL.md'), 'utf8')) + if (summary.name !== input.manifest.name) { + throw new Error('skill-package-skill-name-mismatch') + } +} + +export async function extractSkillBundleArchive(input: { + archivePath: string + destinationDirectory: string + expectedArchiveSha256?: string + expectedBundleDigest?: string + expectedPackageId?: string + expectedVersionId?: string +}): Promise { + const archive = await openSkillTarGzip(input.archivePath) + let destinationCreated = false + try { + const pluginManifest = parseAgentPluginManifest( + await readJsonEntry(archive.reader, AGENT_PLUGIN_MANIFEST_PATH) + ) + const manifest = parseSkillBundleManifest( + await readJsonEntry(archive.reader, ORCA_SKILL_BUNDLE_MANIFEST_PATH) + ) + if ( + pluginManifest.name !== manifest.bundleName || + pluginManifest.version !== manifest.versionId || + (input.expectedBundleDigest && input.expectedBundleDigest !== manifest.bundleDigest) || + (input.expectedPackageId && input.expectedPackageId !== manifest.packageId) || + (input.expectedVersionId && input.expectedVersionId !== manifest.versionId) + ) { + throw new Error('skill-bundle-identity-mismatch') + } + const skillsDirectory = join(input.destinationDirectory, 'skills') + await mkdir(skillsDirectory, { recursive: true, mode: 0o700 }) + destinationCreated = true + for (const skill of manifest.skills) { + for (const file of skill.files) { + const archivePath = `skills/${skill.name}/${file.path}` + const header = parseSkillTarHeader(await archive.reader.readExact(SKILL_TAR_BLOCK_BYTES)) + if ( + !header || + header.path !== archivePath || + header.size !== file.size || + header.executable !== file.executable + ) { + throw new Error('skill-bundle-file-envelope-mismatch') + } + await extractFile( + archive.reader, + join(skillsDirectory, skill.name, ...file.path.split('/')), + file + ) + } + } + await requireArchiveEnd(archive.reader) + const archiveIdentity = await archive.archiveIdentity + if ( + input.expectedArchiveSha256 && + archiveIdentity.archiveSha256 !== input.expectedArchiveSha256 + ) { + throw new Error('skill-package-archive-digest-mismatch') + } + await Promise.all( + manifest.skills.map((skill) => + verifySkill({ directory: join(skillsDirectory, skill.name), manifest: skill }) + ) + ) + return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity } + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)) + archive.abort(failure) + await archive.archiveIdentity.catch(() => undefined) + if (destinationCreated) { + await rm(input.destinationDirectory, { recursive: true, force: true }) + } + throw failure + } +} diff --git a/src/main/skills/skill-bundle-install-request-service.ts b/src/main/skills/skill-bundle-install-request-service.ts new file mode 100644 index 00000000000..23996080c0e --- /dev/null +++ b/src/main/skills/skill-bundle-install-request-service.ts @@ -0,0 +1,107 @@ +import { isAbsolute, join } from 'node:path' +import { + SkillBundleInstallRequestSchema, + type SkillBundleInstallRequest, + type SkillBundleInstallResult, + type SkillBundlePackageIdentity +} from '../../shared/skill-bundle-install-contract' +import { resolveSkillInstallDestination } from './skill-install-destinations' +import type { SkillInstallDestinationAuthority } from './skill-install-destinations' +import { installSkillBundle } from './skill-bundle-install-service' +import { downloadSkillPackageGrant } from './skill-package-download' +import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' +import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem' + +type StagedSkillBundle = { archivePath: string; cleanup(): Promise } + +export type SkillBundleInstallRequestDependencies = { + authority: SkillInstallDestinationAuthority + stateDirectory: string + allowedDownloadOrigins: readonly string[] + requireHttps: boolean + allowTrustedLocalFile?: boolean + signal?: AbortSignal + fetcher?: typeof fetch + detectProviders: () => Promise + resolveStagedUpload?: ( + uploadId: string, + identity: SkillBundlePackageIdentity + ) => Promise +} + +async function resolveIngress( + request: SkillBundleInstallRequest, + dependencies: SkillBundleInstallRequestDependencies +): Promise { + if (request.ingress.kind === 'download-grant') { + return downloadSkillPackageGrant({ + url: request.ingress.url, + expiresAt: request.ingress.expiresAt, + expectedArchiveSha256: request.package.archiveSha256, + expectedCompressedBytes: request.package.compressedBytes, + temporaryRoot: join(dependencies.stateDirectory, 'skill-installs', 'downloads'), + allowedOrigins: dependencies.allowedDownloadOrigins, + requireHttps: dependencies.requireHttps, + signal: dependencies.signal, + fetcher: dependencies.fetcher + }) + } + if (request.ingress.kind === 'staged-upload') { + if (!dependencies.resolveStagedUpload) { + throw new Error('skill-bundle-staged-upload-unsupported') + } + return dependencies.resolveStagedUpload(request.ingress.uploadId, request.package) + } + if (!dependencies.allowTrustedLocalFile || !isAbsolute(request.ingress.path)) { + throw new Error('skill-install-local-ingress-rejected') + } + return { archivePath: request.ingress.path, cleanup: async () => undefined } +} + +export async function executeSkillBundleInstallRequest( + input: unknown, + dependencies: SkillBundleInstallRequestDependencies +): Promise { + const request = SkillBundleInstallRequestSchema.parse(input) + const destination = await resolveSkillInstallDestination( + request.destination, + dependencies.authority + ) + const ingress = await resolveIngress(request, dependencies) + try { + const detectedProviders = destination.wslDistro + ? await detectSkillProvidersInWsl(destination.wslDistro) + : await dependencies.detectProviders() + const filesystem = destination.wslDistro + ? createWslSkillInstallFilesystem({ + distro: destination.wslDistro, + homeDirectory: destination.homeDirectory, + workspaceDirectory: destination.workspaceDirectory + }) + : undefined + return await installSkillBundle({ + operationId: request.operationId, + archivePath: ingress.archivePath, + packageId: request.package.packageId, + versionId: request.package.versionId, + bundleDigest: request.package.bundleDigest, + selectedSkillIds: request.selectedSkillIds, + conflictDecisions: new Map( + request.conflictDecisions.map((decision) => [decision.skillId, decision.resolution]) + ), + scope: destination.scope, + homeDirectory: destination.homeDirectory, + workspaceDirectory: destination.workspaceDirectory, + orcaStateDirectory: dependencies.stateDirectory, + detectedProviders, + destinationIdentity: destination.destinationIdentity, + hostIdentity: dependencies.authority.environmentId, + expectedArchiveSha256: request.package.archiveSha256, + filesystem, + wslDistro: destination.wslDistro, + signal: dependencies.signal + }) + } finally { + await ingress.cleanup() + } +} diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts new file mode 100644 index 00000000000..809019a9056 --- /dev/null +++ b/src/main/skills/skill-bundle-install-service.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { createSkillBundleArchive } from './skill-bundle-creation' +import { installSkillBundle } from './skill-bundle-install-service' +import { listManagedSkillInstalls } from './skill-install-provenance' + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-install-test-')) + temporaryDirectories.push(directory) + return directory +} + +async function createSkill(root: string, name: string): Promise { + const directory = join(root, name) + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${name}\n---\n\n# ${name}\n` + ) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('skill bundle installation', () => { + 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') + const beta = await createSkill(join(root, 'sources'), 'beta-skill') + const homeDirectory = join(root, 'home') + const existingBeta = join(homeDirectory, '.agents', 'skills', 'beta-skill') + await mkdir(existingBeta, { recursive: true }) + await writeFile( + join(existingBeta, 'SKILL.md'), + '---\nname: beta-skill\ndescription: Local\n---\n\n# Keep me\n' + ) + const bundle = await createSkillBundleArchive({ + sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'package_1', + versionId: 'version_1', + bundleName: 'team-skills', + createdAt: '2026-08-11T12:00:00.000Z' + }) + + const result = await installSkillBundle({ + operationId: 'operation_1', + archivePath: bundle.archivePath, + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + selectedSkillIds: ['alpha-skill', 'beta-skill'], + expectedArchiveSha256: bundle.archiveSha256, + scope: 'global', + homeDirectory, + orcaStateDirectory: join(root, 'state'), + detectedProviders: [], + destinationIdentity: 'local-global', + hostIdentity: 'host_1' + }) + + expect(result.status).toBe('partial') + expect(result.skills.map((skill) => [skill.name, skill.status])).toEqual([ + ['alpha-skill', 'installed'], + ['beta-skill', 'kept-local'] + ]) + expect( + await readFile(join(homeDirectory, '.agents', 'skills', 'alpha-skill', 'SKILL.md'), 'utf8') + ).toContain('# alpha-skill') + expect(await readFile(join(existingBeta, 'SKILL.md'), 'utf8')).toContain('# Keep me') + await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual([ + expect.objectContaining({ + name: 'alpha-skill', + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: bundle.manifest.bundleDigest + }) + ]) + }) +}) diff --git a/src/main/skills/skill-bundle-install-service.ts b/src/main/skills/skill-bundle-install-service.ts new file mode 100644 index 00000000000..30243ecd38a --- /dev/null +++ b/src/main/skills/skill-bundle-install-service.ts @@ -0,0 +1,233 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import type { + SkillBundleInstallResult, + SkillBundleSkillResult +} from '../../shared/skill-bundle-install-contract' +import { SkillBundleInstallResultSchema } from '../../shared/skill-bundle-install-contract' +import type { SkillBundleEntry } from '../../shared/skill-bundle-manifest' +import { parseSkillPackageManifest } from '../../shared/skill-package-manifest' +import { extractSkillBundleArchive } from './skill-bundle-extraction' +import { + installSharedExtractedSkill, + skillInstallLocalInput, + type SkillInstallServiceInput +} from './skill-install-service' +import { + previewLocalExtractedSkillPackage, + type LocalExtractedSkillPackage +} from './skill-install-transaction' +import { skillInstallFailureFromError } from './skill-install-operation-error' +import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry' + +export type SkillBundleInstallServiceInput = Omit< + SkillInstallServiceInput, + 'expectedPackageDigest' | 'expectedPackageId' | 'expectedVersionId' | 'conflictResolution' +> & { + packageId: string + versionId: string + bundleDigest: string + selectedSkillIds: readonly string[] + conflictDecisions?: ReadonlyMap< + string, + 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local' + > +} + +function skillManifest(input: { + packageId: string + versionId: string + createdAt: string + skill: SkillBundleEntry +}) { + return parseSkillPackageManifest({ + schemaVersion: 1, + packageId: input.packageId, + versionId: input.versionId, + name: input.skill.name, + description: input.skill.description, + createdAt: input.createdAt, + files: input.skill.files, + packageDigest: input.skill.digest + }) +} + +function conflictResult( + skill: SkillBundleEntry, + preview: Awaited> +): SkillBundleSkillResult { + const state = preview.currentState + const kind = + state.kind === 'modified' || + state.kind === 'unowned' || + state.kind === 'external-link' || + state.kind === 'name-collision' + ? state.kind + : 'modified' + return { + skillId: skill.id, + name: skill.name, + digest: skill.digest, + status: 'kept-local', + placements: [], + conflict: { + kind, + ...('digest' in state && state.digest ? { existingDigest: state.digest } : {}) + } + } +} + +function completedSkillResult(input: { + skill: SkillBundleEntry + preview: Awaited> + result: Awaited> +}): SkillBundleSkillResult { + const status = + input.result.status === 'partial' + ? input.preview.currentState.kind === 'missing' + ? 'installed' + : input.preview.currentState.kind === 'unchanged' + ? 'unchanged' + : 'updated' + : input.result.status === 'conflict' + ? 'kept-local' + : input.result.status === 'cancelled' + ? 'cancelled' + : input.result.status === 'failed' + ? 'failed' + : input.result.status === 'removed' + ? 'failed' + : input.result.status + return { + skillId: input.skill.id, + name: input.skill.name, + digest: input.skill.digest, + status, + ...(input.result.canonicalPath ? { canonicalPath: input.result.canonicalPath } : {}), + placements: input.result.placements + .filter((placement) => placement.status !== 'removed') + .map((placement) => ({ + ...placement, + status: placement.status === 'removed' ? 'skipped' : placement.status + })), + ...(input.result.conflict ? { conflict: input.result.conflict } : {}), + ...(input.result.errorCategory ? { errorCategory: input.result.errorCategory } : {}), + ...(input.result.failure ? { failure: input.result.failure } : {}) + } +} + +function bundleStatus( + skills: readonly SkillBundleSkillResult[] +): SkillBundleInstallResult['status'] { + if (skills.every((skill) => skill.status === 'cancelled')) { + return 'cancelled' + } + if (skills.every((skill) => skill.status === 'failed')) { + return 'failed' + } + return skills.some((skill) => ['kept-local', 'failed', 'cancelled'].includes(skill.status)) + ? 'partial' + : 'complete' +} + +export async function installSkillBundle( + input: SkillBundleInstallServiceInput +): Promise { + const destinationRoot = skillInstallLocalInput({ + ...input, + expectedPackageDigest: input.bundleDigest, + expectedPackageId: input.packageId, + expectedVersionId: input.versionId + }).destinationRoot + await mkdir(destinationRoot, { recursive: true }) + const extractionPath = join(destinationRoot, `.orca-skill-bundle-extract-${randomUUID()}`) + const extracted = await extractSkillBundleArchive({ + archivePath: input.archivePath, + destinationDirectory: extractionPath, + expectedArchiveSha256: input.expectedArchiveSha256, + expectedBundleDigest: input.bundleDigest, + expectedPackageId: input.packageId, + expectedVersionId: input.versionId + }) + try { + const selected = new Set(input.selectedSkillIds) + const skills = extracted.manifest.skills.filter((skill) => selected.has(skill.id)) + if (skills.length !== selected.size) { + throw new Error('skill-bundle-selection-invalid') + } + const results: SkillBundleSkillResult[] = [] + for (const skill of skills) { + if (input.signal?.aborted) { + results.push({ + skillId: skill.id, + name: skill.name, + digest: skill.digest, + status: 'cancelled', + placements: [] + }) + continue + } + const manifest = skillManifest({ + packageId: extracted.manifest.packageId, + versionId: extracted.manifest.versionId, + createdAt: extracted.manifest.createdAt, + skill + }) + const wrapper = join(extractionPath, 'selected', skill.id) + const extractedSkill: LocalExtractedSkillPackage = { + extractionPath: wrapper, + manifest, + archiveSha256: extracted.archiveSha256 + } + const decision = input.conflictDecisions?.get(skill.id) + const serviceInput: SkillInstallServiceInput = { + ...input, + expectedPackageDigest: skill.digest, + expectedPackageId: extracted.manifest.packageId, + expectedVersionId: extracted.manifest.versionId, + sourceBundleDigest: extracted.manifest.bundleDigest, + conflictResolution: decision === 'keep-local' ? undefined : decision + } + const localInput = skillInstallLocalInput(serviceInput) + const preview = await previewLocalExtractedSkillPackage(localInput, extractedSkill) + const isConflict = !['missing', 'unchanged', 'clean-update'].includes( + preview.currentState.kind + ) + if (isConflict && decision !== 'replace-and-discard-local') { + results.push(conflictResult(skill, preview)) + continue + } + await mkdir(wrapper, { recursive: true, mode: 0o700 }) + const source = join(extracted.skillsDirectory, skill.name) + const target = join(wrapper, 'skill') + await (input.filesystem + ? input.filesystem.rename(source, target) + : renameSkillPathWithWindowsRetry(source, target)) + try { + const result = await installSharedExtractedSkill(serviceInput, extractedSkill) + results.push(completedSkillResult({ skill, preview, result })) + } catch (error) { + const failure = skillInstallFailureFromError(error) + results.push({ + skillId: skill.id, + name: skill.name, + digest: skill.digest, + status: failure?.category === 'cancelled' ? 'cancelled' : 'failed', + placements: [], + ...(failure ? { errorCategory: failure.code, failure } : {}) + }) + } + } + return SkillBundleInstallResultSchema.parse({ + operationId: input.operationId, + packageId: extracted.manifest.packageId, + versionId: extracted.manifest.versionId, + bundleDigest: extracted.manifest.bundleDigest, + status: bundleStatus(results), + skills: results + }) + } finally { + await rm(extractionPath, { recursive: true, force: true }) + } +} diff --git a/src/main/skills/skill-bundle-ssh-relay-service.test.ts b/src/main/skills/skill-bundle-ssh-relay-service.test.ts new file mode 100644 index 00000000000..b832b44db2d --- /dev/null +++ b/src/main/skills/skill-bundle-ssh-relay-service.test.ts @@ -0,0 +1,139 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract' +import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest' +import type { IPtyProvider } from '../providers/pty-provider-contract' +import { installSkillBundleOnSshHost } from './skill-bundle-ssh-relay-service' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function userDataPath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-bundle-ssh-client-test-')) + roots.push(root) + return root +} + +function request(bytes: Buffer): SkillBundleInstallRequest { + return { + operationId: 'bundle-operation', + package: { + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + archiveSha256: createHash('sha256').update(bytes).digest('hex'), + compressedBytes: bytes.length + }, + selectedSkillIds: ['skill-1'], + ingress: { + kind: 'download-grant', + url: 'https://storage.googleapis.com/test/bundle.tar.gz', + expiresAt: new Date(Date.now() + 60_000).toISOString() + }, + destination: { scope: 'global', executionTarget: { kind: 'host' } }, + conflictDecisions: [] + } +} + +function result() { + return { + operationId: 'bundle-operation', + packageId: 'package_1', + versionId: 'version_1', + bundleDigest: 'a'.repeat(64), + status: 'complete' as const, + skills: [] + } +} + +describe('installSkillBundleOnSshHost', () => { + it('uses the additive method only when advertised by the SSH host', async () => { + const bytes = Buffer.from('private bundle archive') + const requestHostRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { capabilities: ['skills.install.bundle.v1'] } + } + if (method === 'skills.installBundle') { + return result() + } + throw new Error(`unexpected method ${method}`) + }) + + await expect( + installSkillBundleOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + userDataPath: await userDataPath(), + request: request(bytes), + requireHttps: true + }) + ).resolves.toEqual(result()) + }) + + it('does not send an unknown method to an older SSH host', async () => { + const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] })) + + await expect( + installSkillBundleOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + userDataPath: await userDataPath(), + request: request(Buffer.from('archive')), + requireHttps: true + }) + ).rejects.toThrow('skill-bundle-ssh-update-required') + expect(requestHostRpc).toHaveBeenCalledOnce() + }) + + it('falls back to client-mediated transfer after direct download fails', async () => { + const bytes = Buffer.from('private bundle archive') + const requestHostRpc = vi.fn(async (method: string, params: unknown) => { + if (method === 'relay.status') { + return { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] } + } + if (method === 'skills.installBundle') { + const ingress = (params as { request: SkillBundleInstallRequest }).request.ingress + if (ingress.kind === 'download-grant') { + throw Object.assign(new Error('skill-download-transport-failed'), { code: -32000 }) + } + return result() + } + if (method === 'skills.beginUpload') { + return { uploadId: 'upload_1', chunkBytes: 256 * 1024 } + } + if (method === 'skills.uploadChunk') { + const chunk = params as { offset: number; bytesBase64: string } + return { + acknowledgedOffset: chunk.offset + Buffer.from(chunk.bytesBase64, 'base64').length + } + } + return { ok: true } + }) + + await expect( + installSkillBundleOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + userDataPath: await userDataPath(), + request: request(bytes), + requireHttps: true, + fetcher: vi.fn( + async () => + new Response(bytes, { headers: { 'content-type': SKILL_PACKAGE_CONTENT_TYPE } }) + ) as typeof fetch + }) + ).resolves.toEqual(result()) + expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.installBundle', + 'skills.beginUpload', + 'skills.uploadChunk', + 'skills.commitUpload', + 'skills.installBundle', + 'skills.cancelUpload' + ]) + }) +}) diff --git a/src/main/skills/skill-bundle-ssh-relay-service.ts b/src/main/skills/skill-bundle-ssh-relay-service.ts new file mode 100644 index 00000000000..4e70f53ba20 --- /dev/null +++ b/src/main/skills/skill-bundle-ssh-relay-service.ts @@ -0,0 +1,88 @@ +import { + SkillBundleInstallResultSchema, + type SkillBundleInstallRequest, + type SkillBundleInstallResult +} from '../../shared/skill-bundle-install-contract' +import { + SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_UPLOAD_CAPABILITY +} from '../../shared/skill-install-capability' +import { + SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + type SkillSshWorkspaceAuthority +} from '../../shared/skill-ssh-relay-contract' +import type { IPtyProvider } from '../providers/pty-provider-contract' +import { + SKILL_SSH_REQUEST_TIMEOUT_MS, + requireSkillSshRelayClient, + retryableSkillSshTransportError, + shouldUseSkillSshClientTransfer, + skillSshRelayCapabilities +} from './skill-ssh-relay-client' +import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer' +import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' + +export async function installSkillBundleOnSshHost(input: { + provider: IPtyProvider + userDataPath: string + request: SkillBundleInstallRequest + workspace?: SkillSshWorkspaceAuthority + requireHttps: boolean + signal?: AbortSignal + fetcher?: typeof fetch +}): Promise { + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) + if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { + throw new Error('skill-bundle-ssh-update-required') + } + try { + return SkillBundleInstallResultSchema.parse( + await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: () => + client( + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + { request: input.request, workspace: input.workspace }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + }) + ) + } catch (error) { + if ( + input.request.ingress.kind !== 'download-grant' || + !shouldUseSkillSshClientTransfer(error, input.requireHttps) + ) { + throw error + } + } + if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { + throw new Error('skill-bundle-ssh-download-unavailable') + } + return retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: async () => { + const uploadId = await transferSkillPackageToSshHost(client, input) + try { + return SkillBundleInstallResultSchema.parse( + await client( + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + { + request: { + ...input.request, + ingress: { kind: 'staged-upload', uploadId } + }, + workspace: input.workspace + }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + ) + } finally { + await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined) + } + } + }) +} diff --git a/src/main/skills/skill-client-mediated-transfer.ts b/src/main/skills/skill-client-mediated-transfer.ts index 62069c57ee8..760a2193bce 100644 --- a/src/main/skills/skill-client-mediated-transfer.ts +++ b/src/main/skills/skill-client-mediated-transfer.ts @@ -2,6 +2,7 @@ import { open } from 'node:fs/promises' import { join } from 'node:path' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { SkillPackageIdentity } from '../../shared/skill-install-contract' +import type { SkillBundlePackageIdentity } from '../../shared/skill-bundle-install-contract' import { SKILL_UPLOAD_CHUNK_MAX_BYTES, SkillUploadBeginResultSchema @@ -48,7 +49,7 @@ export async function transferSkillPackageToRuntime(input: { userDataPath: string environmentId: string transferId: string - package: SkillPackageIdentity + package: SkillPackageIdentity | SkillBundlePackageIdentity grant: { url: string; expiresAt: string } requireHttps: boolean signal?: AbortSignal diff --git a/src/main/skills/skill-cloud-grant-installation.test.ts b/src/main/skills/skill-cloud-grant-installation.test.ts index 6804dba4016..11d15a30689 100644 --- a/src/main/skills/skill-cloud-grant-installation.test.ts +++ b/src/main/skills/skill-cloud-grant-installation.test.ts @@ -4,6 +4,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' const mocks = vi.hoisted(() => ({ getRuntimeEnvironmentStatus: vi.fn(), + installSkillBundleOnRemoteRuntime: vi.fn(), installSkillOnRemoteRuntime: vi.fn() })) @@ -14,10 +15,14 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({ getRuntimeEnvironmentStatus: mocks.getRuntimeEnvironmentStatus })) vi.mock('./skill-remote-install-service', () => ({ + installSkillBundleOnRemoteRuntime: mocks.installSkillBundleOnRemoteRuntime, installSkillOnRemoteRuntime: mocks.installSkillOnRemoteRuntime })) -import { installSkillCloudGrant } from './skill-cloud-grant-installation' +import { + installSkillBundleCloudGrant, + installSkillCloudGrant +} from './skill-cloud-grant-installation' const grant = { grant: { @@ -37,6 +42,7 @@ const grant = { describe('installSkillCloudGrant', () => { beforeEach(() => { mocks.getRuntimeEnvironmentStatus.mockReset() + mocks.installSkillBundleOnRemoteRuntime.mockReset() mocks.installSkillOnRemoteRuntime.mockReset() }) @@ -71,4 +77,59 @@ describe('installSkillCloudGrant', () => { expect.objectContaining({ signal }) ) }) + + it('installs selected bundle skills through a capable remote runtime', async () => { + const bundleGrant = { + ...grant, + version: { + ...grant.version, + name: 'private-bundle', + manifest: { + schemaVersion: 1, + packageId: 'package-1', + versionId: 'version-1', + bundleName: 'private-bundle', + description: '', + createdAt: '2026-08-11T00:00:00.000Z', + bundleDigest: 'c'.repeat(64), + skills: [ + { + id: 'skill-1', + name: 'private-skill', + description: '', + digest: 'a'.repeat(64), + files: [] + } + ] + } + } + } as unknown as SkillCloudDownloadGrant + const result = { + operationId: 'operation-1', + packageId: 'package-1', + versionId: 'version-1', + bundleDigest: 'c'.repeat(64), + status: 'complete', + skills: [] + } + mocks.getRuntimeEnvironmentStatus.mockResolvedValue({ + ok: true, + result: { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] } + }) + mocks.installSkillBundleOnRemoteRuntime.mockResolvedValue(result) + + await expect( + installSkillBundleCloudGrant({} as OrcaRuntimeService, bundleGrant, { + operationId: 'operation-1', + environmentId: 'environment-1', + selectedSkillIds: ['skill-1'], + destination: { scope: 'global' } + }) + ).resolves.toEqual({ status: 'ok', value: result }) + expect(mocks.installSkillBundleOnRemoteRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ selectedSkillIds: ['skill-1'] }) + }) + ) + }) }) diff --git a/src/main/skills/skill-cloud-grant-installation.ts b/src/main/skills/skill-cloud-grant-installation.ts index c9a6e5b7725..c2e9d0988ab 100644 --- a/src/main/skills/skill-cloud-grant-installation.ts +++ b/src/main/skills/skill-cloud-grant-installation.ts @@ -1,8 +1,13 @@ import { app } from 'electron' import { + SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability' +import type { + SkillBundleInstallRequest, + SkillBundleInstallResult +} from '../../shared/skill-bundle-install-contract' import type { SkillInstallDestination, SkillInstallRequest @@ -11,7 +16,10 @@ import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract' import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { skillInstallFailureFromError } from './skill-install-operation-error' -import { installSkillOnRemoteRuntime } from './skill-remote-install-service' +import { + installSkillBundleOnRemoteRuntime, + installSkillOnRemoteRuntime +} from './skill-remote-install-service' export type SkillCloudGrantInstallInput = { operationId: string @@ -20,6 +28,102 @@ export type SkillCloudGrantInstallInput = { conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel' } +export type SkillBundleCloudGrantInstallInput = { + operationId: string + environmentId?: string + selectedSkillIds: string[] + destination: SkillInstallDestination + conflictDecisions?: SkillBundleInstallRequest['conflictDecisions'] +} + +function bundleFailureResult( + grant: SkillCloudDownloadGrant, + request: SkillBundleInstallRequest, + failure: NonNullable> +): SkillBundleInstallResult { + const manifest = grant.version.manifest + if (!('skills' in manifest)) { + throw new Error('skill-bundle-cloud-manifest-required') + } + return { + operationId: request.operationId, + packageId: request.package.packageId, + versionId: request.package.versionId, + bundleDigest: request.package.bundleDigest, + status: failure.category === 'cancelled' ? 'cancelled' : 'failed', + skills: manifest.skills + .filter((skill) => request.selectedSkillIds.includes(skill.id)) + .map((skill) => ({ + skillId: skill.id, + name: skill.name, + digest: skill.digest, + status: failure.category === 'cancelled' ? ('cancelled' as const) : ('failed' as const), + placements: [], + errorCategory: failure.code, + failure + })) + } +} + +export async function installSkillBundleCloudGrant( + runtime: OrcaRuntimeService, + grant: SkillCloudDownloadGrant, + input: SkillBundleCloudGrantInstallInput, + signal?: AbortSignal +) { + const manifest = grant.version.manifest + if (!('skills' in manifest)) { + throw new Error('skill-bundle-cloud-manifest-required') + } + const request: SkillBundleInstallRequest = { + operationId: input.operationId, + package: { + packageId: grant.version.packageId, + versionId: grant.version.versionId, + bundleDigest: manifest.bundleDigest, + archiveSha256: grant.version.archiveSha256, + compressedBytes: grant.version.compressedBytes + }, + selectedSkillIds: input.selectedSkillIds, + ingress: { kind: 'download-grant', ...grant.grant }, + destination: input.destination, + conflictDecisions: input.conflictDecisions ?? [] + } + try { + if (!input.environmentId) { + return { + status: 'ok' as const, + value: await runtime.installSharedSkillBundleRequest(request) + } + } + const userDataPath = app.getPath('userData') + const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000) + if ( + status.ok !== true || + status.result.capabilities?.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) !== true + ) { + return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } + } + return { + status: 'ok' as const, + value: await installSkillBundleOnRemoteRuntime({ + userDataPath, + environmentId: input.environmentId, + request, + capabilities: status.result.capabilities ?? [], + requireHttps: app.isPackaged, + signal + }) + } + } catch (error) { + const failure = skillInstallFailureFromError(error) + if (!failure) { + throw error + } + return { status: 'ok' as const, value: bundleFailureResult(grant, request, failure) } + } +} + export async function installSkillCloudGrant( runtime: OrcaRuntimeService, grant: SkillCloudDownloadGrant, diff --git a/src/main/skills/skill-cloud-service.ts b/src/main/skills/skill-cloud-service.ts index ac4d44df898..6d89cebd0ff 100644 --- a/src/main/skills/skill-cloud-service.ts +++ b/src/main/skills/skill-cloud-service.ts @@ -26,9 +26,22 @@ function id(value: string): string { export class SkillCloudService { constructor(private readonly userDataPath: string) {} - publish( + async publish( request: SkillCloudPublishRequest ): Promise> { + const version = await this.publishVersion(request) + if (version.status !== 'ok') { + return version + } + const share = await this.createShare(version.value.packageId, request) + return share.status === 'ok' + ? { status: 'ok', value: { version: version.value, share: share.value } } + : share + } + + publishVersion( + request: SkillCloudPublishRequest + ): Promise> { return this.withAuth(request, async (token, apiUrl) => { const upload = await skillCloudRequest<{ upload: { @@ -76,20 +89,33 @@ export class SkillCloudService { if (finalized.version.packageId !== request.packageId) { throw new Error('skill-cloud-published-package-mismatch') } + return finalized.version + }) + } + + createShare( + packageId: string, + request: SkillCloudOptions & { + pinnedVersionId?: string + userIds: string[] + shareWithOrganization: boolean + idempotencyKey?: string + } + ): Promise> { + return this.withAuth(request, async (token, apiUrl) => { const shared = await skillCloudRequest<{ share: SkillCloudShare }>({ apiUrl, authToken: token, - path: `/v1/skill-packages/${id(request.packageId)}/shares`, + path: `/v1/skill-packages/${id(packageId)}/shares`, method: 'POST', body: { pinnedVersionId: request.pinnedVersionId, userIds: request.userIds, shareWithOrganization: request.shareWithOrganization }, - idempotencyKey: randomUUID(), - signal: request.signal + idempotencyKey: request.idempotencyKey ?? randomUUID() }) - return { version: finalized.version, share: shared.share } + return shared.share }) } diff --git a/src/main/skills/skill-install-provenance.ts b/src/main/skills/skill-install-provenance.ts index 970637caaf3..928f0cb9958 100644 --- a/src/main/skills/skill-install-provenance.ts +++ b/src/main/skills/skill-install-provenance.ts @@ -11,6 +11,7 @@ export type SkillInstallReceiptV1 = { packageId: string versionId: string packageDigest: string + bundleDigest?: string archiveSha256: string scope: 'global' | 'workspace' destinationIdentity: string @@ -60,6 +61,7 @@ function isReceipt(value: unknown): value is SkillInstallReceiptV1 { typeof receipt.packageId === 'string' && typeof receipt.versionId === 'string' && typeof receipt.packageDigest === 'string' && + (receipt.bundleDigest === undefined || typeof receipt.bundleDigest === 'string') && typeof receipt.archiveSha256 === 'string' && (receipt.scope === 'global' || receipt.scope === 'workspace') && typeof receipt.destinationIdentity === 'string' && @@ -104,6 +106,7 @@ export async function listManagedSkillInstalls( packageId: parsed.packageId, versionId: parsed.versionId, packageDigest: parsed.packageDigest, + ...(parsed.bundleDigest ? { bundleDigest: parsed.bundleDigest } : {}), scope: parsed.scope, destinationIdentity: parsed.destinationIdentity, installedAt: parsed.installedAt, diff --git a/src/main/skills/skill-install-service.ts b/src/main/skills/skill-install-service.ts index ff5f6beb693..04c3c32ef34 100644 --- a/src/main/skills/skill-install-service.ts +++ b/src/main/skills/skill-install-service.ts @@ -2,9 +2,11 @@ import { join } from 'node:path' import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract' import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance' import { + installLocalExtractedSkillPackage, installLocalSkillPackage, - previewLocalSkillPackage, - type LocalSkillInstallInput + type LocalExtractedSkillPackage, + type LocalSkillInstallInput, + previewLocalSkillPackage } from './skill-install-transaction' import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation' import { resolveSkillProviderDestinations } from './skill-provider-destinations' @@ -12,6 +14,7 @@ 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' export type SkillInstallServiceInput = Omit< LocalSkillInstallInput, @@ -52,7 +55,7 @@ function canonicalRoot(input: { return join(scopeRoot, '.agents', 'skills') } -function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput { +export function skillInstallLocalInput(input: SkillInstallServiceInput): LocalSkillInstallInput { return { operationId: input.operationId, archivePath: input.archivePath, @@ -65,6 +68,7 @@ function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput { expectedPackageDigest: input.expectedPackageDigest, expectedPackageId: input.expectedPackageId, expectedVersionId: input.expectedVersionId, + sourceBundleDigest: input.sourceBundleDigest, conflictResolution: input.conflictResolution, filesystem: input.filesystem, wslDistro: input.wslDistro, @@ -84,13 +88,47 @@ function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult { export async function installSharedSkill( input: SkillInstallServiceInput ): Promise { - const request = localInput(input) + 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 + }) +} + +export async function installSharedExtractedSkill( + input: SkillInstallServiceInput, + 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 + }) +} + +async function completeSharedSkillInstall(input: { + input: SkillInstallServiceInput + request: LocalSkillInstallInput + manifest: SkillPackageManifestV1 + previousReceipt: Awaited> + result: SkillInstallResult +}): Promise { + const { result } = input if ( !result.canonicalPath || result.status === 'conflict' || @@ -101,16 +139,16 @@ export async function installSharedSkill( } const placements: SkillPlacementResult[] = [canonicalPlacement(result)] const destinations = resolveSkillProviderDestinations({ - scope: input.scope, - homeDirectory: input.homeDirectory, - workspaceDirectory: input.workspaceDirectory, - detectedProviders: input.detectedProviders + 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.signal?.aborted) { + if (input.input.signal?.aborted) { placements.push({ provider: destination.provider, path: join(destination.rootPath, result.name), @@ -129,30 +167,30 @@ export async function installSharedSkill( canonicalPath: result.canonicalPath, skillName: result.name, destination, - previousReceipt, + previousReceipt: input.previousReceipt, packageDigest: result.packageDigest, - fileModes: preview.manifest.files, - filesystem: input.filesystem + fileModes: input.manifest.files, + filesystem: input.input.filesystem }) if (placement) { placements.push(placement) } } - const receipt = await readSkillInstallReceipt(request.stateDirectory, result.canonicalPath) + const receipt = await readSkillInstallReceipt(input.request.stateDirectory, result.canonicalPath) if (!receipt) { throw new Error('skill-install-receipt-missing') } - await writeSkillInstallReceipt(request.stateDirectory, { ...receipt, placements }) + await writeSkillInstallReceipt(input.request.stateDirectory, { ...receipt, placements }) const incomplete = placements.some( (placement) => placement.status === 'failed' || placement.status === 'skipped' ) return verifySkillInstallDiscovery({ result: { ...result, status: incomplete ? 'partial' : result.status, placements }, - scope: input.scope, - homeDirectory: input.homeDirectory, - workspaceDirectory: input.workspaceDirectory, - wslDistro: input.wslDistro, - discover: input.discover + scope: input.input.scope, + homeDirectory: input.input.homeDirectory, + workspaceDirectory: input.input.workspaceDirectory, + wslDistro: input.input.wslDistro, + discover: input.input.discover }) } diff --git a/src/main/skills/skill-install-transaction-result.ts b/src/main/skills/skill-install-transaction-result.ts index 4f048e1ae06..2b580491298 100644 --- a/src/main/skills/skill-install-transaction-result.ts +++ b/src/main/skills/skill-install-transaction-result.ts @@ -80,6 +80,7 @@ export function createSkillInstallReceipt(input: { packageId: input.manifest.packageId, versionId: input.manifest.versionId, packageDigest: input.manifest.packageDigest, + ...(input.request.sourceBundleDigest ? { bundleDigest: input.request.sourceBundleDigest } : {}), archiveSha256: input.archiveSha256, scope: input.request.scope, destinationIdentity: input.request.destinationIdentity, diff --git a/src/main/skills/skill-install-transaction.ts b/src/main/skills/skill-install-transaction.ts index 9c3b6eadd38..e3107a17313 100644 --- a/src/main/skills/skill-install-transaction.ts +++ b/src/main/skills/skill-install-transaction.ts @@ -48,6 +48,7 @@ export type LocalSkillInstallInput = { expectedPackageDigest?: string expectedPackageId?: string expectedVersionId?: string + sourceBundleDigest?: string conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel' filesystem?: SkillInstallFilesystem wslDistro?: string @@ -61,6 +62,12 @@ export type LocalSkillInstallPreview = { currentState: SkillCanonicalState } +export type LocalExtractedSkillPackage = { + extractionPath: string + manifest: SkillPackageManifestV1 + archiveSha256: string +} + export type SkillInstallJournalBoundary = 'before' | 'after' export type SkillInstallTransactionDependencies = { @@ -74,11 +81,9 @@ function lockPath(stateDirectory: string, canonicalPath: string): string { return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`) } -async function inspectExtractedPackage(input: LocalSkillInstallInput): Promise<{ - extractionPath: string - manifest: SkillPackageManifestV1 - archiveSha256: string -}> { +async function inspectExtractedPackage( + input: LocalSkillInstallInput +): Promise { await mkdir(input.destinationRoot, { recursive: true }) const extractionPath = join(input.destinationRoot, `.orca-skill-extract-${randomUUID()}`) const extracted = await extractSkillPackageArchive({ @@ -114,29 +119,45 @@ export async function previewLocalSkillPackage( input: LocalSkillInstallInput ): Promise { const extracted = await inspectExtractedPackage(input) - const canonicalPath = join(input.destinationRoot, extracted.manifest.name) try { - const receipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath) - return { - manifest: extracted.manifest, - canonicalPath, - currentState: await inspectSkillCanonicalState({ - canonicalPath, - manifest: extracted.manifest, - receipt - }) - } + return previewLocalExtractedSkillPackage(input, extracted) } finally { await (input.filesystem ?? nativeSkillInstallFilesystem).remove(extracted.extractionPath) } } +export async function previewLocalExtractedSkillPackage( + input: LocalSkillInstallInput, + extracted: LocalExtractedSkillPackage +): Promise { + const canonicalPath = join(input.destinationRoot, extracted.manifest.name) + const receipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath) + return { + manifest: extracted.manifest, + canonicalPath, + currentState: await inspectSkillCanonicalState({ + canonicalPath, + manifest: extracted.manifest, + receipt, + filesystem: input.filesystem + }) + } +} + export async function installLocalSkillPackage( input: LocalSkillInstallInput, dependencies: SkillInstallTransactionDependencies = {} ): Promise { - const filesystem = input.filesystem ?? nativeSkillInstallFilesystem const extracted = await inspectExtractedPackage(input) + return installLocalExtractedSkillPackage(input, extracted, dependencies) +} + +export async function installLocalExtractedSkillPackage( + input: LocalSkillInstallInput, + extracted: LocalExtractedSkillPackage, + dependencies: SkillInstallTransactionDependencies = {} +): Promise { + const filesystem = input.filesystem ?? nativeSkillInstallFilesystem const canonicalPath = join(input.destinationRoot, extracted.manifest.name) let releaseLock: (() => Promise) | null = null let journal: SkillInstallJournalV1 | null = null diff --git a/src/main/skills/skill-remote-install-service.test.ts b/src/main/skills/skill-remote-install-service.test.ts index 1eaa95ff4f9..23f7ce0d362 100644 --- a/src/main/skills/skill-remote-install-service.test.ts +++ b/src/main/skills/skill-remote-install-service.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SkillInstallRequest, SkillInstallResult } from '../../shared/skill-install-contract' +import type { + SkillBundleInstallRequest, + SkillBundleInstallResult +} from '../../shared/skill-bundle-install-contract' const mocks = vi.hoisted(() => ({ callRuntimeEnvironment: vi.fn(), @@ -13,7 +17,10 @@ vi.mock('./skill-client-mediated-transfer', () => ({ transferSkillPackageToRuntime: mocks.transferSkillPackageToRuntime })) -import { installSkillOnRemoteRuntime } from './skill-remote-install-service' +import { + installSkillBundleOnRemoteRuntime, + installSkillOnRemoteRuntime +} from './skill-remote-install-service' const request: SkillInstallRequest = { operationId: 'operation-1', @@ -40,6 +47,30 @@ const result: SkillInstallResult = { placements: [] } +const bundleRequest: SkillBundleInstallRequest = { + operationId: 'bundle-operation-1', + package: { + packageId: 'package-1', + versionId: 'version-1', + bundleDigest: 'c'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 12 + }, + selectedSkillIds: ['skill-1'], + ingress: request.ingress, + destination: { scope: 'global' }, + conflictDecisions: [] +} + +const bundleResult: SkillBundleInstallResult = { + operationId: bundleRequest.operationId, + packageId: bundleRequest.package.packageId, + versionId: bundleRequest.package.versionId, + bundleDigest: bundleRequest.package.bundleDigest, + status: 'complete', + skills: [] +} + function success(value: unknown) { return { id: 'rpc-1', ok: true, result: value, _meta: { runtimeId: 'runtime-1' } } } @@ -236,3 +267,58 @@ describe('installSkillOnRemoteRuntime', () => { expect(mocks.transferSkillPackageToRuntime).not.toHaveBeenCalled() }) }) + +describe('installSkillBundleOnRemoteRuntime', () => { + beforeEach(() => { + mocks.callRuntimeEnvironment.mockReset() + mocks.transferSkillPackageToRuntime.mockReset() + }) + + it('uses the additive bundle RPC for direct remote installation', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue(success(bundleResult)) + + await expect( + installSkillBundleOnRemoteRuntime({ + userDataPath: '/state', + environmentId: 'environment-1', + request: bundleRequest, + capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'], + requireHttps: true + }) + ).resolves.toEqual(bundleResult) + expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith( + '/state', + 'environment-1', + 'skills.installBundle', + bundleRequest, + 5 * 60_000 + ) + }) + + it('falls back to staged upload while preserving the bundle selection', async () => { + const cleanup = vi.fn(async () => undefined) + mocks.callRuntimeEnvironment + .mockResolvedValueOnce({ + id: 'rpc-1', + ok: false, + error: { code: 'runtime_error', message: 'skill-download-transport-failed' } + }) + .mockResolvedValueOnce(success(bundleResult)) + mocks.transferSkillPackageToRuntime.mockResolvedValue({ uploadId: 'upload-1', cleanup }) + + await expect( + installSkillBundleOnRemoteRuntime({ + userDataPath: '/state', + environmentId: 'environment-1', + request: bundleRequest, + capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'], + requireHttps: true + }) + ).resolves.toEqual(bundleResult) + expect(mocks.callRuntimeEnvironment.mock.calls[1]?.[3]).toMatchObject({ + selectedSkillIds: ['skill-1'], + ingress: { kind: 'staged-upload', uploadId: 'upload-1' } + }) + expect(cleanup).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/skills/skill-remote-install-service.ts b/src/main/skills/skill-remote-install-service.ts index 979c576e817..8ef157f0841 100644 --- a/src/main/skills/skill-remote-install-service.ts +++ b/src/main/skills/skill-remote-install-service.ts @@ -1,11 +1,19 @@ import type { RuntimeCapability } from '../../shared/protocol-version' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + SkillBundleInstallResultSchema, + type SkillBundleInstallRequest, + type SkillBundleInstallResult +} from '../../shared/skill-bundle-install-contract' import { SkillInstallResultSchema, type SkillInstallRequest, type SkillInstallResult } from '../../shared/skill-install-contract' -import { SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' +import { + SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_UPLOAD_CAPABILITY +} from '../../shared/skill-install-capability' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer' import { SkillInstallFailureSchema } from '../../shared/skill-install-failure' @@ -36,6 +44,20 @@ async function install( )) as RuntimeRpcResponse } +async function installBundle( + userDataPath: string, + environmentId: string, + request: SkillBundleInstallRequest +): Promise> { + return (await callRuntimeEnvironment( + userDataPath, + environmentId, + 'skills.installBundle', + request, + 5 * 60_000 + )) as RuntimeRpcResponse +} + function isDirectDownloadUnavailable( response: RuntimeRpcResponse, requireHttps: boolean @@ -125,3 +147,61 @@ export async function installSkillOnRemoteRuntime(input: { } }) } + +export async function installSkillBundleOnRemoteRuntime(input: { + userDataPath: string + environmentId: string + request: SkillBundleInstallRequest + capabilities: readonly RuntimeCapability[] + requireHttps: boolean + signal?: AbortSignal +}): Promise { + if ( + input.request.ingress.kind !== 'download-grant' || + !input.capabilities.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) + ) { + throw new Error('skill-bundle-remote-ingress-invalid') + } + const grant = input.request.ingress + const direct = await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableRemoteInstallTransportError, + call: () => installBundle(input.userDataPath, input.environmentId, input.request) + }) + if (!isDirectDownloadUnavailable(direct, input.requireHttps)) { + if (direct.ok !== true) { + throw remoteFailure(direct) + } + return SkillBundleInstallResultSchema.parse(direct.result) + } + if (!input.capabilities.includes(SKILL_UPLOAD_CAPABILITY)) { + throw new Error('skill-bundle-remote-download-unavailable') + } + return retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableRemoteInstallTransportError, + call: async () => { + const transfer = await transferSkillPackageToRuntime({ + userDataPath: input.userDataPath, + environmentId: input.environmentId, + transferId: input.request.operationId, + package: input.request.package, + grant, + requireHttps: input.requireHttps, + signal: input.signal + }) + try { + const staged = await installBundle(input.userDataPath, input.environmentId, { + ...input.request, + ingress: { kind: 'staged-upload', uploadId: transfer.uploadId } + }) + if (staged.ok !== true) { + throw remoteFailure(staged) + } + return SkillBundleInstallResultSchema.parse(staged.result) + } finally { + await transfer.cleanup().catch(() => undefined) + } + } + }) +} diff --git a/src/main/skills/skill-runtime-capability.ts b/src/main/skills/skill-runtime-capability.ts index 8d5959da25e..66dacdd9680 100644 --- a/src/main/skills/skill-runtime-capability.ts +++ b/src/main/skills/skill-runtime-capability.ts @@ -1,4 +1,5 @@ import { + SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY } from '../../shared/skill-install-capability' @@ -14,6 +15,17 @@ export async function supportsSkillRuntimeManagement( ) } +export async function supportsSkillRuntimeBundleInstall( + userDataPath: string, + environmentId: string +): Promise { + const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000) + return ( + status.ok === true && + status.result.capabilities?.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) === true + ) +} + export async function supportsSkillRuntimeInstall( userDataPath: string, environmentId: string diff --git a/src/main/skills/skill-share-preparation-service.test.ts b/src/main/skills/skill-share-preparation-service.test.ts new file mode 100644 index 00000000000..4d972b5df2b --- /dev/null +++ b/src/main/skills/skill-share-preparation-service.test.ts @@ -0,0 +1,132 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +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 { SkillSharePreparationService } from './skill-share-preparation-service' + +const temporaryDirectories: string[] = [] + +async function createSource(): Promise<{ root: string; source: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-skill-share-preparation-')) + temporaryDirectories.push(root) + const source = join(root, 'source') + await mkdir(source) + await writeFile( + join(source, 'SKILL.md'), + '---\nname: retry-skill\ndescription: Retry publication\n---\n\n# Retry\n' + ) + return { root, source } +} + +function publishedVersion(packageId: string, versionId: string): SkillCloudVersion { + return { + packageId, + versionId, + name: 'retry-skill', + description: 'Retry publication', + packageDigest: 'a'.repeat(64), + archiveSha256: 'b'.repeat(64), + compressedBytes: 100, + createdAt: '2026-08-11T12:00:00.000Z', + releaseNotes: 'retry', + manifest: { + schemaVersion: 1, + packageId, + versionId, + name: 'retry-skill', + description: 'Retry publication', + createdAt: '2026-08-11T12:00:00.000Z', + packageDigest: 'a'.repeat(64), + files: [] + } + } +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })) + ) +}) + +describe('SkillSharePreparationService', () => { + it('reuses a finalized version when share response delivery is lost', async () => { + const { root, source } = await createSource() + const publishVersion = vi.fn(async (request: { packageId: string }) => ({ + status: 'ok' as const, + value: publishedVersion(request.packageId, 'version_retry') + })) + const createShare = vi + .fn() + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce({ + status: 'ok', + value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' } + }) + const service = new SkillSharePreparationService(join(root, 'preparations'), { + publishVersion, + createShare + }) + const preview = await service.prepare({ sourceDirectory: source }) + const input = { + preparationId: preview.preparationId, + releaseNotes: 'retry', + userIds: ['user_2'], + shareWithOrganization: false + } + + await expect(service.publish(input)).rejects.toThrow('response lost') + await expect(service.publish(input)).resolves.toMatchObject({ + status: 'ok', + value: { version: { versionId: 'version_retry' }, share: { id: 'share_retry' } } + }) + + expect(publishVersion).toHaveBeenCalledOnce() + expect(createShare).toHaveBeenCalledTimes(2) + const idempotencyKey = createShare.mock.calls[0]?.[1].idempotencyKey + expect(idempotencyKey).toMatch(/^[A-Za-z0-9_-]{1,128}$/) + expect(idempotencyKey).toBe(createShare.mock.calls[1]?.[1].idempotencyKey) + }) + + it('keeps the finalized version when the audience must be corrected', async () => { + const { root, source } = await createSource() + const publishVersion = vi.fn(async (request: { packageId: string }) => ({ + status: 'ok' as const, + value: publishedVersion(request.packageId, 'version_audience') + })) + const createShare = vi + .fn() + .mockRejectedValueOnce(new Error('skill_package_organization_required')) + .mockResolvedValueOnce({ + status: 'ok', + value: { id: 'share_people', url: 'https://share.test/skills/share/share_people' } + }) + const service = new SkillSharePreparationService(join(root, 'preparations'), { + publishVersion, + createShare + }) + const preview = await service.prepare({ sourceDirectory: source }) + + await expect( + service.publish({ + preparationId: preview.preparationId, + releaseNotes: 'retry', + userIds: [], + shareWithOrganization: true + }) + ).rejects.toThrow('skill_package_organization_required') + await expect( + service.publish({ + preparationId: preview.preparationId, + releaseNotes: 'retry', + userIds: ['user_2'], + shareWithOrganization: false + }) + ).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_people' } } }) + + expect(publishVersion).toHaveBeenCalledOnce() + expect(createShare.mock.calls[0]?.[1].idempotencyKey).not.toBe( + createShare.mock.calls[1]?.[1].idempotencyKey + ) + }) +}) diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts index ef008736514..bfccd47c186 100644 --- a/src/main/skills/skill-share-preparation-service.ts +++ b/src/main/skills/skill-share-preparation-service.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import type { @@ -7,35 +7,58 @@ import type { SkillSharePublishInput, SkillSharePublishOperation } from '../../shared/skill-sharing-contract' -import { createSkillPackageArchive, type CreatedSkillPackage } from './skill-package-creation' +import type { SkillCloudVersion } from '../../shared/skill-cloud-contract' +import { createSkillBundleArchive, type CreatedSkillBundle } from './skill-bundle-creation' import type { SkillCloudService } from './skill-cloud-service' const PREPARATION_TTL_MS = 30 * 60 * 1000 const MAX_PREPARATIONS = 8 type Preparation = { - created: CreatedSkillPackage + created: CreatedSkillBundle expiresAt: number controller: AbortController | null + publishedVersion: SkillCloudVersion | null +} + +function shareIdempotencyKey(preparationId: string, input: SkillSharePublishInput): string { + const audience = JSON.stringify({ + shareWithOrganization: input.shareWithOrganization, + userIds: [...new Set(input.userIds)].sort() + }) + const digest = createHash('sha256').update(audience).digest('hex').slice(0, 32) + return `${preparationId}_${digest}` } function preview(id: string, value: Preparation): SkillSharePreview { const manifest = value.created.manifest + const files = manifest.skills.flatMap((skill) => skill.files) return { preparationId: id, packageId: manifest.packageId, versionId: manifest.versionId, - name: manifest.name, + name: manifest.bundleName, description: manifest.description, - packageDigest: manifest.packageDigest, + packageDigest: manifest.bundleDigest, + skillCount: manifest.skills.length, + skills: manifest.skills.map((skill) => ({ + id: skill.id, + name: skill.name, + description: skill.description, + digest: skill.digest, + fileCount: skill.files.length, + totalBytes: skill.files.reduce((total, file) => total + file.size, 0), + scriptPaths: skill.files + .filter((file) => file.path.startsWith('scripts/')) + .map((file) => file.path), + executablePaths: skill.files.filter((file) => file.executable).map((file) => file.path) + })), archiveSha256: value.created.archiveSha256, - fileCount: manifest.files.length, - totalBytes: manifest.files.reduce((total, file) => total + file.size, 0), + fileCount: files.length, + totalBytes: files.reduce((total, file) => total + file.size, 0), compressedBytes: value.created.compressedBytes, - scriptPaths: manifest.files - .filter((file) => file.path.startsWith('scripts/')) - .map((file) => file.path), - executablePaths: manifest.files.filter((file) => file.executable).map((file) => file.path), + scriptPaths: files.filter((file) => file.path.startsWith('scripts/')).map((file) => file.path), + executablePaths: files.filter((file) => file.executable).map((file) => file.path), expiresAt: new Date(value.expiresAt).toISOString() } } @@ -45,11 +68,14 @@ export class SkillSharePreparationService { constructor( private readonly root: string, - private readonly cloud: Pick + private readonly cloud: Pick ) {} async prepare(input: { - sourceDirectory: string + sources?: { id?: string; sourceDirectory: string }[] + sourceDirectory?: string + bundleName?: string + description?: string packageId?: string }): Promise { await this.prune() @@ -60,8 +86,12 @@ export class SkillSharePreparationService { const directory = join(this.root, preparationId) await mkdir(directory, { recursive: true, mode: 0o700 }) try { - const created = await createSkillPackageArchive({ - sourceDirectory: input.sourceDirectory, + const sources = + input.sources ?? (input.sourceDirectory ? [{ sourceDirectory: input.sourceDirectory }] : []) + const created = await createSkillBundleArchive({ + sources, + bundleName: input.bundleName ?? 'shared-skill', + description: input.description, archivePath: join(directory, 'package.tar.gz'), packageId: input.packageId ?? randomUUID(), versionId: randomUUID() @@ -69,7 +99,8 @@ export class SkillSharePreparationService { const value: Preparation = { created, expiresAt: Date.now() + PREPARATION_TTL_MS, - controller: null + controller: null, + publishedVersion: null } this.preparations.set(preparationId, value) return preview(preparationId, value) @@ -94,21 +125,39 @@ export class SkillSharePreparationService { const controller = new AbortController() preparation.controller = controller try { - const result = await this.cloud.publish({ - archivePath: preparation.created.archivePath, - archiveSha256: preparation.created.archiveSha256, - compressedBytes: preparation.created.compressedBytes, - packageId: preparation.created.manifest.packageId, - releaseNotes: input.releaseNotes, + let version = preparation.publishedVersion + if (!version) { + const published = await this.cloud.publishVersion({ + archivePath: preparation.created.archivePath, + archiveSha256: preparation.created.archiveSha256, + compressedBytes: preparation.created.compressedBytes, + packageId: preparation.created.manifest.packageId, + releaseNotes: input.releaseNotes, + userIds: input.userIds, + shareWithOrganization: input.shareWithOrganization, + signal: controller.signal, + onProgress: (progress) => + onProgress?.({ preparationId: input.preparationId, ...progress }) + }) + if (published.status !== 'ok') { + return published + } + version = published.value + preparation.publishedVersion = version + } + const shared = await this.cloud.createShare(version.packageId, { userIds: input.userIds, shareWithOrganization: input.shareWithOrganization, - signal: controller.signal, - onProgress: (progress) => onProgress?.({ preparationId: input.preparationId, ...progress }) + idempotencyKey: shareIdempotencyKey(input.preparationId, input) }) - if (result.status === 'ok') { - await this.release(input.preparationId) + if (shared.status !== 'ok') { + return shared } - return result satisfies SkillSharePublishOperation + await this.release(input.preparationId) + return { + status: 'ok', + value: { version, share: shared.value } + } satisfies SkillSharePublishOperation } finally { const current = this.preparations.get(input.preparationId) if (current) { diff --git a/src/main/skills/skill-ssh-package-transfer.ts b/src/main/skills/skill-ssh-package-transfer.ts new file mode 100644 index 00000000000..3d5ea2b6bd1 --- /dev/null +++ b/src/main/skills/skill-ssh-package-transfer.ts @@ -0,0 +1,134 @@ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract' +import type { SkillInstallRequest } from '../../shared/skill-install-contract' +import { + SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, + SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, + SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, + SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD +} from '../../shared/skill-ssh-relay-contract' +import { + SKILL_UPLOAD_CHUNK_MAX_BYTES, + SkillUploadBeginResultSchema +} from '../../shared/skill-upload-session-contract' +import { downloadSkillPackageGrant } from './skill-package-download' +import { + SKILL_SSH_REQUEST_TIMEOUT_MS, + retryableSkillSshTransportError, + type SkillSshRelayClient +} from './skill-ssh-relay-client' +import { retrySkillTransferRpc, throwIfSkillTransferCancelled } from './skill-transfer-rpc-retry' + +type SkillSshTransferInput = { + userDataPath: string + request: SkillInstallRequest | SkillBundleInstallRequest + requireHttps: boolean + signal?: AbortSignal + fetcher?: typeof fetch +} + +function allowedOrigins(requireHttps: boolean): string[] { + const origins = ['https://storage.googleapis.com'] + if (!requireHttps && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { + origins.push( + ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + ) + } + return [...new Set(origins)] +} + +export async function transferSkillPackageToSshHost( + client: SkillSshRelayClient, + input: SkillSshTransferInput +): Promise { + if (input.request.ingress.kind !== 'download-grant') { + throw new Error('skill-install-ssh-ingress-invalid') + } + const downloaded = await downloadSkillPackageGrant({ + url: input.request.ingress.url, + expiresAt: input.request.ingress.expiresAt, + expectedArchiveSha256: input.request.package.archiveSha256, + expectedCompressedBytes: input.request.package.compressedBytes, + temporaryRoot: join(input.userDataPath, 'skill-installs', 'ssh-transfers'), + allowedOrigins: allowedOrigins(input.requireHttps), + requireHttps: input.requireHttps, + signal: input.signal, + fetcher: input.fetcher + }) + let uploadId: string | null = null + try { + const begun = SkillUploadBeginResultSchema.parse( + await retrySkillTransferRpc({ + signal: input.signal, + checkCancellationAfterSuccess: false, + retryable: retryableSkillSshTransportError, + call: () => + client( + SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, + { package: input.request.package, transferId: input.request.operationId }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + }) + ) + uploadId = begun.uploadId + throwIfSkillTransferCancelled(input.signal) + const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES) + if ( + begun.acknowledgedOffset > input.request.package.compressedBytes || + !Number.isInteger(chunkBytes) || + chunkBytes < 1 + ) { + throw new Error('skill-transfer-ssh-begin-invalid') + } + const handle = await open(downloaded.archivePath, 'r') + try { + let offset = begun.acknowledgedOffset + while (offset < input.request.package.compressedBytes) { + const bytes = Buffer.alloc( + Math.min(chunkBytes, input.request.package.compressedBytes - offset) + ) + const read = await handle.read(bytes, 0, bytes.length, offset) + if (read.bytesRead !== bytes.length) { + throw new Error('skill-transfer-source-changed') + } + const acknowledged = (await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: () => + client( + SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, + { uploadId, offset, bytesBase64: bytes.toString('base64') }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + })) as { acknowledgedOffset: number } + if (acknowledged.acknowledgedOffset !== offset + bytes.length) { + throw new Error('skill-transfer-ack-invalid') + } + offset = acknowledged.acknowledgedOffset + } + } finally { + await handle.close() + } + await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: () => + client( + SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, + { uploadId }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + }) + const committedId = uploadId + uploadId = null + return committedId + } finally { + await downloaded.cleanup() + if (uploadId) { + await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined) + } + } +} diff --git a/src/main/skills/skill-ssh-relay-client.ts b/src/main/skills/skill-ssh-relay-client.ts new file mode 100644 index 00000000000..cfe6ff726da --- /dev/null +++ b/src/main/skills/skill-ssh-relay-client.ts @@ -0,0 +1,42 @@ +import type { IPtyProvider } from '../providers/pty-provider-contract' + +export const SKILL_SSH_REQUEST_TIMEOUT_MS = 5 * 60_000 + +const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed' +const DEVELOPMENT_DOWNLOAD_POLICY_FAILURES = new Set([ + 'skill-download-url-rejected', + 'skill-download-origin-rejected' +]) + +export type SkillSshRelayClient = NonNullable + +export function requireSkillSshRelayClient(provider: IPtyProvider): SkillSshRelayClient { + if (!provider.requestHostRpc) { + throw new Error('skill-install-ssh-relay-unavailable') + } + return provider.requestHostRpc +} + +export function shouldUseSkillSshClientTransfer(error: unknown, requireHttps: boolean): boolean { + const message = error instanceof Error ? error.message : '' + return ( + message === DIRECT_DOWNLOAD_FAILURE || + (!requireHttps && DEVELOPMENT_DOWNLOAD_POLICY_FAILURES.has(message)) + ) +} + +export function retryableSkillSshTransportError(error: unknown): boolean { + return ( + typeof (error as { code?: unknown })?.code !== 'number' && + (error as Error)?.name !== 'AbortError' + ) +} + +export async function skillSshRelayCapabilities(client: SkillSshRelayClient): Promise { + const status = (await client('relay.status', {}, { timeoutMs: 15_000 })) as { + capabilities?: unknown + } + return Array.isArray(status.capabilities) + ? status.capabilities.filter((value): value is string => typeof value === 'string') + : [] +} diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts index abc0036de9a..3c936739ab1 100644 --- a/src/main/skills/skill-ssh-relay-service.ts +++ b/src/main/skills/skill-ssh-relay-service.ts @@ -1,5 +1,3 @@ -import { open } from 'node:fs/promises' -import { join } from 'node:path' import { SKILL_INSTALL_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, @@ -17,78 +15,28 @@ import { type SkillRemoveRequest } from '../../shared/skill-install-contract' import { - SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, - SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, SKILL_SSH_RELAY_INSTALL_METHOD, SKILL_SSH_RELAY_LIST_METHOD, SKILL_SSH_RELAY_PREVIEW_METHOD, SKILL_SSH_RELAY_REMOVE_METHOD, - SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, type SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' -import { - SKILL_UPLOAD_CHUNK_MAX_BYTES, - SkillUploadBeginResultSchema -} from '../../shared/skill-upload-session-contract' import type { IPtyProvider } from '../providers/pty-provider-contract' -import { downloadSkillPackageGrant } from './skill-package-download' -import { retrySkillTransferRpc, throwIfSkillTransferCancelled } from './skill-transfer-rpc-retry' - -const REQUEST_TIMEOUT_MS = 5 * 60_000 -const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed' -const DEVELOPMENT_DOWNLOAD_POLICY_FAILURES = new Set([ - 'skill-download-url-rejected', - 'skill-download-origin-rejected' -]) - -type SkillSshRelayClient = NonNullable - -function requireClient(provider: IPtyProvider): SkillSshRelayClient { - if (!provider.requestHostRpc) { - throw new Error('skill-install-ssh-relay-unavailable') - } - return provider.requestHostRpc -} - -function allowedOrigins(requireHttps: boolean): string[] { - const origins = ['https://storage.googleapis.com'] - if (!requireHttps && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) { - origins.push( - ...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',') - .map((origin) => origin.trim()) - .filter(Boolean) - ) - } - return [...new Set(origins)] -} - -function shouldUseClientTransfer(error: unknown, requireHttps: boolean): boolean { - const message = error instanceof Error ? error.message : '' - return ( - message === DIRECT_DOWNLOAD_FAILURE || - (!requireHttps && DEVELOPMENT_DOWNLOAD_POLICY_FAILURES.has(message)) - ) -} - -function retryableSshTransportError(error: unknown): boolean { - return ( - typeof (error as { code?: unknown })?.code !== 'number' && - (error as Error)?.name !== 'AbortError' - ) -} - -async function capabilities(client: SkillSshRelayClient): Promise { - const status = (await client('relay.status', {}, { timeoutMs: 15_000 })) as { - capabilities?: unknown - } - return Array.isArray(status.capabilities) - ? status.capabilities.filter((value): value is string => typeof value === 'string') - : [] -} +import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' +import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer' +import { + SKILL_SSH_REQUEST_TIMEOUT_MS, + requireSkillSshRelayClient, + retryableSkillSshTransportError, + shouldUseSkillSshClientTransfer, + skillSshRelayCapabilities +} from './skill-ssh-relay-client' export async function supportsSkillManagementOnSsh(provider: IPtyProvider): Promise { - return (await capabilities(requireClient(provider))).includes(SKILL_MANAGEMENT_CAPABILITY) + return (await skillSshRelayCapabilities(requireSkillSshRelayClient(provider))).includes( + SKILL_MANAGEMENT_CAPABILITY + ) } export async function installSkillOnSshHost(input: { @@ -100,8 +48,8 @@ export async function installSkillOnSshHost(input: { signal?: AbortSignal fetcher?: typeof fetch }): Promise { - const client = requireClient(input.provider) - const supported = await capabilities(client) + const client = requireSkillSshRelayClient(input.provider) + const supported = await skillSshRelayCapabilities(client) if (!supported.includes(SKILL_INSTALL_CAPABILITY)) { throw new Error('skill-install-ssh-update-required') } @@ -109,19 +57,19 @@ export async function installSkillOnSshHost(input: { return SkillInstallResultSchema.parse( await retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSshTransportError, + retryable: retryableSkillSshTransportError, call: () => client( SKILL_SSH_RELAY_INSTALL_METHOD, { request: input.request, workspace: input.workspace }, - { timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal } + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) }) ) } catch (error) { if ( input.request.ingress.kind !== 'download-grant' || - !shouldUseClientTransfer(error, input.requireHttps) + !shouldUseSkillSshClientTransfer(error, input.requireHttps) ) { throw error } @@ -131,7 +79,7 @@ export async function installSkillOnSshHost(input: { } return retrySkillTransferRpc({ signal: input.signal, - retryable: retryableSshTransportError, + retryable: retryableSkillSshTransportError, call: async () => { const uploadId = await transferSkillPackageToSshHost(client, input) try { @@ -145,7 +93,7 @@ export async function installSkillOnSshHost(input: { }, workspace: input.workspace }, - { timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal } + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } ) ) } finally { @@ -160,8 +108,8 @@ export async function previewSkillInstallOnSshHost(input: { request: SkillInstallPreviewRequest workspace?: SkillSshWorkspaceAuthority }): Promise { - const client = requireClient(input.provider) - if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { + const client = requireSkillSshRelayClient(input.provider) + if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { throw new Error('skill-install-ssh-update-required') } return SkillInstallPreviewSchema.parse( @@ -178,15 +126,15 @@ export async function removeSkillInstallOnSshHost(input: { request: SkillRemoveRequest workspace?: SkillSshWorkspaceAuthority }): Promise { - const client = requireClient(input.provider) - if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { + const client = requireSkillSshRelayClient(input.provider) + if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { throw new Error('skill-install-ssh-update-required') } return SkillInstallResultSchema.parse( await client( SKILL_SSH_RELAY_REMOVE_METHOD, { request: input.request, workspace: input.workspace }, - { timeoutMs: REQUEST_TIMEOUT_MS } + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS } ) ) } @@ -196,8 +144,8 @@ export async function listSkillInstallsOnSshHost(input: { connectionId: string workspaces: SkillSshWorkspaceAuthority[] }): Promise { - const client = requireClient(input.provider) - if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { + const client = requireSkillSshRelayClient(input.provider) + if (!(await skillSshRelayCapabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) { throw new Error('skill-install-ssh-update-required') } return ManagedSkillInstallListSchema.parse( @@ -217,96 +165,3 @@ export async function listSkillInstallsOnSshHost(input: { : install.destination })) } - -async function transferSkillPackageToSshHost( - client: SkillSshRelayClient, - input: Parameters[0] -): Promise { - if (input.request.ingress.kind !== 'download-grant') { - throw new Error('skill-install-ssh-ingress-invalid') - } - const downloaded = await downloadSkillPackageGrant({ - url: input.request.ingress.url, - expiresAt: input.request.ingress.expiresAt, - expectedArchiveSha256: input.request.package.archiveSha256, - expectedCompressedBytes: input.request.package.compressedBytes, - temporaryRoot: join(input.userDataPath, 'skill-installs', 'ssh-transfers'), - allowedOrigins: allowedOrigins(input.requireHttps), - requireHttps: input.requireHttps, - signal: input.signal, - fetcher: input.fetcher - }) - let uploadId: string | null = null - try { - const begun = SkillUploadBeginResultSchema.parse( - await retrySkillTransferRpc({ - signal: input.signal, - checkCancellationAfterSuccess: false, - retryable: retryableSshTransportError, - call: () => - client( - SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, - { package: input.request.package, transferId: input.request.operationId }, - { timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal } - ) - }) - ) - uploadId = begun.uploadId - throwIfSkillTransferCancelled(input.signal) - const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES) - if ( - begun.acknowledgedOffset > input.request.package.compressedBytes || - !Number.isInteger(chunkBytes) || - chunkBytes < 1 - ) { - throw new Error('skill-transfer-ssh-begin-invalid') - } - const handle = await open(downloaded.archivePath, 'r') - try { - let offset = begun.acknowledgedOffset - while (offset < input.request.package.compressedBytes) { - const bytes = Buffer.alloc( - Math.min(chunkBytes, input.request.package.compressedBytes - offset) - ) - const read = await handle.read(bytes, 0, bytes.length, offset) - if (read.bytesRead !== bytes.length) { - throw new Error('skill-transfer-source-changed') - } - const acknowledged = (await retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableSshTransportError, - call: () => - client( - SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, - { uploadId, offset, bytesBase64: bytes.toString('base64') }, - { timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal } - ) - })) as { acknowledgedOffset: number } - if (acknowledged.acknowledgedOffset !== offset + bytes.length) { - throw new Error('skill-transfer-ack-invalid') - } - offset = acknowledged.acknowledgedOffset - } - } finally { - await handle.close() - } - await retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableSshTransportError, - call: () => - client( - SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, - { uploadId }, - { timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal } - ) - }) - const committedId = uploadId - uploadId = null - return committedId - } finally { - await downloaded.cleanup() - if (uploadId) { - await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined) - } - } -} diff --git a/src/main/skills/skill-wsl-posix-semantics.integration.test.ts b/src/main/skills/skill-wsl-posix-semantics.integration.test.ts index 00606dc981c..79b64e9b30e 100644 --- a/src/main/skills/skill-wsl-posix-semantics.integration.test.ts +++ b/src/main/skills/skill-wsl-posix-semantics.integration.test.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { execFile } from 'node:child_process' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -11,6 +11,8 @@ import { type SkillPackageFile } from '../../shared/skill-package-manifest' import { installSharedSkill } from './skill-install-service' +import { createSkillBundleArchive } from './skill-bundle-creation' +import { installSkillBundle } from './skill-bundle-install-service' import { writeSkillTarGzip } from './skill-package-tar' import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection' import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem' @@ -124,4 +126,54 @@ describe.runIf(RUN_REAL_WSL)('real WSL POSIX skill semantics', () => { await expect(runWsl('test', '!', '-e', `${skill}/skill.md`)).resolves.toBe('') await expect(runWsl(`${skill}/scripts/run.sh`)).resolves.toBe('ok') }) + + it('installs only selected bundle skills into the distro-owned home', async () => { + const sources = join(localRoot, 'bundle-sources') + const alpha = join(sources, 'wsl-alpha') + const beta = join(sources, 'wsl-beta') + await Promise.all([mkdir(alpha, { recursive: true }), mkdir(beta, { recursive: true })]) + await writeFile( + join(alpha, 'SKILL.md'), + '---\nname: wsl-alpha\ndescription: WSL alpha\n---\n\n# Alpha\n' + ) + await writeFile( + join(beta, 'SKILL.md'), + '---\nname: wsl-beta\ndescription: WSL beta\n---\n\n# Beta\n' + ) + const bundle = await createSkillBundleArchive({ + sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }], + archivePath: join(localRoot, 'wsl-bundle.tar.gz'), + packageId: 'package-wsl-bundle', + versionId: 'version-wsl-bundle', + bundleName: 'wsl-bundle' + }) + const homeDirectory = uncPath(`${guestRoot}/home`) + const filesystem = createWslSkillInstallFilesystem({ distro: DISTRO, homeDirectory }) + + const result = await installSkillBundle({ + operationId: 'wsl-bundle-install', + archivePath: bundle.archivePath, + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + selectedSkillIds: ['wsl-alpha'], + expectedArchiveSha256: bundle.archiveSha256, + scope: 'global', + homeDirectory, + orcaStateDirectory: join(localRoot, 'bundle-state'), + detectedProviders: [], + destinationIdentity: 'global:wsl-bundle', + hostIdentity: 'windows-2', + filesystem, + wslDistro: DISTRO + }) + + expect(result).toMatchObject({ status: 'complete', skills: [{ name: 'wsl-alpha' }] }) + await expect( + runWsl('test', '-f', `${guestRoot}/home/.agents/skills/wsl-alpha/SKILL.md`) + ).resolves.toBe('') + await expect( + runWsl('test', '!', '-e', `${guestRoot}/home/.agents/skills/wsl-beta`) + ).resolves.toBe('') + }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f88b4450a15..9a8e282bf2e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -407,6 +407,11 @@ import type { SkillCloudPackageDetails } from '../shared/skill-cloud-contract' import type { + SkillBundleInstallPreviewInput, + SkillBundleInstallPreviewOperation, + SkillBundlePackageVersionInstallInput, + SkillBundleShareInstallInput, + SkillBundleShareInstallOperation, SkillInstallPreviewInput, SkillInstallPreviewOperation, ManagedSkillInstallListOperation, @@ -2569,7 +2574,8 @@ export type PreloadApi = { acknowledgeUpdateRun: () => Promise getUpdateRun: () => Promise prepareShare: (input: { - skillId: string + skillIds: string[] + bundleName: string target?: SkillDiscoveryTarget packageId?: string }) => Promise @@ -2579,11 +2585,20 @@ export type PreloadApi = { resolveShare: (shareId: string) => Promise createDownloadGrant: (shareId: string) => Promise> installShare: (input: SkillShareInstallInput) => Promise + installBundleShare: ( + input: SkillBundleShareInstallInput + ) => Promise + installBundlePackageVersion: ( + input: SkillBundlePackageVersionInstallInput + ) => Promise installPackageVersion: ( input: SkillPackageVersionInstallInput ) => Promise cancelInstall: (input: SkillInstallCancelInput) => Promise<{ cancelled: boolean }> previewInstall: (input: SkillInstallPreviewInput) => Promise + previewBundleInstall: ( + input: SkillBundleInstallPreviewInput + ) => Promise removeInstall: (input: SkillRemoveInput) => Promise listManagedInstalls: (environmentId?: string) => Promise getPackage: (packageId: string) => Promise> diff --git a/src/preload/index.ts b/src/preload/index.ts index 7b8390d566c..d5407193dc7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,6 +135,11 @@ import type { SkillCloudPackageDetails } from '../shared/skill-cloud-contract' import type { + SkillBundleInstallPreviewInput, + SkillBundleInstallPreviewOperation, + SkillBundlePackageVersionInstallInput, + SkillBundleShareInstallInput, + SkillBundleShareInstallOperation, SkillInstallPreviewInput, SkillInstallPreviewOperation, ManagedSkillInstallListOperation, @@ -2519,7 +2524,8 @@ const api = { acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), prepareShare: (input: { - skillId: string + skillIds: string[] + bundleName: string target?: SkillDiscoveryTarget packageId?: string }): Promise => ipcRenderer.invoke('skills:prepareShare', input), @@ -2535,6 +2541,14 @@ const api = { ipcRenderer.invoke('skills:createDownloadGrant', shareId), installShare: (input: SkillShareInstallInput): Promise => ipcRenderer.invoke('skills:installShare', input), + installBundleShare: ( + input: SkillBundleShareInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundleShare', input), + installBundlePackageVersion: ( + input: SkillBundlePackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installBundlePackageVersion', input), installPackageVersion: ( input: SkillPackageVersionInstallInput ): Promise => @@ -2543,6 +2557,10 @@ const api = { ipcRenderer.invoke('skills:cancelInstall', input), previewInstall: (input: SkillInstallPreviewInput): Promise => ipcRenderer.invoke('skills:previewInstall', input), + previewBundleInstall: ( + input: SkillBundleInstallPreviewInput + ): Promise => + ipcRenderer.invoke('skills:previewBundleInstall', input), removeInstall: (input: SkillRemoveInput): Promise => ipcRenderer.invoke('skills:removeInstall', input), listManagedInstalls: (environmentId?: string): Promise => diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts index 596e3e3502d..24a810708d9 100644 --- a/src/relay/skill-install-handler.test.ts +++ b/src/relay/skill-install-handler.test.ts @@ -5,9 +5,11 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { MethodHandler, RelayDispatcher } from './dispatcher' import { createSkillPackageArchive } from '../main/skills/skill-package-creation' +import { createSkillBundleArchive } from '../main/skills/skill-bundle-creation' import { SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, SKILL_SSH_RELAY_INSTALL_METHOD, SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD } from '../shared/skill-ssh-relay-contract' @@ -52,7 +54,7 @@ async function fixture() { isStale: () => false, signal: new AbortController().signal }) - return { archive, bytes, call, home } + return { archive, bytes, call, home, root } } describe('SkillInstallHandler', () => { @@ -93,11 +95,65 @@ describe('SkillInstallHandler', () => { it('advertises separately gateable install, upload, and management capabilities', () => { expect(SKILL_RELAY_CAPABILITIES).toEqual([ 'skills.install.v1', + 'skills.install.bundle.v1', 'skills.upload.v1', 'skills.manage.v1' ]) }) + it('installs a selected bundle through the additive SSH method', async () => { + const { call, home, root } = await fixture() + const alpha = join(root, 'alpha-skill') + const beta = join(root, 'beta-skill') + await Promise.all([mkdir(alpha), mkdir(beta)]) + await writeFile(join(alpha, 'SKILL.md'), '---\nname: alpha-skill\ndescription: Alpha\n---\n') + await writeFile(join(beta, 'SKILL.md'), '---\nname: beta-skill\ndescription: Beta\n---\n') + const bundle = await createSkillBundleArchive({ + sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }], + archivePath: join(root, 'bundle.tar.gz'), + packageId: 'bundle_package', + versionId: 'bundle_version', + bundleName: 'relay-bundle' + }) + const bundleBytes = await readFile(bundle.archivePath) + const packageIdentity = { + packageId: bundle.manifest.packageId, + versionId: bundle.manifest.versionId, + bundleDigest: bundle.manifest.bundleDigest, + archiveSha256: bundle.archiveSha256, + compressedBytes: bundleBytes.length + } + const begun = (await call(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, { + package: packageIdentity + })) as { uploadId: string } + await call(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, { + uploadId: begun.uploadId, + offset: 0, + bytesBase64: bundleBytes.toString('base64') + }) + await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId }) + const alphaId = bundle.manifest.skills.find((skill) => skill.name === 'alpha-skill')!.id + + const result = (await call(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, { + request: { + operationId: 'bundle_operation', + package: packageIdentity, + selectedSkillIds: [alphaId], + ingress: { kind: 'staged-upload', uploadId: begun.uploadId }, + destination: { scope: 'global', executionTarget: { kind: 'host' } }, + conflictDecisions: [] + } + })) as { status: string; skills: { name: string }[] } + + expect(result).toMatchObject({ status: 'complete', skills: [{ name: 'alpha-skill' }] }) + await expect( + readFile(join(home, '.agents', 'skills', 'alpha-skill', 'SKILL.md')) + ).resolves.toBeDefined() + await expect( + readFile(join(home, '.agents', 'skills', 'beta-skill', 'SKILL.md')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('exposes invalid staged archives through the stable SSH failure contract', async () => { const { call } = await fixture() const bytes = Buffer.from('not a skill archive') diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts index c2f4f016842..ffb04ca993d 100644 --- a/src/relay/skill-install-handler.ts +++ b/src/relay/skill-install-handler.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' import { + SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_UPLOAD_CAPABILITY @@ -10,11 +11,13 @@ import { SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, SKILL_SSH_RELAY_INSTALL_METHOD, SKILL_SSH_RELAY_LIST_METHOD, SKILL_SSH_RELAY_PREVIEW_METHOD, SKILL_SSH_RELAY_REMOVE_METHOD, SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, + SkillSshInstallBundleParamsSchema, SkillSshInstallParamsSchema, SkillSshListParamsSchema, SkillSshPreviewParamsSchema, @@ -40,6 +43,7 @@ import { } from '../main/skills/skill-install-management-service' import { listManagedSkillInstalls } from '../main/skills/skill-install-provenance' import { executeSkillInstallRequest } from '../main/skills/skill-install-request-service' +import { executeSkillBundleInstallRequest } from '../main/skills/skill-bundle-install-request-service' import { SkillUploadSessionService } from '../main/skills/skill-upload-session-service' import { SkillInstallOperationError, @@ -50,6 +54,7 @@ const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host' export const SKILL_RELAY_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, + SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY ] as const @@ -92,6 +97,20 @@ export class SkillInstallHandler { }) ) }) + this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, async (params, context) => { + const input = SkillSshInstallBundleParamsSchema.parse(params) + return this.executeSkillOperation(() => + executeSkillBundleInstallRequest(input.request, { + authority: this.authority(input.workspace), + stateDirectory: this.stateDirectory, + allowedDownloadOrigins: ['https://storage.googleapis.com'], + requireHttps: true, + resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), + detectProviders: this.detectProviders, + signal: context.signal + }) + ) + }) this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_METHOD, async (params) => { const input = SkillSshPreviewParamsSchema.parse(params) return this.executeSkillOperation(() => diff --git a/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx new file mode 100644 index 00000000000..d431754dd03 --- /dev/null +++ b/src/renderer/src/components/skills/SkillBundleInstallFlow.tsx @@ -0,0 +1,333 @@ +import { useEffect, useMemo, useState } from 'react' +import { Download, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { DialogFooter } from '@/components/ui/dialog' +import { useAppStore } from '@/store' +import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' +import { SKILL_BUNDLE_INSTALL_CAPABILITY } from '../../../../shared/skill-install-capability' +import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract' +import type { + SkillBundleInstallPreview, + SkillBundleInstallResult +} from '../../../../shared/skill-bundle-install-contract' +import type { SkillInstallDestination } from '../../../../shared/skill-install-contract' +import { SkillBundleInstallOutcome } from './SkillBundleInstallOutcome' +import { SkillBundleInstallReview } from './SkillBundleInstallReview' +import { SkillInstallTargetFields } from './SkillInstallTargetFields' +import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices' +import { useSkillInstallProgress } from './skill-install-progress-state' +import { translate } from '@/i18n/i18n' + +type BundleVersion = SkillCloudVersion & { + manifest: Extract +} + +const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision']) + +function sameSelection(preview: SkillBundleInstallPreview, selected: ReadonlySet): boolean { + return ( + preview.skills.length === selected.size && + preview.skills.every((skill) => selected.has(skill.id)) + ) +} + +export function SkillBundleInstallFlow(props: { + shareId: string + version: BundleVersion + onClose(): void + onBusyChange(busy: boolean): void +}): React.JSX.Element { + const { onBusyChange } = props + const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments) + const runtimeStatus = useAppStore((state) => state.runtimeStatusByEnvironmentId) + const worktreesByRepo = useAppStore((state) => state.worktreesByRepo) + const repos = useAppStore((state) => state.repos) + const folderWorkspaces = useAppStore((state) => state.folderWorkspaces) + const sshConnectionStates = useAppStore((state) => state.sshConnectionStates) + const sshTargetLabels = useAppStore((state) => state.sshTargetLabels) + const allSkillIds = useMemo( + () => props.version.manifest.skills.map((skill) => skill.id), + [props.version.manifest.skills] + ) + const [selectedSkillIds, setSelectedSkillIds] = useState(() => new Set(allSkillIds)) + const [replaceSkillIds, setReplaceSkillIds] = useState>(() => new Set()) + const [destinationPreview, setDestinationPreview] = useState( + null + ) + const [result, setResult] = useState(null) + const [environmentId, setEnvironmentId] = useState('local') + const [scope, setScope] = useState<'global' | 'workspace'>('global') + const [workspace, setWorkspace] = useState('') + const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>( + null + ) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const installProgress = useSkillInstallProgress() + + useEffect(() => onBusyChange(busy), [busy, onBusyChange]) + + const workspaceChoices = useMemo( + () => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }), + [environmentId, folderWorkspaces, repos, worktreesByRepo] + ) + const sshConnections = useMemo( + () => + [...sshTargetLabels.entries()].map(([id, label]) => ({ + id, + label, + connected: sshConnectionStates.get(id)?.status === 'connected' + })), + [sshConnectionStates, sshTargetLabels] + ) + + const resetPreview = (): void => { + setDestinationPreview(null) + setReplaceSkillIds(new Set()) + } + + const destination = (): SkillInstallDestination | null => { + const choice = workspaceChoices.find((candidate) => candidate.id === workspace) + if (scope === 'workspace' && !choice) { + return null + } + if (scope === 'workspace') { + return choice?.kind === 'worktree' + ? { scope: 'workspace', worktreeId: choice.id } + : { scope: 'workspace', folderWorkspaceId: choice!.id } + } + return environmentId.startsWith('ssh:') + ? { + scope: 'global', + executionTarget: { kind: 'ssh', connectionId: environmentId.slice('ssh:'.length) } + } + : { scope: 'global', ...(executionTarget ? { executionTarget } : {}) } + } + + const install = async ( + requestedIds: ReadonlySet = selectedSkillIds, + reusePreview = true + ): Promise => { + const target = destination() + if (!target || requestedIds.size === 0) { + setError(target ? 'Select at least one skill.' : 'Choose a workspace.') + return + } + setBusy(true) + setError(null) + try { + const selectedSkills = props.version.manifest.skills + .filter((skill) => requestedIds.has(skill.id)) + .map((skill) => ({ id: skill.id, name: skill.name, digest: skill.digest })) + let checked = + reusePreview && destinationPreview && sameSelection(destinationPreview, requestedIds) + ? destinationPreview + : null + if (!checked) { + const operation = await window.api.skills.previewBundleInstall({ + ...(environmentId === 'local' || environmentId.startsWith('ssh:') + ? {} + : { environmentId }), + package: { + packageId: props.version.packageId, + versionId: props.version.versionId, + bundleDigest: props.version.manifest.bundleDigest, + archiveSha256: props.version.archiveSha256, + compressedBytes: props.version.compressedBytes + }, + selectedSkills, + destination: target + }) + if (operation.status === 'unsupported') { + setError(operation.message) + return + } + checked = operation.value + setDestinationPreview(checked) + if (checked.skills.some((skill) => CONFLICT_STATES.has(skill.currentState))) { + return + } + } + const operationId = crypto.randomUUID() + installProgress.begin(operationId) + const operation = await window.api.skills.installBundleShare({ + shareId: props.shareId, + versionId: props.version.versionId, + operationId, + ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }), + selectedSkillIds: [...requestedIds], + destination: target, + conflictDecisions: checked.skills + .filter((skill) => CONFLICT_STATES.has(skill.currentState)) + .map((skill) => ({ + skillId: skill.id, + resolution: replaceSkillIds.has(skill.id) + ? ('replace-and-discard-local' as const) + : ('keep-local' as const) + })) + }) + if (operation.status === 'unsupported') { + setError(operation.message) + } else if (operation.status !== 'ok') { + setError( + operation.status === 'reconnect-required' + ? 'Reconnect your Orca account before installing.' + : operation.message + ) + } else { + setResult(operation.value) + if ( + operation.value.skills.some((skill) => + ['installed', 'updated', 'unchanged'].includes(skill.status) + ) + ) { + notifyInstalledAgentSkillsChanged() + } + } + } catch (cause) { + console.warn('[skills] bundle install failed:', cause) + setError('Installation failed before Orca could verify the requested bundle.') + } finally { + installProgress.finish() + setBusy(false) + } + } + + const cancelInstall = async (): Promise => { + if (!installProgress.activeOperationId) { + return + } + const cancelled = await window.api.skills.cancelInstall({ + operationId: installProgress.activeOperationId, + ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }) + }) + if (!cancelled.cancelled) { + setError('The destination had already finished this installation.') + } + } + + const retryIds = new Set( + result?.skills + .filter((skill) => skill.status === 'failed' || skill.status === 'cancelled') + .map((skill) => skill.skillId) ?? [] + ) + + return ( + <> + {result ? ( + + ) : ( + { + setSelectedSkillIds((current) => { + const next = new Set(current) + if (selected) { + next.add(id) + } else { + next.delete(id) + } + return next + }) + resetPreview() + }} + onToggleAll={(selected) => { + setSelectedSkillIds(new Set(selected ? allSkillIds : [])) + resetPreview() + }} + onToggleReplace={(id, replace) => + setReplaceSkillIds((current) => { + const next = new Set(current) + if (replace) { + next.add(id) + } else { + next.delete(id) + } + return next + }) + } + > + { + setEnvironmentId(value) + resetPreview() + }} + scope={scope} + onScopeChange={(value) => { + setScope(value) + resetPreview() + }} + workspace={workspace} + onWorkspaceChange={(value) => { + setWorkspace(value) + resetPreview() + }} + executionTarget={executionTarget} + onExecutionTargetChange={(value) => { + setExecutionTarget(value) + resetPreview() + }} + runtimeEnvironments={runtimeEnvironments} + runtimeStatus={runtimeStatus} + sshConnections={sshConnections} + workspaceChoices={workspaceChoices} + requiredCapability={SKILL_BUNDLE_INSTALL_CAPABILITY} + /> + + )} + {error ? ( +

+ {error} +

+ ) : null} + {installProgress.phaseLabel ? ( +

+ {installProgress.phaseLabel} +

+ ) : null} + + + {busy && installProgress.activeOperationId ? ( + + ) : null} + {!result || retryIds.size ? ( + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx b/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx new file mode 100644 index 00000000000..65f8bd1505f --- /dev/null +++ b/src/renderer/src/components/skills/SkillBundleInstallOutcome.tsx @@ -0,0 +1,92 @@ +import { AlertTriangle, Check } from 'lucide-react' +import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' +import { translate } from '@/i18n/i18n' + +const RESULT_GROUPS = [ + 'installed', + 'updated', + 'unchanged', + 'kept-local', + 'failed', + 'cancelled' +] as const + +function resultGroupLabel(status: (typeof RESULT_GROUPS)[number]): string { + const labels = { + installed: translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e01', + 'Installed' + ), + updated: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e02', 'Updated'), + unchanged: translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e03', + 'Unchanged' + ), + 'kept-local': translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e04', + 'Kept local' + ), + failed: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e05', 'Failed'), + cancelled: translate('auto.components.skills.SkillBundleInstallOutcome.01c5a12e06', 'Cancelled') + } + return labels[status] +} + +export function SkillBundleInstallOutcome({ + result +}: { + result: SkillBundleInstallResult +}): React.JSX.Element { + const incomplete = result.status !== 'complete' + return ( +
+
+
+ {incomplete ? : } +
+
+

+ {incomplete + ? translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e07', + 'Bundle installation needs attention.' + ) + : translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e08', + 'Skills installed and verified.' + )} +

+

+ {translate( + 'auto.components.skills.SkillBundleInstallOutcome.01c5a12e09', + '{{value0}} selected skills checked.', + { value0: result.skills.length } + )} +

+
+
+
+ {RESULT_GROUPS.map((status) => { + const skills = result.skills.filter((skill) => skill.status === status) + return skills.length ? ( +
+

+ {resultGroupLabel(status)} · {skills.length} +

+ {skills.map((skill) => ( +

+ {skill.name} + {skill.errorCategory ? ` · ${skill.errorCategory}` : ''} +

+ ))} +
+ ) : null + })} +
+
+ ) +} diff --git a/src/renderer/src/components/skills/SkillBundleInstallReview.tsx b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx new file mode 100644 index 00000000000..d6c7bb8b3a7 --- /dev/null +++ b/src/renderer/src/components/skills/SkillBundleInstallReview.tsx @@ -0,0 +1,278 @@ +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 { translate } from '@/i18n/i18n' + +type BundleVersion = SkillCloudVersion & { + manifest: Extract +} + +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 + } +} + +export function SkillBundleInstallReview(props: { + version: BundleVersion + selectedSkillIds: ReadonlySet + destinationPreview: SkillBundleInstallPreview | null + replaceSkillIds: ReadonlySet + busy: boolean + onToggleSkill(skillId: string, selected: boolean): void + onToggleAll(selected: boolean): void + onToggleReplace(skillId: string, replace: boolean): void + 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}

+
+ + {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.01c5a11e14', + '{{value0}} new', + { value0: previewSkills.filter((skill) => skill.currentState === 'missing').length } + )} + + + {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.publisher ? ( +

+ {translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e07', + 'Published by Orca user' + )}{' '} + {props.version.publisher.userId} + {props.version.publisher.organizationId + ? translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e08', + ' in organization {{value0}}', + { value0: props.version.publisher.organizationId } + ) + : ''} + . +

+ ) : null} + {props.version.releaseNotes.trim() ? ( +

+ {translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e09', + 'Release notes:' + )}{' '} + {props.version.releaseNotes} +

+ ) : null} +

+ {translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e0a', + 'Skills contain instructions and may include scripts. Treat them as code from their author.' + )} +

+
+ +
+
+
+

+ {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 ? ( +
+
+ {' '} + {translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e11', + 'Local copies need a decision' + )} +
+

+ {translate( + 'auto.components.skills.SkillBundleInstallReview.01c5a11e12', + 'Orca will keep these local copies by default. Select only the copies you want to discard and replace.' + )} +

+
+ {conflicts.map((conflict) => ( + + ))} +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/skills/SkillCard.tsx b/src/renderer/src/components/skills/SkillCard.tsx index 8afe322f9f8..ec319434a52 100644 --- a/src/renderer/src/components/skills/SkillCard.tsx +++ b/src/renderer/src/components/skills/SkillCard.tsx @@ -4,6 +4,7 @@ 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' @@ -27,10 +28,18 @@ function formatUpdatedAt(value: number | null): string { export function SkillCard({ skill, - onShare + onShare, + selected = false, + selectionMode = false, + selectionDisabled = false, + onSelectionChange }: { skill: DiscoveredSkill onShare?: () => void + selected?: boolean + selectionMode?: boolean + selectionDisabled?: boolean + onSelectionChange?: (selected: boolean) => void }): React.JSX.Element { const revealSkill = async (): Promise => { const result = await window.api.shell.openInFileManager(skill.skillFilePath) @@ -45,6 +54,19 @@ export function SkillCard({
+ {selectionMode ? ( + onSelectionChange?.(value === true)} + /> + ) : null}
diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx index 343536e8b98..344b43367b6 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx @@ -43,6 +43,42 @@ function version(): SkillCloudVersion { } } +function bundleVersion(): SkillCloudVersion { + const skill = (id: string, name: string) => ({ + id, + name, + description: `${name} description`, + digest: DIGEST, + files: [ + { + path: 'SKILL.md', + size: 1, + executable: false, + classification: 'text' as const, + sha256: DIGEST, + identitySha256: DIGEST + } + ] + }) + return { + ...version(), + name: 'team-bundle', + description: 'Two private skills', + packageDigest: 'c'.repeat(64), + releaseNotes: 'Initial team bundle', + manifest: { + schemaVersion: 1, + packageId: 'pkg_1', + versionId: 'ver_1', + bundleName: 'team-bundle', + description: 'Two private skills', + createdAt: '2026-08-11T00:00:00.000Z', + skills: [skill('skill-alpha', 'alpha'), skill('skill-beta', 'beta')], + bundleDigest: 'c'.repeat(64) + } + } +} + function installApi(previewInstall: ReturnType) { let progressListener: | ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void) @@ -52,6 +88,7 @@ function installApi(previewInstall: ReturnType) { .fn() .mockResolvedValue({ status: 'ok', value: { id: 'share_1', version: version() } }), previewInstall, + previewBundleInstall: vi.fn(), installShare: vi.fn().mockResolvedValue({ status: 'ok', value: { @@ -62,6 +99,7 @@ function installApi(previewInstall: ReturnType) { placements: [] } }), + installBundleShare: vi.fn(), cancelInstall: vi.fn().mockResolvedValue({ cancelled: true }), listWslDistros: vi.fn().mockResolvedValue([]), onInstallProgress: vi.fn((listener) => { @@ -75,12 +113,12 @@ function installApi(previewInstall: ReturnType) { } } -async function inspectSkill(): Promise { +async function inspectSkill(expectedDescription = 'A private skill'): Promise { fireEvent.change(screen.getByLabelText('Orca skill link'), { target: { value: 'https://app.orca.dev/skills/share/share_1' } }) fireEvent.click(screen.getByRole('button', { name: 'Inspect skill' })) - await screen.findByText('A private skill') + await screen.findByText(expectedDescription) } beforeEach(() => { @@ -102,6 +140,105 @@ afterEach(() => { }) describe('SkillInstallDialog', () => { + it('installs a selected subset from a shared bundle', async () => { + const skills = installApi(vi.fn()) + skills.resolveShare.mockResolvedValue({ + status: 'ok', + value: { id: 'share_1', version: bundleVersion() } + }) + Object.assign(skills, { + previewBundleInstall: vi.fn().mockResolvedValue({ + status: 'ok', + value: { + packageId: 'pkg_1', + versionId: 'ver_1', + bundleDigest: 'c'.repeat(64), + destinationIdentity: 'local:global', + skills: [{ id: 'skill-beta', name: 'beta', digest: DIGEST, currentState: 'missing' }] + } + }), + installBundleShare: vi.fn().mockResolvedValue({ + status: 'ok', + value: { + operationId: 'op_1', + packageId: 'pkg_1', + versionId: 'ver_1', + bundleDigest: 'c'.repeat(64), + status: 'complete', + skills: [ + { + skillId: 'skill-beta', + name: 'beta', + digest: DIGEST, + status: 'installed', + placements: [] + } + ] + } + }) + }) + Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + render( undefined} />) + await inspectSkill('Two private skills') + + fireEvent.click(screen.getByRole('checkbox', { name: /alpha/ })) + fireEvent.click(screen.getByRole('button', { name: 'Install 1 skills' })) + + await screen.findByText('Skills installed and verified.') + expect(skills.installBundleShare).toHaveBeenCalledWith( + expect.objectContaining({ selectedSkillIds: ['skill-beta'] }) + ) + }) + + it('keeps bundle conflicts local unless replacement is selected', async () => { + const skills = installApi(vi.fn()) + skills.resolveShare.mockResolvedValue({ + status: 'ok', + value: { id: 'share_1', version: bundleVersion() } + }) + Object.assign(skills, { + previewBundleInstall: vi.fn().mockResolvedValue({ + status: 'ok', + value: { + packageId: 'pkg_1', + versionId: 'ver_1', + bundleDigest: 'c'.repeat(64), + destinationIdentity: 'local:global', + skills: [ + { id: 'skill-alpha', name: 'alpha', digest: DIGEST, currentState: 'modified' }, + { id: 'skill-beta', name: 'beta', digest: DIGEST, currentState: 'missing' } + ] + } + }), + installBundleShare: vi.fn().mockResolvedValue({ + status: 'ok', + value: { + operationId: 'op_1', + packageId: 'pkg_1', + versionId: 'ver_1', + bundleDigest: 'c'.repeat(64), + status: 'partial', + skills: [] + } + }) + }) + Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + render( undefined} />) + await inspectSkill('Two private skills') + + fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' })) + await screen.findByText('Local copies need a decision') + expect(skills.installBundleShare).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' })) + await waitFor(() => expect(skills.installBundleShare).toHaveBeenCalledOnce()) + expect(skills.installBundleShare).toHaveBeenCalledWith( + expect.objectContaining({ + conflictDecisions: [{ skillId: 'skill-alpha', resolution: 'keep-local' }] + }) + ) + }) + it('preserves a modified install until the user explicitly discards it', async () => { const previewInstall = vi.fn().mockResolvedValue({ status: 'ok', diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx index a9331aad505..4af3286b4e7 100644 --- a/src/renderer/src/components/skills/SkillInstallDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -17,7 +17,8 @@ import type { } from '../../../../shared/skill-install-contract' import { parseSkillShareId } from './skill-share-link' import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices' -import type { ResolvedSkillShare } from './skill-share-version-summary' +import { isSkillBundleVersion, type ResolvedSkillShare } from './skill-share-version-summary' +import { SkillBundleInstallFlow } from './SkillBundleInstallFlow' import { SkillInstallTargetFields } from './SkillInstallTargetFields' import { SkillInstallOutcome, @@ -53,6 +54,7 @@ export function SkillInstallDialog({ null ) const [busy, setBusy] = useState(false) + const [bundleBusy, setBundleBusy] = useState(false) const [error, setError] = useState(null) const [result, setResult] = useState(null) const [destinationPreview, setDestinationPreview] = useState(null) @@ -209,18 +211,26 @@ export function SkillInstallDialog({ setScope('global') setWorkspace('') setExecutionTarget(null) + setBundleBusy(false) onOpenChange(false) } + const bundleVersion = preview && isSkillBundleVersion(preview.version) ? preview.version : null + return ( - !next && !busy && close()}> + !next && !busy && !bundleBusy && close()}> - {translate( - 'auto.components.skills.SkillInstallDialog.fcbec627cc', - 'Install shared skill' - )} + {bundleVersion + ? translate( + 'auto.components.skills.SkillInstallDialog.01c5a14e01', + 'Install shared skills' + ) + : translate( + 'auto.components.skills.SkillInstallDialog.fcbec627cc', + 'Install shared skill' + )} {translate( @@ -237,6 +247,13 @@ export function SkillInstallDialog({ onLinkChange={setLink} onSubmit={() => void inspect()} /> + ) : bundleVersion ? ( + ) : result && result.status !== 'conflict' ? ( ) : ( @@ -276,51 +293,57 @@ export function SkillInstallDialog({ )} - {error ? ( + {!bundleVersion && error ? (

{error}

) : null} - {installProgress.phaseLabel ? ( + {!bundleVersion && installProgress.phaseLabel ? (

{installProgress.phaseLabel}

) : null} - - - {busy && installProgress.activeOperationId ? ( - - ) : null} - {preview && - (!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? ( - - ) : null} - + {busy && installProgress.activeOperationId ? ( + + ) : null} + {preview && + (!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? ( + + ) : null} + + ) : null}
) diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx index 43062201d7b..217e6d1d2f4 100644 --- a/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.test.tsx @@ -64,32 +64,50 @@ function packageDetails(versions: SkillCloudVersion[]): SkillCloudPackageDetails } function skillsApi( - installed: ManagedSkillInstall, + installedInput: ManagedSkillInstall | ManagedSkillInstall[], versions: SkillCloudVersion[], details = packageDetails(versions) ) { + const installed = Array.isArray(installedInput) ? installedInput : [installedInput] let progressListener: | ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void) | null = null return { - listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: [installed] }), + listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installed }), getPackage: vi.fn().mockResolvedValue({ status: 'ok', value: details }), installPackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: { operationId: 'operation_1', status: 'updated', - name: installed.name, + name: installed[0]?.name ?? 'private-skill', packageDigest: DIGEST, placements: [] } }), + installBundlePackageVersion: vi.fn().mockResolvedValue({ + status: 'ok', + value: { + operationId: 'operation_bundle', + packageId: 'pkg_1', + versionId: versions[0]?.versionId ?? 'ver_1', + bundleDigest: DIGEST, + status: 'complete', + skills: installed.map((skill) => ({ + skillId: skill.name, + name: skill.name, + digest: skill.packageDigest, + status: 'updated', + placements: [] + })) + } + }), removeInstall: vi.fn().mockResolvedValue({ status: 'ok', value: { operationId: 'operation_2', status: 'removed', - name: installed.name, + name: installed[0]?.name ?? 'private-skill', packageDigest: DIGEST, placements: [] } @@ -110,6 +128,34 @@ function skillsApi( } } +function bundleVersion(versionId: string, names: string[]): SkillCloudVersion { + const createdAt = '2026-08-12T00:00:00.000Z' + return { + ...version(versionId, createdAt), + name: 'team-skills', + manifest: { + schemaVersion: 1, + packageId: 'pkg_1', + versionId, + bundleName: 'team-skills', + description: 'Team skills', + createdAt, + bundleDigest: DIGEST, + skills: names.map((name) => ({ + id: name, + name, + description: `${name} description`, + digest: DIGEST, + files: [] + })) + } + } +} + +function bundleInstall(name: string, state: ManagedSkillInstall['state'] = 'unchanged') { + return { ...install('ver_1'), name, bundleDigest: DIGEST, state } +} + async function selectInstall(versionId: string): Promise { await screen.findByText(`global · ${versionId}`) fireEvent.click(screen.getByRole('button', { name: new RegExp(`private-skill.*${versionId}`) })) @@ -278,4 +324,69 @@ describe('SkillInstallManagementDialog', () => { expect.objectContaining({ packageId: 'pkg_1', versionId: 'ver_2' }) ) }) + + it('groups bundle receipts and updates only the managed bundle skills', async () => { + const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill')] + const bundle = bundleVersion('ver_2', ['alpha-skill', 'beta-skill', 'new-skill']) + const skills = skillsApi(installed, [bundle]) + Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + render( undefined} />) + + fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ })) + await screen.findByText('team-skills') + fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' })) + + await waitFor(() => expect(skills.installBundlePackageVersion).toHaveBeenCalledOnce()) + expect(skills.installBundlePackageVersion).toHaveBeenCalledWith( + expect.objectContaining({ + packageId: 'pkg_1', + versionId: 'ver_2', + selectedSkillIds: ['alpha-skill', 'beta-skill'] + }) + ) + expect(skills.installPackageVersion).not.toHaveBeenCalled() + }) + + it('removes a bundle with one confirmation and preserves modified skills', async () => { + const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill', 'modified')] + const skills = skillsApi(installed, [bundleVersion('ver_1', ['alpha-skill', 'beta-skill'])]) + skills.removeInstall + .mockResolvedValueOnce({ + status: 'ok', + value: { + operationId: 'remove_alpha', + status: 'removed', + name: 'alpha-skill', + packageDigest: DIGEST, + placements: [] + } + }) + .mockResolvedValueOnce({ + status: 'ok', + value: { + operationId: 'keep_beta', + status: 'conflict', + name: 'beta-skill', + packageDigest: DIGEST, + placements: [] + } + }) + Object.defineProperty(window, 'api', { configurable: true, value: { skills } }) + render( undefined} />) + + fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ })) + await screen.findByText('team-skills') + fireEvent.click(screen.getByRole('button', { name: 'Remove 2 skills' })) + fireEvent.click(screen.getByRole('button', { name: 'Confirm remove 2 skills' })) + + await screen.findByText('1 removed · 1 modified skill preserved.') + expect(skills.removeInstall).toHaveBeenCalledTimes(2) + expect(skills.removeInstall.mock.calls).toEqual( + expect.arrayContaining([ + [expect.objectContaining({ name: 'alpha-skill' })], + [expect.objectContaining({ name: 'beta-skill' })] + ]) + ) + expect(skills.removeInstall.mock.calls[1]?.[0]).not.toHaveProperty('conflictResolution') + }) }) diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx index fa417123e59..e4f2098b474 100644 --- a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react' -import { Badge } from '@/components/ui/badge' +import { Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, @@ -22,16 +21,18 @@ import type { ManagedSkillInstall, SkillInstallResult } from '../../../../shared/skill-install-contract' +import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract' import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract' import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills' -import { SkillCloudManagementActions } from './SkillCloudManagementActions' import { skillInstallResultLabel } from './skill-install-result-label' import { skillInstallManagementCopy } from './skill-install-management-copy' import { useSkillInstallProgress } from './skill-install-progress-state' - -function installKey(install: ManagedSkillInstall): string { - return `${install.destinationIdentity}:${install.name}:${install.packageId}` -} +import { SkillManagedInstallList } from './SkillManagedInstallList' +import { SkillManagedInstallDetails } from './SkillManagedInstallDetails' +import { + groupManagedSkillInstalls, + type SkillManagedInstallGroup +} from './skill-managed-install-groups' export function SkillInstallManagementDialog({ open, @@ -52,14 +53,17 @@ export function SkillInstallManagementDialog({ const [busy, setBusy] = useState(false) const [confirmRemove, setConfirmRemove] = useState(false) const [result, setResult] = useState(null) + const [bundleResult, setBundleResult] = useState(null) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) const installProgress = useSkillInstallProgress() + const groups = useMemo(() => groupManagedSkillInstalls(installs), [installs]) const selected = useMemo( - () => installs.find((install) => installKey(install) === selectedKey) ?? null, - [installs, selectedKey] + () => groups.find((group) => group.key === selectedKey) ?? null, + [groups, selectedKey] ) + const selectedInstall = selected?.installs[0] ?? null const load = useCallback(async (): Promise => { if (!open) { @@ -91,15 +95,16 @@ export function SkillInstallManagementDialog({ void load() }, [load]) - const selectInstall = async (install: ManagedSkillInstall): Promise => { - setSelectedKey(installKey(install)) + const selectInstall = async (group: SkillManagedInstallGroup): Promise => { + setSelectedKey(group.key) setBusy(true) setError(null) setNotice(null) setResult(null) + setBundleResult(null) setConfirmRemove(false) try { - const operation = await window.api.skills.getPackage(install.packageId) + const operation = await window.api.skills.getPackage(group.packageId) if (operation.status !== 'ok') { setError( operation.status === 'reconnect-required' @@ -109,7 +114,7 @@ export function SkillInstallManagementDialog({ return } setDetails(operation.value) - setVersionId(operation.value.versions[0]?.versionId ?? install.versionId) + setVersionId(operation.value.versions[0]?.versionId ?? group.versionId) } catch (cause) { console.warn('[skills] package history failed:', cause) setError('Version history is unavailable for this skill.') @@ -141,7 +146,7 @@ export function SkillInstallManagementDialog({ } const installVersion = async (discardLocal = false): Promise => { - if (!selected || !versionId) { + if (!selected || !selectedInstall || !versionId) { return } setBusy(true) @@ -150,6 +155,53 @@ export function SkillInstallManagementDialog({ const operationId = crypto.randomUUID() installProgress.begin(operationId) try { + const version = details?.versions.find((candidate) => candidate.versionId === versionId) + const bundleManifest = + version?.manifest && 'skills' in version.manifest ? version.manifest : null + if (bundleManifest) { + const installedNames = new Set(selected.installs.map((install) => install.name)) + const selectedSkills = bundleManifest.skills.filter((skill) => + installedNames.has(skill.name) + ) + if (selectedSkills.length === 0) { + setError('This version does not contain any of the installed bundle skills.') + return + } + const operation = await window.api.skills.installBundlePackageVersion({ + packageId: selected.packageId, + versionId, + operationId, + ...(environmentId === 'local' || environmentId.startsWith('ssh:') + ? {} + : { environmentId }), + selectedSkillIds: selectedSkills.map((skill) => skill.id), + destination: selected.destination, + ...(discardLocal + ? { + conflictDecisions: selectedSkills.map((skill) => ({ + skillId: skill.id, + resolution: 'replace-and-discard-local' as const + })) + } + : {}) + }) + if (operation.status !== 'ok') { + setError( + operation.status === 'reconnect-required' + ? 'Reconnect your Orca account before changing versions.' + : operation.message + ) + return + } + setBundleResult(operation.value) + if (!['failed', 'cancelled'].includes(operation.value.status)) { + notifyInstalledAgentSkillsChanged() + if (operation.value.status === 'complete') { + await load() + } + } + return + } const operation = await window.api.skills.installPackageVersion({ packageId: selected.packageId, versionId, @@ -207,19 +259,41 @@ export function SkillInstallManagementDialog({ setError(null) setNotice(null) try { - const operation = await window.api.skills.removeInstall({ - ...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }), - name: selected.name, - destination: selected.destination, - ...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {}) - }) - if (operation.status !== 'ok') { - setError(operation.message) + const targets = discardLocal + ? selected.installs.filter((install) => install.state === 'modified') + : selected.installs + const operations = await Promise.all( + targets.map((install) => + window.api.skills.removeInstall({ + ...(environmentId === 'local' || environmentId.startsWith('ssh:') + ? {} + : { environmentId }), + name: install.name, + destination: install.destination, + ...(discardLocal ? { conflictResolution: 'replace-and-discard-local' as const } : {}) + }) + ) + ) + const unsupported = operations.find((operation) => operation.status !== 'ok') + if (unsupported?.status === 'unsupported') { + setError(unsupported.message) return } - setResult(operation.value) - if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) { + const values = operations.flatMap((operation) => + operation.status === 'ok' ? [operation.value] : [] + ) + const removed = values.filter((value) => value.status === 'removed').length + const preserved = values.filter((value) => value.status === 'conflict').length + setResult(values.at(-1) ?? null) + setNotice( + selected.installs.length > 1 + ? `${removed} removed${preserved ? ` · ${preserved} modified skill${preserved === 1 ? '' : 's'} preserved` : ''}.` + : null + ) + if (removed > 0) { notifyInstalledAgentSkillsChanged() + } + if (preserved === 0 && values.every((value) => value.status !== 'failed')) { await load() } } catch (cause) { @@ -236,11 +310,14 @@ export function SkillInstallManagementDialog({ setError(null) setNotice(null) setResult(null) + setBundleResult(null) setConfirmRemove(false) onOpenChange(false) } - const destructiveConflict = result?.status === 'conflict' || selected?.state === 'modified' + const destructiveConflict = + result?.status === 'conflict' || + Boolean(selected?.installs.some((install) => install.state === 'modified')) return ( !next && !busy && close()}> @@ -279,101 +356,31 @@ export function SkillInstallManagementDialog({ {copy.noInstalls}

) : null} -
- {installs.map((install) => ( - - ))} -
+ void selectInstall(group)} + /> {selected && details ? ( -
-
-

{selected.name}

-

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

-
- - {destructiveConflict ? ( -
-

- {copy.modified} -

-

{copy.preserveModified}

- - -
- ) : null} -
- - {installProgress.activeOperationId ? ( - - ) : null} - -
- -
+ void installVersion(discardLocal)} + onCancelInstall={() => void cancelInstall()} + onRemove={(discardLocal) => void remove(discardLocal)} + onCloudChanged={refreshPackageDetails} + onPackageDeleted={packageDeleted} + /> ) : null} {result ? (

diff --git a/src/renderer/src/components/skills/SkillInstallReviewContent.tsx b/src/renderer/src/components/skills/SkillInstallReviewContent.tsx index 002d58d30a7..41f4dad1a9c 100644 --- a/src/renderer/src/components/skills/SkillInstallReviewContent.tsx +++ b/src/renderer/src/components/skills/SkillInstallReviewContent.tsx @@ -160,7 +160,7 @@ export function SkillInstallReview({

- {version.manifest.files.length}{' '} + {versionSummary.fileCount}{' '} {translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')} diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx index 9e368eb6997..7a18a8dbbe0 100644 --- a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx +++ b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx @@ -24,6 +24,7 @@ export function SkillInstallTargetFields(props: { runtimeStatus: Map sshConnections: { id: string; label: string; connected: boolean }[] workspaceChoices: SkillInstallWorkspaceChoice[] + requiredCapability?: string }): React.JSX.Element { const [wslDistros, setWslDistros] = useState([]) @@ -82,7 +83,9 @@ export function SkillInstallTargetFields(props: { const unsupported = status !== null && status !== undefined && - status.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true + status.capabilities?.includes( + props.requiredCapability ?? SKILL_INSTALL_CAPABILITY + ) !== true return ( {environment.name}{' '} diff --git a/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx b/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx new file mode 100644 index 00000000000..067ee504fa7 --- /dev/null +++ b/src/renderer/src/components/skills/SkillManagedInstallDetails.tsx @@ -0,0 +1,161 @@ +import { AlertTriangle, Loader2, 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, + 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 + 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 + 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} + +
+ + {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 new file mode 100644 index 00000000000..9276d9a548f --- /dev/null +++ b/src/renderer/src/components/skills/SkillManagedInstallList.tsx @@ -0,0 +1,50 @@ +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/SkillShareDialog.test.tsx b/src/renderer/src/components/skills/SkillShareDialog.test.tsx index 2d967a01b90..57b3f74e26f 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.test.tsx @@ -106,7 +106,8 @@ describe('SkillShareDialog', () => { await screen.findByRole('heading', { name: 'Publish new skill version' }) expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy() expect(skills.prepareShare).toHaveBeenCalledWith({ - skillId: skill.id, + skillIds: [skill.id], + bundleName: skill.name, packageId: 'pkg_1' }) }) @@ -120,7 +121,8 @@ describe('SkillShareDialog', () => { await screen.findByRole('heading', { name: 'Share skill' }) await waitFor(() => expect(skills.prepareShare).toHaveBeenCalledWith({ - skillId: skill.id + skillIds: [skill.id], + bundleName: skill.name }) ) }) diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx index 64df5dfebc2..5c0ec91bfed 100644 --- a/src/renderer/src/components/skills/SkillShareDialog.tsx +++ b/src/renderer/src/components/skills/SkillShareDialog.tsx @@ -19,7 +19,8 @@ import { import { matchingManagedSkillInstall } from './skill-share-package-selection' type SkillShareDialogProps = { - skill: DiscoveredSkill | null + skills?: DiscoveredSkill[] + skill?: DiscoveredSkill | null open: boolean onOpenChange: (open: boolean) => void } @@ -37,10 +38,12 @@ function operationError(status: string): string { } export function SkillShareDialog({ + skills, skill, open, onOpenChange }: SkillShareDialogProps): React.JSX.Element { + const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills]) const [preview, setPreview] = useState(null) const [members, setMembers] = useState([]) const [author, setAuthor] = useState('') @@ -59,7 +62,7 @@ export function SkillShareDialog({ const cancellationRequested = useRef(false) useEffect(() => { - if (!open || !skill) { + if (!open || selectedSkills.length === 0) { return } const current = ++generation.current @@ -74,14 +77,18 @@ export function SkillShareDialog({ try { const operation = await window.api.skills.listManagedInstalls() if (operation.status === 'ok') { - managedInstall = matchingManagedSkillInstall(skill, operation.value) + managedInstall = + selectedSkills.length === 1 + ? matchingManagedSkillInstall(selectedSkills[0], operation.value) + : null } } catch (cause) { console.warn('[skills] managed install lookup failed during share:', cause) } const [nextPreview, auth] = await Promise.all([ window.api.skills.prepareShare({ - skillId: skill.id, + skillIds: selectedSkills.map((skill) => skill.id), + bundleName: selectedSkills.length === 1 ? selectedSkills[0].name : 'shared-skills', ...(managedInstall ? { packageId: managedInstall.packageId } : {}) }), window.api.orcaProfiles.authStatus() @@ -129,7 +136,7 @@ export function SkillShareDialog({ setPreparing(false) } }) - }, [open, skill]) + }, [open, selectedSkills]) useEffect(() => { if (!preview) { diff --git a/src/renderer/src/components/skills/SkillShareReviewContent.tsx b/src/renderer/src/components/skills/SkillShareReviewContent.tsx index 38bce132c87..03f3ac0a5c1 100644 --- a/src/renderer/src/components/skills/SkillShareReviewContent.tsx +++ b/src/renderer/src/components/skills/SkillShareReviewContent.tsx @@ -93,6 +93,7 @@ export function SkillSharePreparationReview({ progress: SkillShareProgress | null progressPercent: number }): React.JSX.Element { + const skillPreviews = preview.skills ?? [] return (
@@ -106,6 +107,13 @@ export function SkillSharePreparationReview({
+ + {translate( + 'auto.components.skills.SkillShareReviewContent.01c5a17e01', + '{{value0}} skills', + { value0: preview.skillCount ?? preview.skills?.length ?? 1 } + )} + {preview.fileCount}{' '} {translate('auto.components.skills.SkillShareReviewContent.3121f44358', 'files')} @@ -124,6 +132,33 @@ export function SkillSharePreparationReview({ {translate('auto.components.skills.SkillShareReviewContent.b3b1d4b911', 'SHA-256')}{' '} {preview.packageDigest}

+ {skillPreviews.length > 1 ? ( +
+ + {translate( + 'auto.components.skills.SkillShareReviewContent.01c5a17e02', + 'Review included skills' + )} + +
+ {skillPreviews.map((skill) => ( +
+
+

{skill.name}

+

{skill.description}

+
+ + {translate( + 'auto.components.skills.SkillShareReviewContent.01c5a17e03', + '{{value0}} files · {{value1}}', + { value0: skill.fileCount, value1: byteLabel(skill.totalBytes) } + )} + +
+ ))} +
+
+ ) : null}
diff --git a/src/renderer/src/components/skills/SkillShareSelectableCard.tsx b/src/renderer/src/components/skills/SkillShareSelectableCard.tsx new file mode 100644 index 00000000000..a3df6cb4d8f --- /dev/null +++ b/src/renderer/src/components/skills/SkillShareSelectableCard.tsx @@ -0,0 +1,31 @@ +import type { DiscoveredSkill } from '../../../../shared/skills' +import { SkillCard } from './SkillCard' +import { isSkillShareEligible } from './SkillShareSelectionControls' + +export function SkillShareSelectableCard({ + skill, + local, + selected, + selectionMode, + onSelectedChange, + onShare +}: { + skill: DiscoveredSkill + local: boolean + selected: boolean + selectionMode: boolean + onSelectedChange: (selected: boolean) => void + onShare: () => void +}): React.JSX.Element { + const eligible = isSkillShareEligible(skill, local) + return ( + + ) +} diff --git a/src/renderer/src/components/skills/SkillShareSelectionControls.tsx b/src/renderer/src/components/skills/SkillShareSelectionControls.tsx new file mode 100644 index 00000000000..87c1df17a5a --- /dev/null +++ b/src/renderer/src/components/skills/SkillShareSelectionControls.tsx @@ -0,0 +1,74 @@ +import { Share2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { translate } from '@/i18n/i18n' + +export function isSkillShareEligible(skill: DiscoveredSkill, local: boolean): boolean { + return local && skill.installed && (skill.sourceKind === 'home' || skill.sourceKind === 'repo') +} + +export function updatedSkillSelection( + current: ReadonlySet, + skillId: string, + selected: boolean +): Set { + const next = new Set(current) + if (selected) { + next.add(skillId) + } else { + next.delete(skillId) + } + return next +} + +export function SkillShareSelectionAction({ + selecting, + selectedCount, + onClick +}: { + selecting: boolean + selectedCount: number + onClick: () => void +}): React.JSX.Element { + const label = selecting + ? selectedCount > 0 + ? translate( + 'auto.components.skills.SkillShareSelectionControls.01c5a15e05', + 'Share {{value0}} skills', + { value0: selectedCount } + ) + : translate('auto.components.skills.SkillShareSelectionControls.01c5a15e01', 'Cancel sharing') + : translate('auto.components.skills.SkillShareSelectionControls.01c5a15e02', 'Share skills') + return ( + + ) +} + +export function SkillShareSelectionStatus({ + selectedCount, + onSelectAll +}: { + selectedCount: number + onSelectAll: () => void +}): React.JSX.Element { + return ( +
+ + {translate( + 'auto.components.skills.SkillShareSelectionControls.01c5a15e03', + '{{value0}} selected', + { value0: selectedCount } + )} + + +
+ ) +} diff --git a/src/renderer/src/components/skills/SkillsPage.tsx b/src/renderer/src/components/skills/SkillsPage.tsx index 7605a8a251e..3d5c1260f15 100644 --- a/src/renderer/src/components/skills/SkillsPage.tsx +++ b/src/renderer/src/components/skills/SkillsPage.tsx @@ -17,7 +17,7 @@ import { discoverSkillsForRuntimeTarget } from '@/runtime/runtime-skills-client' import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-discovery-runtime-target' import { useMountedRef } from '@/hooks/useMountedRef' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' -import { SkillCard } from './SkillCard' +import { SkillShareSelectableCard } from './SkillShareSelectableCard' import { SkillShareDialog } from './SkillShareDialog' import { SkillInstallDialog } from './SkillInstallDialog' import { SkillInstallManagementDialog } from './SkillInstallManagementDialog' @@ -25,6 +25,12 @@ import { pluralize, sourceLabels } from './skill-display-labels' import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter' import { translate } from '@/i18n/i18n' import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from '@/hooks/installed-agent-skills-change-event' +import { + SkillShareSelectionAction, + SkillShareSelectionStatus, + isSkillShareEligible, + updatedSkillSelection +} from './SkillShareSelectionControls' const EMPTY_SKILLS: DiscoveredSkill[] = [] @@ -83,7 +89,9 @@ export default function SkillsPage(): React.JSX.Element { const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget() const [result, setResult] = useState(null) const [loading, setLoading] = useState(true) - const [shareSkill, setShareSkill] = useState(null) + const [shareSkills, setShareSkills] = useState([]) + const [selectingShare, setSelectingShare] = useState(false) + const [selectedSkillIds, setSelectedSkillIds] = useState>(() => new Set()) const [installOpen, setInstallOpen] = useState(false) const [installLink, setInstallLink] = useState('') const [managementOpen, setManagementOpen] = useState(false) @@ -191,8 +199,6 @@ export default function SkillsPage(): React.JSX.Element { const skills = result?.skills ?? EMPTY_SKILLS const visibleSkills = useMemo(() => filterSkills(skills, filters), [filters, skills]) const sourceCounts = useMemo(() => countSkillsBySource(skills), [skills]) - const activeSourceCount = result?.sources.filter((source) => source.exists).length ?? 0 - return (
@@ -214,10 +220,22 @@ export default function SkillsPage(): React.JSX.Element {

{pluralize(skills.length, 'skill')}{' '} {translate('auto.components.skills.SkillsPage.e46e162e2e', 'from')} - {pluralize(activeSourceCount, 'source')} + {pluralize(result?.sources.filter((source) => source.exists).length ?? 0, 'source')}

+ { + if (selectingShare && selectedSkillIds.size > 0) { + setShareSkills(skills.filter((skill) => selectedSkillIds.has(skill.id))) + return + } + setSelectingShare((value) => !value) + setSelectedSkillIds(new Set()) + }} + />