feat(skills): add private skill bundle sharing

This commit is contained in:
Jinwoo-H
2026-08-11 23:14:25 -04:00
parent 41a7b45c4e
commit e4ff69ccba
71 changed files with 5344 additions and 602 deletions
@@ -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/<skill-name>` 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/<skill-name>` 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/<name>/`, 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/<name>` 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.
+203 -94
View File
@@ -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/<name>` 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/<share-id>
@@ -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/<name>/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.
@@ -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()
+99 -41
View File
@@ -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<typeof shareInstallSchema> | z.infer<typeof packageVersionInstallSchema>
input:
| z.infer<typeof skillCloudShareInstallSchema>
| z.infer<typeof skillCloudPackageVersionInstallSchema>
) => {
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<typeof skillCloudBundleShareInstallSchema>
| z.infer<typeof skillCloudBundlePackageVersionInstallSchema>
) => {
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)
@@ -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<typeof SkillBundleInstallPreviewRequestSchema> & {
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 = {
+84
View File
@@ -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<SkillCloudOperation<SkillCloudVersion>> {
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<SkillBundleInstallResult> {
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()
@@ -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 = {
+7
View File
@@ -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(),
@@ -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<string> {
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<string> {
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' })
})
})
+225
View File
@@ -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<void> }
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<SkillBundleEntry> {
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<CreatedSkillBundle> {
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<string>()
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)
}
}
+205
View File
@@ -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<void> {
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<unknown> {
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<void> {
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<void> {
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<void> {
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<SkillBundleExtractionResult> {
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
}
}
@@ -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<void> }
export type SkillBundleInstallRequestDependencies = {
authority: SkillInstallDestinationAuthority
stateDirectory: string
allowedDownloadOrigins: readonly string[]
requireHttps: boolean
allowTrustedLocalFile?: boolean
signal?: AbortSignal
fetcher?: typeof fetch
detectProviders: () => Promise<readonly string[]>
resolveStagedUpload?: (
uploadId: string,
identity: SkillBundlePackageIdentity
) => Promise<StagedSkillBundle>
}
async function resolveIngress(
request: SkillBundleInstallRequest,
dependencies: SkillBundleInstallRequestDependencies
): Promise<StagedSkillBundle> {
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<SkillBundleInstallResult> {
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()
}
}
@@ -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<string> {
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-install-test-'))
temporaryDirectories.push(directory)
return directory
}
async function createSkill(root: string, name: string): Promise<string> {
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
})
])
})
})
@@ -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<ReturnType<typeof previewLocalExtractedSkillPackage>>
): 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<ReturnType<typeof previewLocalExtractedSkillPackage>>
result: Awaited<ReturnType<typeof installSharedExtractedSkill>>
}): 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<SkillBundleInstallResult> {
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 })
}
}
@@ -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<string> {
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'
])
})
})
@@ -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<SkillBundleInstallResult> {
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)
}
}
})
}
@@ -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
@@ -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'] })
})
)
})
})
@@ -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<ReturnType<typeof skillInstallFailureFromError>>
): 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,
+31 -5
View File
@@ -26,9 +26,22 @@ function id(value: string): string {
export class SkillCloudService {
constructor(private readonly userDataPath: string) {}
publish(
async publish(
request: SkillCloudPublishRequest
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
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<SkillCloudOperation<SkillCloudVersion>> {
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<SkillCloudOperation<SkillCloudShare>> {
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
})
}
@@ -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,
+57 -19
View File
@@ -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<SkillInstallResult> {
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<SkillInstallResult> {
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<ReturnType<typeof readSkillInstallReceipt>>
result: SkillInstallResult
}): Promise<SkillInstallResult> {
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
})
}
@@ -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,
+38 -17
View File
@@ -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<LocalExtractedSkillPackage> {
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<LocalSkillInstallPreview> {
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<LocalSkillInstallPreview> {
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<SkillInstallResult> {
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<SkillInstallResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
let releaseLock: (() => Promise<void>) | null = null
let journal: SkillInstallJournalV1 | null = null
@@ -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()
})
})
@@ -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<unknown>
}
async function installBundle(
userDataPath: string,
environmentId: string,
request: SkillBundleInstallRequest
): Promise<RuntimeRpcResponse<unknown>> {
return (await callRuntimeEnvironment(
userDataPath,
environmentId,
'skills.installBundle',
request,
5 * 60_000
)) as RuntimeRpcResponse<unknown>
}
function isDirectDownloadUnavailable(
response: RuntimeRpcResponse<unknown>,
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<SkillBundleInstallResult> {
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)
}
}
})
}
@@ -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<boolean> {
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
@@ -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
)
})
})
@@ -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<SkillCloudService, 'publish'>
private readonly cloud: Pick<SkillCloudService, 'createShare' | 'publishVersion'>
) {}
async prepare(input: {
sourceDirectory: string
sources?: { id?: string; sourceDirectory: string }[]
sourceDirectory?: string
bundleName?: string
description?: string
packageId?: string
}): Promise<SkillSharePreview> {
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) {
@@ -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<string> {
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)
}
}
}
+42
View File
@@ -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<IPtyProvider['requestHostRpc']>
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<string[]> {
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')
: []
}
+26 -171
View File
@@ -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<IPtyProvider['requestHostRpc']>
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<string[]> {
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<boolean> {
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<SkillInstallResult> {
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<SkillInstallPreview> {
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<SkillInstallResult> {
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<ManagedSkillInstall[]> {
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<typeof installSkillOnSshHost>[0]
): Promise<string> {
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)
}
}
}
@@ -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('')
})
})
+16 -1
View File
@@ -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<void>
getUpdateRun: () => Promise<SkillUpdateRun>
prepareShare: (input: {
skillId: string
skillIds: string[]
bundleName: string
target?: SkillDiscoveryTarget
packageId?: string
}) => Promise<SkillSharePreview>
@@ -2579,11 +2585,20 @@ export type PreloadApi = {
resolveShare: (shareId: string) => Promise<SkillShareResolvedOperation>
createDownloadGrant: (shareId: string) => Promise<SkillCloudOperation<SkillCloudDownloadGrant>>
installShare: (input: SkillShareInstallInput) => Promise<SkillShareInstallOperation>
installBundleShare: (
input: SkillBundleShareInstallInput
) => Promise<SkillBundleShareInstallOperation>
installBundlePackageVersion: (
input: SkillBundlePackageVersionInstallInput
) => Promise<SkillBundleShareInstallOperation>
installPackageVersion: (
input: SkillPackageVersionInstallInput
) => Promise<SkillShareInstallOperation>
cancelInstall: (input: SkillInstallCancelInput) => Promise<{ cancelled: boolean }>
previewInstall: (input: SkillInstallPreviewInput) => Promise<SkillInstallPreviewOperation>
previewBundleInstall: (
input: SkillBundleInstallPreviewInput
) => Promise<SkillBundleInstallPreviewOperation>
removeInstall: (input: SkillRemoveInput) => Promise<SkillRemoveOperation>
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
+19 -1
View File
@@ -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<void> => ipcRenderer.invoke('skills:acknowledgeUpdateRun'),
getUpdateRun: (): Promise<SkillUpdateRun> => ipcRenderer.invoke('skills:getUpdateRun'),
prepareShare: (input: {
skillId: string
skillIds: string[]
bundleName: string
target?: SkillDiscoveryTarget
packageId?: string
}): Promise<SkillSharePreview> => ipcRenderer.invoke('skills:prepareShare', input),
@@ -2535,6 +2541,14 @@ const api = {
ipcRenderer.invoke('skills:createDownloadGrant', shareId),
installShare: (input: SkillShareInstallInput): Promise<SkillShareInstallOperation> =>
ipcRenderer.invoke('skills:installShare', input),
installBundleShare: (
input: SkillBundleShareInstallInput
): Promise<SkillBundleShareInstallOperation> =>
ipcRenderer.invoke('skills:installBundleShare', input),
installBundlePackageVersion: (
input: SkillBundlePackageVersionInstallInput
): Promise<SkillBundleShareInstallOperation> =>
ipcRenderer.invoke('skills:installBundlePackageVersion', input),
installPackageVersion: (
input: SkillPackageVersionInstallInput
): Promise<SkillShareInstallOperation> =>
@@ -2543,6 +2557,10 @@ const api = {
ipcRenderer.invoke('skills:cancelInstall', input),
previewInstall: (input: SkillInstallPreviewInput): Promise<SkillInstallPreviewOperation> =>
ipcRenderer.invoke('skills:previewInstall', input),
previewBundleInstall: (
input: SkillBundleInstallPreviewInput
): Promise<SkillBundleInstallPreviewOperation> =>
ipcRenderer.invoke('skills:previewBundleInstall', input),
removeInstall: (input: SkillRemoveInput): Promise<SkillRemoveOperation> =>
ipcRenderer.invoke('skills:removeInstall', input),
listManagedInstalls: (environmentId?: string): Promise<ManagedSkillInstallListOperation> =>
+57 -1
View File
@@ -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')
+19
View File
@@ -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(() =>
@@ -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<SkillCloudVersion['manifest'], { skills: unknown }>
}
const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision'])
function sameSelection(preview: SkillBundleInstallPreview, selected: ReadonlySet<string>): 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<Set<string>>(() => new Set())
const [destinationPreview, setDestinationPreview] = useState<SkillBundleInstallPreview | null>(
null
)
const [result, setResult] = useState<SkillBundleInstallResult | null>(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<string | null>(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<string> = selectedSkillIds,
reusePreview = true
): Promise<void> => {
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<void> => {
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 ? (
<SkillBundleInstallOutcome result={result} />
) : (
<SkillBundleInstallReview
version={props.version}
selectedSkillIds={selectedSkillIds}
destinationPreview={destinationPreview}
replaceSkillIds={replaceSkillIds}
busy={busy}
onToggleSkill={(id, selected) => {
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
})
}
>
<SkillInstallTargetFields
environmentId={environmentId}
onEnvironmentChange={(value) => {
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}
/>
</SkillBundleInstallReview>
)}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
{installProgress.phaseLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{installProgress.phaseLabel}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={props.onClose} disabled={busy}>
{translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e01', 'Close')}
</Button>
{busy && installProgress.activeOperationId ? (
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
{translate(
'auto.components.skills.SkillBundleInstallFlow.01c5a13e02',
'Cancel installation'
)}
</Button>
) : null}
{!result || retryIds.size ? (
<Button
type="button"
disabled={
busy || (!result && (!selectedSkillIds.size || (scope === 'workspace' && !workspace)))
}
onClick={() => void (result ? install(retryIds, false) : install())}
className="w-40"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
{busy
? translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e03', 'Installing…')
: result
? translate(
'auto.components.skills.SkillBundleInstallFlow.01c5a13e04',
'Retry {{value0}} skills',
{ value0: retryIds.size }
)
: translate(
'auto.components.skills.SkillBundleInstallFlow.01c5a13e05',
'Install {{value0}} skills',
{ value0: selectedSkillIds.size }
)}
</Button>
) : null}
</DialogFooter>
</>
)
}
@@ -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 (
<div className="space-y-3">
<div
className="flex items-center gap-3 rounded-md border border-border p-3"
role="status"
aria-live="polite"
>
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
{incomplete ? <AlertTriangle className="size-4" /> : <Check className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">
{incomplete
? translate(
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e07',
'Bundle installation needs attention.'
)
: translate(
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e08',
'Skills installed and verified.'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallOutcome.01c5a12e09',
'{{value0}} selected skills checked.',
{ value0: result.skills.length }
)}
</p>
</div>
</div>
<div className="space-y-2">
{RESULT_GROUPS.map((status) => {
const skills = result.skills.filter((skill) => skill.status === status)
return skills.length ? (
<section key={status} className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{resultGroupLabel(status)} · {skills.length}
</p>
{skills.map((skill) => (
<p key={skill.skillId} className="text-xs">
{skill.name}
{skill.errorCategory ? ` · ${skill.errorCategory}` : ''}
</p>
))}
</section>
) : null
})}
</div>
</div>
)
}
@@ -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<SkillCloudVersion['manifest'], { skills: unknown }>
}
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<string>
destinationPreview: SkillBundleInstallPreview | null
replaceSkillIds: ReadonlySet<string>
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 (
<div className="space-y-5">
<section className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{manifest.bundleName}</h3>
<p className="text-xs leading-5 text-muted-foreground">{manifest.description}</p>
</div>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e01',
'Immutable version'
)}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e02',
'{{value0}} skills',
{ value0: selectedSkills.length }
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e03',
'{{value0}} files',
{ value0: selectedFiles.length }
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e04',
'{{value0}} scripts',
{
value0: selectedFiles.filter((file) => file.path.startsWith('scripts/')).length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e05',
'{{value0}} executable',
{ value0: selectedFiles.filter((file) => file.executable).length }
)}
</Badge>
</div>
{props.destinationPreview ? (
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground" role="status">
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e14',
'{{value0}} new',
{ value0: previewSkills.filter((skill) => skill.currentState === 'missing').length }
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e15',
'{{value0}} unchanged',
{
value0: previewSkills.filter((skill) => skill.currentState === 'unchanged').length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e16',
'{{value0}} updates',
{
value0: previewSkills.filter((skill) => skill.currentState === 'clean-update')
.length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e17',
'{{value0}} conflicts',
{ value0: conflicts.length }
)}
</Badge>
</div>
) : null}
<p className="truncate font-mono text-[11px] text-muted-foreground">
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e06', 'SHA-256')}{' '}
{manifest.bundleDigest}
</p>
{props.version.publisher ? (
<p className="text-xs text-muted-foreground">
{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 }
)
: ''}
.
</p>
) : null}
{props.version.releaseNotes.trim() ? (
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e09',
'Release notes:'
)}{' '}
{props.version.releaseNotes}
</p>
) : null}
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0a',
'Skills contain instructions and may include scripts. Treat them as code from their author.'
)}
</p>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0b',
'Skills to install'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0c',
'Choose any subset from this bundle.'
)}
</p>
</div>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={allSelected ? true : props.selectedSkillIds.size ? 'indeterminate' : false}
disabled={props.busy}
onCheckedChange={(checked) => props.onToggleAll(checked === true)}
/>
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e0d', 'Select all')}
</label>
</div>
<div className="max-h-52 space-y-1 overflow-y-auto rounded-md border border-border p-1 scrollbar-sleek">
{manifest.skills.map((skill) => {
const counts = skillCounts(skill)
const state = props.destinationPreview?.skills.find((item) => item.id === skill.id)
return (
<label
key={skill.id}
className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent"
>
<Checkbox
checked={props.selectedSkillIds.has(skill.id)}
disabled={props.busy}
onCheckedChange={(checked) => props.onToggleSkill(skill.id, checked === true)}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{skill.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{skill.description ||
translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0e',
'No description'
)}
</span>
</span>
<span className="shrink-0 text-right text-[11px] text-muted-foreground">
<span className="block">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0f',
'{{value0}} files · {{value1}} scripts',
{ value0: skill.files.length, value1: counts.scripts }
)}
</span>
<span className="block">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e10',
'{{value0}} executable',
{ value0: counts.executables }
)}
{state ? ` · ${state.currentState}` : ''}
</span>
</span>
</label>
)
})}
</div>
</section>
{props.children}
{conflicts.length ? (
<section className="space-y-3 rounded-md border border-border p-3" role="alert">
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="size-4" />{' '}
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e11',
'Local copies need a decision'
)}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e12',
'Orca will keep these local copies by default. Select only the copies you want to discard and replace.'
)}
</p>
<div className="space-y-2">
{conflicts.map((conflict) => (
<label key={conflict.id} className="flex items-center gap-2 text-xs">
<Checkbox
checked={props.replaceSkillIds.has(conflict.id)}
disabled={props.busy}
onCheckedChange={(checked) =>
props.onToggleReplace(conflict.id, checked === true)
}
/>
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e13',
'Replace {{value0}} ({{value1}})',
{ value0: conflict.name, value1: conflict.currentState }
)}
</label>
))}
</div>
</section>
) : null}
</div>
)
}
@@ -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<void> => {
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
@@ -45,6 +54,19 @@ export function SkillCard({
<Card className="rounded-lg">
<CardContent className="space-y-3 p-4">
<div className="flex min-w-0 items-start gap-3">
{selectionMode ? (
<Checkbox
className="mt-2"
checked={selected}
disabled={selectionDisabled}
aria-label={translate(
'auto.components.skills.SkillCard.01c5a16e01',
'Select {{value0}}',
{ value0: skill.name }
)}
onCheckedChange={(value) => onSelectionChange?.(value === true)}
/>
) : null}
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<BookOpen className="size-4 text-muted-foreground" />
</div>
@@ -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<typeof vi.fn>) {
let progressListener:
| ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void)
@@ -52,6 +88,7 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
.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<typeof vi.fn>) {
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<typeof vi.fn>) {
}
}
async function inspectSkill(): Promise<void> {
async function inspectSkill(expectedDescription = 'A private skill'): Promise<void> {
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(<SkillInstallDialog open onOpenChange={() => 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(<SkillInstallDialog open onOpenChange={() => 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',
@@ -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<string | null>(null)
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(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 (
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
<Dialog open={open} onOpenChange={(next) => !next && !busy && !bundleBusy && close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{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'
)}
</DialogTitle>
<DialogDescription>
{translate(
@@ -237,6 +247,13 @@ export function SkillInstallDialog({
onLinkChange={setLink}
onSubmit={() => void inspect()}
/>
) : bundleVersion ? (
<SkillBundleInstallFlow
shareId={preview.shareId}
version={bundleVersion}
onClose={close}
onBusyChange={setBundleBusy}
/>
) : result && result.status !== 'conflict' ? (
<SkillInstallOutcome result={result} />
) : (
@@ -276,51 +293,57 @@ export function SkillInstallDialog({
</SkillInstallReview>
)}
{error ? (
{!bundleVersion && error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
{installProgress.phaseLabel ? (
{!bundleVersion && installProgress.phaseLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{installProgress.phaseLabel}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
</Button>
{busy && installProgress.activeOperationId ? (
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
{translate(
'auto.components.skills.SkillInstallDialog.05588076a9',
'Cancel installation'
)}
{!bundleVersion ? (
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
</Button>
) : null}
{preview &&
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
<Button
type="button"
disabled={busy || (scope === 'workspace' && !workspace)}
onClick={() => void install()}
className="w-32"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
{busy
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
: result
? translate(
'auto.components.skills.SkillInstallDialog.59c3b76cdd',
'Retry install'
)
: translate(
'auto.components.skills.SkillInstallDialog.39acb9e8f4',
'Install skill'
)}
</Button>
) : null}
</DialogFooter>
{busy && installProgress.activeOperationId ? (
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
{translate(
'auto.components.skills.SkillInstallDialog.05588076a9',
'Cancel installation'
)}
</Button>
) : null}
{preview &&
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
<Button
type="button"
disabled={busy || (scope === 'workspace' && !workspace)}
onClick={() => void install()}
className="w-32"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
{busy
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
: result
? translate(
'auto.components.skills.SkillInstallDialog.59c3b76cdd',
'Retry install'
)
: translate(
'auto.components.skills.SkillInstallDialog.39acb9e8f4',
'Install skill'
)}
</Button>
) : null}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
)
@@ -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<void> {
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(<SkillInstallManagementDialog open onOpenChange={() => 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(<SkillInstallManagementDialog open onOpenChange={() => 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')
})
})
@@ -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<SkillInstallResult | null>(null)
const [bundleResult, setBundleResult] = useState<SkillBundleInstallResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(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<void> => {
if (!open) {
@@ -91,15 +95,16 @@ export function SkillInstallManagementDialog({
void load()
}, [load])
const selectInstall = async (install: ManagedSkillInstall): Promise<void> => {
setSelectedKey(installKey(install))
const selectInstall = async (group: SkillManagedInstallGroup): Promise<void> => {
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<void> => {
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 (
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
@@ -279,101 +356,31 @@ export function SkillInstallManagementDialog({
{copy.noInstalls}
</p>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{installs.map((install) => (
<Button
key={installKey(install)}
type="button"
variant={selectedKey === installKey(install) ? 'secondary' : 'outline'}
className="h-auto justify-start p-3 text-left"
onClick={() => void selectInstall(install)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{install.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{install.scope} · {install.versionId}
</span>
</span>
<Badge
variant={install.state === 'unchanged' ? 'outline' : 'destructive'}
className="ml-auto"
>
{install.state}
</Badge>
</Button>
))}
</div>
<SkillManagedInstallList
groups={groups}
selectedKey={selectedKey}
onSelect={(group) => void selectInstall(group)}
/>
{selected && details ? (
<section className="space-y-3 rounded-md border border-border p-3">
<div>
<h3 className="text-sm font-semibold">{selected.name}</h3>
<p className="text-xs text-muted-foreground">
{copy.installedVersion} {selected.versionId}
</p>
</div>
<Select value={versionId} onValueChange={setVersionId}>
<SelectTrigger>
<SelectValue placeholder={copy.chooseVersion} />
</SelectTrigger>
<SelectContent>
{details.versions.map((version) => (
<SelectItem key={version.versionId} value={version.versionId}>
{version.versionId} · {new Date(version.createdAt).toLocaleDateString()}
</SelectItem>
))}
</SelectContent>
</Select>
{destructiveConflict ? (
<div className="space-y-2 rounded-md border border-border p-3" role="alert">
<p className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="size-4" /> {copy.modified}
</p>
<p className="text-xs text-muted-foreground">{copy.preserveModified}</p>
<Button variant="destructive" size="sm" onClick={() => void installVersion(true)}>
{copy.discardAndInstall}
</Button>
<Button variant="destructive" size="sm" onClick={() => void remove(true)}>
{copy.discardAndRemove}
</Button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={
busy || (versionId === selected.versionId && result?.status !== 'partial')
}
onClick={() => void installVersion()}
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCcw className="size-4" />
)}
{result?.status === 'partial' ? copy.retryCoverage : copy.installVersion}
</Button>
{installProgress.activeOperationId ? (
<Button variant="secondary" size="sm" onClick={() => void cancelInstall()}>
{copy.cancelInstall}
</Button>
) : null}
<Button
variant={confirmRemove ? 'destructive' : 'outline'}
size="sm"
disabled={busy}
onClick={() => void remove()}
>
<Trash2 className="size-4" /> {confirmRemove ? copy.confirmRemove : copy.remove}
</Button>
</div>
<SkillCloudManagementActions
details={details}
selectedVersionId={versionId}
onChanged={refreshPackageDetails}
onPackageDeleted={packageDeleted}
/>
</section>
<SkillManagedInstallDetails
selected={selected}
details={details}
versionId={versionId}
busy={busy}
confirmRemove={confirmRemove}
result={result}
bundleResult={bundleResult}
installActive={Boolean(installProgress.activeOperationId)}
destructiveConflict={destructiveConflict}
copy={copy}
onVersionChange={setVersionId}
onInstall={(discardLocal) => void installVersion(discardLocal)}
onCancelInstall={() => void cancelInstall()}
onRemove={(discardLocal) => void remove(discardLocal)}
onCloudChanged={refreshPackageDetails}
onPackageDeleted={packageDeleted}
/>
) : null}
{result ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
@@ -160,7 +160,7 @@ export function SkillInstallReview({
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">
{version.manifest.files.length}{' '}
{versionSummary.fileCount}{' '}
{translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')}
</Badge>
<Badge variant="outline">
@@ -24,6 +24,7 @@ export function SkillInstallTargetFields(props: {
runtimeStatus: Map<string, { status: { capabilities?: string[] } | null }>
sshConnections: { id: string; label: string; connected: boolean }[]
workspaceChoices: SkillInstallWorkspaceChoice[]
requiredCapability?: string
}): React.JSX.Element {
const [wslDistros, setWslDistros] = useState<string[]>([])
@@ -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 (
<SelectItem key={environment.id} value={environment.id} disabled={unsupported}>
{environment.name}{' '}
@@ -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<typeof skillInstallManagementCopy>
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<void>
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 (
<section className="space-y-3 rounded-md border border-border p-3">
<div>
<h3 className="text-sm font-semibold">
{bundleManifest?.bundleName ?? selected.installs[0]?.name}
</h3>
<p className="text-xs text-muted-foreground">
{copy.installedVersion} {selected.versionId}
</p>
{selected.bundleDigest ? (
<div className="mt-2 space-y-1" aria-label={copy.installedBundleSkills}>
{selected.installs.map((install) => (
<p key={install.name} className="flex justify-between gap-3 text-xs">
<span className="truncate">{install.name}</span>
<span className="text-muted-foreground">{install.state}</span>
</p>
))}
</div>
) : null}
</div>
<Select value={versionId} onValueChange={onVersionChange}>
<SelectTrigger>
<SelectValue placeholder={copy.chooseVersion} />
</SelectTrigger>
<SelectContent>
{details.versions.map((version) => (
<SelectItem key={version.versionId} value={version.versionId}>
{version.versionId} · {new Date(version.createdAt).toLocaleDateString()}
</SelectItem>
))}
</SelectContent>
</Select>
{destructiveConflict ? (
<div className="space-y-2 rounded-md border border-border p-3" role="alert">
<p className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="size-4" /> {copy.modified}
</p>
<p className="text-xs text-muted-foreground">{copy.preserveModified}</p>
<Button variant="destructive" size="sm" onClick={() => onInstall(true)}>
{copy.discardAndInstall}
</Button>
<Button variant="destructive" size="sm" onClick={() => onRemove(true)}>
{copy.discardAndRemove}
</Button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={
busy ||
(versionId === selected.versionId &&
result?.status !== 'partial' &&
bundleResult?.status !== 'partial')
}
onClick={() => onInstall()}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <RotateCcw className="size-4" />}
{result?.status === 'partial' || bundleResult?.status === 'partial'
? copy.retryCoverage
: selected.bundleDigest
? copy.installSkills(selected.installs.length)
: copy.installVersion}
</Button>
{installActive ? (
<Button variant="secondary" size="sm" onClick={onCancelInstall}>
{copy.cancelInstall}
</Button>
) : null}
<Button
variant={confirmRemove ? 'destructive' : 'outline'}
size="sm"
disabled={busy}
onClick={() => onRemove()}
>
<Trash2 className="size-4" />{' '}
{confirmRemove
? selected.bundleDigest
? copy.confirmRemoveSkills(selected.installs.length)
: copy.confirmRemove
: selected.bundleDigest
? copy.removeSkills(selected.installs.length)
: copy.remove}
</Button>
</div>
<SkillCloudManagementActions
details={details}
selectedVersionId={versionId}
onChanged={onCloudChanged}
onPackageDeleted={onPackageDeleted}
/>
{bundleResult ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{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
)}
</p>
) : null}
</section>
)
}
@@ -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 (
<div className="grid gap-2 sm:grid-cols-2">
{groups.map((group) => {
const state = groupInstallState(group)
const isBundle = Boolean(group.bundleDigest)
return (
<Button
key={group.key}
type="button"
variant={selectedKey === group.key ? 'secondary' : 'outline'}
className="h-auto justify-start p-3 text-left"
onClick={() => onSelect(group)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{isBundle
? translate(
'auto.components.skills.SkillManagedInstallList.86c76cb262',
'{{count}} skill bundle',
{ count: group.installs.length }
)
: group.installs[0]?.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{group.installs[0]?.scope} · {group.versionId}
</span>
</span>
<Badge variant={state === 'unchanged' ? 'outline' : 'destructive'} className="ml-auto">
{state}
</Badge>
</Button>
)
})}
</div>
)
}
@@ -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
})
)
})
@@ -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<SkillSharePreview | null>(null)
const [members, setMembers] = useState<SelectableOrgMember[]>([])
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) {
@@ -93,6 +93,7 @@ export function SkillSharePreparationReview({
progress: SkillShareProgress | null
progressPercent: number
}): React.JSX.Element {
const skillPreviews = preview.skills ?? []
return (
<div className="space-y-5">
<section className="space-y-2">
@@ -106,6 +107,13 @@ export function SkillSharePreparationReview({
</div>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">
{translate(
'auto.components.skills.SkillShareReviewContent.01c5a17e01',
'{{value0}} skills',
{ value0: preview.skillCount ?? preview.skills?.length ?? 1 }
)}
</Badge>
<Badge variant="outline">
{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}
</p>
{skillPreviews.length > 1 ? (
<details className="rounded-md border border-border px-3 py-2 text-xs">
<summary className="cursor-pointer font-medium">
{translate(
'auto.components.skills.SkillShareReviewContent.01c5a17e02',
'Review included skills'
)}
</summary>
<div className="mt-2 space-y-2">
{skillPreviews.map((skill) => (
<div key={skill.id} className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-medium">{skill.name}</p>
<p className="truncate text-muted-foreground">{skill.description}</p>
</div>
<span className="shrink-0 text-muted-foreground">
{translate(
'auto.components.skills.SkillShareReviewContent.01c5a17e03',
'{{value0}} files · {{value1}}',
{ value0: skill.fileCount, value1: byteLabel(skill.totalBytes) }
)}
</span>
</div>
))}
</div>
</details>
) : null}
</section>
<section className="space-y-2">
@@ -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 (
<SkillCard
skill={skill}
selected={selected}
selectionMode={selectionMode}
selectionDisabled={!eligible}
onSelectionChange={onSelectedChange}
onShare={eligible ? onShare : undefined}
/>
)
}
@@ -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<string>,
skillId: string,
selected: boolean
): Set<string> {
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 (
<Button type="button" variant={selecting ? 'secondary' : 'outline'} size="sm" onClick={onClick}>
<Share2 className="size-3.5" />
{label}
</Button>
)
}
export function SkillShareSelectionStatus({
selectedCount,
onSelectAll
}: {
selectedCount: number
onSelectAll: () => void
}): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>
{translate(
'auto.components.skills.SkillShareSelectionControls.01c5a15e03',
'{{value0}} selected',
{ value0: selectedCount }
)}
</span>
<Button type="button" variant="ghost" size="xs" onClick={onSelectAll}>
{translate(
'auto.components.skills.SkillShareSelectionControls.01c5a15e04',
'Select all results'
)}
</Button>
</div>
)
}
@@ -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<SkillDiscoveryResult | null>(null)
const [loading, setLoading] = useState(true)
const [shareSkill, setShareSkill] = useState<DiscoveredSkill | null>(null)
const [shareSkills, setShareSkills] = useState<DiscoveredSkill[]>([])
const [selectingShare, setSelectingShare] = useState(false)
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(() => 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 (
<main className="flex min-h-0 flex-1 flex-col bg-background">
<header className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
@@ -214,10 +220,22 @@ export default function SkillsPage(): React.JSX.Element {
<p className="truncate text-xs text-muted-foreground">
{pluralize(skills.length, 'skill')}{' '}
{translate('auto.components.skills.SkillsPage.e46e162e2e', 'from')}
{pluralize(activeSourceCount, 'source')}
{pluralize(result?.sources.filter((source) => source.exists).length ?? 0, 'source')}
</p>
</div>
</div>
<SkillShareSelectionAction
selecting={selectingShare}
selectedCount={selectedSkillIds.size}
onClick={() => {
if (selectingShare && selectedSkillIds.size > 0) {
setShareSkills(skills.filter((skill) => selectedSkillIds.has(skill.id)))
return
}
setSelectingShare((value) => !value)
setSelectedSkillIds(new Set())
}}
/>
<Button
type="button"
variant="outline"
@@ -237,6 +255,20 @@ export default function SkillsPage(): React.JSX.Element {
</header>
<section className="flex shrink-0 flex-col gap-3 border-b border-border px-5 py-4">
{selectingShare ? (
<SkillShareSelectionStatus
selectedCount={selectedSkillIds.size}
onSelectAll={() =>
setSelectedSkillIds(
new Set(
visibleSkills
.filter((skill) => isSkillShareEligible(skill, runtimeTarget?.kind === 'local'))
.map((skill) => skill.id)
)
)
}
/>
) : null}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
@@ -336,16 +368,18 @@ export default function SkillsPage(): React.JSX.Element {
{visibleSkills.length > 0 ? (
<div className="mx-auto flex max-w-5xl flex-col gap-3">
{visibleSkills.map((skill) => (
<SkillCard
<SkillShareSelectableCard
key={skill.id}
skill={skill}
onShare={
runtimeTarget?.kind === 'local' &&
skill.installed &&
(skill.sourceKind === 'home' || skill.sourceKind === 'repo')
? () => setShareSkill(skill)
: undefined
local={runtimeTarget?.kind === 'local'}
selected={selectedSkillIds.has(skill.id)}
selectionMode={selectingShare}
onSelectedChange={(selected) =>
setSelectedSkillIds((current) =>
updatedSkillSelection(current, skill.id, selected)
)
}
onShare={() => setShareSkills([skill])}
/>
))}
</div>
@@ -358,9 +392,15 @@ export default function SkillsPage(): React.JSX.Element {
)}
</section>
<SkillShareDialog
skill={shareSkill}
open={shareSkill !== null}
onOpenChange={(nextOpen) => !nextOpen && setShareSkill(null)}
skills={shareSkills}
open={shareSkills.length > 0}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setShareSkills([])
setSelectingShare(false)
setSelectedSkillIds(new Set())
}
}}
/>
<SkillInstallDialog
key={installLink || 'manual-install'}
@@ -64,6 +64,34 @@ export function skillInstallManagementCopy() {
'Confirm remove'
),
remove: translate('auto.components.skills.SkillInstallManagementDialog.e91af0079f', 'Remove'),
installedBundleSkills: translate(
'auto.components.skills.SkillInstallManagementDialog.1c9e7f420a',
'Installed bundle skills'
),
installSkills: (count: number) =>
translate(
'auto.components.skills.SkillInstallManagementDialog.34c2ef9e71',
'Install {{count}} skills',
{ count }
),
removeSkills: (count: number) =>
translate(
'auto.components.skills.SkillInstallManagementDialog.a0cab67c2f',
'Remove {{count}} skills',
{ count }
),
confirmRemoveSkills: (count: number) =>
translate(
'auto.components.skills.SkillInstallManagementDialog.f970d8088d',
'Confirm remove {{count}} skills',
{ count }
),
bundleResult: (installed: number, updated: number, keptLocal: number) =>
translate(
'auto.components.skills.SkillInstallManagementDialog.dab29e4b54',
'{{installed}} installed · {{updated}} updated · {{keptLocal}} kept local',
{ installed, updated, keptLocal }
),
close: translate('auto.components.skills.SkillInstallManagementDialog.8095927ff3', 'Close')
}
}
@@ -0,0 +1,55 @@
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
export type SkillManagedInstallGroup = {
key: string
packageId: string
versionId: string
bundleDigest?: string
destinationIdentity: string
destination: ManagedSkillInstall['destination']
installs: ManagedSkillInstall[]
}
function groupKey(install: ManagedSkillInstall): string {
const identity = [
install.destinationIdentity,
install.packageId,
install.versionId,
install.bundleDigest ?? install.name
]
return identity.join(':')
}
export function groupManagedSkillInstalls(
installs: readonly ManagedSkillInstall[]
): SkillManagedInstallGroup[] {
const groups = new Map<string, SkillManagedInstallGroup>()
for (const install of installs) {
const key = groupKey(install)
const existing = groups.get(key)
if (existing) {
existing.installs.push(install)
continue
}
groups.set(key, {
key,
packageId: install.packageId,
versionId: install.versionId,
...(install.bundleDigest ? { bundleDigest: install.bundleDigest } : {}),
destinationIdentity: install.destinationIdentity,
destination: install.destination,
installs: [install]
})
}
return [...groups.values()].map((group) => ({
...group,
installs: group.installs.sort((left, right) => left.name.localeCompare(right.name))
}))
}
export function groupInstallState(group: SkillManagedInstallGroup): ManagedSkillInstall['state'] {
if (group.installs.some((install) => install.state === 'modified')) {
return 'modified'
}
return group.installs.some((install) => install.state === 'missing') ? 'missing' : 'unchanged'
}
@@ -1,14 +1,27 @@
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
import type { SkillBundleManifestV1 } from '../../../../shared/skill-bundle-manifest'
export type ResolvedSkillShare = { shareId: string; version: SkillCloudVersion }
export function isSkillBundleVersion(
version: SkillCloudVersion
): version is SkillCloudVersion & { manifest: SkillBundleManifestV1 } {
return 'skills' in version.manifest
}
export function summarizeSkillShareVersion(version: SkillCloudVersion | undefined): {
scriptCount: number
executableCount: number
fileCount: number
} {
const files = version
? 'skills' in version.manifest
? version.manifest.skills.flatMap((skill) => skill.files)
: version.manifest.files
: []
return {
scriptCount:
version?.manifest.files.filter((file) => file.path.startsWith('scripts/')).length ?? 0,
executableCount: version?.manifest.files.filter((file) => file.executable).length ?? 0
scriptCount: files.filter((file) => file.path.startsWith('scripts/')).length,
executableCount: files.filter((file) => file.executable).length,
fileCount: files.length
}
}
+67 -4
View File
@@ -4147,7 +4147,8 @@
"3af85f6add": "Done"
},
"SkillCard": {
"d25a1b8ae6": "Share skill"
"d25a1b8ae6": "Share skill",
"01c5a16e01": "Select {{value0}}"
},
"SkillCloudManagementActions": {
"ed753624c0": "Delete Cloud package",
@@ -4173,7 +4174,8 @@
"05588076a9": "Cancel installation",
"d198ec91e5": "Close",
"4a00d133c5": "Orca verifies access and package identity before changing the selected machine.",
"fcbec627cc": "Install shared skill"
"fcbec627cc": "Install shared skill",
"01c5a14e01": "Install shared skills"
},
"SkillInstallManagementDialog": {
"8095927ff3": "Close",
@@ -4193,7 +4195,12 @@
"176fef9516": "· SSH",
"6cb1fbe039": "This computer",
"3677ae58e7": "Update, roll back, or safely remove versions installed by Orca.",
"44d118a8f7": "Manage installed skills"
"44d118a8f7": "Manage installed skills",
"1c9e7f420a": "Installed bundle skills",
"34c2ef9e71": "Install {{count}} skills",
"a0cab67c2f": "Remove {{count}} skills",
"f970d8088d": "Confirm remove {{count}} skills",
"dab29e4b54": "{{installed}} installed · {{updated}} updated · {{keptLocal}} kept local"
},
"SkillInstallReviewContent": {
"89e2601162": "Discard and replace",
@@ -4257,7 +4264,63 @@
"77f636eac3": "executable",
"8edd32622f": "scripts",
"3121f44358": "files",
"2dca0b720b": "Publish new skill version"
"2dca0b720b": "Publish new skill version",
"01c5a17e01": "{{value0}} skills",
"01c5a17e02": "Review included skills",
"01c5a17e03": "{{value0}} files · {{value1}}"
},
"SkillBundleInstallFlow": {
"01c5a13e01": "Close",
"01c5a13e02": "Cancel installation",
"01c5a13e03": "Installing…",
"01c5a13e04": "Retry {{value0}} skills",
"01c5a13e05": "Install {{value0}} skills"
},
"SkillBundleInstallOutcome": {
"01c5a12e01": "Installed",
"01c5a12e02": "Updated",
"01c5a12e03": "Unchanged",
"01c5a12e04": "Kept local",
"01c5a12e05": "Failed",
"01c5a12e06": "Cancelled",
"01c5a12e07": "Bundle installation needs attention.",
"01c5a12e08": "Skills installed and verified.",
"01c5a12e09": "{{value0}} selected skills checked."
},
"SkillBundleInstallReview": {
"01c5a11e01": "Immutable version",
"01c5a11e02": "{{value0}} skills",
"01c5a11e03": "{{value0}} files",
"01c5a11e04": "{{value0}} scripts",
"01c5a11e05": "{{value0}} executable",
"01c5a11e06": "SHA-256",
"01c5a11e07": "Published by Orca user",
"01c5a11e08": " in organization {{value0}}",
"01c5a11e09": "Release notes:",
"01c5a11e0a": "Skills contain instructions and may include scripts. Treat them as code from their author.",
"01c5a11e0b": "Skills to install",
"01c5a11e0c": "Choose any subset from this bundle.",
"01c5a11e0d": "Select all",
"01c5a11e0e": "No description",
"01c5a11e0f": "{{value0}} files · {{value1}} scripts",
"01c5a11e10": "{{value0}} executable",
"01c5a11e11": "Local copies need a decision",
"01c5a11e12": "Orca will keep these local copies by default. Select only the copies you want to discard and replace.",
"01c5a11e13": "Replace {{value0}} ({{value1}})",
"01c5a11e14": "{{value0}} new",
"01c5a11e15": "{{value0}} unchanged",
"01c5a11e16": "{{value0}} updates",
"01c5a11e17": "{{value0}} conflicts"
},
"SkillShareSelectionControls": {
"01c5a15e05": "Share {{value0}} skills",
"01c5a15e01": "Cancel sharing",
"01c5a15e02": "Share skills",
"01c5a15e03": "{{value0}} selected",
"01c5a15e04": "Select all results"
},
"SkillManagedInstallList": {
"86c76cb262": "{{count}} skill bundle"
}
},
"sidebar": {
+4
View File
@@ -3083,10 +3083,14 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
createDownloadGrant: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
installBundleShare: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installPackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
cancelInstall: () => Promise.resolve({ cancelled: false }),
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
previewBundleInstall: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
listManagedInstalls: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
+2
View File
@@ -1,5 +1,6 @@
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_CANCEL_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
@@ -127,6 +128,7 @@ export const RUNTIME_CAPABILITIES = [
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_CANCEL_CAPABILITY,
SKILL_INSTALL_RESULT_V2_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract'
const digest = 'a'.repeat(64)
function request() {
return {
operationId: 'operation_1',
package: {
packageId: 'package_1',
versionId: 'version_1',
bundleDigest: digest,
archiveSha256: digest,
compressedBytes: 1024
},
selectedSkillIds: ['alpha', 'beta'],
ingress: { kind: 'local-file' as const, path: '/tmp/bundle.tar.gz' },
destination: { scope: 'global' as const },
conflictDecisions: [{ skillId: 'alpha', resolution: 'keep-local' as const }]
}
}
describe('skill bundle install contract', () => {
it('accepts one aggregate request with per-skill conflict decisions', () => {
expect(SkillBundleInstallRequestSchema.parse(request())).toMatchObject({
selectedSkillIds: ['alpha', 'beta'],
conflictDecisions: [{ skillId: 'alpha', resolution: 'keep-local' }]
})
})
it('rejects duplicate selections and decisions for unselected skills', () => {
expect(() =>
SkillBundleInstallRequestSchema.parse({
...request(),
selectedSkillIds: ['alpha', 'alpha']
})
).toThrow()
expect(() =>
SkillBundleInstallRequestSchema.parse({
...request(),
conflictDecisions: [{ skillId: 'gamma', resolution: 'keep-local' }]
})
).toThrow()
})
})
+151
View File
@@ -0,0 +1,151 @@
import { z } from 'zod'
import { SkillInstallDestinationSchema } from './skill-install-contract'
import { SkillInstallFailureSchema, type SkillInstallFailure } from './skill-install-failure'
const DIGEST_PATTERN = /^[a-f0-9]{64}$/
const ID_SCHEMA = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/)
const SKILL_NAME_SCHEMA = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
export const SkillBundlePackageIdentitySchema = z
.object({
packageId: ID_SCHEMA,
versionId: ID_SCHEMA,
bundleDigest: z.string().regex(DIGEST_PATTERN),
archiveSha256: z.string().regex(DIGEST_PATTERN),
compressedBytes: z
.number()
.int()
.positive()
.max(40 * 1024 * 1024)
})
.strict()
const SkillBundleInstallIngressSchema = z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('download-grant'),
url: z.url(),
expiresAt: z.iso.datetime({ offset: true })
})
.strict(),
z.object({ kind: z.literal('staged-upload'), uploadId: ID_SCHEMA }).strict(),
z.object({ kind: z.literal('local-file'), path: z.string().min(1) }).strict()
])
export const SkillBundleSelectedSkillSchema = z
.object({ id: ID_SCHEMA, name: SKILL_NAME_SCHEMA, digest: z.string().regex(DIGEST_PATTERN) })
.strict()
const SkillBundleConflictDecisionSchema = z
.object({
skillId: ID_SCHEMA,
resolution: z.enum(['keep-local', 'replace-unmodified', 'replace-and-discard-local'])
})
.strict()
function hasUniqueSkillIds(values: readonly { id?: string; skillId?: string }[]): boolean {
const ids = values.map((value) => value.id ?? value.skillId)
return new Set(ids).size === ids.length
}
export const SkillBundleInstallRequestSchema = z
.object({
operationId: ID_SCHEMA,
package: SkillBundlePackageIdentitySchema,
selectedSkillIds: z.array(ID_SCHEMA).min(1).max(512),
ingress: SkillBundleInstallIngressSchema,
destination: SkillInstallDestinationSchema,
conflictDecisions: z.array(SkillBundleConflictDecisionSchema).max(512).default([])
})
.strict()
.refine((value) => new Set(value.selectedSkillIds).size === value.selectedSkillIds.length, {
message: 'skill-bundle-selection-duplicate'
})
.refine((value) => hasUniqueSkillIds(value.conflictDecisions), {
message: 'skill-bundle-conflict-decision-duplicate'
})
.refine(
(value) =>
value.conflictDecisions.every((decision) =>
value.selectedSkillIds.includes(decision.skillId)
),
{ message: 'skill-bundle-conflict-decision-unselected' }
)
export const SkillBundleInstallPreviewRequestSchema = z
.object({
package: SkillBundlePackageIdentitySchema,
selectedSkills: z.array(SkillBundleSelectedSkillSchema).min(1).max(512),
destination: SkillInstallDestinationSchema
})
.strict()
.refine((value) => hasUniqueSkillIds(value.selectedSkills), {
message: 'skill-bundle-selection-duplicate'
})
const SkillBundleCurrentStateSchema = z.enum([
'missing',
'unchanged',
'clean-update',
'modified',
'unowned',
'external-link',
'name-collision'
])
export const SkillBundleInstallPreviewSchema = z.object({
packageId: ID_SCHEMA,
versionId: ID_SCHEMA,
bundleDigest: z.string().regex(DIGEST_PATTERN),
destinationIdentity: z.string(),
skills: z.array(
SkillBundleSelectedSkillSchema.extend({ currentState: SkillBundleCurrentStateSchema })
)
})
const SkillBundlePlacementResultSchema = z.object({
provider: z.string(),
path: z.string(),
topology: z.enum(['canonical-copy', 'provider-alias', 'independent-copy']),
status: z.enum(['installed', 'unchanged', 'skipped', 'failed']),
errorCategory: z.string().optional(),
failure: SkillInstallFailureSchema.optional()
})
export const SkillBundleInstallResultSchema = z.object({
operationId: ID_SCHEMA,
packageId: ID_SCHEMA,
versionId: ID_SCHEMA,
bundleDigest: z.string().regex(DIGEST_PATTERN),
status: z.enum(['complete', 'partial', 'cancelled', 'failed']),
skills: z.array(
z.object({
skillId: ID_SCHEMA,
name: SKILL_NAME_SCHEMA,
digest: z.string().regex(DIGEST_PATTERN),
status: z.enum(['installed', 'updated', 'unchanged', 'kept-local', 'failed', 'cancelled']),
canonicalPath: z.string().optional(),
placements: z.array(SkillBundlePlacementResultSchema),
conflict: z
.object({
kind: z.enum(['modified', 'unowned', 'external-link', 'name-collision']),
existingDigest: z.string().optional()
})
.optional(),
errorCategory: z.string().optional(),
failure: SkillInstallFailureSchema.optional()
})
)
})
export type SkillBundlePackageIdentity = z.infer<typeof SkillBundlePackageIdentitySchema>
export type SkillBundleSelectedSkill = z.infer<typeof SkillBundleSelectedSkillSchema>
export type SkillBundleInstallRequest = z.infer<typeof SkillBundleInstallRequestSchema>
export type SkillBundleInstallPreviewRequest = z.infer<
typeof SkillBundleInstallPreviewRequestSchema
>
export type SkillBundleInstallPreview = z.infer<typeof SkillBundleInstallPreviewSchema>
export type SkillBundleInstallResult = z.infer<typeof SkillBundleInstallResultSchema>
export type SkillBundleSkillResult = SkillBundleInstallResult['skills'][number]
export type SkillBundlePlacementResult = SkillBundleSkillResult['placements'][number]
export type SkillBundleInstallFailure = SkillInstallFailure
+164
View File
@@ -0,0 +1,164 @@
import { createHash } from 'node:crypto'
import { z } from 'zod'
import {
SkillPackageFileSchema,
computeSkillPackageDigest,
validateSkillPackageName,
validateSkillPackagePath,
type SkillPackageFile
} from './skill-package-manifest'
export const SKILL_BUNDLE_SCHEMA_VERSION = 1 as const
export const AGENT_PLUGIN_SCHEMA_V1 =
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' as const
export const ORCA_SKILL_BUNDLE_MANIFEST_PATH = 'dev.orca.skill-sharing/manifest.json'
export const AGENT_PLUGIN_MANIFEST_PATH = 'plugin.json'
const SHA256_PATTERN = /^[a-f0-9]{64}$/
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/
const AgentPluginAuthorSchema = z
.object({ name: z.string().optional(), email: z.string().optional(), url: z.string().optional() })
.strict()
export const AgentPluginManifestV1Schema = z
.object({
$schema: z.literal(AGENT_PLUGIN_SCHEMA_V1),
name: z.string().min(1).max(64).regex(PLUGIN_NAME_PATTERN),
version: z.string().optional(),
description: z.string().optional(),
author: AgentPluginAuthorSchema.optional(),
homepage: z.string().optional(),
repository: z.string().optional(),
license: z.string().optional(),
keywords: z.array(z.string()).optional(),
extensions: z.record(z.string(), z.record(z.string(), z.unknown())).optional()
})
.strict()
export type AgentPluginManifestV1 = z.infer<typeof AgentPluginManifestV1Schema>
export const SkillBundleEntrySchema = z
.object({
id: z.string().regex(ID_PATTERN),
name: z.string(),
description: z.string().max(4096),
digest: z.string().regex(SHA256_PATTERN),
files: z.array(SkillPackageFileSchema).min(1).max(512)
})
.strict()
export type SkillBundleEntry = z.infer<typeof SkillBundleEntrySchema>
export const SkillBundleManifestV1Schema = z
.object({
schemaVersion: z.literal(SKILL_BUNDLE_SCHEMA_VERSION),
packageId: z.string().regex(ID_PATTERN),
versionId: z.string().regex(ID_PATTERN),
bundleName: z.string().min(1).max(64).regex(PLUGIN_NAME_PATTERN),
description: z.string().max(4096),
createdAt: z.iso.datetime({ offset: true }),
skills: z.array(SkillBundleEntrySchema).min(1).max(512),
bundleDigest: z.string().regex(SHA256_PATTERN)
})
.strict()
export type SkillBundleManifestV1 = z.infer<typeof SkillBundleManifestV1Schema>
function validateSkillFiles(files: readonly SkillPackageFile[]): void {
let previousPath: string | null = null
const foldedPaths = new Set<string>()
for (const file of files) {
validateSkillPackagePath(file.path)
if (previousPath !== null && file.path <= previousPath) {
throw new Error('skill-bundle-manifest-path-order')
}
previousPath = file.path
const foldedPath = file.path.toLocaleLowerCase('en-US')
if (foldedPaths.has(foldedPath)) {
throw new Error('skill-bundle-case-collision')
}
foldedPaths.add(foldedPath)
}
if (!files.some((file) => file.path === 'SKILL.md')) {
throw new Error('skill-package-skill-markdown-required')
}
}
export function computeSkillBundleDigest(skills: readonly SkillBundleEntry[]): string {
return createHash('sha256')
.update(
JSON.stringify(
skills.map((skill) => ({ id: skill.id, name: skill.name, digest: skill.digest }))
)
)
.digest('hex')
}
export function parseAgentPluginManifest(value: unknown): AgentPluginManifestV1 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('skill-bundle-plugin-manifest-invalid')
}
const candidate = value as Record<string, unknown>
const extensions =
candidate.extensions &&
typeof candidate.extensions === 'object' &&
!Array.isArray(candidate.extensions)
? candidate.extensions
: undefined
const parsed = AgentPluginManifestV1Schema.safeParse({
$schema: candidate.$schema,
name: candidate.name,
version: candidate.version,
description: candidate.description,
author: candidate.author,
homepage: candidate.homepage,
repository: candidate.repository,
license: candidate.license,
keywords: candidate.keywords,
extensions
})
if (!parsed.success) {
throw new Error('skill-bundle-plugin-manifest-invalid')
}
return parsed.data
}
export function parseSkillBundleManifest(value: unknown): SkillBundleManifestV1 {
const parsed = SkillBundleManifestV1Schema.safeParse(value)
if (!parsed.success) {
throw new Error('skill-bundle-manifest-invalid')
}
const foldedNames = new Set<string>()
const ids = new Set<string>()
let totalFiles = 0
let totalBytes = 0
let previousName: string | null = null
for (const skill of parsed.data.skills) {
validateSkillPackageName(skill.name)
if (previousName !== null && skill.name <= previousName) {
throw new Error('skill-bundle-skill-order')
}
previousName = skill.name
const foldedName = skill.name.toLocaleLowerCase('en-US')
if (foldedNames.has(foldedName) || ids.has(skill.id)) {
throw new Error('skill-bundle-skill-collision')
}
foldedNames.add(foldedName)
ids.add(skill.id)
validateSkillFiles(skill.files)
if (computeSkillPackageDigest(skill.files) !== skill.digest) {
throw new Error('skill-bundle-skill-digest-mismatch')
}
totalFiles += skill.files.length
totalBytes += skill.files.reduce((sum, file) => sum + file.size, 0)
if (totalFiles > 512 || totalBytes > 32 * 1024 * 1024) {
throw new Error('skill-bundle-content-limit')
}
}
if (computeSkillBundleDigest(parsed.data.skills) !== parsed.data.bundleDigest) {
throw new Error('skill-bundle-digest-mismatch')
}
return parsed.data
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { SkillPackageManifestV1 } from './skill-package-manifest'
import type { SkillBundleManifestV1 } from './skill-bundle-manifest'
export type SkillCloudOptions = {
apiUrl?: string
@@ -22,7 +23,7 @@ export type SkillCloudVersion = {
compressedBytes: number
createdAt: string
releaseNotes: string
manifest: SkillPackageManifestV1
manifest: SkillPackageManifestV1 | SkillBundleManifestV1
publisher?: { userId: string; organizationId?: string }
}
+1
View File
@@ -3,6 +3,7 @@ export const SKILL_INSTALL_RESULT_V2_CAPABILITY = 'skills.install-result.v2' as
export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as const
export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const
export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const
export const SKILL_BUNDLE_INSTALL_CAPABILITY = 'skills.install.bundle.v1' as const
export const SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE =
'Update Orca on the selected machine to install shared skills.'
+5
View File
@@ -112,6 +112,7 @@ export type ManagedSkillInstall = {
packageId: string
versionId: string
packageDigest: string
bundleDigest?: string
scope: 'global' | 'workspace'
destinationIdentity: string
destination: SkillInstallDestination
@@ -124,6 +125,10 @@ export const ManagedSkillInstallSchema: z.ZodType<ManagedSkillInstall> = z.objec
packageId: z.string(),
versionId: z.string(),
packageDigest: z.string().regex(/^[a-f0-9]{64}$/),
bundleDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
scope: z.enum(['global', 'workspace']),
destinationIdentity: z.string(),
destination: SkillInstallDestinationSchema,
+55
View File
@@ -10,6 +10,12 @@ import type {
SkillPackageIdentity,
ManagedSkillInstall
} from './skill-install-contract'
import type {
SkillBundleInstallPreview,
SkillBundleInstallResult,
SkillBundlePackageIdentity,
SkillBundleSelectedSkill
} from './skill-bundle-install-contract'
export type SkillSharePreview = {
preparationId: string
@@ -18,6 +24,17 @@ export type SkillSharePreview = {
name: string
description: string
packageDigest: string
skillCount?: number
skills?: {
id: string
name: string
description: string
digest: string
fileCount: number
totalBytes: number
scriptPaths: string[]
executablePaths: string[]
}[]
archiveSha256: string
fileCount: number
totalBytes: number
@@ -73,6 +90,44 @@ export type SkillShareInstallOperation =
| { status: 'reconnect-required' }
| { status: 'unsupported'; message: string }
export type SkillBundleShareInstallInput = {
shareId: string
operationId?: string
versionId?: string
environmentId?: string
selectedSkillIds: string[]
destination: SkillInstallDestination
conflictDecisions?: {
skillId: string
resolution: 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
}[]
}
export type SkillBundlePackageVersionInstallInput = Omit<
SkillBundleShareInstallInput,
'shareId'
> & {
packageId: string
versionId: string
}
export type SkillBundleShareInstallOperation =
| { status: 'ok'; value: SkillBundleInstallResult }
| { status: 'unconfigured'; message: string }
| { status: 'reconnect-required' }
| { status: 'unsupported'; message: string }
export type SkillBundleInstallPreviewInput = {
environmentId?: string
package: SkillBundlePackageIdentity
selectedSkills: SkillBundleSelectedSkill[]
destination: SkillInstallDestination
}
export type SkillBundleInstallPreviewOperation =
| { status: 'ok'; value: SkillBundleInstallPreview }
| { status: 'unsupported'; message: string }
export type SkillInstallPreviewInput = {
environmentId?: string
package: SkillPackageIdentity
+10
View File
@@ -4,8 +4,10 @@ import {
SkillInstallRequestSchema,
SkillRemoveRequestSchema
} from './skill-install-contract'
import { SkillBundleInstallRequestSchema } from './skill-bundle-install-contract'
export const SKILL_SSH_RELAY_INSTALL_METHOD = 'skills.install' as const
export const SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD = 'skills.installBundle' as const
export const SKILL_SSH_RELAY_PREVIEW_METHOD = 'skills.previewInstall' as const
export const SKILL_SSH_RELAY_REMOVE_METHOD = 'skills.removeInstall' as const
export const SKILL_SSH_RELAY_LIST_METHOD = 'skills.listManagedInstalls' as const
@@ -28,6 +30,13 @@ export const SkillSshInstallParamsSchema = z
})
.strict()
export const SkillSshInstallBundleParamsSchema = z
.object({
request: SkillBundleInstallRequestSchema,
workspace: SkillSshWorkspaceAuthoritySchema.optional()
})
.strict()
export const SkillSshPreviewParamsSchema = z
.object({
request: SkillInstallPreviewRequestSchema,
@@ -48,5 +57,6 @@ export const SkillSshListParamsSchema = z
export type SkillSshWorkspaceAuthority = z.infer<typeof SkillSshWorkspaceAuthoritySchema>
export type SkillSshInstallParams = z.infer<typeof SkillSshInstallParamsSchema>
export type SkillSshInstallBundleParams = z.infer<typeof SkillSshInstallBundleParamsSchema>
export type SkillSshPreviewParams = z.infer<typeof SkillSshPreviewParamsSchema>
export type SkillSshRemoveParams = z.infer<typeof SkillSshRemoveParamsSchema>
+2 -1
View File
@@ -1,11 +1,12 @@
import { z } from 'zod'
import { SkillPackageIdentitySchema } from './skill-install-contract'
import { SkillBundlePackageIdentitySchema } from './skill-bundle-install-contract'
export const SKILL_UPLOAD_CHUNK_MAX_BYTES = 256 * 1024
export const SkillUploadBeginRequestSchema = z
.object({
package: SkillPackageIdentitySchema,
package: z.union([SkillPackageIdentitySchema, SkillBundlePackageIdentitySchema]),
transferId: z.string().min(1).max(128).optional()
})
.strict()
+213
View File
@@ -0,0 +1,213 @@
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import type { SkillCloudPublishResult } from '../../src/shared/skill-cloud-contract'
import type { SkillSharePreview } from '../../src/shared/skill-sharing-contract'
import { expect, test } from './helpers/orca-app'
const RUN_STAGING = process.env.ORCA_E2E_SKILL_STAGING === '1'
const SKILL_NAME = `orca-staging-${randomUUID().slice(0, 8)}`
test.use({
orcaAppExtraEnv: {
ORCA_ARTIFACTS_API_URL: 'https://cloud-api-staging.onorca.dev',
ORCA_CLOUD_API_URL: 'https://auth-staging.onorca.dev',
ORCA_CLOUD_AUTH_URL: 'https://auth-staging.onorca.dev',
ORCA_CLOUD_CLIENT_ID: 'orca-desktop'
}
})
test.skip(!RUN_STAGING, 'Set ORCA_E2E_SKILL_STAGING=1 to run the live staging journey.')
test.describe.configure({ mode: 'serial' })
test('publishes, updates, revokes, and deletes without losing local state', async ({
electronApp,
orcaPage
}) => {
test.setTimeout(12 * 60_000)
const sourceRoot = mkdtempSync(join(tmpdir(), 'orca-staging-skill-source-'))
const source = join(sourceRoot, '.agents', 'skills', SKILL_NAME)
const home = await electronApp.evaluate(({ app }) => app.getPath('home'))
const globalSkill = join(home, '.agents', 'skills', SKILL_NAME)
let packageId: string | null = null
try {
mkdirSync(source, { recursive: true })
writeSkill(source, 'v1')
await connectCloud(orcaPage)
const first = await publish(
orcaPage,
sourceRoot,
'Initial staging journey',
undefined,
(preview) => {
packageId = preview.packageId
}
)
const firstInstall = await installVersion(orcaPage, first.published, { scope: 'global' })
expect(firstInstall).toMatchObject({ status: 'ok', value: { status: 'installed' } })
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
writeSkill(globalSkill, 'local')
writeSkill(source, 'v2')
const second = await publish(
orcaPage,
sourceRoot,
'Second immutable version',
first.preview.packageId
)
const conflict = await installVersion(orcaPage, second.published, { scope: 'global' })
expect(conflict).toMatchObject({
status: 'ok',
value: { status: 'conflict', conflict: { kind: 'modified' } }
})
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: local')
const update = await installVersion(
orcaPage,
second.published,
{ scope: 'global' },
'replace-and-discard-local'
)
expect(update).toMatchObject({ status: 'ok', value: { status: 'updated' } })
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v2')
const rollback = await installVersion(
orcaPage,
first.published,
{ scope: 'global' },
'replace-unmodified'
)
expect(rollback).toMatchObject({ status: 'ok', value: { status: 'updated' } })
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
expect(
await orcaPage.evaluate(
(shareId) => window.api.skills.revokeShare(shareId),
first.published.share.id
)
).toMatchObject({ status: 'ok' })
const revokedInstall = await orcaPage.evaluate(
async ({ shareId }) => {
try {
return await window.api.skills.installShare({ shareId, destination: { scope: 'global' } })
} catch {
return { status: 'rejected' as const }
}
},
{ shareId: first.published.share.id }
)
expect(revokedInstall.status).toBe('rejected')
expect(readFileSync(join(globalSkill, 'SKILL.md'), 'utf8')).toContain('version: v1')
const removed = await orcaPage.evaluate(
(name) => window.api.skills.removeInstall({ name, destination: { scope: 'global' } }),
SKILL_NAME
)
expect(removed).toMatchObject({ status: 'ok', value: { status: 'removed' } })
expect(existsSync(globalSkill)).toBe(false)
expect(
await orcaPage.evaluate((id) => window.api.skills.getPackage(id), packageId)
).toMatchObject({ status: 'ok' })
expect(
await orcaPage.evaluate((id) => window.api.skills.deletePackage(id), packageId)
).toMatchObject({ status: 'ok' })
packageId = null
} finally {
if (packageId) {
await orcaPage
.evaluate((id) => window.api.skills.deletePackage(id), packageId)
.catch(() => undefined)
}
rmSync(globalSkill, { recursive: true, force: true })
rmSync(sourceRoot, { recursive: true, force: true })
}
})
function writeSkill(directory: string, version: string): void {
writeFileSync(
join(directory, 'SKILL.md'),
`---\nname: ${SKILL_NAME}\ndescription: Orca staging installation journey\n---\n\n# Staging journey\n\nversion: ${version}\n`
)
}
async function connectCloud(page: Page): Promise<void> {
const result = await page.evaluate(() => window.api.orcaProfiles.connectCurrent())
expect(result.status).toBe('connected')
}
async function publish(
page: Page,
cwd: string,
releaseNotes: string,
packageId?: string,
onPrepared?: (preview: SkillSharePreview) => void
): Promise<{ preview: SkillSharePreview; published: SkillCloudPublishResult }> {
const sourceDirectory = join(cwd, '.agents', 'skills', SKILL_NAME)
const skill = await page.evaluate(
async ({ cwd, sourceDirectory }) =>
(await window.api.skills.discover({ cwd })).skills.find(
(candidate) => candidate.directoryPath === sourceDirectory
),
{ cwd, sourceDirectory }
)
if (!skill) {
throw new Error('staging skill source was not discovered')
}
const preview = await page.evaluate(
({ skillId, cwd, packageId }) =>
window.api.skills.prepareShare({
skillId,
target: { cwd },
...(packageId ? { packageId } : {})
}),
{ skillId: skill.id, cwd, packageId }
)
onPrepared?.(preview)
const operation = await page.evaluate(
async ({ preparationId, releaseNotes }) => {
const auth = await window.api.orcaProfiles.authStatus()
const userId = auth.cloud?.userId
if (!userId) {
throw new Error('staging user identity unavailable')
}
return window.api.skills.publishShare({
preparationId,
releaseNotes,
userIds: [userId],
shareWithOrganization: false
})
},
{ preparationId: preview.preparationId, releaseNotes }
)
expect(operation.status).toBe('ok')
if (operation.status !== 'ok') {
throw new Error(`staging publish failed: ${operation.status}`)
}
return { preview, published: operation.value }
}
function installVersion(
page: Page,
published: SkillCloudPublishResult,
destination: { scope: 'global' },
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local'
) {
return page.evaluate(
({ packageId, versionId, destination, conflictResolution }) =>
window.api.skills.installPackageVersion({
packageId,
versionId,
destination,
...(conflictResolution ? { conflictResolution } : {})
}),
{
packageId: published.version.packageId,
versionId: published.version.versionId,
destination,
conflictResolution
}
)
}