feat: add private skill sharing and installer

This commit is contained in:
Jinwoo-H
2026-08-11 08:16:39 -04:00
parent 0aa7d0b5c1
commit ff6674e1f5
95 changed files with 11440 additions and 33 deletions
+2
View File
@@ -94,6 +94,8 @@ docs/**
!docs/readme/
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/agent-skill-sharing-implementation-checklist.md
!docs/agent-skill-sharing-installation-plan.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
+1
View File
@@ -92,6 +92,7 @@ const winSpeechNativeResource = {
module.exports = {
appId,
productName: 'Orca',
protocols: [{ name: 'Orca', schemes: ['orca'] }],
...(devChannelBuildVersion
? { extraMetadata: { version: devChannelBuildVersion } }
: localBuildVersion
@@ -0,0 +1,876 @@
# Agent skill sharing implementation checklist
Status: proposed.
Last updated: 2026-08-11.
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
This checklist turns the architecture plan into ordered implementation and release work. A checked
item means evidence exists in code, tests, reviewed infrastructure, or release documentation; it
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.
- [ ] Installation works on local macOS, Linux, native Windows, WSL, paired Orca runtimes, and
supported SSH targets.
- [ ] One canonical `.agents/skills/<skill-name>` copy is installed; provider-specific placements
are reconciled only for detected agents.
- [ ] Install, update, rollback, and removal preserve modified or unowned local content unless the
user explicitly approves replacement.
- [ ] Package ingestion rejects unsafe archives before any destination mutation.
- [ ] Interrupted operations recover to a complete previous or requested version.
- [ ] Private package bytes remain access-controlled, published versions persist until deletion,
and incomplete uploads expire automatically.
- [ ] 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.
## 0. Confirm scope and ownership
### Product and legal decisions
- [x] Record `vercel-labs/skills` as a behavioral reference only.
- [x] Record upstream baseline commit `c6f69c631292444cc541ac6d91e2226b0ff247da`.
- [x] Decide not to copy upstream source, tests, fixtures, registry data, or path tables.
- [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.
- [ ] Add an architecture decision record covering the reference-only boundary and review it with
engineering and legal owners.
- [ ] Add a review check that flags any proposed copied or mechanically translated upstream
material so attribution and licensing can be reconsidered before merge.
- [ ] Assign desktop, runtime, Cloud API, Terraform, security, design, and release owners.
- [ ] Confirm the first release ends after WSL and SSH support; keep team reconciliation as a later
product milestone.
### Product semantics
- [ ] Confirm private means authorized Orca users and organizations, not end-to-end encryption
from Orca Cloud operators.
- [ ] Confirm a published immutable version persists until package or version deletion; it has no
age-based object expiry.
- [ ] Confirm an unfinished upload grant expires after 15 minutes and its quarantine object is
deleted after one day.
- [ ] Confirm a durable share URL identifies an authorization record, not a GCS object or bearer
credential.
- [ ] Confirm a signed download grant lasts five minutes and is the maximum share-revocation lag.
- [ ] Confirm updates create immutable versions and never mutate existing package objects.
- [ ] Confirm rollback installs a selected prior immutable version.
- [ ] Confirm revoking or deleting a Cloud share does not silently remove existing local installs.
- [ ] Confirm sharing and updating use an artifact-like flow—preview, upload, durable link, update,
unshare/delete—while retaining skill-specific version, ACL, trust, and install semantics.
- [ ] Confirm V1 has no checked-in workspace desired-state lockfile and no automatic fleet-wide
installation.
### Existing-code and provider research
- [ ] Inventory current skill discovery, `observeSkillPackage`, package identity, topology, and
freshness code; record reusable contracts and missing behavior.
- [ ] Inventory plugin staging, provenance, lock serialization, and Windows rename retry designs;
extract only primitives that both domains can name and test accurately.
- [ ] Inventory current desktop IPC and remote skill RPC registration.
- [ ] Inventory existing SSH execution and file-transfer providers and identify the host-side
installer entry point.
- [ ] Research official documentation for every initially supported agent's global and project
skill directories.
- [ ] Verify each provider path with a real installation on every supported platform.
- [ ] Record whether each provider reads `.agents/skills` directly, how it is detected, and which
alias mechanisms it supports.
- [ ] Add a normal reviewed maintenance process for provider registry changes; do not create an
upstream synchronization job.
## 1. Define package and install contracts
### Shared package manifest
- [ ] Add `src/shared/skill-package-manifest.ts`.
- [ ] Define `SkillPackageManifestV1` with schema version, stable package ID, immutable version ID,
name, description, creation time, file identities, and package digest.
- [ ] Normalize manifest paths to `/` while keeping filesystem conversion host-owned.
- [ ] Define the canonical digest algorithm over normalized paths, file identity, executable state,
and classification.
- [ ] Require `skill/SKILL.md`, valid frontmatter, and agreement between skill and package names.
- [ ] Specify deterministic serialization so identical inputs produce identical identities across
macOS, Linux, Windows, and WSL.
- [ ] Version the schema and require additive changes or a new schema version.
- [ ] Reject unknown schema versions with a stable error category.
### Package limits and file policy
- [ ] Enforce maximum path depth 16.
- [ ] Enforce maximum 2,048 archive entries.
- [ ] Enforce maximum 512 regular files.
- [ ] Enforce maximum 4 MiB per file.
- [ ] Enforce maximum 32 MiB total extracted bytes.
- [ ] Enforce maximum 40 MiB compressed bytes.
- [ ] Reject absolute, parent-traversal, NUL-containing, and Windows drive-prefixed paths.
- [ ] Reject duplicate normalized paths and Unicode or case-fold collisions.
- [ ] Reject symlinks, hardlinks, devices, FIFOs, sockets, encrypted entries, and other special
files in V1.
- [ ] Preserve executable modes represented by the package manifest.
- [ ] Document that install never executes package scripts.
### Install request, preview, and result
- [ ] Add `src/shared/skill-install-contract.ts`.
- [ ] Define requests with operation ID, immutable package identity, ingress kind, destination, and
optional conflict resolution.
- [ ] Support download-grant, staged-upload, and trusted in-process local-file ingress.
- [ ] Ensure arbitrary remote RPC callers cannot supply a local filesystem path.
- [ ] Model global, Git worktree, and folder-workspace destinations without assuming Git exists.
- [ ] Define preview output for destination, current state, provider coverage, conflicts, and trust
metadata.
- [ ] Define structured installed, updated, unchanged, conflict, partial, and failed results.
- [ ] Define stable error categories for admission, transport, archive, filesystem, conflict,
recovery, provider placement, and compatibility failures.
- [ ] Ensure responses never include grants, credentials, ACL membership, or private package
contents.
### Capability and wire compatibility
- [ ] Add `src/shared/skill-install-capability.ts` with `skills.install.v1` and an update-required
compatibility message.
- [ ] Read and apply `docs/reference/remote-wire-compatibility.md` before changing RPC contracts.
- [ ] Add new methods without changing existing discovery behavior.
- [ ] Keep new request and response fields optional until all supported peers understand them.
- [ ] Do not add a terminal stream opcode for skill installation.
- [ ] Define behavior for new client/old host, old client/new host, and capability loss during an
operation.
### Phase 1 contract gate
- [ ] Review package, ingress, conflict, result, and capability contracts before implementing
filesystem mutation or Cloud APIs.
- [ ] Freeze stable V1 error categories used by desktop, runtime, SSH, and Cloud tests.
## 2. Build and validate packages
### Package creation
- [ ] Add `src/main/skills/skill-package-creation.ts`.
- [ ] Accept a specific skill directory rather than an arbitrary parent tree.
- [ ] Observe and validate the source before staging.
- [ ] Copy the source into an owner-private staging directory.
- [ ] Observe the staged copy again and fail if its identity differs from the source snapshot.
- [ ] Generate a deterministic tar archive with `manifest.json` and a `skill/` envelope.
- [ ] Include only `skill/` contents in the eventual installed directory.
- [ ] Bind the user-visible share preview to the final staged digest.
- [ ] Clean staging files on success, cancellation, source drift, and error.
### Bounded extraction
- [ ] Add `src/main/skills/skill-package-extraction.ts`.
- [ ] Stream archive inspection and extraction without buffering the entire package.
- [ ] Validate `manifest.json` before trusting archive file metadata.
- [ ] Enforce compressed, extracted, entry, file, depth, and per-file limits during extraction,
not only after it.
- [ ] Create extraction staging on the same filesystem as the canonical destination.
- [ ] Convert manifest paths with the destination runtime's path APIs.
- [ ] Re-observe extracted `skill/` and compare every file identity and package digest.
- [ ] Delete partial extraction bytes on cancellation or failure.
### Package tests
- [ ] Test deterministic manifests, archives, and digests on every target operating system.
- [ ] Test source changes during packaging.
- [ ] Test CRLF/LF behavior explicitly and document whether byte identity changes.
- [ ] Test executable-mode preservation and Windows's mode limitations.
- [ ] Test missing, malformed, and identity-mismatched `SKILL.md` files.
- [ ] Test every size, count, and depth boundary at limit, one below, and one above.
- [ ] Test traversal, absolute paths, drive paths, Unicode/case collisions, duplicate paths, all
rejected link and special-file types, and encrypted entries.
- [ ] Test truncated archives and invalid tar and content checksums.
- [ ] Fuzz archive path normalization and envelope parsing with bounded resources.
### Phase 2 package gate
- [ ] Prove invalid archive classes fail before destination mutation.
- [ ] Prove the same source bytes produce the same digest on macOS, Linux, native Windows, and WSL.
- [ ] Prove package creation and extraction require no `npx`, external Node installation, or
upstream CLI runtime.
## 3. Implement the local installer transaction
### Destination resolution
- [ ] Add `src/main/skills/skill-install-destinations.ts`.
- [ ] Resolve global canonical roots as `<host-home>/.agents/skills/<skill-name>` on the executing
host.
- [ ] Resolve workspace canonical roots from runtime-owned worktree or folder-workspace identity.
- [ ] Reject client-supplied remote paths that do not resolve to the selected workspace identity.
- [ ] Resolve WSL home, paths, and target distro inside the selected distro.
- [ ] Keep all path joins platform-native and reject destination escapes after realpath-aware
containment checks.
- [ ] Support long Windows paths and case-insensitive destination collision checks.
### Admission, locking, and ingress
- [ ] Add `src/main/skills/skill-package-download.ts`.
- [ ] Add `src/main/skills/skill-install-service.ts`.
- [ ] Validate request shape, schema, capability, package identity, scope, destination, and policy
before download or mutation.
- [ ] Recover prior journals for the same destination before planning a new operation.
- [ ] Acquire a filesystem-backed cross-process lock keyed by canonical destination.
- [ ] Bound lock wait time and return a retryable busy result.
- [ ] Stream ingress to an owner-private bounded temporary file and hash it while downloading.
- [ ] Require HTTPS whenever the configured Cloud endpoint uses HTTPS.
- [ ] Allow only configured Orca skill-bucket origins and reject credential-bearing cross-host
redirects.
- [ ] Check expected compressed bytes, archive SHA-256, package digest, cancellation, grant expiry,
and disconnects.
- [ ] Delete partial ingress bytes on every incomplete path.
### Planning and conflict handling
- [ ] Add `src/main/skills/skill-install-planner.ts`.
- [ ] Classify missing canonical destinations as installable.
- [ ] Classify an identical requested digest as unchanged and continue placement repair.
- [ ] Permit clean updates only when installed bytes still match Orca provenance.
- [ ] Return a modified conflict when installed bytes drift from their receipt.
- [ ] Return an unowned conflict when a destination exists without Orca provenance.
- [ ] Return topology conflicts for files, external links, broken links, and name collisions.
- [ ] Classify every requested provider placement independently.
- [ ] Repeat current-state inspection immediately before commit and invalidate stale previews.
- [ ] Require explicit confirmation before discarding local modifications or unowned content.
### Durable commit and recovery
- [ ] Add `src/main/skills/skill-install-transaction.ts`.
- [ ] Copy extracted bytes into a hidden sibling staging directory and verify the digest again.
- [ ] Preserve executable modes where the host supports them.
- [ ] Keep staging, replacement, and backup on the destination filesystem.
- [ ] Write and durably flush a versioned transaction journal before moving the current
destination.
- [ ] Move an approved existing destination to a journal-owned backup.
- [ ] Rename verified staging into the canonical path.
- [ ] Add bounded retries for Windows `EPERM`, `EACCES`, and `EBUSY` rename races caused by
antivirus or indexing.
- [ ] Observe the committed destination and require the requested digest.
- [ ] Restore the backup after failed commit and retain sufficient journal state after a crash.
- [ ] Never remove a path based only on a filename pattern; require journal ownership,
containment, and expected identities.
- [ ] Run bounded recovery at startup and before later operations for the destination.
### Provenance
- [ ] Add `src/main/skills/skill-install-provenance.ts`.
- [ ] Store versioned receipts outside installed skill directories.
- [ ] Record package/version IDs, digest, scope, destination identity, canonical identity,
placements, previous version, timestamp, and runtime identity.
- [ ] Exclude credentials, grants, share values, manifests, and package contents.
- [ ] Serialize receipt updates across processes.
- [ ] Write receipt updates through durable temporary-file replacement.
- [ ] Support aggregate-index reconstruction from bounded per-install receipts.
- [ ] Mark the journal complete only after receipt publication, then remove transaction backups and
staging.
### Verification and discovery
- [ ] Re-run skill discovery on the destination after commit.
- [ ] Verify the canonical skill and successful provider placements are observable.
- [ ] Determine success from installed bytes and discovery, not process exit alone.
- [ ] Invalidate renderer skill caches and refresh the Skills page.
- [ ] Return canonical success with `partial` when an optional provider placement fails.
## 4. Implement provider placements
### Provider registry
- [ ] Add `src/main/skills/skill-provider-destinations.ts`.
- [ ] Add data-only records for Orca agent ID, canonical-root support, global/project resolvers,
detection evidence, and platform alias support.
- [ ] Populate only provider paths independently verified from official documentation and real
installations.
- [ ] Detect an agent before creating its provider-specific configuration root.
- [ ] Avoid modifying roots for agents that consume `.agents/skills` directly.
### Placement reconciliation
- [ ] Add `src/main/skills/skill-placement-reconciliation.ts`.
- [ ] Create relative directory aliases from real parent directories on POSIX.
- [ ] Create directory junctions with absolute canonical targets on Windows.
- [ ] Detect provider parents already linked to canonical storage.
- [ ] Fall back to an independently copied and verified directory when aliases are unavailable or
denied and policy allows it.
- [ ] Record each canonical copy, provider alias, junction, or independent-copy topology.
- [ ] Reconcile placements idempotently after install and update.
- [ ] Repair broken Orca-owned aliases.
- [ ] Leave unowned or modified provider placements untouched unless explicitly replaced.
- [ ] Preserve canonical success when a provider placement fails and make coverage retryable.
### Provider validation
- [ ] Confirm every initial provider discovers a global canonical or reconciled install.
- [ ] Confirm every initial provider discovers a workspace install only in that workspace.
- [ ] Test symlink, junction, and copy fallback behavior after provider and canonical parent paths
are moved or linked.
- [ ] Test provider release upgrades against the registry through the normal review process.
## 5. Implement update, rollback, and removal
### Update and rollback
- [ ] Resolve the latest accessible immutable Cloud version without mutating local state.
- [ ] Compare the requested digest with provenance and observe current bytes before offering an
update.
- [ ] Route clean updates through the same install transaction.
- [ ] Offer keep-local, authorized publish-as-new-version, and explicit discard-and-replace choices
for modified installs.
- [ ] Reconcile recorded aliases, junctions, and independent copies during updates.
- [ ] Implement rollback as installation of a selected retained immutable version.
- [ ] Never invoke `npx skills update` or write ownership metadata for the community CLI.
### Local removal
- [ ] Observe the canonical destination and every receipt-owned placement before mutation.
- [ ] Remove only aliases or junctions that still target the recorded canonical path.
- [ ] Remove independent copies only when they match their receipt unless discard is explicit.
- [ ] Remove the canonical copy only when Orca owns it, it is unmodified, and no retained receipt
depends on it.
- [ ] Publish provenance changes durably before completing removal.
- [ ] Leave changed or unowned paths intact and report exactly what remains.
### Recovery failure injection
- [ ] Inject failure before and after every journal transition.
- [ ] Verify partial downloads and extraction staging are deleted.
- [ ] Verify a moved destination is restored from backup.
- [ ] Verify a placed destination without a receipt is completed or restored according to journal
state.
- [ ] Verify a published receipt with an incomplete journal is finalized safely.
- [ ] Verify interrupted placement reconciliation preserves the canonical install and retries.
- [ ] Test cancellation during download, extraction, staging copy, commit, provenance, and
placement reconciliation.
## 6. Provision GCP infrastructure
### Environment and Terraform prerequisites
- [x] Inspect the active `onorca-cloud` project read-only and record the existing Cloud Run, GCS,
Cloud SQL, IAM, and enabled-service baseline.
- [ ] Locate the authoritative Orca Cloud Terraform worktree, state, modules, and deployment
pipeline.
- [ ] Confirm or create a separate staging GCP project before exercising new lifecycle and IAM
behavior.
- [ ] Confirm production names and quotas, including availability of
`onorca-cloud-skill-packages`.
- [ ] Confirm `US` storage satisfies initial residency requirements.
- [ ] Confirm Cloud SQL connection and PostgreSQL user provisioning conventions.
- [ ] Keep Firestore, Cloud Tasks, and new Pub/Sub dependencies out of V1.
- [ ] Declare every durable resource and IAM binding in Terraform; do not provision them manually
with `gcloud`.
### Dedicated GCS bucket
- [ ] Declare private bucket `onorca-cloud-skill-packages`, subject to Terraform validation.
- [ ] Set location to `US` for parity with current artifact storage.
- [ ] Enforce uniform bucket-level access and public-access prevention.
- [ ] Configure seven-day soft delete.
- [ ] Keep object versioning disabled and record object generations in PostgreSQL.
- [ ] Add a one-day deletion lifecycle for `uploads/` quarantine objects only.
- [ ] Add exact approved production and development Orca origins to CORS; never use `*`.
- [ ] Allow only required signed POST upload and GET/HEAD download behavior and response headers.
- [ ] Keep the bucket off public custom domains.
- [ ] Define immutable final keys as
`packages/v1/sha256/<prefix>/<package-digest>/package.tar.gz`.
- [ ] Define tenant-bound random quarantine keys as `uploads/<upload-id>/package.tar.gz`.
### Database and secret
- [ ] Declare database `orca_skills` on existing regional PostgreSQL 17 instance
`orca-cloud-auth-db`.
- [ ] Declare dedicated principal `orca_skills_app` with access only to `orca_skills`.
- [ ] Store its connection URL in Secret Manager as `orca-cloud-skills-database-url`.
- [ ] Attach the existing Cloud SQL instance to `orca-cloud-api` without replacing the service.
- [ ] Inject only the skill database secret into the API service.
- [ ] Verify backups and point-in-time recovery cover the new database.
### IAM
- [ ] Grant `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` bucket-scoped
`roles/storage.objectUser`.
- [ ] Grant the API service account `roles/cloudsql.client` for the existing instance.
- [ ] Grant service-account-scoped IAM Credentials `signBlob` for self-signing V4 policies and
URLs.
- [ ] Grant Secret Manager accessor only for skill-specific secrets.
- [ ] Verify bucket IAM contains neither `allUsers` nor `allAuthenticatedUsers`.
- [ ] Do not grant desktop, remote runtime, or end-user identities direct bucket IAM.
- [ ] Do not create or distribute long-lived GCP service-account keys.
- [ ] Keep deployment-account permissions unchanged except for reviewed Terraform management
requirements.
### Cloud Run configuration
- [ ] Extend `orca-cloud-api` in `us-central1`; do not create a separate V1 worker service.
- [ ] Configure bucket, 40 MiB compressed limit, 15-minute upload TTL, five-minute download TTL,
fixed finalize concurrency, and skill database URL.
- [ ] Reuse existing auth base URL and application CORS configuration.
- [ ] Stream validation with fixed buffers under the existing 512 MiB memory limit.
- [ ] Add a small per-instance finalization semaphore and retryable `429` or `503` with
`Retry-After` when saturated.
- [ ] Preserve current service scaling initially and tune only from measured CPU, latency,
database, and error data.
- [ ] Define criteria for splitting finalization into a worker service if it harms existing API
traffic.
### Terraform review and apply
- [ ] Format and validate Terraform.
- [ ] Produce and review a staging plan.
- [ ] Verify the plan does not replace the existing artifact bucket, Cloud SQL instance, Cloud Run
services, or unrelated IAM.
- [ ] Apply to staging and capture resource and IAM verification evidence.
- [ ] Produce and review the production plan after staging gates pass.
- [ ] Apply the approved production plan only during the rollout phase.
- [ ] Verify production with read-only bucket, Cloud Run, database, IAM, lifecycle, and CORS
commands without printing secret payloads or signed values.
## 7. Implement Cloud metadata and APIs
### PostgreSQL migrations
- [ ] Add `skill_packages` with owner organization, slug/name, creator, timestamps, and deletion
state.
- [ ] Add immutable `skill_package_versions` with package and archive identities, GCS key and
generation, sizes, manifest, release notes, creator, and publication state.
- [ ] Add tenant/user-bound `skill_package_uploads` with quarantine identity, expiry,
finalization state, and failure category.
- [ ] Add `skill_package_acl` for organization and user principals and permissions.
- [ ] Add unpredictable, revocable, optionally version-pinned `skill_share_records`.
- [ ] Add bounded `skill_package_audit_events` without contents, filenames, or signed values.
- [ ] Add unique package slug per organization, immutable version and digest constraints, and
unique final GCS key/generation constraints.
- [ ] Add active-share, ACL-principal, and pending-upload expiration indexes.
- [ ] Add foreign keys preventing blob-reference deletion while a published version uses it.
- [ ] Test forward migration, rollback strategy, backup restoration, and migration compatibility
during mixed API versions.
### Authorization and lifecycle model
- [ ] Begin every share resolution, grant, update lookup, and revoke mutation with an authenticated
principal.
- [ ] Evaluate package ownership and ACL in the same request.
- [ ] Ensure share ID locates a record but never authorizes access by itself.
- [ ] Return constant-shape authorization failures that do not disclose package existence.
- [ ] Recheck organization membership for every grant, including after preview.
- [ ] Apply organization retention and legal deletion rules over product rollback retention.
- [ ] Make package deletion revoke shares before dereferencing immutable objects.
- [ ] Delete an object only after a transaction proves no retained version references it.
- [ ] Design metadata/object reconciliation for partial publication and deletion failures.
### Upload and finalization APIs
- [ ] Implement `POST /v1/skill-packages/uploads` with existing authentication, organization
membership, quota, rate, and concurrency checks.
- [ ] Insert a single-use pending upload before issuing a grant.
- [ ] Create a 15-minute V4 signed POST policy bound to exact key, content type, upload ID, expected
archive SHA-256 metadata, and 40 MiB length range.
- [ ] Upload directly from the client to GCS without routing package bytes through Cloud Run.
- [ ] Implement idempotent finalize by upload ID and manifest identity.
- [ ] Validate GCS key, size, type, metadata, tenant, generation, and expiry before streaming.
- [ ] Stream once to calculate archive SHA-256 and validate envelope, manifest, paths, limits, and
package digest.
- [ ] Promote with an if-absent generation precondition to the content-addressed final key.
- [ ] If the final key exists, verify recorded identity without exposing cross-tenant
deduplication.
- [ ] Publish the version and complete the upload in a PostgreSQL transaction.
- [ ] Delete quarantine bytes after success and rely on lifecycle cleanup for abandonment.
- [ ] Make all mutating endpoints accept idempotency keys.
### Package, version, ACL, and share APIs
- [ ] Implement package creation and version publication under a stable package identity.
- [ ] Implement package details and paginated version history.
- [ ] Implement organization and selected-user access management.
- [ ] Implement durable share creation with optional pinned version and expiry.
- [ ] Implement share resolution with authenticated metadata preview.
- [ ] Implement immediate share revocation for new grant requests.
- [ ] Implement Cloud package/version deletion with retention and reference checks.
- [ ] Implement version update lookup and rollback selection.
### Download grants
- [ ] Implement `POST /v1/skill-shares/<share-id>/download-grants` after fresh ACL evaluation.
- [ ] Generate a five-minute V4 signed GET URL for the exact immutable key and stored generation.
- [ ] Set only response content type and safe attachment filename overrides.
- [ ] Grant no list or write capability.
- [ ] Return expected archive identity and byte count beside the grant for runtime verification.
- [ ] Test revocation immediately blocks new grants while already issued URLs expire within five
minutes.
### Cloud tests
- [ ] Test organization, user, pinned-link, expiry, and revocation ACL behavior.
- [ ] Test membership removal between preview, grant, and download.
- [ ] Test expired, reused, wrong-tenant, wrong-key, wrong-size, and wrong-hash uploads.
- [ ] Test malformed, oversized, and resource-exhausting archives during finalization.
- [ ] Test finalization semaphore saturation and retry behavior.
- [ ] Test concurrent idempotent mutations and partial database/GCS failures.
- [ ] Test deduplication without cross-tenant timing or response disclosure.
- [ ] Test quota and rate limits.
- [ ] Test durable shares resolving intended latest and pinned immutable versions.
- [ ] Test update, rollback, revocation, deletion, soft-delete recovery, and orphan reconciliation.
## 8. Implement desktop Cloud client and UX
### Cloud client
- [ ] Add typed calls for upload grants, GCS upload, finalization, package/version catalog,
ACL/access management, share creation/resolution/revocation, and download grants.
- [ ] Keep desktop contracts storage-provider-neutral.
- [ ] Implement bounded progress, cancellation, retry, and idempotency behavior.
- [ ] Redact signed policies, URLs, credentials, private paths, and package contents at creation.
### Share experience
- [ ] Read and apply `docs/STYLEGUIDE.md`; use canonical CSS tokens and shadcn primitives.
- [ ] Add **Share skill** to eligible installed and workspace skills.
- [ ] Show name, description, author, organization, file count, total size, scripts, and executable
files before upload.
- [ ] Let the author choose organization or selected-person access.
- [ ] Accept optional release label and notes.
- [ ] Package the exact previewed bytes and invalidate preview after source drift.
- [ ] Show bounded upload and finalization progress with cancellation.
- [ ] Return a copyable durable Orca URL after publication.
- [ ] Add access editing, version publishing, unshare, and package deletion actions.
- [ ] Make clear that unsharing blocks future installs but does not remove installed copies.
### Install experience
- [ ] Show authenticated author, organization, description, version, digest, file summary, scripts,
and executable files.
- [ ] Treat the package as code from its author and require an explicit install action.
- [ ] Show current-machine global installation as the default.
- [ ] Allow selection of connected machine, Git worktree, or plain folder workspace.
- [ ] Show detected-agent coverage and canonical/provider topology before commit.
- [ ] Show installed-state conflicts and explicit resolution choices.
- [ ] Show phase progress without exposing grants or local private paths.
- [ ] Render installed, unchanged, updated, partial, conflict, unsupported, cancelled, and failed
results with actionable recovery.
- [ ] Add incomplete-coverage retry.
### Skills page lifecycle
- [ ] Show installed package/version identity and update availability.
- [ ] Offer clean update, modified-copy choices, prior-version rollback, and safe local removal.
- [ ] Show whether a package came from Orca Cloud and its accessible version history.
- [ ] Refresh discovery and installation state after local or remote actions.
- [ ] Keep Cloud deletion, share revocation, and local removal clearly separate.
### UX validation
- [ ] Test keyboard shortcuts and labels with platform-aware macOS versus Windows/Linux behavior.
- [ ] Test loading, error, cancellation, partial, conflict, and stale-preview states.
- [ ] Test screen-reader names, focus order, progress announcements, and destructive confirmations.
- [ ] Test long names, release notes, paths, organization names, and localized copy.
- [ ] Review trust and privacy wording with security and design.
## 9. Implement paired runtime installation
### Runtime and IPC surface
- [ ] Register preview, install, update, and remove through runtime RPC and desktop IPC.
- [ ] Add bounded `beginUpload`, `uploadChunk`, `commitUpload`, and `cancelUpload` methods.
- [ ] Route every request to the runtime that owns the selected host and workspace.
- [ ] Keep transfer separate from installation so grants, relayed chunks, and local files converge
at the validated ingress boundary.
- [ ] Make upload sessions bounded by count, total bytes, idle lifetime, and chunk size.
- [ ] Require monotonic offsets and idempotent acknowledged-chunk retry or full restart.
- [ ] Release staging bytes after cancellation, disconnect, timeout, and runtime restart.
### Direct and client-mediated transfer
- [ ] Prefer destination-runtime direct download when it can reach approved Orca Cloud storage.
- [ ] Verify origin, redirects, generation, byte count, archive hash, and package digest on the
destination runtime.
- [ ] Fall back to authenticated client-mediated chunk transfer when the host cannot reach GCS.
- [ ] Never pass the desktop's local path as a remote package source.
- [ ] Preserve the same inspection, transaction, provenance, and result behavior for both
transports.
- [ ] Test connection loss and resumption or restart behavior at every transfer boundary.
### Mixed versions
- [ ] Hide or disable remote install when `skills.install.v1` is absent.
- [ ] Show a specific host-update-required message instead of attempting the RPC.
- [ ] Test new client/old server and old client/new server.
- [ ] Test optional-field omission in both directions.
- [ ] Test a capability changing after preview but before execution.
- [ ] Confirm older servers retain skill discovery and ignore no unknown stream opcode because none
is introduced.
## 10. Validate native Windows and WSL on `windows 2`
The connected Orca environment `windows 2` was reachable on 2026-08-11 and ran Orca `1.4.180`.
Read-only discovery found a native Windows Git checkout at `C:\Users\neil\orca\orca` and a plain
folder workspace at `C:\Users\neil\.claude`. WSL availability and distro inventory still need to
be discovered on the host before execution tests begin.
### Host preparation
- [x] Confirm `windows 2` is saved and reachable through `orca environment list` and
`orca status --environment "windows 2" --json`.
- [x] Confirm the host provides both a native Git checkout and a native plain folder workspace.
- [ ] Record Windows edition/build, architecture, filesystem type, long-path policy, current user
home, temp root, antivirus status, and developer-mode/symlink policy without collecting
secrets.
- [ ] Discover installed WSL version and distros, each distro's running state, default user, home,
filesystem, and Orca host support.
- [ ] Ensure at least two WSL distros are available or record a reproducible second-distro setup
for the multi-distro test.
- [ ] Create isolated test skills and destination roots under explicit test workspaces; never use
production user skill directories for destructive failure injection.
- [ ] Record exact Orca client/server versions and capabilities with each test run.
### Native Windows package and destination tests
- [ ] Package on macOS and install on native Windows; compare manifest and digest.
- [ ] Package on native Windows and install on macOS/Linux; compare identity and executable-mode
policy.
- [ ] Test global home resolution without constructing the path on the macOS client.
- [ ] Test the discovered Git checkout and plain folder workspace independently.
- [ ] Test path joining, drive prefixes, UNC rejection/handling policy, reserved names, trailing
dots/spaces, long paths, Unicode normalization, and case collisions.
- [ ] Test source and destination paths containing spaces and non-ASCII characters.
- [ ] Test junction creation succeeds for a detected provider.
- [ ] Force junction denial and verify an independently copied, digest-verified fallback.
- [ ] Test an unowned junction, external link, broken junction, and provider parent that is already
linked.
- [ ] Test copy-fallback drift during update and removal.
- [ ] Hold destination files open to simulate antivirus/indexer contention and verify bounded
`EPERM`, `EACCES`, and `EBUSY` rename retries.
- [ ] Verify retry exhaustion restores the old version and yields an actionable result.
- [ ] Terminate the runtime before and after each journal boundary and verify startup recovery.
- [ ] Test permission-denied, read-only, disk-full, cancellation, runtime disconnect, and partial
provider-coverage paths.
### WSL tests
- [ ] Execute package ingress, extraction, home resolution, installation, provenance, and discovery
inside the selected distro.
- [ ] Prove the Windows client never constructs a Linux home or mutates the distro through a
translated Windows path.
- [ ] Test global installs for at least two distros with different default users/homes.
- [ ] Test Linux case sensitivity and executable modes inside each distro.
- [ ] Test a distro-owned Git worktree and plain folder workspace.
- [ ] Test a workspace on the distro filesystem and document behavior for `/mnt/c` separately.
- [ ] Test provider detection and POSIX alias creation inside the distro.
- [ ] Test concurrent native Windows and WSL installs of the same package without provenance or
lock collision across host identities.
- [ ] Test distro stopped, distro removed, default user changed, home moved, and runtime restarted
between preview and install.
- [ ] Block outbound GCS access inside WSL and verify client-mediated chunk transfer.
- [ ] Disconnect the macOS client during transfer and commit; verify bounded cleanup and recovery.
### Windows/WSL mixed-version tests
- [ ] Use `windows 2` as old host with a newer client and verify capability-gated UI.
- [ ] Upgrade `windows 2` only through the normal supported update path, then test an older client
against the newer host.
- [ ] Verify folder-workspace IDs, runtime-owned paths, and results survive client/host version
skew.
- [ ] Capture structured test evidence without signed URLs, tokens, skill contents, or private
absolute paths beyond approved test fixtures.
### Windows/WSL release gate
- [ ] Real native Windows passes junction success, junction denial, verified copy fallback,
antivirus contention, long paths, crash recovery, Git worktree, and folder-workspace tests.
- [ ] Real WSL passes two-distro home ownership, Linux semantics, provider placement, offline-GCS
fallback, cancellation, and crash recovery tests.
- [ ] All temporary test installations, terminals, workspaces, and packages are removed through
verified, recoverable cleanup.
## 11. Implement and validate SSH targets
- [ ] Define a host-side installer command that invokes the same installer core and structured
result contract.
- [ ] Transfer immutable packages through the existing SSH/SFTP provider into bounded private
staging.
- [ ] Invoke installation on the SSH host so it owns home, path, provider, and filesystem
resolution.
- [ ] Add capability/version detection for the required host component.
- [ ] If unsupported, offer a package download or explicit command; never install into the
desktop's home as fallback.
- [ ] Propagate cancellation and clean partial SSH transfers and staging.
- [ ] Test SSH-only macOS, Linux at the supported floor, and Windows where the existing provider
supports it.
- [ ] Test Git worktree and folder-workspace scope over SSH.
- [ ] Test connection loss during upload, extraction, commit, provenance, and result return.
- [ ] Prove SSH and paired runtimes return the same result and error-category contracts.
## 12. Add observability, operations, and security controls
### Metrics and dashboards
- [ ] Record bounded package byte/file counts and package, upload, finalization, download, transfer,
install, placement, and recovery durations.
- [ ] Record outcomes by error category, OS, destination kind, transport, conflict type, and
placement topology.
- [ ] Record junction, alias, copy-fallback, rollback, recovery, capability-absence, and orphan
reconciliation counts.
- [ ] Add dashboards for grant/finalize/share rates, authorization and rate limits, finalization
saturation, archive rejection, and digest mismatch.
- [ ] Add Cloud Run 5xx, CPU, memory, instance, and skill-route latency panels and alerts.
- [ ] Add GCS quarantine/published bytes, object count, and lifecycle failure panels and alerts.
- [ ] Add PostgreSQL connection, storage, query latency, migration, and transaction panels and
alerts.
- [ ] Add signed-policy/URL generation and IAM Credentials failure alerts.
- [ ] Add budget alerts for GCS storage/egress, Cloud Run growth, and Cloud SQL storage.
### Logging and privacy
- [ ] Log package/version IDs, phases, destination labels, placement outcomes, and bounded error
categories only.
- [ ] Exclude package contents, filenames, manifests, raw local paths, ACL membership, durable share
URLs, upload policies, download grants, and credentials.
- [ ] Redact sensitive network values before logger invocation, not after ingestion.
- [ ] Audit authorization and lifecycle events without instruction or script contents.
- [ ] Verify diagnostics and support bundles preserve the same exclusions.
### Security review
- [ ] Threat-model package creation, archive ingestion, manifest trust, path containment, local
conflicts, grants, redirects/SSRF, authorization, tenant isolation, and instruction/script
trust.
- [ ] Verify owner-private staging permissions on supported hosts.
- [ ] Rate-limit uploads, finalization, downloads, share resolution, and remote transfer sessions.
- [ ] Test malicious redirects, DNS/host confusion, expired grants, mismatched generations, and
oversized streaming bodies.
- [ ] Test archive and filesystem race conditions, including source drift and destination changes
after preview.
- [ ] Review organization departure, deletion, retention, soft-delete, and operator recovery.
- [ ] Complete privacy and security sign-off before external rollout.
### Runbooks and kill switches
- [ ] Add independent server-side flags for upload grants, download grants, and remote
installation.
- [ ] Verify disabling Cloud grants does not break skill discovery or already installed skills.
- [ ] Document finalization saturation, GCS/IAM signing failure, database outage, corrupt package,
orphan reconciliation, and rollback response.
- [ ] Document coordinated PostgreSQL point-in-time and GCS generation/soft-delete restoration.
- [ ] Document package/share deletion, organization departure, legal retention, and audit handling.
- [ ] Document how and when to split finalization into its own service.
## 13. Complete automated and end-to-end validation
### Transaction matrix
- [ ] Test fresh install, identical reinstall, clean update, rollback, explicit replacement, and
safe removal.
- [ ] Test modified and unowned canonical and provider conflicts.
- [ ] Test canonical/provider paths as regular files, directories, aliases, junctions, external
links, and broken links.
- [ ] Test concurrent desktop, headless runtime, CLI, SSH, and recovery attempts.
- [ ] Test permission and read-only failures, disk exhaustion, cancellation, process termination,
and host disconnect.
- [ ] Test independent-copy drift and incomplete provider coverage.
### Platform and target matrix
- [ ] Run macOS ARM64 and x64 behavior where supported.
- [ ] Run Linux against Ubuntu 20.04/glibc 2.31 and verify bundled native binaries respect the
floor.
- [ ] Run native Windows and WSL scenarios on `windows 2`.
- [ ] Run local and remote Git worktrees and plain folder workspaces.
- [ ] Run paired runtimes with both client-newer and server-newer combinations.
- [ ] Run supported SSH-only macOS, Linux, and Windows targets.
- [ ] Run a remote target without outbound Cloud connectivity through chunk transfer.
### Required journeys
- [ ] Share on machine A, install globally on machine B, launch a detected agent, and discover the
skill.
- [ ] Share on machine A, install into a folder workspace on a connected runtime, and discover it
only in that workspace.
- [ ] Modify installed bytes, publish an update, and prove Orca refuses silent replacement.
- [ ] Disconnect during commit, reconnect, recover, and prove either the old or new version is
complete.
- [ ] Revoke a share, prove a new install fails, and prove the existing local install remains.
- [ ] Publish a new immutable version, update one machine, leave another pinned, then rollback the
updated machine.
- [ ] Remove local installation without deleting its Cloud package, then delete/revoke Cloud
access without mutating another local installation.
### CI and test evidence
- [ ] Add deterministic fixtures authored for Orca without copying upstream fixtures.
- [ ] Add failure-injection hooks available only to tests/development harnesses.
- [ ] Add a CLI-only developer harness for local and remote integration tests.
- [ ] Keep CI commands compatible with macOS, Linux, Windows PowerShell/cmd, and WSL.
- [ ] Archive bounded test results and security evidence without secrets or private contents.
- [ ] Make package safety, transaction recovery, platform, and mixed-version suites required release
checks.
## 14. Stage and release
### Staging
- [ ] Apply reviewed Terraform and migrations to staging.
- [ ] Deploy `orca-cloud-api` skill routes disabled by server-side flags.
- [ ] 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.
- [ ] Verify logs, metrics, traces, diagnostics, and support bundles contain no grants or private
package data.
- [ ] Load-test finalization and choose the fixed semaphore from observed memory, CPU, request
latency, and database usage.
- [ ] Exercise at least one quarantine lifecycle deletion and published-object soft-delete
recovery.
### Production infrastructure and internal rollout
- [ ] 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.
- [ ] 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
- [ ] 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,
unrecoverable local mutation, or cross-tenant authorization defect.
- [ ] Publish user documentation for sharing, access, install destinations, updates, rollback,
removal, retention, and trust.
- [ ] Publish admin documentation for organization access, user departure, deletion, and retention.
- [ ] Remove rollout flags only after sustained healthy usage and a reviewed decision.
### First-release gate
- [ ] Threat model and privacy review are approved.
- [ ] Cross-platform package and transaction suites are required and green.
- [ ] `windows 2` native Windows and WSL release gates pass on real filesystems.
- [ ] Real SSH paths and host-owned resolution pass.
- [ ] Mixed-version tests pass in both directions.
- [ ] Cancellation and crash recovery pass during transfer and every commit boundary.
- [ ] Durable-share authorization, revocation lag, retention, deletion, and recovery are documented
and tested.
- [ ] The UI identifies author, organization, scripts, and executable content before install.
- [ ] Telemetry, logs, and diagnostic bundles contain no credentials or private contents.
- [ ] Kill switches are tested without affecting discovery or existing installations.
## 15. Post-release team library and reconciliation
- [ ] Measure sharing, installation, update, conflict, fallback, failure, and multi-machine demand
without collecting private contents.
- [ ] Design an organization skill library and immutable version history from usage evidence.
- [ ] Add **Install on another machine** and bounded multi-machine progress.
- [ ] Add optional desired-version policy for selected personal or organization machines.
- [ ] Add opt-in drift and missing-install reconciliation that never overwrites modifications.
- [ ] Evaluate an explicit, reviewable project desired-state manifest.
- [ ] Evaluate direct machine-to-machine transfer if Cloud persistence is not appropriate.
- [ ] Define offline convergence without durable shared credentials.
- [ ] Document organization removal, user departure, package retention, and reconciliation
semantics before enabling policy-driven installs.
File diff suppressed because it is too large Load Diff
+22
View File
@@ -70,6 +70,7 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
import { SkillCloudService } from './skills/skill-cloud-service'
import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate'
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
import {
@@ -174,6 +175,8 @@ import {
} from './startup/single-instance-lock'
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
import { parseSkillShareId } from '../shared/skill-share-link'
import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state'
import {
isStartupDiagnosticsEnabled,
logStartupDiagnostic,
@@ -385,6 +388,7 @@ const expectedRendererReload = createWebContentsTimedFlag()
const recoveryReloadInFlight = createWebContentsTimedFlag()
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
const pendingOpenSettings = createWebContentsTimedFlag()
const skillShareDeepLinks = new SkillShareDeepLinkState()
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
@@ -685,6 +689,9 @@ function focusExistingWindow(): void {
}
function requestDesktopActivation(argv: readonly string[] = []): void {
skillShareDeepLinks.capture(argv, (shareId) => {
mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -692,6 +699,16 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
desktopActivationGate.requestActivation()
}
app.on('open-url', (event, url) => {
if (!parseSkillShareId(url)) {
return
}
event.preventDefault()
requestDesktopActivation([url])
})
skillShareDeepLinks.capture(process.argv)
const handleMacAppActivation = createMacAppActivationHandler({
getWindow: () => mainWindow,
requestActivation: requestDesktopActivation
@@ -873,6 +890,10 @@ ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
pendingOpenSettings.matches(event.sender.id, { consume: true })
)
ipcMain.handle('ui:consumePendingSkillShare', () => {
return skillShareDeepLinks.consume()
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
@@ -2635,6 +2656,7 @@ void app.whenReady().then(async () => {
isArtifactSharingEnabled(store?.getSettings())
)
)
runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData')))
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
runtimeService.setCommitMessageAgentEnvironmentResolvers({
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.
+1 -1
View File
@@ -176,7 +176,7 @@ export function registerCoreHandlers(
registerTerminalRenderDesyncEvidenceHandler()
registerComputerUsePermissionHandlers()
registerSettingsHandlers(store, agentAwakeService)
registerSkillsHandlers(store)
registerSkillsHandlers(store, runtime)
if (automations) {
registerAutomationHandlers(store, automations)
}
+312
View File
@@ -0,0 +1,312 @@
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain } from 'electron'
import { z } from 'zod'
import {
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE
} from '../../shared/skill-install-capability'
import {
ManagedSkillInstallListSchema,
SkillInstallDestinationSchema,
SkillInstallPreviewSchema,
SkillInstallResultSchema,
SkillPackageIdentitySchema,
type SkillInstallRequest
} from '../../shared/skill-install-contract'
import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills'
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { installSkillOnRemoteRuntime } from '../skills/skill-remote-install-service'
import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target'
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
import { supportsSkillRuntimeManagement } from '../skills/skill-runtime-capability'
import { listWslDistrosAsync } from '../wsl'
import {
callRuntimeEnvironment,
getRuntimeEnvironmentStatus
} from './runtime-environment-transport-routing'
const environmentIdSchema = z.string().min(1).max(128)
const skillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
const sharePrepareSchema = z
.object({
skillId: z.string().min(1).max(4096),
target: SkillDiscoveryTargetSchema.optional(),
packageId: z.string().min(1).max(128).optional()
})
.strict()
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),
shareWithOrganization: z.boolean()
})
.strict()
const installDestinationFields = {
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 installPreviewSchema = z
.object({
environmentId: environmentIdSchema.optional(),
package: SkillPackageIdentitySchema,
name: skillNameSchema,
destination: SkillInstallDestinationSchema
})
.strict()
const removeSchema = z
.object({
environmentId: environmentIdSchema.optional(),
name: skillNameSchema,
destination: SkillInstallDestinationSchema,
conflictResolution: z.enum(['replace-and-discard-local', 'cancel']).optional()
})
.strict()
async function installGrant(
runtime: OrcaRuntimeService,
grant: SkillCloudDownloadGrant,
input: z.infer<typeof shareInstallSchema> | z.infer<typeof packageVersionInstallSchema>
) {
const request: SkillInstallRequest = {
operationId: randomUUID(),
package: {
packageId: grant.version.packageId,
versionId: grant.version.versionId,
packageDigest: grant.version.packageDigest,
archiveSha256: grant.version.archiveSha256,
compressedBytes: grant.version.compressedBytes
},
ingress: {
kind: 'download-grant',
url: grant.grant.url,
expiresAt: grant.grant.expiresAt
},
destination: input.destination,
conflictResolution: input.conflictResolution
}
if (!input.environmentId) {
return { status: 'ok' as const, value: await runtime.installSharedSkillRequest(request) }
}
const userDataPath = app.getPath('userData')
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
if (
status.ok !== true ||
status.result.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
return {
status: 'ok' as const,
value: await installSkillOnRemoteRuntime({
userDataPath,
environmentId: input.environmentId,
request,
capabilities: status.result.capabilities ?? [],
requireHttps: app.isPackaged
})
}
}
function registerSharingHandlers(
runtime: OrcaRuntimeService,
discover: (target?: z.infer<typeof SkillDiscoveryTargetSchema>) => Promise<SkillDiscoveryResult>
): void {
const preparations = new SkillSharePreparationService(
join(app.getPath('userData'), 'skill-share-preparations'),
{ publish: (request) => runtime.publishSkillPackage(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) {
throw new Error('skill-share-source-not-found')
}
return preparations.prepare({
sourceDirectory: skill.directoryPath,
packageId: input.packageId
})
})
ipcMain.handle('skills:publishShare', async (_event, value: unknown) => {
const input = sharePublishSchema.parse(value)
return preparations.publish(input, (progress) => {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.webContents.send('skills:shareProgress', progress)
}
}
})
})
ipcMain.handle('skills:cancelShare', (_event, id: unknown) => {
preparations.cancel(z.string().uuid().parse(id))
})
ipcMain.handle('skills:releaseShare', async (_event, id: unknown) => {
await preparations.release(z.string().uuid().parse(id))
})
}
function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
ipcMain.handle('skills:resolveShare', (_event, shareId: unknown) =>
runtime.resolveSkillShare(z.string().min(1).max(128).parse(shareId), {})
)
ipcMain.handle('skills:createDownloadGrant', (_event, shareId: unknown) =>
runtime.createSkillDownloadGrant(z.string().min(1).max(128).parse(shareId), {})
)
ipcMain.handle('skills:installShare', async (_event, value: unknown) => {
const input = shareInstallSchema.parse(value)
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
const grant = await runtime.createSkillDownloadGrant(input.shareId, {
versionId: input.versionId,
installTarget
})
return grant.status === 'ok' ? installGrant(runtime, grant.value, input) : grant
})
ipcMain.handle('skills:installPackageVersion', async (_event, value: unknown) => {
const input = packageVersionInstallSchema.parse(value)
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
const grant = await runtime.createSkillPackageVersionDownloadGrant(
input.packageId,
input.versionId,
{ installTarget }
)
return grant.status === 'ok' ? installGrant(runtime, grant.value, input) : grant
})
ipcMain.handle('skills:getPackage', (_event, packageId: unknown) =>
runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {})
)
}
function registerInstallManagementHandlers(runtime: OrcaRuntimeService): void {
ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => {
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
if (!environmentId) {
return listWslDistrosAsync()
}
const response = await callRuntimeEnvironment(
app.getPath('userData'),
environmentId,
'host.wsl.listDistros',
{},
15_000
)
return response.ok === true && Array.isArray(response.result)
? response.result.filter((distro): distro is string => typeof distro === 'string')
: []
})
ipcMain.handle('skills:previewInstall', async (_event, value: unknown) => {
const input = installPreviewSchema.parse(value)
const request = { package: input.package, name: input.name, destination: input.destination }
if (!input.environmentId) {
return {
status: 'ok' as const,
value: await runtime.previewSharedSkillInstallRequest(request)
}
}
const userDataPath = app.getPath('userData')
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
const response = await callRuntimeEnvironment(
userDataPath,
input.environmentId,
'skills.previewInstall',
request,
30_000
)
if (response.ok !== true) {
throw new Error(`skill-preview-remote-${response.error.code}`)
}
return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) }
})
ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => {
const input = removeSchema.parse(value)
const request = {
operationId: randomUUID(),
name: input.name,
destination: input.destination,
conflictResolution: input.conflictResolution
}
if (!input.environmentId) {
return {
status: 'ok' as const,
value: await runtime.removeSharedSkillInstallRequest(request)
}
}
const userDataPath = app.getPath('userData')
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
const response = await callRuntimeEnvironment(
userDataPath,
input.environmentId,
'skills.removeInstall',
request,
5 * 60_000
)
if (response.ok !== true) {
throw new Error(`skill-remove-remote-${response.error.code}`)
}
return { status: 'ok' as const, value: SkillInstallResultSchema.parse(response.result) }
})
ipcMain.handle('skills:listManagedInstalls', async (_event, environmentIdValue: unknown) => {
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
if (!environmentId) {
return { status: 'ok' as const, value: await runtime.listManagedSkillInstalls() }
}
if (environmentId.startsWith('ssh:')) {
const value = await runtime.listManagedSkillInstalls(environmentId.slice('ssh:'.length))
return { status: 'ok' as const, value }
}
const userDataPath = app.getPath('userData')
if (!(await supportsSkillRuntimeManagement(userDataPath, environmentId))) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'skills.listManagedInstalls',
{},
30_000
)
if (response.ok !== true) {
throw new Error(`skill-list-managed-remote-${response.error.code}`)
}
return { status: 'ok' as const, value: ManagedSkillInstallListSchema.parse(response.result) }
})
}
export function registerSkillCloudIpcHandlers(
runtime: OrcaRuntimeService,
discover: (target?: z.infer<typeof SkillDiscoveryTargetSchema>) => Promise<SkillDiscoveryResult>
): void {
registerSharingHandlers(runtime, discover)
registerCloudInstallHandlers(runtime)
registerInstallManagementHandlers(runtime)
}
+12 -5
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import {
SkillDiscoveryTargetSchema,
type SkillDiscoveryResult,
@@ -18,8 +19,13 @@ import {
discoverSkillsOnTarget,
resolveSkillDiscoveryTarget
} from '../skills/skill-discovery-target'
import { registerSkillCloudIpcHandlers } from './skill-cloud-ipc-handlers'
export function registerSkillsHandlers(store: Store): void {
export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeService): void {
const discover = async (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos())
}
const scanInventory = (): Promise<SkillFreshnessInventory> =>
// Why: the update command targets this machine's global homes. WSL and SSH
// inventories stay out until their installer rail has an equivalent proof.
@@ -51,12 +57,13 @@ export function registerSkillsHandlers(store: Store): void {
ipcMain.handle(
'skills:discover',
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos())
}
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => discover(target)
)
if (runtime) {
registerSkillCloudIpcHandlers(runtime, discover)
}
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
return scanInventory()
})
@@ -103,6 +103,11 @@ export type { PtyProcessInfo, PtySpawnResult }
type PtyProbeOptions = { signal?: AbortSignal }
export type IPtyProvider = {
requestHostRpc?: (
method: string,
params: unknown,
options?: { signal?: AbortSignal; timeoutMs?: number }
) => Promise<unknown>
/** Fresh local spawns currently route to an in-process, non-persistent fallback. */
readonly routesFreshSpawnsToLocalProvider?: true
/** Re-probes a degraded durable host before main commits to fallback spawn semantics. */
+6
View File
@@ -39,6 +39,12 @@ export class SshPtyProvider implements IPtyProvider {
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
private readonly outputState: SshPtyProviderOutputState
requestHostRpc = (
method: string,
params: unknown,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<unknown> => this.mux.request(method, params as Record<string, unknown>, options)
constructor(
connectionId: string,
mux: SshChannelMultiplexer,
+432 -1
View File
@@ -106,7 +106,7 @@ import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-messag
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance'
import { createHash, randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { isAbsolute, join, resolve } from 'node:path'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
@@ -134,6 +134,46 @@ import type {
ArtifactWriteRequest
} from '../../shared/artifacts'
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
import type {
SkillCloudDownloadGrant,
SkillCloudOperation,
SkillCloudOptions,
SkillCloudPackageDetails,
SkillCloudPublishRequest,
SkillCloudPublishResult,
SkillCloudVersion
} from '../../shared/skill-cloud-contract'
import type { SkillCloudService } from '../skills/skill-cloud-service'
import type {
SkillInstallPreview,
SkillInstallPreviewRequest,
SkillInstallRequest,
SkillInstallResult,
ManagedSkillInstall,
SkillRemoveRequest
} from '../../shared/skill-install-contract'
import { executeSkillInstallRequest } from '../skills/skill-install-request-service'
import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations'
import {
previewSharedSkillInstall,
removeSharedSkillInstall
} from '../skills/skill-install-management-service'
import { listManagedSkillInstalls } from '../skills/skill-install-provenance'
import { getWslHome } from '../wsl'
import { WslSkillInstallFilesystem } from '../skills/skill-wsl-install-filesystem'
import { nativeSkillInstallFilesystem } from '../skills/skill-install-filesystem'
import type {
SkillUploadBeginRequest,
SkillUploadChunkRequest
} from '../../shared/skill-upload-session-contract'
import { SkillUploadSessionService } from '../skills/skill-upload-session-service'
import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract'
import {
installSkillOnSshHost,
listSkillInstallsOnSshHost,
previewSkillInstallOnSshHost,
removeSkillInstallOnSshHost
} from '../skills/skill-ssh-relay-service'
import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout'
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
@@ -3249,6 +3289,8 @@ export class OrcaRuntimeService {
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
private automationService: AutomationService | null = null
private artifactService: ArtifactCloudService | null = null
private skillCloudService: SkillCloudService | null = null
private skillUploadSessions: SkillUploadSessionService | null = null
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
private mobileDictation: {
id: string
@@ -4528,6 +4570,381 @@ export class OrcaRuntimeService {
this.artifactService = service
}
setSkillCloudService(service: SkillCloudService): void {
this.skillCloudService = service
}
publishSkillPackage(
request: SkillCloudPublishRequest
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
return this.requireSkillCloudService().publish(request)
}
resolveSkillShare(
shareId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
return this.requireSkillCloudService().resolveShare(shareId, options)
}
createSkillDownloadGrant(
shareId: string,
options: SkillCloudOptions & {
versionId?: string
installTarget?: 'local' | 'remote'
}
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
return this.requireSkillCloudService().createDownloadGrant(shareId, options)
}
createSkillPackageVersionDownloadGrant(
packageId: string,
versionId: string,
options: SkillCloudOptions & { installTarget?: 'local' | 'remote' }
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
return this.requireSkillCloudService().createPackageVersionDownloadGrant(
packageId,
versionId,
options
)
}
getSkillPackage(
packageId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<SkillCloudPackageDetails>> {
return this.requireSkillCloudService().getPackage(packageId, options)
}
replaceSkillPackageAccess(
packageId: string,
access: { userIds: string[]; shareWithOrganization: boolean },
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.requireSkillCloudService().replaceAccess(packageId, access, options)
}
revokeSkillShare(
shareId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.requireSkillCloudService().revokeShare(shareId, options)
}
deleteSkillPackageVersion(
packageId: string,
versionId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.requireSkillCloudService().deleteVersion(packageId, versionId, options)
}
deleteSkillPackage(
packageId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.requireSkillCloudService().deletePackage(packageId, options)
}
async installSharedSkillRequest(
request: SkillInstallRequest,
signal?: AbortSignal
): Promise<SkillInstallResult> {
const runtimeId = this.getStatus().runtimeId
const sshTarget = await this.resolveSkillSshTarget(request.destination)
if (sshTarget) {
return installSkillOnSshHost({
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
})
}
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 executeSkillInstallRequest(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
})
}
async previewSharedSkillInstallRequest(
request: SkillInstallPreviewRequest
): Promise<SkillInstallPreview> {
const runtimeId = this.getStatus().runtimeId
const sshTarget = await this.resolveSkillSshTarget(request.destination)
if (sshTarget) {
return previewSkillInstallOnSshHost({
provider: sshTarget.provider,
request: {
...request,
destination:
request.destination.scope === 'global'
? { scope: 'global', executionTarget: { kind: 'host' } }
: request.destination
},
workspace: sshTarget.workspace
})
}
return previewSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration
})
}
async removeSharedSkillInstallRequest(request: SkillRemoveRequest): Promise<SkillInstallResult> {
const runtimeId = this.getStatus().runtimeId
const sshTarget = await this.resolveSkillSshTarget(request.destination)
if (sshTarget) {
return removeSkillInstallOnSshHost({
provider: sshTarget.provider,
request: {
...request,
destination:
request.destination.scope === 'global'
? { scope: 'global', executionTarget: { kind: 'host' } }
: request.destination
},
workspace: sshTarget.workspace
})
}
return removeSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration
})
}
async listManagedSkillInstalls(connectionId?: string): Promise<ManagedSkillInstall[]> {
if (connectionId) {
const provider = this.requireSkillSshProvider(connectionId)
return listSkillInstallsOnSshHost({
provider,
connectionId,
workspaces: await this.listSkillSshWorkspaces(connectionId)
})
}
const runtimeId = this.getStatus().runtimeId
const [installs, worktrees] = await Promise.all([
listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), {
observeReceipt: async (receipt) => {
if (!receipt.wslDistro) {
return nativeSkillInstallFilesystem.observeSkill(
receipt.canonicalPath,
receipt.fileModes
)
}
const filesystem = new WslSkillInstallFilesystem(receipt.wslDistro, [
dirname(receipt.canonicalPath)
])
return filesystem.observeSkill(receipt.canonicalPath, receipt.fileModes)
}
}),
this.listResolvedWorktrees()
])
const folderWorkspaces = this.listFolderWorkspaces()
return installs.flatMap((install): ManagedSkillInstall[] => {
if (install.scope === 'global') {
const wslPrefix = `global:${runtimeId}:wsl:`
return [
{
...install,
destination: install.destinationIdentity.startsWith(wslPrefix)
? {
scope: 'global',
executionTarget: {
kind: 'wsl',
distro: install.destinationIdentity.slice(wslPrefix.length)
}
}
: { scope: 'global' }
}
]
}
const worktree = worktrees.find(
(candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}`
)
if (worktree) {
return [{ ...install, destination: { scope: 'workspace', worktreeId: worktree.id } }]
}
const folder = folderWorkspaces.find(
(candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}`
)
return folder
? [{ ...install, destination: { scope: 'workspace', folderWorkspaceId: folder.id } }]
: []
})
}
async skillInstallDestinationUsesSsh(
destination: SkillInstallRequest['destination']
): Promise<boolean> {
return Boolean(await this.resolveSkillSshTarget(destination))
}
private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority {
return {
environmentId: runtimeId,
homeDirectory: homedir(),
resolveWorktree: async (id) => {
const repo = this.listRepos().find(
(candidate) => candidate.id === getRepoIdFromWorktreeId(id)
)
if (repo?.connectionId) {
throw new Error('skill-install-ssh-dispatch-required')
}
const projectRuntime = this.resolveProjectRuntimeForWorktree(id)
const worktree = await this.showManagedWorktree(`id:${id}`)
if (worktree.id !== id) {
return null
}
return {
id,
path: worktree.path,
...(projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl'
? { wslDistro: projectRuntime.runtime.distro }
: {})
}
},
resolveFolderWorkspace: async (id) => {
const workspace = this.listFolderWorkspaces().find((candidate) => candidate.id === id)
if (!workspace || workspace.connectionId) {
return null
}
return {
id,
path: workspace.folderPath,
...(parseWslUncPath(workspace.folderPath)?.distro
? { wslDistro: parseWslUncPath(workspace.folderPath)!.distro }
: {})
}
},
resolveWsl: async (distro) => {
if (process.platform !== 'win32') {
return null
}
const homeDirectory = getWslHome(distro)
return homeDirectory ? { homeDirectory } : null
}
}
}
private async resolveSkillSshTarget(destination: SkillInstallRequest['destination']): Promise<{
provider: IPtyProvider
workspace?: SkillSshWorkspaceAuthority
} | null> {
if (destination.scope === 'global') {
return destination.executionTarget?.kind === 'ssh'
? { provider: this.requireSkillSshProvider(destination.executionTarget.connectionId) }
: null
}
if (destination.worktreeId) {
const repo = this.listRepos().find(
(candidate) => candidate.id === getRepoIdFromWorktreeId(destination.worktreeId!)
)
if (!repo?.connectionId) {
return null
}
const worktree = await this.showManagedWorktree(`id:${destination.worktreeId}`)
if (worktree.id !== destination.worktreeId) {
throw new Error('skill-install-workspace-not-found')
}
return {
provider: this.requireSkillSshProvider(repo.connectionId),
workspace: { kind: 'worktree', id: worktree.id, path: worktree.path }
}
}
const folder = this.listFolderWorkspaces().find(
(candidate) => candidate.id === destination.folderWorkspaceId
)
if (!folder?.connectionId) {
return null
}
return {
provider: this.requireSkillSshProvider(folder.connectionId),
workspace: { kind: 'folder', id: folder.id, path: folder.folderPath }
}
}
private requireSkillSshProvider(connectionId: string): IPtyProvider {
const provider = this.getSshProviderFn?.(connectionId)
if (!provider?.requestHostRpc) {
throw new Error('skill-install-ssh-relay-unavailable')
}
return provider
}
private async listSkillSshWorkspaces(
connectionId: string
): Promise<SkillSshWorkspaceAuthority[]> {
const repos = new Map(
this.listRepos()
.filter((repo) => repo.connectionId === connectionId)
.map((repo) => [repo.id, repo])
)
const worktrees = (await this.listResolvedWorktrees())
.filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id)))
.map(
(worktree): SkillSshWorkspaceAuthority => ({
kind: 'worktree',
id: worktree.id,
path: worktree.path
})
)
const folders = this.listFolderWorkspaces()
.filter((folder) => folder.connectionId === connectionId)
.map(
(folder): SkillSshWorkspaceAuthority => ({
kind: 'folder',
id: folder.id,
path: folder.folderPath
})
)
return [...worktrees, ...folders]
}
beginSkillUpload(request: SkillUploadBeginRequest): Promise<{
uploadId: string
chunkBytes: number
}> {
return this.requireSkillUploadSessions().begin(request)
}
appendSkillUploadChunk(
request: SkillUploadChunkRequest
): Promise<{ acknowledgedOffset: number }> {
return this.requireSkillUploadSessions().append(request)
}
commitSkillUpload(uploadId: string): Promise<{ uploadId: string }> {
return this.requireSkillUploadSessions().commit(uploadId)
}
cancelSkillUpload(uploadId: string): Promise<void> {
return this.requireSkillUploadSessions().cancel(uploadId)
}
listArtifacts(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
return this.requireArtifactService().list(options)
}
@@ -4569,6 +4986,20 @@ export class OrcaRuntimeService {
return this.artifactService
}
private requireSkillCloudService(): SkillCloudService {
if (!this.skillCloudService) {
throw new Error('Skill Cloud service is unavailable.')
}
return this.skillCloudService
}
private requireSkillUploadSessions(): SkillUploadSessionService {
this.skillUploadSessions ??= new SkillUploadSessionService(
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
)
return this.skillUploadSessions
}
getRuntimeId(): string {
return this.runtimeId
}
+75 -1
View File
@@ -1,11 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcContext } from '../core'
vi.mock('electron', () => ({
app: { getPath: () => '/orca-state', isPackaged: true }
}))
vi.mock('../../../skills/skill-discovery-target', () => ({
resolveSkillDiscoveryTarget: vi.fn((target) => ({ kind: 'native-host', cwd: target?.cwd })),
discoverSkillsOnTarget: vi.fn(async () => ({ skills: [], sources: [], scannedAt: 1 }))
}))
import { SKILL_METHODS } from './skills'
import { resolveSkillDiscoveryTarget } from '../../../skills/skill-discovery-target'
@@ -41,6 +44,22 @@ function discoverMethod() {
return method
}
function installMethod() {
const method = SKILL_METHODS.find((entry) => entry.name === 'skills.install')
if (!method) {
throw new Error('skills.install method not registered')
}
return method
}
function method(name: string) {
const value = SKILL_METHODS.find((entry) => entry.name === name)
if (!value) {
throw new Error(`${name} method not registered`)
}
return value
}
describe('skills.discover RPC', () => {
it('resolves the project runtime from the owning runtime store when the caller omits it', async () => {
const resolveProjectRuntimeForWorktree = vi.fn(() => WSL_RUNTIME)
@@ -66,3 +85,58 @@ describe('skills.discover RPC', () => {
)
})
})
describe('skills.install RPC', () => {
it('delegates installation to the executing runtime service', async () => {
const installSharedSkillRequest = vi.fn(async () => ({ status: 'installed' }))
const runtime = {
installSharedSkillRequest
}
const request = {
operationId: 'operation_1',
package: {
packageId: 'package_1',
versionId: 'version_1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 100
},
ingress: {
kind: 'download-grant' as const,
url: 'https://storage.googleapis.com/package',
expiresAt: '2026-08-11T12:00:00.000Z'
},
destination: { scope: 'global' as const }
}
await installMethod().handler(request, { runtime } as unknown as RpcContext)
expect(installSharedSkillRequest).toHaveBeenCalledWith(request, undefined)
})
})
describe('skill management RPC', () => {
it('delegates preview and removal to the executing runtime', async () => {
const previewSharedSkillInstallRequest = vi.fn(async () => ({ currentState: 'missing' }))
const removeSharedSkillInstallRequest = vi.fn(async () => ({ status: 'removed' }))
const runtime = { previewSharedSkillInstallRequest, removeSharedSkillInstallRequest }
const destination = { scope: 'global' as const }
const packageIdentity = {
packageId: 'package_1',
versionId: 'version_1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 100
}
await method('skills.previewInstall').handler(
{ name: 'example', package: packageIdentity, destination },
{ runtime } as unknown as RpcContext
)
await method('skills.removeInstall').handler(
{ operationId: 'operation_1', name: 'example', destination },
{ runtime } as unknown as RpcContext
)
expect(previewSharedSkillInstallRequest).toHaveBeenCalledOnce()
expect(removeSharedSkillInstallRequest).toHaveBeenCalledOnce()
})
})
+50
View File
@@ -1,5 +1,15 @@
import { defineMethod, type RpcMethod } from '../core'
import { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
import {
SkillInstallPreviewRequestSchema,
SkillInstallRequestSchema,
SkillRemoveRequestSchema
} from '../../../../shared/skill-install-contract'
import {
SkillUploadBeginRequestSchema,
SkillUploadChunkRequestSchema,
SkillUploadCommitRequestSchema
} from '../../../../shared/skill-upload-session-contract'
import {
discoverSkillsOnTarget,
resolveSkillDiscoveryTarget
@@ -21,5 +31,45 @@ export const SKILL_METHODS: RpcMethod[] = [
}
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos())
}
}),
defineMethod({
name: 'skills.install',
params: SkillInstallRequestSchema,
handler: (params, { runtime, signal }) => runtime.installSharedSkillRequest(params, signal)
}),
defineMethod({
name: 'skills.previewInstall',
params: SkillInstallPreviewRequestSchema,
handler: (params, { runtime }) => runtime.previewSharedSkillInstallRequest(params)
}),
defineMethod({
name: 'skills.removeInstall',
params: SkillRemoveRequestSchema,
handler: (params, { runtime }) => runtime.removeSharedSkillInstallRequest(params)
}),
defineMethod({
name: 'skills.listManagedInstalls',
params: null,
handler: (_params, { runtime }) => runtime.listManagedSkillInstalls()
}),
defineMethod({
name: 'skills.beginUpload',
params: SkillUploadBeginRequestSchema,
handler: (params, { runtime }) => runtime.beginSkillUpload(params)
}),
defineMethod({
name: 'skills.uploadChunk',
params: SkillUploadChunkRequestSchema,
handler: (params, { runtime }) => runtime.appendSkillUploadChunk(params)
}),
defineMethod({
name: 'skills.commitUpload',
params: SkillUploadCommitRequestSchema,
handler: (params, { runtime }) => runtime.commitSkillUpload(params.uploadId)
}),
defineMethod({
name: 'skills.cancelUpload',
params: SkillUploadCommitRequestSchema,
handler: (params, { runtime }) => runtime.cancelSkillUpload(params.uploadId)
})
]
@@ -0,0 +1,93 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
downloadSkillPackageGrant: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('./skill-package-download', () => ({
downloadSkillPackageGrant: mocks.downloadSkillPackageGrant
}))
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
describe('transferSkillPackageToRuntime', () => {
let root = ''
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-skill-transfer-test-'))
mocks.callRuntimeEnvironment.mockReset()
mocks.downloadSkillPackageGrant.mockReset()
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('rejects an invalid acknowledgement and cancels the remote session', async () => {
const archivePath = join(root, 'package.tar.gz')
await writeFile(archivePath, Buffer.from('package-data'))
const downloadCleanup = vi.fn(async () => undefined)
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup: downloadCleanup })
mocks.callRuntimeEnvironment.mockImplementation(
async (_userData: string, _environment: string, method: string) => {
if (method === 'skills.beginUpload') {
return {
id: 'rpc-1',
ok: true,
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
_meta: { runtimeId: 'runtime-1' }
}
}
if (method === 'skills.uploadChunk') {
return {
id: 'rpc-2',
ok: true,
result: { acknowledgedOffset: 1 },
_meta: { runtimeId: 'runtime-1' }
}
}
return {
id: 'rpc-3',
ok: true,
result: undefined,
_meta: { runtimeId: 'runtime-1' }
}
}
)
await expect(
transferSkillPackageToRuntime({
userDataPath: root,
environmentId: 'environment-1',
package: {
packageId: 'package-1',
versionId: 'version-1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 12
},
grant: {
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
expiresAt: '2099-01-01T00:00:00.000Z'
},
requireHttps: true
})
).rejects.toThrow('skill-transfer-ack-invalid')
expect(downloadCleanup).toHaveBeenCalledOnce()
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
root,
'environment-1',
'skills.cancelUpload',
{ uploadId: 'upload-1' },
5 * 60_000
)
})
})
@@ -0,0 +1,109 @@
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 { SKILL_UPLOAD_CHUNK_MAX_BYTES } from '../../shared/skill-upload-session-contract'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { downloadSkillPackageGrant } from './skill-package-download'
function allowedOrigins(allowConfiguredOrigins: boolean): string[] {
const origins = ['https://storage.googleapis.com']
if (allowConfiguredOrigins && 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)]
}
async function remoteCall<T>(
userDataPath: string,
environmentId: string,
method: string,
params: unknown
): Promise<T> {
const response = (await callRuntimeEnvironment(
userDataPath,
environmentId,
method,
params,
5 * 60_000
)) as RuntimeRpcResponse<T>
if (response.ok !== true) {
throw new Error(`skill-transfer-remote-${response.error.code}`)
}
return response.result
}
export async function transferSkillPackageToRuntime(input: {
userDataPath: string
environmentId: string
package: SkillPackageIdentity
grant: { url: string; expiresAt: string }
requireHttps: boolean
}): Promise<{ uploadId: string; cleanup(): Promise<void> }> {
const downloaded = await downloadSkillPackageGrant({
url: input.grant.url,
expiresAt: input.grant.expiresAt,
expectedArchiveSha256: input.package.archiveSha256,
expectedCompressedBytes: input.package.compressedBytes,
temporaryRoot: join(input.userDataPath, 'skill-installs', 'client-transfers'),
allowedOrigins: allowedOrigins(!input.requireHttps),
requireHttps: input.requireHttps
})
let uploadId: string | null = null
try {
const begun = await remoteCall<{ uploadId: string; chunkBytes: number }>(
input.userDataPath,
input.environmentId,
'skills.beginUpload',
{ package: input.package }
)
uploadId = begun.uploadId
const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES)
if (!Number.isInteger(chunkBytes) || chunkBytes < 1) {
throw new Error('skill-transfer-chunk-size-invalid')
}
const handle = await open(downloaded.archivePath, 'r')
try {
let offset = 0
while (offset < input.package.compressedBytes) {
const bytes = Buffer.alloc(Math.min(chunkBytes, input.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 remoteCall<{ acknowledgedOffset: number }>(
input.userDataPath,
input.environmentId,
'skills.uploadChunk',
{ uploadId, offset, bytesBase64: bytes.toString('base64') }
)
if (acknowledged.acknowledgedOffset !== offset + bytes.length) {
throw new Error('skill-transfer-ack-invalid')
}
offset = acknowledged.acknowledgedOffset
}
} finally {
await handle.close()
}
await remoteCall(input.userDataPath, input.environmentId, 'skills.commitUpload', { uploadId })
const committedId = uploadId
uploadId = null
return {
uploadId: committedId,
cleanup: () =>
remoteCall(input.userDataPath, input.environmentId, 'skills.cancelUpload', {
uploadId: committedId
})
}
} finally {
await downloaded.cleanup()
if (uploadId) {
await remoteCall(input.userDataPath, input.environmentId, 'skills.cancelUpload', {
uploadId
}).catch(() => undefined)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { SkillCloudOperation, SkillCloudOptions } from '../../shared/skill-cloud-contract'
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
import {
allowsArtifactCloudAuthOverride,
resolveArtifactCloudApiUrl
} from '../artifacts/artifact-cloud-config'
export async function runSkillCloudOperation<T>(input: {
userDataPath: string
options: SkillCloudOptions
operation(token: string, apiUrl: string): Promise<T>
}): Promise<SkillCloudOperation<T>> {
const apiUrl = resolveArtifactCloudApiUrl(input.options.apiUrl)
const active = ensureActiveOrcaProfile(input.userDataPath)
const stamp = {
profileId: active.profile.id,
userId: active.profile.cloud?.userId,
cloudProfileId: active.profile.cloud?.cloudProfileId,
organizationId: active.profile.cloud?.activeOrgId ?? ''
}
const assertCurrent = () => {
const current = ensureActiveOrcaProfile(input.userDataPath)
if (
current.profile.id !== stamp.profileId ||
current.profile.cloud?.userId !== stamp.userId ||
current.profile.cloud?.cloudProfileId !== stamp.cloudProfileId ||
(current.profile.cloud?.activeOrgId ?? '') !== stamp.organizationId
) {
throw new Error('The signed-in Orca account changed during the skill request.')
}
}
const override = input.options.authToken?.trim()
if (override) {
if (!allowsArtifactCloudAuthOverride()) {
throw new Error('Skill authentication overrides are available only in development builds.')
}
const value = await input.operation(override, apiUrl)
assertCurrent()
return { status: 'ok', value }
}
const config = getOrcaCloudAuthConfig()
if (!config.configured) {
return { status: 'unconfigured', message: config.setupMessage }
}
const result = await runWithFreshOrcaCloudSession(
config.config,
active,
input.userDataPath,
async (session) => {
const value = await input.operation(session.accessToken, apiUrl)
assertCurrent()
return value
}
)
return result.status === 'ok'
? { status: 'ok', value: result.value }
: { status: 'reconnect-required' }
}
@@ -0,0 +1,71 @@
import { 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 { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('uploadSkillPackageToSignedPolicy', () => {
it('streams exact bytes with policy fields and bounded progress', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
roots.push(root)
const archivePath = join(root, 'package.tar.gz')
const archive = Buffer.from('private-package-bytes')
await writeFile(archivePath, archive)
let uploaded = Buffer.alloc(0)
const fetcher = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
const chunks: Buffer[] = []
const body = init?.body as unknown as AsyncIterable<Buffer>
for await (const chunk of body) {
chunks.push(Buffer.from(chunk))
}
uploaded = Buffer.concat(chunks)
expect(Number(init?.headers && new Headers(init.headers).get('content-length'))).toBe(
uploaded.length
)
return new Response(null, { status: 204 })
}) as typeof fetch
const progress = vi.fn()
await uploadSkillPackageToSignedPolicy({
policy: {
url: 'https://storage.googleapis.com/upload',
fields: { key: 'uploads/private/package.tar.gz', policy: 'opaque-policy' }
},
archivePath,
expectedBytes: archive.length,
fetcher,
onProgress: progress
})
expect(uploaded.includes(archive)).toBe(true)
expect(uploaded.toString('utf8')).toContain('opaque-policy')
expect(progress).toHaveBeenLastCalledWith(archive.length)
})
it('rejects insecure destinations and source drift before upload', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
roots.push(root)
const archivePath = join(root, 'package.tar.gz')
await writeFile(archivePath, 'bytes')
await expect(
uploadSkillPackageToSignedPolicy({
policy: { url: 'http://storage.test/upload', fields: {} },
archivePath,
expectedBytes: 5
})
).rejects.toThrow('skill-cloud-upload-url-invalid')
await expect(
uploadSkillPackageToSignedPolicy({
policy: { url: 'https://storage.test/upload', fields: {} },
archivePath,
expectedBytes: 4
})
).rejects.toThrow('skill-cloud-upload-source-changed')
})
})
@@ -0,0 +1,91 @@
import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
type SignedPostPolicy = {
url: string
fields: Record<string, string>
}
function quoted(value: string): string {
return value
.replaceAll('\\', '\\\\')
.replaceAll('"', '\\"')
.replaceAll('\r', '')
.replaceAll('\n', '')
}
function fieldBytes(boundary: string, name: string, value: string): Buffer {
return Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(name)}"\r\n\r\n${value}\r\n`,
'utf8'
)
}
export async function uploadSkillPackageToSignedPolicy(input: {
policy: SignedPostPolicy
archivePath: string
expectedBytes: number
signal?: AbortSignal
onProgress?: (bytesSent: number) => void
fetcher?: typeof fetch
}): Promise<void> {
const policyUrl = new URL(input.policy.url)
if (policyUrl.protocol !== 'https:' || policyUrl.username || policyUrl.password) {
throw new Error('skill-cloud-upload-url-invalid')
}
const archive = await stat(input.archivePath)
if (!archive.isFile() || archive.size !== input.expectedBytes) {
throw new Error('skill-cloud-upload-source-changed')
}
const boundary = `orca-skill-${randomBytes(18).toString('hex')}`
const fields = Object.entries(input.policy.fields).map(([name, value]) =>
fieldBytes(boundary, name, value)
)
const fileHeader = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="package.tar.gz"\r\nContent-Type: application/vnd.orca.skill+tar+gzip\r\n\r\n`,
'utf8'
)
const ending = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')
const contentLength =
fields.reduce((total, field) => total + field.length, 0) +
fileHeader.length +
archive.size +
ending.length
async function* body() {
for (const field of fields) {
yield field
}
yield fileHeader
let bytesSent = 0
for await (const chunk of createReadStream(input.archivePath, { signal: input.signal })) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
bytesSent += bytes.length
if (bytesSent > input.expectedBytes) {
throw new Error('skill-cloud-upload-source-changed')
}
input.onProgress?.(bytesSent)
yield bytes
}
if (bytesSent !== input.expectedBytes) {
throw new Error('skill-cloud-upload-source-changed')
}
yield ending
}
const request: RequestInit & { duplex: 'half' } = {
method: 'POST',
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(contentLength)
},
body: Readable.from(body()) as unknown as BodyInit,
duplex: 'half',
redirect: 'error',
signal: input.signal
}
const response = await (input.fetcher ?? fetch)(policyUrl, request)
if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error('skill-cloud-upload-failed')
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { classifySkillCloudInstallTarget } from './skill-cloud-install-target'
function runtime(usesSsh: boolean): OrcaRuntimeService {
return {
skillInstallDestinationUsesSsh: vi.fn().mockResolvedValue(usesSsh)
} as unknown as OrcaRuntimeService
}
describe('classifySkillCloudInstallTarget', () => {
it('treats paired runtimes and SSH-owned workspaces as remote', async () => {
await expect(
classifySkillCloudInstallTarget(runtime(false), {
environmentId: 'environment_1',
destination: { scope: 'global' }
})
).resolves.toBe('remote')
await expect(
classifySkillCloudInstallTarget(runtime(true), {
destination: { scope: 'workspace', worktreeId: 'worktree_1' }
})
).resolves.toBe('remote')
})
it('keeps native and WSL installs in the local grant lane', async () => {
await expect(
classifySkillCloudInstallTarget(runtime(false), {
destination: { scope: 'global', executionTarget: { kind: 'wsl', distro: 'Ubuntu' } }
})
).resolves.toBe('local')
})
})
@@ -0,0 +1,11 @@
import type { SkillInstallDestination } from '../../shared/skill-install-contract'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
export async function classifySkillCloudInstallTarget(
runtime: OrcaRuntimeService,
input: { environmentId?: string; destination: SkillInstallDestination }
): Promise<'local' | 'remote'> {
return input.environmentId || (await runtime.skillInstallDestinationUsesSsh(input.destination))
? 'remote'
: 'local'
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest'
import { skillCloudRequest, type SkillCloudRequestError } from './skill-cloud-request'
vi.mock('electron', () => ({ app: { isPackaged: false } }))
describe('skillCloudRequest', () => {
it('sends credentials only to the validated Orca origin', async () => {
const fetcher = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
expect(String(input)).toBe('http://127.0.0.1:8787/v1/skill-shares/share_1')
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer private-token')
expect(init?.redirect).toBe('error')
return Response.json({ share: { id: 'share_1' } })
}) as typeof fetch
await expect(
skillCloudRequest({
apiUrl: 'http://127.0.0.1:8787',
authToken: 'private-token',
path: '/v1/skill-shares/share_1',
fetcher
})
).resolves.toEqual({ share: { id: 'share_1' } })
})
it('rejects non-API paths and returns structured errors without response contents', async () => {
await expect(
skillCloudRequest({
apiUrl: 'http://127.0.0.1:8787',
authToken: 'token',
path: 'https://attacker.test/v1/skills'
})
).rejects.toThrow('skill-cloud-request-path-invalid')
const fetcher = vi.fn(async () =>
Response.json({ code: 'skill_share_not_found', private: 'do-not-reflect' }, { status: 404 })
) as typeof fetch
const request = skillCloudRequest({
apiUrl: 'http://127.0.0.1:8787',
authToken: 'token',
path: '/v1/skill-shares/missing',
fetcher
})
await expect(request).rejects.toMatchObject({
statusCode: 404,
code: 'skill_share_not_found',
message: 'The skill Cloud request failed.'
} satisfies Partial<SkillCloudRequestError>)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
export class SkillCloudRequestError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string
) {
super(message)
}
}
export async function skillCloudRequest<T>(input: {
apiUrl?: string
authToken: string
path: string
method?: 'DELETE' | 'GET' | 'POST' | 'PUT'
body?: unknown
signal?: AbortSignal
idempotencyKey?: string
fetcher?: typeof fetch
}): Promise<T> {
const apiUrl = resolveArtifactCloudApiUrl(input.apiUrl)
const url = new URL(input.path, `${apiUrl}/`)
if (url.origin !== apiUrl || !url.pathname.startsWith('/v1/')) {
throw new Error('skill-cloud-request-path-invalid')
}
const response = await (input.fetcher ?? fetch)(url, {
method: input.method ?? 'GET',
headers: {
authorization: `Bearer ${input.authToken}`,
accept: 'application/json',
...(input.body === undefined ? {} : { 'content-type': 'application/json' }),
...(input.idempotencyKey ? { 'idempotency-key': input.idempotencyKey } : {})
},
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
signal: input.signal,
redirect: 'error'
})
if (response.status === 204) {
return undefined as T
}
const value: unknown = await response.json().catch(() => null)
if (!response.ok) {
const error = value as { code?: unknown; message?: unknown } | null
throw new SkillCloudRequestError(
response.status,
typeof error?.code === 'string' ? error.code : 'skill_cloud_request_failed',
typeof error?.message === 'string' ? error.message : 'The skill Cloud request failed.'
)
}
return value as T
}
+220
View File
@@ -0,0 +1,220 @@
import { randomUUID } from 'node:crypto'
import type {
SkillCloudDownloadGrant,
SkillCloudInstallTarget,
SkillCloudOperation,
SkillCloudOptions,
SkillCloudPackageDetails,
SkillCloudPublishRequest,
SkillCloudPublishResult,
SkillCloudShare,
SkillCloudVersion
} from '../../shared/skill-cloud-contract'
import { runSkillCloudOperation } from './skill-cloud-auth'
import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
import { skillCloudRequest } from './skill-cloud-request'
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
function id(value: string): string {
if (!ID_PATTERN.test(value)) {
throw new Error('skill-cloud-id-invalid')
}
return encodeURIComponent(value)
}
export class SkillCloudService {
constructor(private readonly userDataPath: string) {}
publish(
request: SkillCloudPublishRequest
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
return this.withAuth(request, async (token, apiUrl) => {
const upload = await skillCloudRequest<{
upload: {
id: string
policy: { url: string; fields: Record<string, string>; expiresAt: string }
}
}>({
apiUrl,
authToken: token,
path: '/v1/skill-packages/uploads',
method: 'POST',
body: {
expectedArchiveSha256: request.archiveSha256,
expectedCompressedBytes: request.compressedBytes
},
idempotencyKey: randomUUID(),
signal: request.signal
})
await uploadSkillPackageToSignedPolicy({
policy: upload.upload.policy,
archivePath: request.archivePath,
expectedBytes: request.compressedBytes,
signal: request.signal,
onProgress: (bytesSent) =>
request.onProgress?.({
phase: 'uploading',
bytesSent,
totalBytes: request.compressedBytes
})
})
request.onProgress?.({
phase: 'finalizing',
bytesSent: request.compressedBytes,
totalBytes: request.compressedBytes
})
const finalized = await skillCloudRequest<{ version: SkillCloudVersion }>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/uploads/${id(upload.upload.id)}/finalize`,
method: 'POST',
body: { releaseNotes: request.releaseNotes },
idempotencyKey: upload.upload.id,
signal: request.signal
})
if (finalized.version.packageId !== request.packageId) {
throw new Error('skill-cloud-published-package-mismatch')
}
const shared = await skillCloudRequest<{ share: SkillCloudShare }>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(request.packageId)}/shares`,
method: 'POST',
body: {
pinnedVersionId: request.pinnedVersionId,
userIds: request.userIds,
shareWithOrganization: request.shareWithOrganization
},
idempotencyKey: randomUUID(),
signal: request.signal
})
return { version: finalized.version, share: shared.share }
})
}
resolveShare(
shareId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
return this.withAuth(options, async (token, apiUrl) => {
const result = await skillCloudRequest<{
share: { id: string; version: SkillCloudVersion }
}>({ apiUrl, authToken: token, path: `/v1/skill-shares/${id(shareId)}` })
return result.share
})
}
createDownloadGrant(
shareId: string,
options: SkillCloudOptions & { versionId?: string; installTarget?: SkillCloudInstallTarget }
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<SkillCloudDownloadGrant>({
apiUrl,
authToken: token,
path: `/v1/skill-shares/${id(shareId)}/download-grants`,
method: 'POST',
body: {
...(options.versionId ? { versionId: options.versionId } : {}),
...(options.installTarget ? { installTarget: options.installTarget } : {})
}
})
)
}
createPackageVersionDownloadGrant(
packageId: string,
versionId: string,
options: SkillCloudOptions & { installTarget?: SkillCloudInstallTarget }
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<SkillCloudDownloadGrant>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(packageId)}/versions/${id(versionId)}/download-grants`,
method: 'POST',
body: options.installTarget ? { installTarget: options.installTarget } : {}
})
)
}
getPackage(
packageId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<SkillCloudPackageDetails>> {
return this.withAuth(options, async (token, apiUrl) => {
const result = await skillCloudRequest<{ package: SkillCloudPackageDetails }>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(packageId)}`
})
return result.package
})
}
replaceAccess(
packageId: string,
access: { userIds: string[]; shareWithOrganization: boolean },
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<void>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(packageId)}/access`,
method: 'PUT',
body: access,
idempotencyKey: randomUUID()
})
)
}
revokeShare(shareId: string, options: SkillCloudOptions): Promise<SkillCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<void>({
apiUrl,
authToken: token,
path: `/v1/skill-shares/${id(shareId)}`,
method: 'DELETE'
})
)
}
deleteVersion(
packageId: string,
versionId: string,
options: SkillCloudOptions
): Promise<SkillCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<void>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(packageId)}/versions/${id(versionId)}`,
method: 'DELETE'
})
)
}
deletePackage(packageId: string, options: SkillCloudOptions): Promise<SkillCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<void>({
apiUrl,
authToken: token,
path: `/v1/skill-packages/${id(packageId)}`,
method: 'DELETE'
})
)
}
private withAuth<T>(
options: SkillCloudOptions,
operation: (token: string, apiUrl: string) => Promise<T>
): Promise<SkillCloudOperation<T>> {
return runSkillCloudOperation({
userDataPath: this.userDataPath,
options,
operation
})
}
}
+28
View File
@@ -0,0 +1,28 @@
import { rename } from 'node:fs/promises'
const WINDOWS_RENAME_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
export async function renameSkillPathWithWindowsRetry(
source: string,
target: string
): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
try {
await rename(source, target)
return
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
const retryable = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
if (
process.platform !== 'win32' ||
!retryable ||
attempt >= WINDOWS_RENAME_RETRY_DELAYS_MS.length
) {
throw error
}
await new Promise<void>((resolve) =>
setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt])
)
}
}
}
@@ -0,0 +1,97 @@
import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillInstallRequest } from '../../shared/skill-install-contract'
import { resolveSkillInstallDestination } from './skill-install-destinations'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-destination-test-'))
roots.push(root)
const home = join(root, 'home')
const worktree = join(root, 'worktree')
const folder = join(root, 'folder')
await Promise.all([mkdir(home), mkdir(worktree), mkdir(folder)])
return {
home,
worktree,
folder,
authority: {
environmentId: 'environment_1',
homeDirectory: await realpath(home),
resolveWorktree: async (id: string) => (id === 'worktree_1' ? { id, path: worktree } : null),
resolveFolderWorkspace: async (id: string) =>
id === 'folder_1' ? { id, path: folder } : null,
resolveWsl: async (distro: string) => (distro === 'Ubuntu' ? { homeDirectory: home } : null)
}
}
}
describe('resolveSkillInstallDestination', () => {
it('derives global paths from host-owned home and environment identity', async () => {
const { authority } = await fixture()
await expect(
resolveSkillInstallDestination(
{ scope: 'global', environmentId: 'environment_1', executionTarget: { kind: 'host' } },
authority
)
).resolves.toEqual({
scope: 'global',
homeDirectory: authority.homeDirectory,
destinationIdentity: 'global:environment_1'
})
})
it.each([
[{ scope: 'workspace', worktreeId: 'worktree_1' }, 'worktree'],
[{ scope: 'workspace', folderWorkspaceId: 'folder_1' }, 'folder']
] as const)('resolves %s identity without accepting a caller path', async (destination, key) => {
const fixtureValue = await fixture()
const result = await resolveSkillInstallDestination(
destination as SkillInstallRequest['destination'],
fixtureValue.authority
)
expect(result.workspaceDirectory).toBe(await realpath(fixtureValue[key]))
})
it('rejects another environment and unknown workspace before filesystem mutation', async () => {
const { authority } = await fixture()
await expect(
resolveSkillInstallDestination({ scope: 'global', environmentId: 'other' }, authority)
).rejects.toThrow('skill-install-environment-mismatch')
await expect(
resolveSkillInstallDestination({ scope: 'workspace', worktreeId: 'missing' }, authority)
).rejects.toThrow('skill-install-workspace-not-found')
})
it('resolves a selected WSL distro through host-owned authority', async () => {
const { authority } = await fixture()
await expect(
resolveSkillInstallDestination(
{ scope: 'global', executionTarget: { kind: 'wsl', distro: 'Ubuntu' } },
authority
)
).resolves.toMatchObject({
scope: 'global',
homeDirectory: authority.homeDirectory,
destinationIdentity: 'global:environment_1:wsl:Ubuntu',
wslDistro: 'Ubuntu'
})
})
it('never substitutes the desktop home for an SSH target', async () => {
const { authority } = await fixture()
await expect(
resolveSkillInstallDestination(
{ scope: 'global', executionTarget: { kind: 'ssh', connectionId: 'ssh_1' } },
authority
)
).rejects.toThrow('skill-install-ssh-dispatch-required')
})
})
@@ -0,0 +1,102 @@
import { lstat, realpath } from 'node:fs/promises'
import { isAbsolute, relative, resolve } from 'node:path'
import type { SkillInstallRequest } from '../../shared/skill-install-contract'
type WorkspaceIdentity = {
id: string
path: string
wslDistro?: string
}
export type SkillInstallDestinationAuthority = {
environmentId: string
homeDirectory: string
resolveWorktree(id: string): Promise<WorkspaceIdentity | null>
resolveFolderWorkspace(id: string): Promise<WorkspaceIdentity | null>
resolveWsl?(distro: string): Promise<{ homeDirectory: string } | null>
}
export type ResolvedSkillInstallDestination = {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
destinationIdentity: string
wslDistro?: string
}
async function requireDirectory(path: string, category: string): Promise<string> {
const stat = await lstat(path).catch(() => null)
if (!stat || !stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(category)
}
return realpath(path)
}
function requireContained(root: string, path: string): void {
const child = relative(resolve(root), resolve(path))
if (
child === '..' ||
child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) ||
isAbsolute(child)
) {
throw new Error('skill-install-destination-escape')
}
}
export async function resolveSkillInstallDestination(
destination: SkillInstallRequest['destination'],
authority: SkillInstallDestinationAuthority
): Promise<ResolvedSkillInstallDestination> {
const homeDirectory = await requireDirectory(
authority.homeDirectory,
'skill-install-home-unavailable'
)
if (destination.scope === 'global') {
if (destination.environmentId && destination.environmentId !== authority.environmentId) {
throw new Error('skill-install-environment-mismatch')
}
if (destination.executionTarget?.kind === 'wsl') {
const wsl = await authority.resolveWsl?.(destination.executionTarget.distro)
if (!wsl) {
throw new Error('skill-install-wsl-unavailable')
}
return {
scope: 'global',
homeDirectory: await requireDirectory(
wsl.homeDirectory,
'skill-install-wsl-home-unavailable'
),
destinationIdentity: `global:${authority.environmentId}:wsl:${destination.executionTarget.distro}`,
wslDistro: destination.executionTarget.distro
}
}
if (destination.executionTarget?.kind === 'ssh') {
throw new Error('skill-install-ssh-dispatch-required')
}
return {
scope: 'global',
homeDirectory,
destinationIdentity: `global:${authority.environmentId}`
}
}
const workspace = destination.worktreeId
? await authority.resolveWorktree(destination.worktreeId)
: await authority.resolveFolderWorkspace(destination.folderWorkspaceId!)
const expectedId = destination.worktreeId ?? destination.folderWorkspaceId
if (!workspace || workspace.id !== expectedId) {
throw new Error('skill-install-workspace-not-found')
}
const workspaceDirectory = await requireDirectory(
workspace.path,
'skill-install-workspace-unavailable'
)
requireContained(workspaceDirectory, workspaceDirectory)
return {
scope: 'workspace',
homeDirectory,
workspaceDirectory,
destinationIdentity: `workspace:${authority.environmentId}:${workspace.id}`,
...(workspace.wslDistro ? { wslDistro: workspace.wslDistro } : {})
}
}
@@ -0,0 +1,36 @@
import { rm } from 'node:fs/promises'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
import {
SKILL_PACKAGE_OBSERVATION_LIMITS,
observeSkillPackage,
type ObservedSkillPackage
} from './skill-package-identity'
export type SkillInstalledFileMode = { path: string; executable: boolean }
export type SkillInstallFilesystem = {
prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise<void>
observeSkill(
path: string,
files?: readonly SkillInstalledFileMode[]
): Promise<ObservedSkillPackage>
rename(source: string, target: string): Promise<void>
remove(path: string): Promise<void>
createAlias?(canonicalPath: string, destinationPath: string): Promise<void>
aliasTargets?(canonicalPath: string, destinationPath: string): Promise<boolean>
}
export const nativeSkillInstallFilesystem: SkillInstallFilesystem = {
prepareExtractedSkill: async () => undefined,
observeSkill: (path, files) =>
observeSkillPackage(
path,
SKILL_PACKAGE_OBSERVATION_LIMITS,
process.platform === 'win32' && files
? new Set(files.filter((file) => file.executable).map((file) => file.path))
: undefined
),
rename: renameSkillPathWithWindowsRetry,
remove: (path) => rm(path, { recursive: true, force: true })
}
+82
View File
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'
import { dirname } from 'node:path'
const LOCK_RETRY_MS = 50
const LOCK_STALE_MS = 30 * 60 * 1000
type SkillInstallLockOwner = {
token: string
pid: number
createdAt: number
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}
async function removeStaleLock(path: string): Promise<void> {
const lockStat = await stat(path).catch(() => null)
if (!lockStat || Date.now() - lockStat.mtimeMs < LOCK_STALE_MS) {
return
}
let owner: SkillInstallLockOwner | null = null
try {
owner = JSON.parse(await readFile(path, 'utf8')) as SkillInstallLockOwner
} catch {
owner = null
}
if (owner && Number.isInteger(owner.pid) && processIsAlive(owner.pid)) {
return
}
await rm(path, { force: true })
}
export async function acquireSkillInstallLock(input: {
path: string
timeoutMs?: number
}): Promise<() => Promise<void>> {
await mkdir(dirname(input.path), { recursive: true, mode: 0o700 })
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
const owner: SkillInstallLockOwner = {
token: randomUUID(),
pid: process.pid,
createdAt: Date.now()
}
for (;;) {
try {
const handle = await open(input.path, 'wx', 0o600)
try {
await handle.writeFile(JSON.stringify(owner), 'utf8')
await handle.sync()
} finally {
await handle.close()
}
return async () => {
let current: SkillInstallLockOwner | null = null
try {
current = JSON.parse(await readFile(input.path, 'utf8')) as SkillInstallLockOwner
} catch {
current = null
}
if (current?.token === owner.token) {
await rm(input.path, { force: true })
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error
}
await removeStaleLock(input.path)
if (Date.now() >= deadline) {
throw new Error('skill-install-busy')
}
await new Promise<void>((resolve) => setTimeout(resolve, LOCK_RETRY_MS))
}
}
}
@@ -0,0 +1,78 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
previewSharedSkillInstall,
removeSharedSkillInstall
} from './skill-install-management-service'
describe('skill install management', () => {
let root = ''
let homeDirectory = ''
let stateDirectory = ''
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-skill-management-test-'))
homeDirectory = join(root, 'home')
stateDirectory = join(root, 'state')
await Promise.all([mkdir(homeDirectory), mkdir(stateDirectory)])
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function dependencies() {
return {
authority: {
environmentId: 'runtime-1',
homeDirectory,
resolveWorktree: async () => null,
resolveFolderWorkspace: async () => null
},
stateDirectory,
detectProviders: async () => ['codex', 'claude']
}
}
it('previews a missing global install without mutating the destination', async () => {
const preview = await previewSharedSkillInstall(
{
name: 'example',
package: {
packageId: 'package-1',
versionId: 'version-1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 100
},
destination: { scope: 'global' }
},
dependencies()
)
expect(preview).toMatchObject({
name: 'example',
currentState: 'missing',
destinationIdentity: 'global:runtime-1'
})
expect(preview.providers.map((provider) => provider.provider)).toEqual(['codex', 'claude'])
})
it('refuses to remove an unowned destination', async () => {
const canonicalPath = join(homeDirectory, '.agents', 'skills', 'example')
await mkdir(canonicalPath, { recursive: true })
const result = await removeSharedSkillInstall(
{
operationId: 'operation-1',
name: 'example',
destination: { scope: 'global' }
},
dependencies()
)
expect(result).toMatchObject({ status: 'conflict', conflict: { kind: 'unowned' } })
})
})
@@ -0,0 +1,133 @@
import { lstat } from 'node:fs/promises'
import { join } from 'node:path'
import type {
SkillInstallPreview,
SkillInstallPreviewRequest,
SkillInstallResult,
SkillRemoveRequest
} from '../../shared/skill-install-contract'
import type { SkillInstallDestinationAuthority } from './skill-install-destinations'
import { resolveSkillInstallDestination } from './skill-install-destinations'
import { inspectSkillCanonicalState } from './skill-install-planner'
import { readSkillInstallReceipt } from './skill-install-provenance'
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
type ManagementDependencies = {
authority: SkillInstallDestinationAuthority
stateDirectory: string
detectProviders(): Promise<readonly string[]>
}
function canonicalPath(
destination: Awaited<ReturnType<typeof resolveSkillInstallDestination>>,
name: string
): string {
return join(
destination.scope === 'global' ? destination.homeDirectory : destination.workspaceDirectory!,
'.agents',
'skills',
name
)
}
export async function previewSharedSkillInstall(
request: SkillInstallPreviewRequest,
dependencies: ManagementDependencies
): Promise<SkillInstallPreview> {
const destination = await resolveSkillInstallDestination(
request.destination,
dependencies.authority
)
const path = canonicalPath(destination, request.name)
const filesystem = destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory
})
: undefined
const installStateDirectory = join(dependencies.stateDirectory, 'skill-installs')
const [receipt, detectedProviders] = await Promise.all([
readSkillInstallReceipt(installStateDirectory, path),
destination.wslDistro
? detectSkillProvidersInWsl(destination.wslDistro)
: dependencies.detectProviders()
])
const current = await inspectSkillCanonicalState({
canonicalPath: path,
receipt,
manifest: {
schemaVersion: 1,
packageId: request.package.packageId,
versionId: request.package.versionId,
name: request.name,
description: '',
createdAt: new Date(0).toISOString(),
files: [],
packageDigest: request.package.packageDigest
},
filesystem
})
const providers = resolveSkillProviderDestinations({
scope: destination.scope,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory,
detectedProviders
})
return {
name: request.name,
packageDigest: request.package.packageDigest,
destinationIdentity: destination.destinationIdentity,
currentState: current.kind,
providers: await Promise.all(
providers.map(async (provider) => {
const placementPath = join(provider.rootPath, request.name)
const stat = await lstat(placementPath).catch(() => null)
return {
provider: provider.provider,
topology: provider.readsCanonicalRoot
? ('canonical-copy' as const)
: ('provider-alias' as const),
state: provider.readsCanonicalRoot || !stat ? ('ready' as const) : ('conflict' as const)
}
})
)
}
}
export async function removeSharedSkillInstall(
request: SkillRemoveRequest,
dependencies: ManagementDependencies
): Promise<SkillInstallResult> {
const destination = await resolveSkillInstallDestination(
request.destination,
dependencies.authority
)
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
const providerRoots = resolveSkillProviderDestinations({
scope: destination.scope,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory,
detectedProviders
}).map((provider) => provider.rootPath)
return removeLocalSharedSkill({
operationId: request.operationId,
canonicalPath: canonicalPath(destination, request.name),
stateDirectory: join(dependencies.stateDirectory, 'skill-installs'),
allowedProviderRoots: providerRoots,
conflictResolution: request.conflictResolution,
filesystem
})
}
+69
View File
@@ -0,0 +1,69 @@
import { lstat, readdir } from 'node:fs/promises'
import { basename, dirname } from 'node:path'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem,
type SkillInstalledFileMode
} from './skill-install-filesystem'
export type SkillCanonicalState =
| { kind: 'missing' }
| { kind: 'unchanged'; digest: string }
| { kind: 'clean-update'; digest: string }
| { kind: 'modified'; digest?: string }
| { kind: 'unowned'; digest?: string }
| { kind: 'external-link' }
| { kind: 'name-collision' }
export async function inspectSkillCanonicalState(input: {
canonicalPath: string
manifest: SkillPackageManifestV1
receipt: SkillInstallReceiptV1 | null
filesystem?: SkillInstallFilesystem
}): Promise<SkillCanonicalState> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const requestedName = basename(input.canonicalPath)
const siblingNames = await readdir(dirname(input.canonicalPath)).catch(() => [])
if (
siblingNames.some(
(name) =>
name !== requestedName &&
name.toLocaleLowerCase('en-US') === requestedName.toLocaleLowerCase('en-US')
)
) {
return { kind: 'name-collision' }
}
const destinationStat = await lstat(input.canonicalPath).catch(() => null)
if (!destinationStat) {
return { kind: 'missing' }
}
if (destinationStat.isSymbolicLink()) {
return { kind: 'external-link' }
}
if (!destinationStat.isDirectory()) {
return { kind: 'name-collision' }
}
const observe = async (files?: readonly SkillInstalledFileMode[]): Promise<string | undefined> =>
filesystem
.observeSkill(input.canonicalPath, files)
.then((value) => value.observedDigest)
.catch(() => undefined)
const receiptDigest = input.receipt
? await observe(input.receipt.fileModes ?? input.manifest.files)
: undefined
if (input.receipt && receiptDigest === input.receipt.packageDigest) {
return receiptDigest === input.manifest.packageDigest
? { kind: 'unchanged', digest: receiptDigest }
: { kind: 'clean-update', digest: receiptDigest }
}
const requestedDigest = await observe(input.manifest.files)
if (requestedDigest === input.manifest.packageDigest) {
return { kind: 'unchanged', digest: requestedDigest }
}
if (!input.receipt) {
return requestedDigest ? { kind: 'unowned', digest: requestedDigest } : { kind: 'unowned' }
}
return requestedDigest ? { kind: 'modified', digest: requestedDigest } : { kind: 'modified' }
}
@@ -0,0 +1,42 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { listManagedSkillInstalls, writeSkillInstallReceipt } from './skill-install-provenance'
describe('managed skill install receipts', () => {
let root = ''
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-skill-receipts-test-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
it('lists bounded receipt metadata without exposing package contents', async () => {
await writeSkillInstallReceipt(root, {
schemaVersion: 1,
packageId: 'package-1',
versionId: 'version-1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
scope: 'global',
destinationIdentity: 'global:runtime-1',
canonicalPath: join(root, 'missing', 'example'),
placements: [],
installedAt: '2026-08-11T00:00:00.000Z',
hostIdentity: 'runtime-1'
})
await expect(listManagedSkillInstalls(root)).resolves.toEqual([
expect.objectContaining({
name: 'example',
packageId: 'package-1',
versionId: 'version-1',
state: 'missing'
})
])
})
})
+152
View File
@@ -0,0 +1,152 @@
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, open, readdir, readFile, rm } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
import type { ManagedSkillInstall } from '../../shared/skill-install-contract'
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
import type { SkillInstalledFileMode } from './skill-install-filesystem'
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
export type SkillInstallReceiptV1 = {
schemaVersion: 1
packageId: string
versionId: string
packageDigest: string
archiveSha256: string
scope: 'global' | 'workspace'
destinationIdentity: string
canonicalPath: string
placements: SkillPlacementResult[]
previousVersionId?: string
installedAt: string
hostIdentity: string
fileModes?: SkillInstalledFileMode[]
wslDistro?: string
}
export function skillInstallStateKey(canonicalPath: string): string {
const normalized =
process.platform === 'win32' ? resolve(canonicalPath).toLowerCase() : resolve(canonicalPath)
return createHash('sha256').update(normalized).digest('hex')
}
export function skillInstallReceiptPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'receipts', `${skillInstallStateKey(canonicalPath)}.json`)
}
export async function writeSkillStateFile(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`
const handle = await open(temporary, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally {
await handle.close()
}
try {
await renameSkillPathWithWindowsRetry(temporary, path)
} finally {
await rm(temporary, { force: true }).catch(() => undefined)
}
}
function isReceipt(value: unknown): value is SkillInstallReceiptV1 {
if (!value || typeof value !== 'object') {
return false
}
const receipt = value as Partial<SkillInstallReceiptV1>
return (
receipt.schemaVersion === 1 &&
typeof receipt.packageId === 'string' &&
typeof receipt.versionId === 'string' &&
typeof receipt.packageDigest === 'string' &&
typeof receipt.archiveSha256 === 'string' &&
(receipt.scope === 'global' || receipt.scope === 'workspace') &&
typeof receipt.destinationIdentity === 'string' &&
typeof receipt.canonicalPath === 'string' &&
Array.isArray(receipt.placements) &&
typeof receipt.installedAt === 'string' &&
typeof receipt.hostIdentity === 'string' &&
(receipt.fileModes === undefined ||
(Array.isArray(receipt.fileModes) &&
receipt.fileModes.every(
(file) => typeof file.path === 'string' && typeof file.executable === 'boolean'
))) &&
(receipt.wslDistro === undefined || typeof receipt.wslDistro === 'string')
)
}
export async function listManagedSkillInstalls(
stateDirectory: string,
options?: {
observeReceipt?: (receipt: SkillInstallReceiptV1) => Promise<{ observedDigest: string } | null>
}
): Promise<Omit<ManagedSkillInstall, 'destination'>[]> {
const receiptsDirectory = join(stateDirectory, 'receipts')
const entries = (await readdir(receiptsDirectory, { withFileTypes: true }).catch(() => []))
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.slice(0, 2048)
const installs = await Promise.all(
entries.map(async (entry): Promise<Omit<ManagedSkillInstall, 'destination'> | null> => {
const parsed = await readFile(join(receiptsDirectory, entry.name), 'utf8')
.then((value): unknown => JSON.parse(value))
.catch(() => null)
if (!isReceipt(parsed)) {
return null
}
const observed = options?.observeReceipt
? await options.observeReceipt(parsed).catch(() => null)
: await nativeSkillInstallFilesystem
.observeSkill(parsed.canonicalPath, parsed.fileModes)
.catch(() => null)
return {
name: basename(parsed.canonicalPath),
packageId: parsed.packageId,
versionId: parsed.versionId,
packageDigest: parsed.packageDigest,
scope: parsed.scope,
destinationIdentity: parsed.destinationIdentity,
installedAt: parsed.installedAt,
state: observed
? observed.observedDigest === parsed.packageDigest
? 'unchanged'
: 'modified'
: 'missing'
}
})
)
return installs
.filter((install): install is Omit<ManagedSkillInstall, 'destination'> => install !== null)
.sort((left, right) => right.installedAt.localeCompare(left.installedAt))
}
export async function readSkillInstallReceipt(
stateDirectory: string,
canonicalPath: string
): Promise<SkillInstallReceiptV1 | null> {
try {
const parsed: unknown = JSON.parse(
await readFile(skillInstallReceiptPath(stateDirectory, canonicalPath), 'utf8')
)
return isReceipt(parsed) && resolve(parsed.canonicalPath) === resolve(canonicalPath)
? parsed
: null
} catch {
return null
}
}
export async function writeSkillInstallReceipt(
stateDirectory: string,
receipt: SkillInstallReceiptV1
): Promise<void> {
await writeSkillStateFile(skillInstallReceiptPath(stateDirectory, receipt.canonicalPath), receipt)
}
export async function removeSkillInstallReceipt(
stateDirectory: string,
canonicalPath: string
): Promise<void> {
await rm(skillInstallReceiptPath(stateDirectory, canonicalPath), { force: true })
}
+138
View File
@@ -0,0 +1,138 @@
import { lstat, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import {
skillInstallStateKey,
writeSkillInstallReceipt,
type SkillInstallReceiptV1
} from './skill-install-provenance'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem,
type SkillInstalledFileMode
} from './skill-install-filesystem'
type InstallJournalPhase =
| 'prepared'
| 'backup-created'
| 'canonical-placed'
| 'receipt-published'
| 'complete'
export type SkillInstallJournalV1 = {
schemaVersion: 1
operation: 'install'
phase: InstallJournalPhase
canonicalPath: string
extractionPath: string
stagingPath: string
backupPath: string
receipt: SkillInstallReceiptV1
}
export function skillInstallJournalPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'journals', `${skillInstallStateKey(canonicalPath)}.json`)
}
export async function skillInstallPathExists(path: string): Promise<boolean> {
return Boolean(await lstat(path).catch(() => null))
}
function isInstallJournal(value: unknown, canonicalPath: string): value is SkillInstallJournalV1 {
if (!value || typeof value !== 'object') {
return false
}
const journal = value as Partial<SkillInstallJournalV1>
return (
journal.schemaVersion === 1 &&
journal.operation === 'install' &&
typeof journal.phase === 'string' &&
journal.canonicalPath === canonicalPath &&
typeof journal.extractionPath === 'string' &&
typeof journal.stagingPath === 'string' &&
typeof journal.backupPath === 'string' &&
Boolean(journal.receipt)
)
}
async function readJournal(
stateDirectory: string,
canonicalPath: string
): Promise<SkillInstallJournalV1 | null> {
try {
const value: unknown = JSON.parse(
await readFile(skillInstallJournalPath(stateDirectory, canonicalPath), 'utf8')
)
if (!isInstallJournal(value, canonicalPath)) {
throw new Error('skill-install-journal-invalid')
}
return value
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
export async function skillInstallDestinationMatches(
path: string,
digest: string,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem,
files?: readonly SkillInstalledFileMode[]
): Promise<boolean> {
try {
return (await filesystem.observeSkill(path, files)).observedDigest === digest
} catch {
return false
}
}
export async function cleanSkillInstallJournalFiles(
journal: SkillInstallJournalV1,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
): Promise<void> {
await filesystem.remove(journal.extractionPath)
await filesystem.remove(journal.stagingPath)
await filesystem.remove(journal.backupPath)
}
export async function recoverSkillInstallTransaction(
stateDirectory: string,
canonicalPath: string,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
): Promise<void> {
const journal = await readJournal(stateDirectory, canonicalPath)
if (!journal) {
return
}
const destinationExists = await skillInstallPathExists(canonicalPath)
const backupExists = await skillInstallPathExists(journal.backupPath)
const destinationIsRequested =
destinationExists &&
(await skillInstallDestinationMatches(
canonicalPath,
journal.receipt.packageDigest,
filesystem,
journal.receipt.fileModes
))
if (destinationIsRequested) {
await writeSkillInstallReceipt(stateDirectory, journal.receipt)
await cleanSkillInstallJournalFiles(journal, filesystem)
await rm(skillInstallJournalPath(stateDirectory, canonicalPath), { force: true })
return
}
if (!destinationExists && backupExists) {
await filesystem.rename(journal.backupPath, canonicalPath)
await filesystem.remove(journal.extractionPath)
await filesystem.remove(journal.stagingPath)
await rm(skillInstallJournalPath(stateDirectory, canonicalPath), { force: true })
return
}
if (journal.phase === 'prepared' && !backupExists) {
await cleanSkillInstallJournalFiles(journal, filesystem)
await rm(skillInstallJournalPath(stateDirectory, canonicalPath), { force: true })
return
}
throw new Error('skill-install-recovery-conflict')
}
@@ -0,0 +1,93 @@
import { createHash } from 'node:crypto'
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, vi } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import { executeSkillInstallRequest } from './skill-install-request-service'
import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-request-test-'))
roots.push(root)
const home = join(root, 'home')
const source = join(root, 'source')
await Promise.all([mkdir(home), mkdir(source)])
await writeFile(
join(source, 'SKILL.md'),
'---\nname: request-skill\ndescription: Request test\n---\n\n# Request\n'
)
const archive = await createSkillPackageArchive({
sourceDirectory: source,
archivePath: join(root, 'package.tar.gz'),
packageId: 'package_1',
versionId: 'version_1'
})
const archiveBytes = await readFile(archive.archivePath)
return {
root,
home,
archive,
request: {
operationId: 'operation_1',
package: {
packageId: archive.manifest.packageId,
versionId: archive.manifest.versionId,
packageDigest: archive.manifest.packageDigest,
archiveSha256: createHash('sha256').update(archiveBytes).digest('hex'),
compressedBytes: archiveBytes.length
},
ingress: {
kind: 'download-grant' as const,
url: 'https://storage.test/package.tar.gz',
expiresAt: new Date(Date.now() + 60_000).toISOString()
},
destination: { scope: 'global' as const, environmentId: 'environment_1' }
},
dependencies: {
authority: {
environmentId: 'environment_1',
homeDirectory: home,
resolveWorktree: async () => null,
resolveFolderWorkspace: async () => null
},
stateDirectory: join(root, 'state'),
allowedDownloadOrigins: ['https://storage.test'],
requireHttps: true,
fetcher: vi.fn(
async () =>
new Response(archiveBytes, {
headers: { 'content-type': SKILL_PACKAGE_CONTENT_TYPE }
})
) as typeof fetch,
detectProviders: async () => ['codex']
}
}
}
describe('executeSkillInstallRequest', () => {
it('downloads, verifies, and installs on the destination-owning host', async () => {
const { root, request, dependencies } = await fixture()
const result = await executeSkillInstallRequest(request, dependencies)
expect(result.status).toBe('installed')
expect(
await readFile(join(root, 'home', '.agents', 'skills', 'request-skill', 'SKILL.md'), 'utf8')
).toContain('# Request')
})
it('rejects local paths at the remote request boundary', async () => {
const { request, dependencies, archive } = await fixture()
await expect(
executeSkillInstallRequest(
{ ...request, ingress: { kind: 'local-file', path: archive.archivePath } },
dependencies
)
).rejects.toThrow('skill-install-local-ingress-rejected')
})
})
@@ -0,0 +1,107 @@
import { isAbsolute, join } from 'node:path'
import {
SkillInstallRequestSchema,
type SkillInstallRequest,
type SkillInstallResult
} from '../../shared/skill-install-contract'
import {
resolveSkillInstallDestination,
type SkillInstallDestinationAuthority
} from './skill-install-destinations'
import { installSharedSkill } from './skill-install-service'
import { downloadSkillPackageGrant } from './skill-package-download'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
type StagedSkillPackage = {
archivePath: string
cleanup(): Promise<void>
}
export type SkillInstallRequestDependencies = {
authority: SkillInstallDestinationAuthority
stateDirectory: string
allowedDownloadOrigins: readonly string[]
requireHttps: boolean
allowTrustedLocalFile?: boolean
signal?: AbortSignal
fetcher?: typeof fetch
detectProviders: () => Promise<readonly string[]>
resolveStagedUpload?: (
uploadId: string,
identity: SkillInstallRequest['package']
) => Promise<StagedSkillPackage>
}
async function resolveIngress(
request: SkillInstallRequest,
dependencies: SkillInstallRequestDependencies
): Promise<StagedSkillPackage> {
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-install-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 executeSkillInstallRequest(
input: unknown,
dependencies: SkillInstallRequestDependencies
): Promise<SkillInstallResult> {
const request = SkillInstallRequestSchema.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 installSharedSkill({
operationId: request.operationId,
archivePath: ingress.archivePath,
scope: destination.scope,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory,
orcaStateDirectory: dependencies.stateDirectory,
detectedProviders,
destinationIdentity: destination.destinationIdentity,
hostIdentity: dependencies.authority.environmentId,
expectedArchiveSha256: request.package.archiveSha256,
expectedPackageDigest: request.package.packageDigest,
expectedPackageId: request.package.packageId,
expectedVersionId: request.package.versionId,
conflictResolution: request.conflictResolution,
filesystem,
wslDistro: destination.wslDistro
})
} finally {
await ingress.cleanup()
}
}
@@ -0,0 +1,146 @@
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import {
installSharedSkill,
removeSharedSkill,
type SkillInstallServiceInput
} from './skill-install-service'
const temporaryDirectories: string[] = []
async function fixture(): Promise<{
root: string
input: SkillInstallServiceInput
}> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-service-test-'))
temporaryDirectories.push(root)
const source = join(root, 'source')
await mkdir(source)
await writeFile(
join(source, 'SKILL.md'),
'---\nname: test-skill\ndescription: Test\n---\n\n# Test\n'
)
const archive = await createSkillPackageArchive({
sourceDirectory: source,
archivePath: join(root, 'package.tar.gz'),
packageId: 'package_1',
versionId: 'version_1'
})
return {
root,
input: {
operationId: 'operation_1',
archivePath: archive.archivePath,
scope: 'global',
homeDirectory: join(root, 'home'),
orcaStateDirectory: join(root, 'orca-state'),
detectedProviders: ['codex', 'claude'],
destinationIdentity: 'global:test-host',
hostIdentity: 'test-host',
expectedArchiveSha256: archive.archiveSha256,
expectedPackageDigest: archive.manifest.packageDigest,
expectedPackageId: archive.manifest.packageId,
expectedVersionId: archive.manifest.versionId
}
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill install service', () => {
it('installs one canonical copy and aliases Claude to it', async () => {
const { root, input } = await fixture()
const result = await installSharedSkill(input)
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
expect(result.status).toBe('installed')
expect(result.placements).toHaveLength(2)
expect((await lstat(claude)).isSymbolicLink()).toBe(process.platform !== 'win32')
expect(await realpath(claude)).toBe(await realpath(canonical))
})
it('leaves an unowned provider copy untouched and reports partial coverage', async () => {
const { root, input } = await fixture()
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
await mkdir(claude, { recursive: true })
await writeFile(join(claude, 'SKILL.md'), 'unowned')
const result = await installSharedSkill(input)
expect(result.status).toBe('partial')
expect(result.placements.at(-1)).toMatchObject({
provider: 'claude',
status: 'skipped',
errorCategory: 'skill-placement-unowned'
})
expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toBe('unowned')
})
it('removes the canonical skill and its owned provider alias', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
const result = await removeSharedSkill({
operationId: 'remove_1',
skillName: 'test-skill',
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
detectedProviders: input.detectedProviders
})
expect(result.status).toBe('removed')
await expect(
lstat(join(root, 'home', '.agents', 'skills', 'test-skill'))
).rejects.toMatchObject({
code: 'ENOENT'
})
await expect(
lstat(join(root, 'home', '.claude', 'skills', 'test-skill'))
).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('preserves modified canonical and provider content during removal', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
await writeFile(join(canonical, 'local.md'), 'keep canonical')
const conflict = await removeSharedSkill({
operationId: 'remove_1',
skillName: 'test-skill',
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
detectedProviders: input.detectedProviders
})
expect(conflict.conflict?.kind).toBe('modified')
expect(await readFile(join(canonical, 'local.md'), 'utf8')).toBe('keep canonical')
await rm(claude, { force: true })
await mkdir(claude)
await writeFile(join(claude, 'local.md'), 'keep provider')
const removed = await removeSharedSkill({
operationId: 'remove_2',
skillName: 'test-skill',
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
detectedProviders: input.detectedProviders,
conflictResolution: 'replace-and-discard-local'
})
expect(removed.status).toBe('partial')
expect(await readFile(join(claude, 'local.md'), 'utf8')).toBe('keep provider')
})
})
+146
View File
@@ -0,0 +1,146 @@
import { join } from 'node:path'
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance'
import {
installLocalSkillPackage,
previewLocalSkillPackage,
type LocalSkillInstallInput
} from './skill-install-transaction'
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import type { SkillInstallFilesystem } from './skill-install-filesystem'
export type SkillInstallServiceInput = Omit<
LocalSkillInstallInput,
'destinationRoot' | 'stateDirectory' | 'scope'
> & {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
filesystem?: SkillInstallFilesystem
wslDistro?: string
}
export type SkillRemoveServiceInput = {
operationId: string
skillName: string
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
conflictResolution?: 'replace-and-discard-local' | 'cancel'
filesystem?: SkillInstallFilesystem
}
function canonicalRoot(input: {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
}): string {
const scopeRoot = input.scope === 'global' ? input.homeDirectory : input.workspaceDirectory
if (!scopeRoot) {
throw new Error('skill-install-workspace-required')
}
return join(scopeRoot, '.agents', 'skills')
}
function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput {
return {
operationId: input.operationId,
archivePath: input.archivePath,
destinationRoot: canonicalRoot(input),
stateDirectory: join(input.orcaStateDirectory, 'skill-installs'),
scope: input.scope,
destinationIdentity: input.destinationIdentity,
hostIdentity: input.hostIdentity,
expectedArchiveSha256: input.expectedArchiveSha256,
expectedPackageDigest: input.expectedPackageDigest,
expectedPackageId: input.expectedPackageId,
expectedVersionId: input.expectedVersionId,
conflictResolution: input.conflictResolution,
filesystem: input.filesystem,
wslDistro: input.wslDistro
}
}
function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult {
return {
provider: 'agent-skills',
path: result.canonicalPath!,
topology: 'canonical-copy',
status: result.status === 'unchanged' ? 'unchanged' : 'installed'
}
}
export async function installSharedSkill(
input: SkillInstallServiceInput
): Promise<SkillInstallResult> {
const request = localInput(input)
const preview = await previewLocalSkillPackage(request)
const previousReceipt = await readSkillInstallReceipt(
request.stateDirectory,
preview.canonicalPath
)
const result = await installLocalSkillPackage(request)
if (!result.canonicalPath || result.status === 'conflict' || result.status === 'failed') {
return result
}
const placements: SkillPlacementResult[] = [canonicalPlacement(result)]
const destinations = resolveSkillProviderDestinations({
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
detectedProviders: input.detectedProviders
})
for (const destination of destinations) {
const placement = await reconcileSkillProviderPlacement({
canonicalPath: result.canonicalPath,
skillName: result.name,
destination,
previousReceipt,
packageDigest: result.packageDigest,
fileModes: preview.manifest.files,
filesystem: input.filesystem
})
if (placement) {
placements.push(placement)
}
}
const receipt = await readSkillInstallReceipt(request.stateDirectory, result.canonicalPath)
if (!receipt) {
throw new Error('skill-install-receipt-missing')
}
await writeSkillInstallReceipt(request.stateDirectory, { ...receipt, placements })
const incomplete = placements.some(
(placement) => placement.status === 'failed' || placement.status === 'skipped'
)
return { ...result, status: incomplete ? 'partial' : result.status, placements }
}
export async function removeSharedSkill(
input: SkillRemoveServiceInput
): Promise<SkillInstallResult> {
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(input.skillName)) {
throw new Error('skill-install-name-invalid')
}
const providerDestinations = resolveSkillProviderDestinations({
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
detectedProviders: input.detectedProviders
})
return removeLocalSharedSkill({
operationId: input.operationId,
canonicalPath: join(canonicalRoot(input), input.skillName),
stateDirectory: join(input.orcaStateDirectory, 'skill-installs'),
allowedProviderRoots: providerDestinations
.filter((destination) => !destination.readsCanonicalRoot)
.map((destination) => destination.rootPath),
conflictResolution: input.conflictResolution,
filesystem: input.filesystem
})
}
@@ -0,0 +1,118 @@
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 { createSkillPackageArchive } from './skill-package-creation'
import {
installLocalSkillPackage,
previewLocalSkillPackage,
type LocalSkillInstallInput
} from './skill-install-transaction'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-install-test-'))
temporaryDirectories.push(directory)
return directory
}
async function packageVersion(root: string, version: string, body: string) {
const source = join(root, `source-${version}`)
await mkdir(source, { recursive: true })
await writeFile(
join(source, 'SKILL.md'),
`---\nname: test-skill\ndescription: Test skill\n---\n\n${body}\n`
)
return createSkillPackageArchive({
sourceDirectory: source,
archivePath: join(root, `${version}.tar.gz`),
packageId: 'package_1',
versionId: version,
createdAt: '2026-08-11T12:00:00.000Z'
})
}
function installInput(
root: string,
archive: Awaited<ReturnType<typeof packageVersion>>,
overrides: Partial<LocalSkillInstallInput> = {}
): LocalSkillInstallInput {
return {
operationId: `operation-${archive.manifest.versionId}`,
archivePath: archive.archivePath,
destinationRoot: join(root, 'skills'),
stateDirectory: join(root, 'state'),
scope: 'global',
destinationIdentity: 'global:test-host',
hostIdentity: 'test-host',
expectedArchiveSha256: archive.archiveSha256,
expectedPackageDigest: archive.manifest.packageDigest,
expectedPackageId: archive.manifest.packageId,
expectedVersionId: archive.manifest.versionId,
...overrides
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill install transaction', () => {
it('installs, previews unchanged bytes, and updates a clean receipt-owned skill', async () => {
const root = await temporaryDirectory()
const first = await packageVersion(root, 'version_1', '# First')
const second = await packageVersion(root, 'version_2', '# Second')
const installed = await installLocalSkillPackage(installInput(root, first))
expect(installed.status).toBe('installed')
expect(await readFile(join(root, 'skills', 'test-skill', 'SKILL.md'), 'utf8')).toContain(
'# First'
)
const preview = await previewLocalSkillPackage(installInput(root, first))
expect(preview.currentState.kind).toBe('unchanged')
expect((await installLocalSkillPackage(installInput(root, first))).status).toBe('unchanged')
const updated = await installLocalSkillPackage(installInput(root, second))
expect(updated.status).toBe('updated')
expect(await readFile(join(root, 'skills', 'test-skill', 'SKILL.md'), 'utf8')).toContain(
'# Second'
)
})
it('refuses to overwrite a modified receipt-owned installation', async () => {
const root = await temporaryDirectory()
const first = await packageVersion(root, 'version_1', '# First')
const second = await packageVersion(root, 'version_2', '# Second')
await installLocalSkillPackage(installInput(root, first))
await writeFile(join(root, 'skills', 'test-skill', 'local.md'), 'local change')
const result = await installLocalSkillPackage(installInput(root, second))
expect(result.status).toBe('conflict')
expect(result.conflict?.kind).toBe('modified')
expect(await readFile(join(root, 'skills', 'test-skill', 'local.md'), 'utf8')).toBe(
'local change'
)
})
it('requires explicit replacement for an unowned directory', async () => {
const root = await temporaryDirectory()
const archive = await packageVersion(root, 'version_1', '# Cloud')
const destination = join(root, 'skills', 'test-skill')
await mkdir(destination, { recursive: true })
await writeFile(join(destination, 'SKILL.md'), 'unowned')
const conflict = await installLocalSkillPackage(installInput(root, archive))
expect(conflict.conflict?.kind).toBe('unowned')
const replaced = await installLocalSkillPackage(
installInput(root, archive, { conflictResolution: 'replace-and-discard-local' })
)
expect(replaced.status).toBe('installed')
expect(await readFile(join(destination, 'SKILL.md'), 'utf8')).toContain('# Cloud')
})
})
@@ -0,0 +1,279 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import type { SkillInstallResult } from '../../shared/skill-install-contract'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
import { acquireSkillInstallLock } from './skill-install-lock'
import { inspectSkillCanonicalState, type SkillCanonicalState } from './skill-install-planner'
import {
readSkillInstallReceipt,
skillInstallStateKey,
writeSkillInstallReceipt,
writeSkillStateFile,
type SkillInstallReceiptV1
} from './skill-install-provenance'
import { extractSkillPackageArchive } from './skill-package-extraction'
import { recoverSkillRemovalTransaction } from './skill-remove-transaction'
import {
cleanSkillInstallJournalFiles,
recoverSkillInstallTransaction,
skillInstallDestinationMatches,
skillInstallJournalPath,
skillInstallPathExists,
type SkillInstallJournalV1
} from './skill-install-recovery'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem
} from './skill-install-filesystem'
export { recoverSkillInstallTransaction } from './skill-install-recovery'
export type LocalSkillInstallInput = {
operationId: string
archivePath: string
destinationRoot: string
stateDirectory: string
scope: 'global' | 'workspace'
destinationIdentity: string
hostIdentity: string
expectedArchiveSha256?: string
expectedPackageDigest?: string
expectedPackageId?: string
expectedVersionId?: string
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
filesystem?: SkillInstallFilesystem
wslDistro?: string
}
export type LocalSkillInstallPreview = {
manifest: SkillPackageManifestV1
canonicalPath: string
currentState: SkillCanonicalState
}
function lockPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
}
function conflictResult(
operationId: string,
manifest: SkillPackageManifestV1,
state: SkillCanonicalState
): SkillInstallResult {
const kind =
state.kind === 'modified' ||
state.kind === 'unowned' ||
state.kind === 'external-link' ||
state.kind === 'name-collision'
? state.kind
: 'modified'
return {
operationId,
status: 'conflict',
name: manifest.name,
packageDigest: manifest.packageDigest,
placements: [],
conflict: {
kind,
...('digest' in state && state.digest ? { existingDigest: state.digest } : {})
}
}
}
async function inspectExtractedPackage(input: LocalSkillInstallInput): Promise<{
extractionPath: string
manifest: SkillPackageManifestV1
archiveSha256: string
}> {
await mkdir(input.destinationRoot, { recursive: true })
const extractionPath = join(input.destinationRoot, `.orca-skill-extract-${randomUUID()}`)
const extracted = await extractSkillPackageArchive({
archivePath: input.archivePath,
destinationDirectory: extractionPath,
expectedArchiveSha256: input.expectedArchiveSha256,
expectedPackageDigest: input.expectedPackageDigest,
expectedPackageId: input.expectedPackageId,
expectedVersionId: input.expectedVersionId,
filesystem: input.filesystem
})
return { extractionPath, manifest: extracted.manifest, archiveSha256: extracted.archiveSha256 }
}
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
})
}
} finally {
await (input.filesystem ?? nativeSkillInstallFilesystem).remove(extracted.extractionPath)
}
}
function replacementAllowed(state: SkillCanonicalState, input: LocalSkillInstallInput): boolean {
if (state.kind === 'missing' || state.kind === 'clean-update') {
return input.conflictResolution !== 'cancel'
}
return (
(state.kind === 'modified' || state.kind === 'unowned') &&
input.conflictResolution === 'replace-and-discard-local'
)
}
function createReceipt(input: {
request: LocalSkillInstallInput
manifest: SkillPackageManifestV1
archiveSha256: string
canonicalPath: string
previous: SkillInstallReceiptV1 | null
}): SkillInstallReceiptV1 {
return {
schemaVersion: 1,
packageId: input.manifest.packageId,
versionId: input.manifest.versionId,
packageDigest: input.manifest.packageDigest,
archiveSha256: input.archiveSha256,
scope: input.request.scope,
destinationIdentity: input.request.destinationIdentity,
canonicalPath: input.canonicalPath,
placements: [
{
provider: 'agent-skills',
path: input.canonicalPath,
topology: 'canonical-copy',
status: 'installed'
}
],
...(input.previous ? { previousVersionId: input.previous.versionId } : {}),
installedAt: new Date().toISOString(),
hostIdentity: input.request.hostIdentity,
fileModes: input.manifest.files.map((file) => ({
path: file.path,
executable: file.executable
})),
...(input.request.wslDistro ? { wslDistro: input.request.wslDistro } : {})
}
}
export async function installLocalSkillPackage(
input: LocalSkillInstallInput
): Promise<SkillInstallResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const extracted = await inspectExtractedPackage(input)
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
const releaseLock = await acquireSkillInstallLock({
path: lockPath(input.stateDirectory, canonicalPath)
})
let journal: SkillInstallJournalV1 | null = null
try {
await recoverSkillRemovalTransaction(input.stateDirectory, canonicalPath, filesystem)
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem)
const previous = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
const state = await inspectSkillCanonicalState({
canonicalPath,
manifest: extracted.manifest,
receipt: previous,
filesystem
})
const receipt = createReceipt({
request: input,
manifest: extracted.manifest,
archiveSha256: extracted.archiveSha256,
canonicalPath,
previous
})
if (state.kind === 'unchanged') {
await writeSkillInstallReceipt(input.stateDirectory, receipt)
return {
operationId: input.operationId,
status: 'unchanged',
name: extracted.manifest.name,
packageDigest: extracted.manifest.packageDigest,
canonicalPath,
placements: receipt.placements.map((placement) => ({
...placement,
status: 'unchanged'
}))
}
}
if (!replacementAllowed(state, input)) {
return conflictResult(input.operationId, extracted.manifest, state)
}
const transactionId = randomUUID()
const stagingPath = join(
input.destinationRoot,
`.${extracted.manifest.name}.orca-staging-${transactionId}`
)
const backupPath = join(
input.destinationRoot,
`.${extracted.manifest.name}.orca-backup-${transactionId}`
)
await filesystem.rename(join(extracted.extractionPath, 'skill'), stagingPath)
journal = {
schemaVersion: 1,
operation: 'install',
phase: 'prepared',
canonicalPath,
extractionPath: extracted.extractionPath,
stagingPath,
backupPath,
receipt
}
const statePath = skillInstallJournalPath(input.stateDirectory, canonicalPath)
await writeSkillStateFile(statePath, journal)
if (await skillInstallPathExists(canonicalPath)) {
await filesystem.rename(canonicalPath, backupPath)
journal.phase = 'backup-created'
await writeSkillStateFile(statePath, journal)
}
await filesystem.rename(stagingPath, canonicalPath)
journal.phase = 'canonical-placed'
await writeSkillStateFile(statePath, journal)
if (
!(await skillInstallDestinationMatches(
canonicalPath,
extracted.manifest.packageDigest,
filesystem,
extracted.manifest.files
))
) {
throw new Error('skill-install-committed-digest-mismatch')
}
await writeSkillInstallReceipt(input.stateDirectory, receipt)
journal.phase = 'receipt-published'
await writeSkillStateFile(statePath, journal)
await cleanSkillInstallJournalFiles(journal, filesystem)
journal.phase = 'complete'
await writeSkillStateFile(statePath, journal)
await rm(statePath, { force: true })
return {
operationId: input.operationId,
status: previous ? 'updated' : 'installed',
name: extracted.manifest.name,
packageDigest: extracted.manifest.packageDigest,
canonicalPath,
placements: receipt.placements
}
} catch (error) {
if (journal) {
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem).catch(
() => undefined
)
}
throw error
} finally {
await filesystem.remove(extracted.extractionPath)
await releaseLock()
}
}
@@ -0,0 +1,111 @@
import { chmod, 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 { createSkillPackageArchive } from './skill-package-creation'
import { extractSkillPackageArchive } from './skill-package-extraction'
const temporaryDirectories: string[] = []
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-package-test-'))
temporaryDirectories.push(directory)
return directory
}
async function createSkill(root: string): Promise<string> {
const skill = join(root, 'test-skill')
await mkdir(join(skill, 'scripts'), { recursive: true })
await writeFile(
join(skill, 'SKILL.md'),
'---\nname: test-skill\ndescription: A private test skill\n---\n\n# Test\n'
)
await writeFile(join(skill, 'scripts', 'run.sh'), '#!/bin/sh\necho test\n')
await chmod(join(skill, 'scripts', 'run.sh'), 0o755)
return skill
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill package creation and extraction', () => {
it('round trips a validated skill and preserves executable identity', async () => {
const root = await temporaryDirectory()
const sourceDirectory = await createSkill(root)
const archivePath = join(root, 'package.tar.gz')
const created = await createSkillPackageArchive({
sourceDirectory,
archivePath,
packageId: 'package_1',
versionId: 'version_1',
createdAt: '2026-08-11T12:00:00.000Z'
})
const extracted = await extractSkillPackageArchive({
archivePath,
destinationDirectory: join(root, 'extracted'),
expectedArchiveSha256: created.archiveSha256,
expectedPackageDigest: created.manifest.packageDigest
})
expect(extracted.manifest).toEqual(created.manifest)
expect(extracted.manifest.files.map((file) => [file.path, file.executable])).toEqual([
['SKILL.md', false],
['scripts/run.sh', true]
])
expect(await readFile(join(extracted.skillDirectory, 'scripts', 'run.sh'), 'utf8')).toContain(
'echo test'
)
})
it('creates deterministic archives for fixed publication metadata', async () => {
const root = await temporaryDirectory()
const sourceDirectory = await createSkill(root)
const input = {
sourceDirectory,
packageId: 'package_1',
versionId: 'version_1',
createdAt: '2026-08-11T12:00:00.000Z'
}
const first = await createSkillPackageArchive({
...input,
archivePath: join(root, 'one.tar.gz')
})
const second = await createSkillPackageArchive({
...input,
archivePath: join(root, 'two.tar.gz')
})
expect(first.archiveSha256).toBe(second.archiveSha256)
expect(await readFile(first.archivePath)).toEqual(await readFile(second.archivePath))
})
it('rejects archive and package identity mismatches before publishing extraction', async () => {
const root = await temporaryDirectory()
const sourceDirectory = await createSkill(root)
const created = await createSkillPackageArchive({
sourceDirectory,
archivePath: join(root, 'package.tar.gz'),
packageId: 'package_1',
versionId: 'version_1'
})
await expect(
extractSkillPackageArchive({
archivePath: created.archivePath,
destinationDirectory: join(root, 'wrong-package'),
expectedPackageDigest: 'f'.repeat(64)
})
).rejects.toThrow('skill-package-identity-mismatch')
await expect(
extractSkillPackageArchive({
archivePath: created.archivePath,
destinationDirectory: join(root, 'wrong-archive'),
expectedArchiveSha256: 'f'.repeat(64)
})
).rejects.toThrow('skill-package-archive-digest-mismatch')
})
})
+134
View File
@@ -0,0 +1,134 @@
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 {
computeSkillPackageDigest,
parseSkillPackageManifest,
type SkillPackageManifestV1
} from '../../shared/skill-package-manifest'
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity'
import { extractSkillPackageArchive } from './skill-package-extraction'
import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar'
export type CreatedSkillPackage = {
manifest: SkillPackageManifestV1
archivePath: string
archiveSha256: string
compressedBytes: number
}
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 packageManifest(input: {
packageId: string
versionId: string
createdAt: string
name: string
description: string
observed: ObservedSkillPackage
}): SkillPackageManifestV1 {
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 parseSkillPackageManifest({
schemaVersion: 1,
packageId: input.packageId,
versionId: input.versionId,
name: input.name,
description: input.description,
createdAt: input.createdAt,
files,
packageDigest: computeSkillPackageDigest(files)
})
}
export async function createSkillPackageArchive(input: {
sourceDirectory: string
archivePath: string
packageId: string
versionId: string
createdAt?: string
}): Promise<CreatedSkillPackage> {
const sourceObservation = await observeSkillPackage(input.sourceDirectory)
const workDirectory = await mkdtemp(join(tmpdir(), 'orca-skill-package-'))
const stagedSkill = join(workDirectory, 'skill')
const verificationDirectory = join(workDirectory, 'verification')
const temporaryArchive = `${input.archivePath}.${process.pid}.${randomUUID()}.tmp`
try {
await cp(input.sourceDirectory, stagedSkill, {
recursive: true,
verbatimSymlinks: true,
force: false,
errorOnExist: true
})
const stagedObservation = await observeSkillPackage(stagedSkill)
if (!observationsMatch(sourceObservation, stagedObservation)) {
throw new Error('skill-package-source-changed-during-staging')
}
const summary = summarizeSkillMarkdown(await readFile(join(stagedSkill, 'SKILL.md'), 'utf8'))
if (!summary.name) {
throw new Error('skill-package-skill-name-required')
}
const manifest = packageManifest({
packageId: input.packageId,
versionId: input.versionId,
createdAt: input.createdAt ?? new Date().toISOString(),
name: summary.name,
description: summary.description ?? '',
observed: stagedObservation
})
const manifestBytes = Buffer.from(JSON.stringify(manifest), 'utf8')
const entries: SkillTarWriteEntry[] = [
{
path: 'manifest.json',
size: manifestBytes.length,
executable: false,
bytes: manifestBytes
},
...manifest.files.map((file) => ({
path: `skill/${file.path}`,
size: file.size,
executable: file.executable,
sourcePath: join(stagedSkill, ...file.path.split('/'))
}))
]
await mkdir(dirname(input.archivePath), { recursive: true })
const archiveIdentity = await writeSkillTarGzip(temporaryArchive, entries)
await extractSkillPackageArchive({
archivePath: temporaryArchive,
destinationDirectory: verificationDirectory,
expectedArchiveSha256: archiveIdentity.archiveSha256,
expectedPackageDigest: manifest.packageDigest,
expectedPackageId: manifest.packageId,
expectedVersionId: manifest.versionId
})
await renameSkillPathWithWindowsRetry(temporaryArchive, input.archivePath)
return { manifest, archivePath: input.archivePath, ...archiveIdentity }
} finally {
await rm(workDirectory, { recursive: true, force: true })
await rm(temporaryArchive, { force: true }).catch(() => undefined)
}
}
@@ -0,0 +1,124 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest'
import { downloadSkillPackageGrant } from './skill-package-download'
const roots: string[] = []
const bytes = Buffer.from('private skill package')
const digest = createHash('sha256').update(bytes).digest('hex')
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function temporaryRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-download-test-'))
roots.push(root)
return root
}
function response(body: BodyInit | null, init: ResponseInit = {}): Response {
const headers = new Headers(init.headers)
if (body && !headers.has('content-type')) {
headers.set('content-type', SKILL_PACKAGE_CONTENT_TYPE)
}
return new Response(body, { ...init, headers })
}
function fetcher(implementation: (url: string) => Promise<Response>): typeof fetch {
return vi.fn(async (input) => implementation(String(input))) as typeof fetch
}
async function input(overrides: Record<string, unknown> = {}) {
return {
url: 'https://storage.test/package.tar.gz?signature=private',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
expectedArchiveSha256: digest,
expectedCompressedBytes: bytes.length,
temporaryRoot: await temporaryRoot(),
allowedOrigins: ['https://storage.test'],
requireHttps: true,
fetcher: fetcher(async () => response(bytes)),
...overrides
}
}
describe('downloadSkillPackageGrant', () => {
it('streams a verified package into an owner-private temporary file', async () => {
const result = await downloadSkillPackageGrant(await input())
expect(await readFile(result.archivePath)).toEqual(bytes)
expect(result.archiveSha256).toBe(digest)
expect(result.compressedBytes).toBe(bytes.length)
await result.cleanup()
await expect(readFile(result.archivePath)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects untrusted origins and insecure grant URLs before fetch', async () => {
const untrusted = await input({ url: 'https://attacker.test/package.tar.gz' })
await expect(downloadSkillPackageGrant(untrusted)).rejects.toThrow(
'skill-download-origin-rejected'
)
expect(untrusted.fetcher).not.toHaveBeenCalled()
const insecure = await input({
url: 'http://storage.test/package.tar.gz',
allowedOrigins: ['http://storage.test']
})
await expect(downloadSkillPackageGrant(insecure)).rejects.toThrow('skill-download-url-rejected')
})
it('allows same-origin redirects but rejects signed cross-origin redirects', async () => {
const sameOriginFetch = fetcher(async (url) =>
url.includes('/first')
? response(null, { status: 307, headers: { location: '/second' } })
: response(bytes)
)
const result = await downloadSkillPackageGrant(
await input({
url: 'https://storage.test/first?signature=private',
fetcher: sameOriginFetch
})
)
expect(sameOriginFetch).toHaveBeenCalledTimes(2)
await result.cleanup()
const crossOriginFetch = fetcher(async () =>
response(null, {
status: 307,
headers: { location: 'https://other.test/package.tar.gz' }
})
)
await expect(
downloadSkillPackageGrant(
await input({
fetcher: crossOriginFetch,
allowedOrigins: ['https://storage.test', 'https://other.test']
})
)
).rejects.toThrow('skill-download-cross-origin-redirect')
})
it('deletes partial bytes after size and digest failures', async () => {
const sizeInput = await input({
expectedCompressedBytes: bytes.length - 1,
fetcher: fetcher(async () => response(bytes))
})
await expect(downloadSkillPackageGrant(sizeInput)).rejects.toThrow('skill-download-size-limit')
expect(await readdir(sizeInput.temporaryRoot)).toEqual([])
const digestInput = await input({ expectedArchiveSha256: '0'.repeat(64) })
await expect(downloadSkillPackageGrant(digestInput)).rejects.toThrow(
'skill-download-archive-digest-mismatch'
)
expect(await readdir(digestInput.temporaryRoot)).toEqual([])
})
it('rejects expired grants without network access', async () => {
const expired = await input({ expiresAt: new Date(Date.now() - 1).toISOString() })
await expect(downloadSkillPackageGrant(expired)).rejects.toThrow('skill-download-grant-expired')
expect(expired.fetcher).not.toHaveBeenCalled()
})
})
+199
View File
@@ -0,0 +1,199 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises'
import { join } from 'node:path'
import {
SKILL_PACKAGE_CONTENT_TYPE,
SKILL_PACKAGE_MAX_COMPRESSED_BYTES
} from '../../shared/skill-package-manifest'
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
const MAX_REDIRECTS = 3
export type SkillPackageDownloadResult = {
archivePath: string
archiveSha256: string
compressedBytes: number
cleanup(): Promise<void>
}
type SkillPackageDownloadInput = {
url: string
expiresAt: string
expectedArchiveSha256: string
expectedCompressedBytes: number
temporaryRoot: string
allowedOrigins: readonly string[]
requireHttps: boolean
signal?: AbortSignal
fetcher?: typeof fetch
now?: () => number
}
function validateIdentity(input: SkillPackageDownloadInput): void {
if (!/^[a-f0-9]{64}$/.test(input.expectedArchiveSha256)) {
throw new Error('skill-download-archive-identity-invalid')
}
if (
!Number.isInteger(input.expectedCompressedBytes) ||
input.expectedCompressedBytes < 1 ||
input.expectedCompressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
) {
throw new Error('skill-download-size-invalid')
}
}
function validateUrl(
value: string,
allowedOrigins: ReadonlySet<string>,
requireHttps: boolean
): URL {
const url = new URL(value)
if (url.username || url.password || (requireHttps && url.protocol !== 'https:')) {
throw new Error('skill-download-url-rejected')
}
if (!allowedOrigins.has(url.origin)) {
throw new Error('skill-download-origin-rejected')
}
return url
}
function throwIfUnavailable(input: SkillPackageDownloadInput, expiresAt: number): void {
if (input.signal?.aborted) {
throw new Error('skill-download-cancelled')
}
if (input.now!() >= expiresAt) {
throw new Error('skill-download-grant-expired')
}
}
async function fetchWithoutCredentialRedirect(
input: SkillPackageDownloadInput,
allowedOrigins: ReadonlySet<string>,
expiresAt: number
): Promise<Response> {
let url = validateUrl(input.url, allowedOrigins, input.requireHttps)
for (let redirectCount = 0; ; redirectCount += 1) {
throwIfUnavailable(input, expiresAt)
let response: Response
try {
response = await input.fetcher!(url, {
method: 'GET',
redirect: 'manual',
signal: input.signal
})
} catch {
if (input.signal?.aborted) {
throw new Error('skill-download-cancelled')
}
throw new Error('skill-download-transport-failed')
}
if (!REDIRECT_STATUSES.has(response.status)) {
return response
}
if (redirectCount >= MAX_REDIRECTS) {
throw new Error('skill-download-redirect-limit')
}
const location = response.headers.get('location')
if (!location) {
throw new Error('skill-download-redirect-invalid')
}
const redirected = validateUrl(new URL(location, url).href, allowedOrigins, input.requireHttps)
if (redirected.origin !== url.origin) {
throw new Error('skill-download-cross-origin-redirect')
}
url = redirected
}
}
function hashesEqual(actual: string, expected: string): boolean {
return timingSafeEqual(Buffer.from(actual, 'hex'), Buffer.from(expected, 'hex'))
}
export async function downloadSkillPackageGrant(
input: SkillPackageDownloadInput
): Promise<SkillPackageDownloadResult> {
input.fetcher ??= fetch
input.now ??= Date.now
validateIdentity(input)
const expiresAt = Date.parse(input.expiresAt)
if (!Number.isFinite(expiresAt)) {
throw new Error('skill-download-grant-expiry-invalid')
}
const allowedOrigins = new Set(
input.allowedOrigins.map(
(origin) => validateUrl(origin, new Set([new URL(origin).origin]), false).origin
)
)
throwIfUnavailable(input, expiresAt)
const response = await fetchWithoutCredentialRedirect(input, allowedOrigins, expiresAt)
if (!response.ok || !response.body) {
throw new Error('skill-download-transport-failed')
}
if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) {
throw new Error('skill-download-content-type-invalid')
}
const contentLength = response.headers.get('content-length')
if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
await mkdir(input.temporaryRoot, { recursive: true, mode: 0o700 })
const temporaryDirectory = await mkdtemp(join(input.temporaryRoot, '.orca-skill-download-'))
const archivePath = join(temporaryDirectory, 'package.tar.gz')
try {
const handle = await open(archivePath, 'wx', 0o600)
const reader = response.body.getReader()
const hash = createHash('sha256')
let compressedBytes = 0
try {
for (;;) {
throwIfUnavailable(input, expiresAt)
const chunk = await reader.read()
if (chunk.done) {
break
}
compressedBytes += chunk.value.byteLength
if (
compressedBytes > input.expectedCompressedBytes ||
compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
) {
throw new Error('skill-download-size-limit')
}
hash.update(chunk.value)
let offset = 0
while (offset < chunk.value.byteLength) {
const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset)
if (written.bytesWritten === 0) {
throw new Error('skill-download-write-failed')
}
offset += written.bytesWritten
}
}
await handle.sync()
} catch (error) {
await reader.cancel().catch(() => undefined)
if (input.signal?.aborted) {
throw new Error('skill-download-cancelled')
}
throw error
} finally {
await handle.close()
}
if (compressedBytes !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
const archiveSha256 = hash.digest('hex')
if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) {
throw new Error('skill-download-archive-digest-mismatch')
}
return {
archivePath,
archiveSha256,
compressedBytes,
cleanup: () => rm(temporaryDirectory, { recursive: true, force: true })
}
} catch (error) {
await rm(temporaryDirectory, { recursive: true, force: true })
throw error
}
}
+202
View File
@@ -0,0 +1,202 @@
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import {
SKILL_PACKAGE_MAX_MANIFEST_BYTES,
parseSkillPackageManifest,
type SkillPackageFile,
type SkillPackageManifestV1
} from '../../shared/skill-package-manifest'
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
import type { ObservedSkillPackage } from './skill-package-identity'
import {
openSkillTarGzip,
parseSkillTarHeader,
SKILL_TAR_BLOCK_BYTES,
type TarByteReader
} from './skill-package-tar'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem
} from './skill-install-filesystem'
export type SkillPackageExtractionResult = {
manifest: SkillPackageManifestV1
skillDirectory: string
archiveSha256: string
compressedBytes: number
}
async function consumeZeroPadding(reader: TarByteReader, size: number): Promise<void> {
const padding = (SKILL_TAR_BLOCK_BYTES - (size % SKILL_TAR_BLOCK_BYTES)) % SKILL_TAR_BLOCK_BYTES
if (padding === 0) {
return
}
if (!(await reader.readExact(padding)).every((byte) => byte === 0)) {
throw new Error('skill-package-tar-padding-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 chunkOffset = 0
while (chunkOffset < bytes.length) {
const result = await handle.write(
bytes,
chunkOffset,
bytes.length - chunkOffset,
offset + chunkOffset
)
if (result.bytesWritten === 0) {
throw new Error('skill-package-extraction-write-failed')
}
chunkOffset += result.bytesWritten
}
offset += bytes.length
}
} finally {
await handle.close()
}
if (hash.digest('hex') !== expected.sha256) {
throw new Error('skill-package-file-digest-mismatch')
}
await consumeZeroPadding(reader, expected.size)
}
function observedFilesMatch(
observed: ObservedSkillPackage,
manifest: SkillPackageManifestV1
): boolean {
return (
observed.observedDigest === manifest.packageDigest &&
observed.files.length === manifest.files.length &&
observed.files.every((actual, index) => {
const expected = manifest.files[index]
return (
actual.path === expected.path &&
actual.size === expected.size &&
actual.executable === expected.executable &&
actual.classification === expected.classification &&
actual.exactSha256 === expected.sha256 &&
actual.identitySha256 === expected.identitySha256
)
})
)
}
async function readManifest(reader: TarByteReader): Promise<SkillPackageManifestV1> {
const header = parseSkillTarHeader(await reader.readExact(SKILL_TAR_BLOCK_BYTES))
if (
!header ||
header.path !== 'manifest.json' ||
header.executable ||
header.size > SKILL_PACKAGE_MAX_MANIFEST_BYTES
) {
throw new Error('skill-package-manifest-envelope-invalid')
}
const bytes = await reader.readExact(header.size)
await consumeZeroPadding(reader, header.size)
try {
return parseSkillPackageManifest(JSON.parse(bytes.toString('utf8')))
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error('skill-package-manifest-invalid')
}
throw error
}
}
async function requireArchiveEnd(reader: TarByteReader): Promise<void> {
for (let index = 0; index < 2; index += 1) {
const block = await reader.readExact(SKILL_TAR_BLOCK_BYTES)
if (!block.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')
}
}
}
export async function extractSkillPackageArchive(input: {
archivePath: string
destinationDirectory: string
expectedArchiveSha256?: string
expectedPackageDigest?: string
expectedPackageId?: string
expectedVersionId?: string
filesystem?: SkillInstallFilesystem
}): Promise<SkillPackageExtractionResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const archive = await openSkillTarGzip(input.archivePath)
let destinationCreated = false
try {
const manifest = await readManifest(archive.reader)
if (
(input.expectedPackageDigest && manifest.packageDigest !== input.expectedPackageDigest) ||
(input.expectedPackageId && manifest.packageId !== input.expectedPackageId) ||
(input.expectedVersionId && manifest.versionId !== input.expectedVersionId)
) {
throw new Error('skill-package-identity-mismatch')
}
await mkdir(input.destinationDirectory, { mode: 0o700 })
destinationCreated = true
const skillDirectory = join(input.destinationDirectory, 'skill')
await mkdir(skillDirectory, { mode: 0o700 })
for (const expected of manifest.files) {
const header = parseSkillTarHeader(await archive.reader.readExact(SKILL_TAR_BLOCK_BYTES))
if (
!header ||
header.path !== `skill/${expected.path}` ||
header.size !== expected.size ||
header.executable !== expected.executable
) {
throw new Error('skill-package-file-envelope-mismatch')
}
await extractFile(archive.reader, join(skillDirectory, ...expected.path.split('/')), expected)
}
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 filesystem.prepareExtractedSkill(skillDirectory, manifest)
const observed = await filesystem.observeSkill(skillDirectory, manifest.files)
if (!observedFilesMatch(observed, manifest)) {
throw new Error('skill-package-extracted-identity-mismatch')
}
const summary = summarizeSkillMarkdown(await readFile(join(skillDirectory, 'SKILL.md'), 'utf8'))
if (summary.name !== manifest.name) {
throw new Error('skill-package-skill-name-mismatch')
}
return { manifest, skillDirectory, ...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
}
}
+5 -2
View File
@@ -166,7 +166,8 @@ function matchesFileIdentity(
export async function observeSkillPackage(
packageRoot: string,
limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS
limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS,
executablePaths?: ReadonlySet<string>
): Promise<ObservedSkillPackage> {
const files: ObservedSkillFile[] = []
const treeEntries: SkillGitTreeFileEntry[] = []
@@ -242,7 +243,9 @@ export async function observeSkillPackage(
limits.maximumSingleFileBytes
)
totalBytes += bytes.length
const executable = (fileStat.mode & 0o111) !== 0
const executable = executablePaths
? executablePaths.has(manifestPath)
: (fileStat.mode & 0o111) !== 0
files.push(describeObservedSkillFile(manifestPath, bytes, executable))
treeEntries.push({ path: manifestPath, executable, blobSha: gitBlobSha(bytes) })
} else {
+277
View File
@@ -0,0 +1,277 @@
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream } from 'node:fs'
import { once } from 'node:events'
import type { Readable, Writable } from 'node:stream'
import { Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { createGunzip, createGzip } from 'node:zlib'
import { SKILL_PACKAGE_MAX_COMPRESSED_BYTES } from '../../shared/skill-package-manifest'
const TAR_BLOCK_BYTES = 512
const TAR_EXPANDED_LIMIT = 40 * 1024 * 1024
export type SkillTarWriteEntry = {
path: string
size: number
executable: boolean
sourcePath?: string
bytes?: Buffer
}
export type SkillTarReadEntry = {
path: string
size: number
executable: boolean
}
function writeField(header: Buffer, offset: number, length: number, value: string): void {
const bytes = Buffer.from(value, 'utf8')
if (bytes.length > length) {
throw new Error('skill-package-tar-path-limit')
}
bytes.copy(header, offset)
}
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
const encoded = value.toString(8).padStart(length - 1, '0')
if (encoded.length > length - 1) {
throw new Error('skill-package-tar-number-limit')
}
writeField(header, offset, length, `${encoded}\0`)
}
function splitTarPath(path: string): { name: string; prefix: string } {
if (Buffer.byteLength(path, 'utf8') <= 100) {
return { name: path, prefix: '' }
}
for (let index = path.lastIndexOf('/'); index > 0; index = path.lastIndexOf('/', index - 1)) {
const prefix = path.slice(0, index)
const name = path.slice(index + 1)
if (Buffer.byteLength(prefix, 'utf8') <= 155 && Buffer.byteLength(name, 'utf8') <= 100) {
return { name, prefix }
}
}
throw new Error('skill-package-tar-path-limit')
}
function tarHeader(entry: SkillTarWriteEntry): Buffer {
const header = Buffer.alloc(TAR_BLOCK_BYTES)
const { name, prefix } = splitTarPath(entry.path)
writeField(header, 0, 100, name)
writeOctal(header, 100, 8, entry.executable ? 0o755 : 0o644)
writeOctal(header, 108, 8, 0)
writeOctal(header, 116, 8, 0)
writeOctal(header, 124, 12, entry.size)
writeOctal(header, 136, 12, 0)
header.fill(0x20, 148, 156)
header[156] = '0'.charCodeAt(0)
writeField(header, 257, 6, 'ustar')
writeField(header, 263, 2, '00')
writeField(header, 345, 155, prefix)
const checksum = header.reduce((sum, byte) => sum + byte, 0)
const checksumText = checksum.toString(8).padStart(6, '0')
writeField(header, 148, 8, `${checksumText}\0 `)
return header
}
async function writeWithBackpressure(stream: Writable, bytes: Buffer): Promise<void> {
if (!stream.write(bytes)) {
await once(stream, 'drain')
}
}
export async function writeSkillTarGzip(
archivePath: string,
entries: readonly SkillTarWriteEntry[]
): Promise<{ archiveSha256: string; compressedBytes: number }> {
const archiveHash = createHash('sha256')
let compressedBytes = 0
const gzip = createGzip({ level: 9 })
const hashTransform = new Transform({
transform(chunk: Buffer, _encoding, callback) {
compressedBytes += chunk.length
if (compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES) {
callback(new Error('skill-package-compressed-size-limit'))
return
}
archiveHash.update(chunk)
callback(null, chunk)
}
})
const sink = pipeline(
gzip,
hashTransform,
createWriteStream(archivePath, { flags: 'wx', mode: 0o600 })
)
try {
for (const entry of entries) {
await writeWithBackpressure(gzip, tarHeader(entry))
if (entry.bytes) {
if (entry.bytes.length !== entry.size) {
throw new Error('skill-package-source-size-mismatch')
}
await writeWithBackpressure(gzip, entry.bytes)
} else if (entry.sourcePath) {
let written = 0
for await (const chunk of createReadStream(entry.sourcePath)) {
written += chunk.length
if (written > entry.size) {
throw new Error('skill-package-source-size-mismatch')
}
await writeWithBackpressure(gzip, chunk)
}
if (written !== entry.size) {
throw new Error('skill-package-source-size-mismatch')
}
} else {
throw new Error('skill-package-source-required')
}
const padding = (TAR_BLOCK_BYTES - (entry.size % TAR_BLOCK_BYTES)) % TAR_BLOCK_BYTES
if (padding > 0) {
await writeWithBackpressure(gzip, Buffer.alloc(padding))
}
}
await writeWithBackpressure(gzip, Buffer.alloc(TAR_BLOCK_BYTES * 2))
gzip.end()
await sink
return { archiveSha256: archiveHash.digest('hex'), compressedBytes }
} catch (error) {
gzip.destroy(error instanceof Error ? error : new Error(String(error)))
await sink.catch(() => undefined)
throw error
}
}
export class TarByteReader {
private readonly iterator: AsyncIterator<Buffer>
private current: Buffer<ArrayBufferLike> = Buffer.alloc(0)
private currentOffset = 0
private consumed = 0
constructor(stream: Readable) {
this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator<Buffer>
}
async readExact(length: number): Promise<Buffer> {
const bytes = await this.readExactOrNull(length)
if (!bytes) {
throw new Error('skill-package-tar-truncated')
}
return bytes
}
async readExactOrNull(length: number): Promise<Buffer | null> {
const result = Buffer.alloc(length)
let written = 0
while (written < length) {
if (this.currentOffset >= this.current.length) {
const next = await this.iterator.next()
if (next.done) {
if (written === 0) {
return null
}
throw new Error('skill-package-tar-truncated')
}
this.current = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value)
this.currentOffset = 0
}
const available = Math.min(length - written, this.current.length - this.currentOffset)
this.current.copy(result, written, this.currentOffset, this.currentOffset + available)
this.currentOffset += available
written += available
this.consumed += available
if (this.consumed > TAR_EXPANDED_LIMIT) {
throw new Error('skill-package-expanded-size-limit')
}
}
return result
}
}
function readString(header: Buffer, offset: number, length: number): string {
const end = header.indexOf(0, offset)
return header
.subarray(offset, end === -1 || end > offset + length ? offset + length : end)
.toString('utf8')
}
function readOctal(header: Buffer, offset: number, length: number): number {
const value = readString(header, offset, length).trim()
if (!/^[0-7]+$/.test(value)) {
throw new Error('skill-package-tar-number-invalid')
}
const parsed = Number.parseInt(value, 8)
if (!Number.isSafeInteger(parsed)) {
throw new Error('skill-package-tar-number-invalid')
}
return parsed
}
function validateTarChecksum(header: Buffer): void {
const expected = readOctal(header, 148, 8)
const copy = Buffer.from(header)
copy.fill(0x20, 148, 156)
if (copy.reduce((sum, byte) => sum + byte, 0) !== expected) {
throw new Error('skill-package-tar-checksum-invalid')
}
}
export function parseSkillTarHeader(header: Buffer): SkillTarReadEntry | null {
if (header.length !== TAR_BLOCK_BYTES) {
throw new Error('skill-package-tar-truncated')
}
if (header.every((byte) => byte === 0)) {
return null
}
validateTarChecksum(header)
const type = header[156]
if (type !== 0 && type !== '0'.charCodeAt(0)) {
throw new Error('skill-package-tar-entry-type')
}
const name = readString(header, 0, 100)
const prefix = readString(header, 345, 155)
const path = prefix ? `${prefix}/${name}` : name
if (!path) {
throw new Error('skill-package-tar-path-invalid')
}
const mode = readOctal(header, 100, 8)
return { path, size: readOctal(header, 124, 12), executable: (mode & 0o111) !== 0 }
}
export async function openSkillTarGzip(archivePath: string): Promise<{
reader: TarByteReader
archiveIdentity: Promise<{ archiveSha256: string; compressedBytes: number }>
abort: (error: Error) => void
}> {
const archiveHash = createHash('sha256')
let compressedBytes = 0
const source = createReadStream(archivePath)
const verifier = new Transform({
transform(chunk: Buffer, _encoding, callback) {
compressedBytes += chunk.length
if (compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES) {
callback(new Error('skill-package-compressed-size-limit'))
return
}
archiveHash.update(chunk)
callback(null, chunk)
}
})
const gunzip = createGunzip()
const completion = pipeline(source, verifier, gunzip)
const archiveIdentity = completion.then(() => ({
archiveSha256: archiveHash.digest('hex'),
compressedBytes
}))
return {
reader: new TarByteReader(gunzip),
archiveIdentity,
abort: (error) => {
source.destroy(error)
verifier.destroy(error)
gunzip.destroy(error)
}
}
}
export const SKILL_TAR_BLOCK_BYTES = TAR_BLOCK_BYTES
@@ -0,0 +1,302 @@
import { randomUUID } from 'node:crypto'
import { cp, lstat, mkdir, readdir, realpath, symlink } from 'node:fs/promises'
import { dirname, join, relative, resolve } from 'node:path'
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
import type { SkillProviderDestination } from './skill-provider-destinations'
import {
nativeSkillInstallFilesystem,
type SkillInstalledFileMode,
type SkillInstallFilesystem
} from './skill-install-filesystem'
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
async function pathExists(path: string): Promise<boolean> {
return Boolean(await lstat(path).catch(() => null))
}
function previousPlacement(
receipt: SkillInstallReceiptV1 | null,
provider: string,
path: string
): SkillPlacementResult | null {
return (
receipt?.placements.find(
(placement) =>
placement.provider === provider && normalizedPath(placement.path) === normalizedPath(path)
) ?? null
)
}
async function createProviderAlias(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem
): Promise<void> {
if (filesystem.createAlias) {
await filesystem.createAlias(canonicalPath, destinationPath)
return
}
const parent = dirname(destinationPath)
await mkdir(parent, { recursive: true })
if (process.platform === 'win32') {
await symlink(resolve(canonicalPath), destinationPath, 'junction')
return
}
const realParent = await realpath(parent).catch(() => resolve(parent))
const realCanonical = await realpath(canonicalPath)
await symlink(relative(realParent, realCanonical), destinationPath, 'dir')
}
async function createVerifiedCopy(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
fileModes?: readonly SkillInstalledFileMode[]
): Promise<void> {
const temporary = `${destinationPath}.orca-copy-${randomUUID()}`
try {
await cp(canonicalPath, temporary, { recursive: true, verbatimSymlinks: true })
const [source, copied] = await Promise.all([
filesystem.observeSkill(canonicalPath, fileModes),
filesystem.observeSkill(temporary, fileModes)
])
if (source.observedDigest !== copied.observedDigest) {
throw new Error('skill-placement-copy-digest-mismatch')
}
await filesystem.rename(temporary, destinationPath)
} finally {
await filesystem.remove(temporary)
}
}
async function replaceOwnedCopy(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
fileModes?: readonly SkillInstalledFileMode[]
): Promise<void> {
const replacement = `${destinationPath}.orca-copy-${randomUUID()}`
const backup = `${destinationPath}.orca-backup-${randomUUID()}`
try {
await createVerifiedCopy(canonicalPath, replacement, filesystem, fileModes)
await filesystem.rename(destinationPath, backup)
try {
await filesystem.rename(replacement, destinationPath)
} catch (error) {
await filesystem.rename(backup, destinationPath)
throw error
}
await filesystem.remove(backup)
} finally {
await filesystem.remove(replacement)
}
}
async function reconcileExistingPlacement(input: {
canonicalPath: string
destinationPath: string
destination: SkillProviderDestination
previousReceipt: SkillInstallReceiptV1 | null
packageDigest: string
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
}): Promise<SkillPlacementResult> {
if (input.filesystem.aliasTargets) {
return (await input.filesystem.aliasTargets(input.canonicalPath, input.destinationPath))
? {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'unchanged'
}
: {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'skipped',
errorCategory: 'skill-placement-unowned-link'
}
}
const stat = await lstat(input.destinationPath)
const previous = previousPlacement(
input.previousReceipt,
input.destination.provider,
input.destinationPath
)
if (stat.isSymbolicLink()) {
const target = await realpath(input.destinationPath).catch(() => null)
if (target && normalizedPath(target) === normalizedPath(input.canonicalPath)) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'unchanged'
}
}
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'skipped',
errorCategory: 'skill-placement-unowned-link'
}
}
if (stat.isDirectory() && previous?.topology === 'independent-copy') {
const observed = await input.filesystem
.observeSkill(input.destinationPath, input.previousReceipt?.fileModes)
.catch(() => null)
if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'skipped',
errorCategory: 'skill-placement-modified-copy'
}
}
if (observed.observedDigest === input.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'unchanged'
}
}
await replaceOwnedCopy(
input.canonicalPath,
input.destinationPath,
input.filesystem,
input.fileModes
)
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'installed'
}
}
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'skipped',
errorCategory: 'skill-placement-unowned'
}
}
async function createMissingPlacement(input: {
canonicalPath: string
destinationPath: string
destination: SkillProviderDestination
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
}): Promise<SkillPlacementResult> {
try {
await createProviderAlias(input.canonicalPath, input.destinationPath, input.filesystem)
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'installed'
}
} catch {
if (input.filesystem.createAlias) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'provider-alias',
status: 'failed',
errorCategory: 'skill-placement-create-failed'
}
}
try {
await createVerifiedCopyAtMissingDestination(
input.canonicalPath,
input.destinationPath,
input.filesystem,
input.fileModes
)
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'installed'
}
} catch {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'failed',
errorCategory: 'skill-placement-create-failed'
}
}
}
}
async function createVerifiedCopyAtMissingDestination(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
fileModes?: readonly SkillInstalledFileMode[]
): Promise<void> {
await mkdir(dirname(destinationPath), { recursive: true })
await mkdir(destinationPath, { mode: 0o700 })
let complete = false
try {
for (const entry of await readdir(canonicalPath)) {
await cp(join(canonicalPath, entry), join(destinationPath, entry), {
recursive: true,
verbatimSymlinks: true,
force: false,
errorOnExist: true
})
}
const [source, copied] = await Promise.all([
filesystem.observeSkill(canonicalPath, fileModes),
filesystem.observeSkill(destinationPath, fileModes)
])
if (source.observedDigest !== copied.observedDigest) {
throw new Error('skill-placement-copy-digest-mismatch')
}
complete = true
} finally {
if (!complete) {
await filesystem.remove(destinationPath)
}
}
}
export async function reconcileSkillProviderPlacement(input: {
canonicalPath: string
skillName: string
destination: SkillProviderDestination
previousReceipt: SkillInstallReceiptV1 | null
packageDigest: string
filesystem?: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
}): Promise<SkillPlacementResult | null> {
if (input.destination.readsCanonicalRoot) {
return null
}
const destinationPath = join(input.destination.rootPath, input.skillName)
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
try {
return (await pathExists(destinationPath))
? await reconcileExistingPlacement({ ...input, destinationPath, filesystem })
: await createMissingPlacement({ ...input, destinationPath, filesystem })
} catch {
return {
provider: input.destination.provider,
path: destinationPath,
topology: 'independent-copy',
status: 'failed',
errorCategory: 'skill-placement-reconciliation-failed'
}
}
}
@@ -0,0 +1,44 @@
import { join } from 'node:path'
export type OrcaSkillProviderId = 'codex' | 'claude'
export type SkillProviderDestination = {
provider: OrcaSkillProviderId
readsCanonicalRoot: boolean
rootPath: string
}
const SUPPORTED_PROVIDER_IDS = new Set<OrcaSkillProviderId>(['codex', 'claude'])
export function resolveSkillProviderDestinations(input: {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
detectedProviders: readonly string[]
}): SkillProviderDestination[] {
const detected = new Set(
input.detectedProviders.filter((provider): provider is OrcaSkillProviderId =>
SUPPORTED_PROVIDER_IDS.has(provider as OrcaSkillProviderId)
)
)
const scopeRoot = input.scope === 'global' ? input.homeDirectory : input.workspaceDirectory
if (!scopeRoot) {
throw new Error('skill-install-workspace-required')
}
const destinations: SkillProviderDestination[] = []
if (detected.has('codex')) {
destinations.push({
provider: 'codex',
readsCanonicalRoot: true,
rootPath: join(scopeRoot, '.agents', 'skills')
})
}
if (detected.has('claude')) {
destinations.push({
provider: 'claude',
readsCanonicalRoot: false,
rootPath: join(scopeRoot, '.claude', 'skills')
})
}
return destinations
}
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillInstallRequest, SkillInstallResult } from '../../shared/skill-install-contract'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
transferSkillPackageToRuntime: vi.fn()
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
vi.mock('./skill-client-mediated-transfer', () => ({
transferSkillPackageToRuntime: mocks.transferSkillPackageToRuntime
}))
import { installSkillOnRemoteRuntime } from './skill-remote-install-service'
const request: SkillInstallRequest = {
operationId: 'operation-1',
package: {
packageId: 'package-1',
versionId: 'version-1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 12
},
ingress: {
kind: 'download-grant',
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
expiresAt: '2099-01-01T00:00:00.000Z'
},
destination: { scope: 'global' }
}
const result: SkillInstallResult = {
operationId: request.operationId,
status: 'installed',
name: 'example',
packageDigest: request.package.packageDigest,
placements: []
}
function success(value: unknown) {
return { id: 'rpc-1', ok: true, result: value, _meta: { runtimeId: 'runtime-1' } }
}
describe('installSkillOnRemoteRuntime', () => {
beforeEach(() => {
mocks.callRuntimeEnvironment.mockReset()
mocks.transferSkillPackageToRuntime.mockReset()
})
it('uses direct remote download when the runtime can reach storage', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue(success(result))
await expect(
installSkillOnRemoteRuntime({
userDataPath: '/state',
environmentId: 'environment-1',
request,
capabilities: ['skills.install.v1', 'skills.upload.v1'],
requireHttps: true
})
).resolves.toEqual(result)
expect(mocks.transferSkillPackageToRuntime).not.toHaveBeenCalled()
})
it('falls back to a staged client transfer and always cleans it up', 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(result))
mocks.transferSkillPackageToRuntime.mockResolvedValue({ uploadId: 'upload-1', cleanup })
await expect(
installSkillOnRemoteRuntime({
userDataPath: '/state',
environmentId: 'environment-1',
request,
capabilities: ['skills.install.v1', 'skills.upload.v1'],
requireHttps: true
})
).resolves.toEqual(result)
expect(mocks.callRuntimeEnvironment.mock.calls[1]?.[3]).toMatchObject({
ingress: { kind: 'staged-upload', uploadId: 'upload-1' }
})
expect(cleanup).toHaveBeenCalledOnce()
})
it('does not call upload RPCs when the runtime lacks the upload capability', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue({
id: 'rpc-1',
ok: false,
error: { code: 'runtime_error', message: 'skill-download-transport-failed' }
})
await expect(
installSkillOnRemoteRuntime({
userDataPath: '/state',
environmentId: 'environment-1',
request,
capabilities: ['skills.install.v1'],
requireHttps: true
})
).rejects.toThrow('skill-install-remote-download-unavailable')
expect(mocks.transferSkillPackageToRuntime).not.toHaveBeenCalled()
})
it('cleans the staged upload when remote installation fails', async () => {
const cleanup = vi.fn(async () => undefined)
mocks.callRuntimeEnvironment
.mockResolvedValueOnce({
id: 'rpc-1',
ok: false,
error: { code: 'skill-download-transport-failed', message: 'unavailable' }
})
.mockResolvedValueOnce({
id: 'rpc-2',
ok: false,
error: { code: 'runtime_error', message: 'install failed' }
})
mocks.transferSkillPackageToRuntime.mockResolvedValue({ uploadId: 'upload-1', cleanup })
await expect(
installSkillOnRemoteRuntime({
userDataPath: '/state',
environmentId: 'environment-1',
request,
capabilities: ['skills.install.v1', 'skills.upload.v1'],
requireHttps: true
})
).rejects.toThrow('skill-install-remote-runtime_error')
expect(cleanup).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,76 @@
import type { RuntimeCapability } from '../../shared/protocol-version'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import {
SkillInstallResultSchema,
type SkillInstallRequest,
type SkillInstallResult
} from '../../shared/skill-install-contract'
import { SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed'
async function install(
userDataPath: string,
environmentId: string,
request: SkillInstallRequest
): Promise<RuntimeRpcResponse<unknown>> {
return (await callRuntimeEnvironment(
userDataPath,
environmentId,
'skills.install',
request,
5 * 60_000
)) as RuntimeRpcResponse<unknown>
}
function isDownloadTransportFailure(response: RuntimeRpcResponse<unknown>): boolean {
return (
response.ok === false &&
(response.error.code === DIRECT_DOWNLOAD_FAILURE ||
response.error.message === DIRECT_DOWNLOAD_FAILURE)
)
}
export async function installSkillOnRemoteRuntime(input: {
userDataPath: string
environmentId: string
request: SkillInstallRequest
capabilities: readonly RuntimeCapability[]
requireHttps: boolean
}): Promise<SkillInstallResult> {
if (input.request.ingress.kind !== 'download-grant') {
throw new Error('skill-install-remote-ingress-invalid')
}
const direct = await install(input.userDataPath, input.environmentId, input.request)
if (!isDownloadTransportFailure(direct)) {
if (direct.ok !== true) {
throw new Error(`skill-install-remote-${direct.error.code}`)
}
return SkillInstallResultSchema.parse(direct.result)
}
if (!input.capabilities.includes(SKILL_UPLOAD_CAPABILITY)) {
throw new Error('skill-install-remote-download-unavailable')
}
const transfer = await transferSkillPackageToRuntime({
userDataPath: input.userDataPath,
environmentId: input.environmentId,
package: input.request.package,
grant: input.request.ingress,
requireHttps: input.requireHttps
})
try {
const staged = await install(input.userDataPath, input.environmentId, {
...input.request,
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
})
if (staged.ok !== true) {
throw new Error(`skill-install-remote-${staged.error.code}`)
}
return SkillInstallResultSchema.parse(staged.result)
} finally {
await transfer.cleanup().catch(() => undefined)
}
}
@@ -0,0 +1,68 @@
import { lstat, readlink } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
import type { SkillInstallFilesystem } from './skill-install-filesystem'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
function pathInside(root: string, path: string): boolean {
const child = relative(resolve(root), resolve(path))
return (
child !== '' &&
child !== '..' &&
!child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) &&
!isAbsolute(child)
)
}
async function aliasTargetsCanonical(path: string, canonicalPath: string): Promise<boolean> {
const target = await readlink(path).catch(() => null)
return Boolean(
target && normalizedPath(resolve(dirname(path), target)) === normalizedPath(canonicalPath)
)
}
export async function isRemovableSkillPlacement(input: {
placement: SkillPlacementResult
receipt: SkillInstallReceiptV1
allowedProviderRoots: readonly string[]
filesystem: SkillInstallFilesystem
}): Promise<boolean> {
if (input.placement.topology === 'canonical-copy') {
return false
}
if (
!input.allowedProviderRoots.some(
(root) =>
pathInside(root, input.placement.path) &&
normalizedPath(input.placement.path) ===
normalizedPath(join(root, basename(input.receipt.canonicalPath)))
)
) {
return false
}
if (input.placement.topology === 'provider-alias' && input.filesystem.aliasTargets) {
return input.filesystem.aliasTargets(input.receipt.canonicalPath, input.placement.path)
}
const stat = await lstat(input.placement.path).catch(() => null)
if (!stat) {
return false
}
if (input.placement.topology === 'provider-alias') {
return (
stat.isSymbolicLink() &&
aliasTargetsCanonical(input.placement.path, input.receipt.canonicalPath)
)
}
if (!stat.isDirectory()) {
return false
}
const observed = await input.filesystem
.observeSkill(input.placement.path, input.receipt.fileModes)
.catch(() => null)
return observed?.observedDigest === input.receipt.packageDigest
}
+271
View File
@@ -0,0 +1,271 @@
import { randomUUID } from 'node:crypto'
import { lstat, readFile, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
import { acquireSkillInstallLock } from './skill-install-lock'
import {
readSkillInstallReceipt,
removeSkillInstallReceipt,
skillInstallStateKey,
writeSkillStateFile,
type SkillInstallReceiptV1
} from './skill-install-provenance'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem
} from './skill-install-filesystem'
import { isRemovableSkillPlacement } from './skill-removable-placement'
type RemovalMove = {
sourcePath: string
backupPath: string
placement: SkillPlacementResult
}
type SkillRemovalJournalV1 = {
schemaVersion: 1
operation: 'remove'
phase: 'prepared' | 'moving' | 'receipt-removed'
canonicalPath: string
movedCount: number
moves: RemovalMove[]
}
export type LocalSkillRemovalInput = {
operationId: string
canonicalPath: string
stateDirectory: string
allowedProviderRoots: readonly string[]
conflictResolution?: 'replace-and-discard-local' | 'cancel'
filesystem?: SkillInstallFilesystem
}
function journalPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'removal-journals', `${skillInstallStateKey(canonicalPath)}.json`)
}
function lockPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
}
function validBackup(move: RemovalMove): boolean {
return (
dirname(move.sourcePath) === dirname(move.backupPath) &&
basename(move.backupPath).startsWith(`.${basename(move.sourcePath)}.orca-remove-backup-`)
)
}
function isRemovalJournal(value: unknown, canonicalPath: string): value is SkillRemovalJournalV1 {
if (!value || typeof value !== 'object') {
return false
}
const journal = value as Partial<SkillRemovalJournalV1>
return (
journal.schemaVersion === 1 &&
journal.operation === 'remove' &&
journal.canonicalPath === canonicalPath &&
(journal.phase === 'prepared' ||
journal.phase === 'moving' ||
journal.phase === 'receipt-removed') &&
Number.isInteger(journal.movedCount) &&
Array.isArray(journal.moves) &&
journal.moves.every(validBackup) &&
journal.movedCount! >= 0 &&
journal.movedCount! <= journal.moves.length
)
}
async function readJournal(
stateDirectory: string,
canonicalPath: string
): Promise<SkillRemovalJournalV1 | null> {
try {
const value: unknown = JSON.parse(
await readFile(journalPath(stateDirectory, canonicalPath), 'utf8')
)
if (!isRemovalJournal(value, canonicalPath)) {
throw new Error('skill-removal-journal-invalid')
}
return value
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
async function pathExists(path: string): Promise<boolean> {
return Boolean(await lstat(path).catch(() => null))
}
export async function recoverSkillRemovalTransaction(
stateDirectory: string,
canonicalPath: string,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
): Promise<void> {
const journal = await readJournal(stateDirectory, canonicalPath)
if (!journal) {
return
}
if (journal.phase === 'receipt-removed') {
for (const move of journal.moves.slice(0, journal.movedCount)) {
await filesystem.remove(move.backupPath)
}
await rm(journalPath(stateDirectory, canonicalPath), { force: true })
return
}
for (const move of journal.moves.slice(0, journal.movedCount).toReversed()) {
if ((await pathExists(move.backupPath)) && !(await pathExists(move.sourcePath))) {
await filesystem.rename(move.backupPath, move.sourcePath)
}
}
await rm(journalPath(stateDirectory, canonicalPath), { force: true })
}
function conflictResult(
input: LocalSkillRemovalInput,
receipt: SkillInstallReceiptV1 | null,
kind: 'modified' | 'unowned' | 'external-link' | 'name-collision'
): SkillInstallResult {
return {
operationId: input.operationId,
status: 'conflict',
name: basename(input.canonicalPath),
packageDigest: receipt?.packageDigest ?? '',
canonicalPath: input.canonicalPath,
placements: [],
conflict: { kind }
}
}
async function inspectCanonicalRemoval(
input: LocalSkillRemovalInput,
receipt: SkillInstallReceiptV1 | null
): Promise<'missing' | 'owned' | 'modified' | 'unowned' | 'external-link' | 'name-collision'> {
const stat = await lstat(input.canonicalPath).catch(() => null)
if (!stat) {
return receipt ? 'missing' : 'unowned'
}
if (!receipt) {
return 'unowned'
}
if (stat.isSymbolicLink()) {
return 'external-link'
}
if (!stat.isDirectory()) {
return 'name-collision'
}
const observed = await (input.filesystem ?? nativeSkillInstallFilesystem)
.observeSkill(input.canonicalPath, receipt.fileModes)
.catch(() => null)
return observed?.observedDigest === receipt.packageDigest ? 'owned' : 'modified'
}
export async function removeLocalSharedSkill(
input: LocalSkillRemovalInput
): Promise<SkillInstallResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const releaseLock = await acquireSkillInstallLock({
path: lockPath(input.stateDirectory, input.canonicalPath)
})
try {
await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem)
const receipt = await readSkillInstallReceipt(input.stateDirectory, input.canonicalPath)
const state = await inspectCanonicalRemoval(input, receipt)
if (!receipt) {
return conflictResult(input, null, 'unowned')
}
if (state === 'unowned' || state === 'external-link' || state === 'name-collision') {
return conflictResult(input, receipt, state)
}
if (state === 'modified' && input.conflictResolution !== 'replace-and-discard-local') {
return conflictResult(input, receipt, 'modified')
}
const removedPlacements: SkillPlacementResult[] = []
const skippedPlacements: SkillPlacementResult[] = []
const moves: RemovalMove[] = []
for (const placement of receipt.placements) {
if (placement.topology === 'canonical-copy') {
continue
}
if (
await isRemovableSkillPlacement({
placement,
receipt,
allowedProviderRoots: input.allowedProviderRoots,
filesystem
})
) {
moves.push({
sourcePath: placement.path,
backupPath: join(
dirname(placement.path),
`.${basename(placement.path)}.orca-remove-backup-${randomUUID()}`
),
placement
})
} else if (await pathExists(placement.path)) {
skippedPlacements.push({
...placement,
status: 'skipped',
errorCategory: 'skill-removal-placement-modified-or-unowned'
})
}
}
if (state !== 'missing') {
moves.push({
sourcePath: input.canonicalPath,
backupPath: join(
dirname(input.canonicalPath),
`.${basename(input.canonicalPath)}.orca-remove-backup-${randomUUID()}`
),
placement: {
provider: 'agent-skills',
path: input.canonicalPath,
topology: 'canonical-copy',
status: 'installed'
}
})
}
const journal: SkillRemovalJournalV1 = {
schemaVersion: 1,
operation: 'remove',
phase: 'prepared',
canonicalPath: input.canonicalPath,
movedCount: 0,
moves
}
const statePath = journalPath(input.stateDirectory, input.canonicalPath)
await writeSkillStateFile(statePath, journal)
for (const move of moves) {
journal.movedCount += 1
journal.phase = 'moving'
await writeSkillStateFile(statePath, journal)
await filesystem.rename(move.sourcePath, move.backupPath)
removedPlacements.push({ ...move.placement, status: 'removed' })
}
await removeSkillInstallReceipt(input.stateDirectory, input.canonicalPath)
journal.phase = 'receipt-removed'
await writeSkillStateFile(statePath, journal)
await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem)
return {
operationId: input.operationId,
status: skippedPlacements.length > 0 ? 'partial' : 'removed',
name: basename(input.canonicalPath),
packageDigest: receipt.packageDigest,
canonicalPath: input.canonicalPath,
placements: [...removedPlacements, ...skippedPlacements]
}
} catch (error) {
await recoverSkillRemovalTransaction(
input.stateDirectory,
input.canonicalPath,
filesystem
).catch(() => undefined)
throw error
} finally {
await releaseLock()
}
}
@@ -0,0 +1,12 @@
import { SKILL_MANAGEMENT_CAPABILITY } from '../../shared/skill-install-capability'
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
export async function supportsSkillRuntimeManagement(
userDataPath: string,
environmentId: string
): Promise<boolean> {
const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000)
return (
status.ok === true && status.result.capabilities?.includes(SKILL_MANAGEMENT_CAPABILITY) === true
)
}
@@ -0,0 +1,145 @@
import { randomUUID } from 'node:crypto'
import { mkdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import type {
SkillSharePreview,
SkillShareProgress,
SkillSharePublishInput,
SkillSharePublishOperation
} from '../../shared/skill-sharing-contract'
import { createSkillPackageArchive, type CreatedSkillPackage } from './skill-package-creation'
import type { SkillCloudService } from './skill-cloud-service'
const PREPARATION_TTL_MS = 30 * 60 * 1000
const MAX_PREPARATIONS = 8
type Preparation = {
created: CreatedSkillPackage
expiresAt: number
controller: AbortController | null
}
function preview(id: string, value: Preparation): SkillSharePreview {
const manifest = value.created.manifest
return {
preparationId: id,
packageId: manifest.packageId,
versionId: manifest.versionId,
name: manifest.name,
description: manifest.description,
packageDigest: manifest.packageDigest,
archiveSha256: value.created.archiveSha256,
fileCount: manifest.files.length,
totalBytes: manifest.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),
expiresAt: new Date(value.expiresAt).toISOString()
}
}
export class SkillSharePreparationService {
private readonly preparations = new Map<string, Preparation>()
constructor(
private readonly root: string,
private readonly cloud: Pick<SkillCloudService, 'publish'>
) {}
async prepare(input: {
sourceDirectory: string
packageId?: string
}): Promise<SkillSharePreview> {
await this.prune()
if (this.preparations.size >= MAX_PREPARATIONS) {
throw new Error('skill-share-preparation-limit')
}
const preparationId = randomUUID()
const directory = join(this.root, preparationId)
await mkdir(directory, { recursive: true, mode: 0o700 })
try {
const created = await createSkillPackageArchive({
sourceDirectory: input.sourceDirectory,
archivePath: join(directory, 'package.tar.gz'),
packageId: input.packageId ?? randomUUID(),
versionId: randomUUID()
})
const value: Preparation = {
created,
expiresAt: Date.now() + PREPARATION_TTL_MS,
controller: null
}
this.preparations.set(preparationId, value)
return preview(preparationId, value)
} catch (error) {
await rm(directory, { recursive: true, force: true })
throw error
}
}
async publish(
input: SkillSharePublishInput,
onProgress?: (progress: SkillShareProgress) => void
): Promise<SkillSharePublishOperation> {
await this.prune()
const preparation = this.preparations.get(input.preparationId)
if (!preparation || preparation.expiresAt <= Date.now()) {
throw new Error('skill-share-preparation-expired')
}
if (preparation.controller) {
throw new Error('skill-share-publish-in-progress')
}
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,
userIds: input.userIds,
shareWithOrganization: input.shareWithOrganization,
signal: controller.signal,
onProgress: (progress) => onProgress?.({ preparationId: input.preparationId, ...progress })
})
if (result.status === 'ok') {
await this.release(input.preparationId)
}
return result satisfies SkillSharePublishOperation
} finally {
const current = this.preparations.get(input.preparationId)
if (current) {
current.controller = null
}
}
}
cancel(preparationId: string): void {
this.preparations.get(preparationId)?.controller?.abort()
}
async release(preparationId: string): Promise<void> {
const preparation = this.preparations.get(preparationId)
preparation?.controller?.abort()
this.preparations.delete(preparationId)
await rm(join(this.root, preparationId), { recursive: true, force: true })
}
async dispose(): Promise<void> {
for (const preparation of this.preparations.values()) {
preparation.controller?.abort()
}
this.preparations.clear()
await rm(this.root, { recursive: true, force: true })
}
private async prune(): Promise<void> {
const expired = [...this.preparations.entries()]
.filter(([, value]) => value.expiresAt <= Date.now() && !value.controller)
.map(([id]) => id)
await Promise.all(expired.map((id) => this.release(id)))
}
}
@@ -0,0 +1,118 @@
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 { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest'
import type { IPtyProvider } from '../providers/pty-provider-contract'
import { installSkillOnSshHost } from './skill-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-skill-ssh-client-test-'))
roots.push(root)
return root
}
function result() {
return {
operationId: 'operation_1',
status: 'installed' as const,
name: 'ssh-skill',
packageDigest: 'a'.repeat(64),
placements: []
}
}
function request(bytes: Buffer) {
return {
operationId: 'operation_1',
package: {
packageId: 'package_1',
versionId: 'version_1',
packageDigest: 'a'.repeat(64),
archiveSha256: createHash('sha256').update(bytes).digest('hex'),
compressedBytes: bytes.length
},
ingress: {
kind: 'download-grant' as const,
url: 'https://storage.googleapis.com/test/package.tar.gz',
expiresAt: new Date(Date.now() + 60_000).toISOString()
},
destination: { scope: 'global' as const, executionTarget: { kind: 'host' as const } }
}
}
describe('installSkillOnSshHost', () => {
it('uses client-mediated upload only after direct host download fails', async () => {
const bytes = Buffer.from('private skill archive')
let received = 0
const requestHostRpc = vi.fn(async (method: string, params: unknown) => {
if (method === 'relay.status') {
return {
capabilities: ['skills.install.v1', 'skills.upload.v1', 'skills.manage.v1']
}
}
if (method === 'skills.install') {
const ingress = (params as { request: ReturnType<typeof request> }).request.ingress
if (ingress.kind === 'download-grant') {
throw new Error('skill-download-transport-failed')
}
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 }
received = chunk.offset + Buffer.from(chunk.bytesBase64, 'base64').length
return { acknowledgedOffset: received }
}
if (method === 'skills.commitUpload' || method === 'skills.cancelUpload') {
return { ok: true }
}
throw new Error(`unexpected method ${method}`)
})
await expect(
installSkillOnSshHost({
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(received).toBe(bytes.length)
expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([
'relay.status',
'skills.install',
'skills.beginUpload',
'skills.uploadChunk',
'skills.commitUpload',
'skills.install',
'skills.cancelUpload'
])
})
it('does not call unknown install methods on an old relay', async () => {
const requestHostRpc = vi.fn(async () => ({ capabilities: [] }))
await expect(
installSkillOnSshHost({
provider: { requestHostRpc } as unknown as IPtyProvider,
userDataPath: await userDataPath(),
request: request(Buffer.from('archive')),
requireHttps: true
})
).rejects.toThrow('skill-install-ssh-update-required')
expect(requestHostRpc).toHaveBeenCalledOnce()
})
})
+255
View File
@@ -0,0 +1,255 @@
import { open } from 'node:fs/promises'
import { join } from 'node:path'
import {
SKILL_INSTALL_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
import {
ManagedSkillInstallListSchema,
SkillInstallPreviewSchema,
SkillInstallResultSchema,
type ManagedSkillInstall,
type SkillInstallPreview,
type SkillInstallPreviewRequest,
type SkillInstallRequest,
type SkillInstallResult,
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 } from '../../shared/skill-upload-session-contract'
import type { IPtyProvider } from '../providers/pty-provider-contract'
import { downloadSkillPackageGrant } from './skill-package-download'
const REQUEST_TIMEOUT_MS = 5 * 60_000
const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed'
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)]
}
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')
: []
}
export async function supportsSkillManagementOnSsh(provider: IPtyProvider): Promise<boolean> {
return (await capabilities(requireClient(provider))).includes(SKILL_MANAGEMENT_CAPABILITY)
}
export async function installSkillOnSshHost(input: {
provider: IPtyProvider
userDataPath: string
request: SkillInstallRequest
workspace?: SkillSshWorkspaceAuthority
requireHttps: boolean
signal?: AbortSignal
fetcher?: typeof fetch
}): Promise<SkillInstallResult> {
const client = requireClient(input.provider)
const supported = await capabilities(client)
if (!supported.includes(SKILL_INSTALL_CAPABILITY)) {
throw new Error('skill-install-ssh-update-required')
}
try {
return SkillInstallResultSchema.parse(
await client(
SKILL_SSH_RELAY_INSTALL_METHOD,
{ request: input.request, workspace: input.workspace },
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
)
)
} catch (error) {
if (
input.request.ingress.kind !== 'download-grant' ||
(error as Error).message !== DIRECT_DOWNLOAD_FAILURE
) {
throw error
}
}
if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) {
throw new Error('skill-install-ssh-download-unavailable')
}
const uploadId = await transferSkillPackageToSshHost(client, input)
try {
return SkillInstallResultSchema.parse(
await client(
SKILL_SSH_RELAY_INSTALL_METHOD,
{
request: {
...input.request,
ingress: { kind: 'staged-upload', uploadId }
},
workspace: input.workspace
},
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
)
)
} finally {
await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined)
}
}
export async function previewSkillInstallOnSshHost(input: {
provider: IPtyProvider
request: SkillInstallPreviewRequest
workspace?: SkillSshWorkspaceAuthority
}): Promise<SkillInstallPreview> {
const client = requireClient(input.provider)
if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
throw new Error('skill-install-ssh-update-required')
}
return SkillInstallPreviewSchema.parse(
await client(
SKILL_SSH_RELAY_PREVIEW_METHOD,
{ request: input.request, workspace: input.workspace },
{ timeoutMs: 30_000 }
)
)
}
export async function removeSkillInstallOnSshHost(input: {
provider: IPtyProvider
request: SkillRemoveRequest
workspace?: SkillSshWorkspaceAuthority
}): Promise<SkillInstallResult> {
const client = requireClient(input.provider)
if (!(await capabilities(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 }
)
)
}
export async function listSkillInstallsOnSshHost(input: {
provider: IPtyProvider
connectionId: string
workspaces: SkillSshWorkspaceAuthority[]
}): Promise<ManagedSkillInstall[]> {
const client = requireClient(input.provider)
if (!(await capabilities(client)).includes(SKILL_MANAGEMENT_CAPABILITY)) {
throw new Error('skill-install-ssh-update-required')
}
return ManagedSkillInstallListSchema.parse(
await client(
SKILL_SSH_RELAY_LIST_METHOD,
{ workspaces: input.workspaces },
{ timeoutMs: 30_000 }
)
).map((install) => ({
...install,
destination:
install.destination.scope === 'global'
? {
scope: 'global' as const,
executionTarget: { kind: 'ssh' as const, connectionId: input.connectionId }
}
: 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 = (await client(
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
{ package: input.request.package },
{ timeoutMs: REQUEST_TIMEOUT_MS, signal: input.signal }
)) as { uploadId: string; chunkBytes: number }
uploadId = begun.uploadId
const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES)
if (!uploadId || !Number.isInteger(chunkBytes) || chunkBytes < 1) {
throw new Error('skill-transfer-ssh-begin-invalid')
}
const handle = await open(downloaded.archivePath, 'r')
try {
let offset = 0
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 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 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)
}
}
}
@@ -0,0 +1,76 @@
import { createHash } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { SkillUploadSessionService } from './skill-upload-session-service'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function identity(bytes: Buffer) {
return {
packageId: 'package_1',
versionId: 'version_1',
packageDigest: 'a'.repeat(64),
archiveSha256: createHash('sha256').update(bytes).digest('hex'),
compressedBytes: bytes.length
}
}
describe('SkillUploadSessionService', () => {
it('accepts monotonic chunks, acknowledges identical retries, and transfers ownership', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-session-'))
roots.push(root)
const service = new SkillUploadSessionService(join(root, 'uploads'))
const bytes = Buffer.from('immutable skill package')
const packageIdentity = identity(bytes)
const begun = await service.begin({ package: packageIdentity })
const first = bytes.subarray(0, 8)
const second = bytes.subarray(8)
await expect(
service.append({ uploadId: begun.uploadId, offset: 0, bytesBase64: first.toString('base64') })
).resolves.toEqual({ acknowledgedOffset: first.length })
await expect(
service.append({ uploadId: begun.uploadId, offset: 0, bytesBase64: first.toString('base64') })
).resolves.toEqual({ acknowledgedOffset: first.length })
await service.append({
uploadId: begun.uploadId,
offset: first.length,
bytesBase64: second.toString('base64')
})
await service.commit(begun.uploadId)
const staged = await service.take(begun.uploadId, packageIdentity)
await expect(readFile(staged.archivePath)).resolves.toEqual(bytes)
await staged.cleanup()
await expect(readFile(staged.archivePath)).rejects.toThrow()
})
it('rejects gaps, changed retries, and an archive hash mismatch', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-upload-session-'))
roots.push(root)
const service = new SkillUploadSessionService(join(root, 'uploads'))
const bytes = Buffer.from('package')
const packageIdentity = identity(bytes)
const begun = await service.begin({ package: packageIdentity })
await expect(
service.append({ uploadId: begun.uploadId, offset: 1, bytesBase64: 'YQ==' })
).rejects.toThrow('skill-upload-offset-invalid')
await service.append({ uploadId: begun.uploadId, offset: 0, bytesBase64: 'YQ==' })
await expect(
service.append({ uploadId: begun.uploadId, offset: 0, bytesBase64: 'Yg==' })
).rejects.toThrow('skill-upload-retry-mismatch')
await service.append({
uploadId: begun.uploadId,
offset: 1,
bytesBase64: Buffer.from('xxxxxx').toString('base64')
})
await expect(service.commit(begun.uploadId)).rejects.toThrow(
'skill-upload-archive-hash-mismatch'
)
})
})
@@ -0,0 +1,166 @@
import { createHash, randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { mkdir, open, rm, stat, type FileHandle } from 'node:fs/promises'
import { join } from 'node:path'
import { SKILL_PACKAGE_MAX_COMPRESSED_BYTES } from '../../shared/skill-package-manifest'
import {
SKILL_UPLOAD_CHUNK_MAX_BYTES,
type SkillUploadBeginRequest,
type SkillUploadChunkRequest
} from '../../shared/skill-upload-session-contract'
const MAX_SESSIONS = 4
const SESSION_IDLE_MS = 10 * 60_000
type UploadSession = {
id: string
path: string
package: SkillUploadBeginRequest['package']
handle: FileHandle | null
bytesReceived: number
touchedAt: number
committed: boolean
}
export class SkillUploadSessionService {
private readonly sessions = new Map<string, UploadSession>()
private initialized: Promise<void> | null = null
constructor(private readonly root: string) {}
async begin(request: SkillUploadBeginRequest): Promise<{ uploadId: string; chunkBytes: number }> {
await this.initialize()
await this.prune()
if (this.sessions.size >= MAX_SESSIONS) {
throw new Error('skill-upload-session-limit')
}
const id = randomUUID()
const path = join(this.root, `${id}.tar.gz`)
const handle = await open(path, 'wx+', 0o600)
this.sessions.set(id, {
id,
path,
package: request.package,
handle,
bytesReceived: 0,
touchedAt: Date.now(),
committed: false
})
return { uploadId: id, chunkBytes: SKILL_UPLOAD_CHUNK_MAX_BYTES }
}
async append(request: SkillUploadChunkRequest): Promise<{ acknowledgedOffset: number }> {
const session = this.requireActive(request.uploadId)
const bytes = Buffer.from(request.bytesBase64, 'base64')
if (
bytes.length === 0 ||
bytes.length > SKILL_UPLOAD_CHUNK_MAX_BYTES ||
bytes.toString('base64') !== request.bytesBase64
) {
throw new Error('skill-upload-chunk-invalid')
}
if (request.offset > session.bytesReceived) {
throw new Error('skill-upload-offset-invalid')
}
if (request.offset < session.bytesReceived) {
if (request.offset + bytes.length > session.bytesReceived || !session.handle) {
throw new Error('skill-upload-offset-invalid')
}
const existing = Buffer.alloc(bytes.length)
const read = await session.handle.read(existing, 0, existing.length, request.offset)
if (read.bytesRead !== bytes.length || !existing.equals(bytes)) {
throw new Error('skill-upload-retry-mismatch')
}
session.touchedAt = Date.now()
return { acknowledgedOffset: session.bytesReceived }
}
if (session.bytesReceived + bytes.length > session.package.compressedBytes) {
throw new Error('skill-upload-size-limit')
}
const write = await session.handle!.write(bytes, 0, bytes.length, request.offset)
if (write.bytesWritten !== bytes.length) {
throw new Error('skill-upload-write-incomplete')
}
session.bytesReceived += bytes.length
session.touchedAt = Date.now()
return { acknowledgedOffset: session.bytesReceived }
}
async commit(uploadId: string): Promise<{ uploadId: string }> {
const session = this.requireActive(uploadId)
if (session.bytesReceived !== session.package.compressedBytes || !session.handle) {
throw new Error('skill-upload-size-mismatch')
}
await session.handle.sync()
await session.handle.close()
session.handle = null
const identity = await this.hash(session.path)
if (identity !== session.package.archiveSha256) {
await this.cancel(uploadId)
throw new Error('skill-upload-archive-hash-mismatch')
}
session.committed = true
session.touchedAt = Date.now()
return { uploadId }
}
async take(
uploadId: string,
identity: SkillUploadBeginRequest['package']
): Promise<{ archivePath: string; cleanup(): Promise<void> }> {
const session = this.sessions.get(uploadId)
if (!session?.committed || JSON.stringify(session.package) !== JSON.stringify(identity)) {
throw new Error('skill-upload-session-unavailable')
}
session.touchedAt = Date.now()
return {
archivePath: session.path,
cleanup: () => this.cancel(uploadId)
}
}
async cancel(uploadId: string): Promise<void> {
const session = this.sessions.get(uploadId)
if (!session) {
return
}
this.sessions.delete(uploadId)
await session.handle?.close().catch(() => undefined)
await rm(session.path, { force: true })
}
private requireActive(uploadId: string): UploadSession {
const session = this.sessions.get(uploadId)
if (!session || session.committed || Date.now() - session.touchedAt > SESSION_IDLE_MS) {
throw new Error('skill-upload-session-unavailable')
}
return session
}
private async initialize(): Promise<void> {
this.initialized ??= (async () => {
await rm(this.root, { recursive: true, force: true })
await mkdir(this.root, { recursive: true, mode: 0o700 })
})()
return this.initialized
}
private async prune(): Promise<void> {
const expired = [...this.sessions.values()]
.filter((session) => Date.now() - session.touchedAt > SESSION_IDLE_MS)
.map((session) => session.id)
await Promise.all(expired.map((id) => this.cancel(id)))
}
private async hash(path: string): Promise<string> {
const size = (await stat(path)).size
if (size < 1 || size > SKILL_PACKAGE_MAX_COMPRESSED_BYTES) {
throw new Error('skill-upload-size-limit')
}
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) {
hash.update(chunk as Buffer)
}
return hash.digest('hex')
}
}
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() }))
vi.mock('node:child_process', () => ({
execFile: Object.assign(vi.fn(), {
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
})
}))
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.agents\\skills'
beforeEach(() => {
execFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
})
describe('WslSkillInstallFilesystem', () => {
it('applies and verifies manifest modes through bounded guest argv batches', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
const manifest = {
files: [
{ path: 'SKILL.md', executable: false },
{ path: 'scripts/run.sh', executable: true }
]
} as SkillPackageManifestV1
await filesystem.prepareExtractedSkill(`${WSL_ROOT}\\.orca-skill-extract-1\\skill`, manifest)
expect(execFileAsyncMock).toHaveBeenCalledTimes(2)
const calls = execFileAsyncMock.mock.calls.map(([, args]) => args as string[])
expect(calls[0]).toEqual(
expect.arrayContaining([
'600',
'/home/jin/.agents/skills/.orca-skill-extract-1/skill/SKILL.md',
'700',
'/home/jin/.agents/skills/.orca-skill-extract-1/skill/scripts/run.sh'
])
)
expect(calls[1]).toEqual(
expect.arrayContaining([
'600',
'/home/jin/.agents/skills/.orca-skill-extract-1/skill/SKILL.md',
'700',
'/home/jin/.agents/skills/.orca-skill-extract-1/skill/scripts/run.sh'
])
)
})
it('maps Windows workspace roots into the selected distro and rejects escapes', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [
'C:\\Users\\jin\\repo\\.agents\\skills'
])
await filesystem.rename(
'C:\\Users\\jin\\repo\\.agents\\skills\\.skill.orca-staging-1',
'C:\\Users\\jin\\repo\\.agents\\skills\\skill'
)
expect(execFileAsyncMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining([
'/mnt/c/Users/jin/repo/.agents/skills/.skill.orca-staging-1',
'/mnt/c/Users/jin/repo/.agents/skills/skill'
])
)
await expect(filesystem.remove('C:\\Users\\jin\\repo\\unrelated')).rejects.toThrow(
'skill-install-wsl-path-outside-root'
)
expect(execFileAsyncMock).toHaveBeenCalledOnce()
})
it('rejects a path from another distro before spawning wsl.exe', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
await expect(
filesystem.remove('\\\\wsl.localhost\\Debian\\home\\jin\\.agents\\skills\\skill')
).rejects.toThrow('skill-install-wsl-path-invalid')
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,223 @@
import { execFile } from 'node:child_process'
import { join, posix } from 'node:path'
import { promisify } from 'node:util'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
SKILL_PACKAGE_OBSERVATION_LIMITS,
observeSkillPackage,
type ObservedSkillPackage
} from './skill-package-identity'
import type { SkillInstallFilesystem, SkillInstalledFileMode } from './skill-install-filesystem'
const execFileAsync = promisify(execFile)
const BATCH_FILE_COUNT = 8
const GUEST_COMMAND_TIMEOUT_MS = 30_000
const APPLY_MODES_SCRIPT = [
'set -eu',
'while [ "$#" -gt 1 ]; do',
' mode=$1; file=$2; shift 2',
' chmod "$mode" -- "$file"',
'done'
].join('\n')
const VERIFY_MODES_SCRIPT = [
'set -eu',
'while [ "$#" -gt 1 ]; do',
' expected=$1; file=$2; shift 2',
' actual=$(stat -c %a -- "$file")',
' [ "$actual" = "$expected" ] || exit 42',
'done'
].join('\n')
const RENAME_SCRIPT = [
'set -eu',
'[ ! -e "$2" ] && [ ! -L "$2" ] || exit 43',
'mv -- "$1" "$2"',
'sync -f "$2" 2>/dev/null || sync'
].join('\n')
const REMOVE_SCRIPT = [
'set -eu',
'parent=$(dirname -- "$1")',
'rm -rf -- "$1"',
'sync -f "$parent" 2>/dev/null || sync'
].join('\n')
const CREATE_ALIAS_SCRIPT = [
'set -eu',
'parent=$(dirname -- "$2")',
'mkdir -p -- "$parent"',
'chmod 700 -- "$parent"',
'ln -s -- "$1" "$2"',
'sync -f "$parent" 2>/dev/null || sync'
].join('\n')
const ALIAS_TARGET_SCRIPT = [
'set -eu',
'[ -L "$2" ] || exit 0',
'[ "$(readlink -f -- "$1")" = "$(readlink -f -- "$2")" ] && printf yes'
].join('\n')
export class WslSkillInstallFilesystem implements SkillInstallFilesystem {
private readonly guestAllowedRoots: string[]
constructor(
private readonly distro: string,
allowedRoots: readonly string[]
) {
this.guestAllowedRoots = allowedRoots.map((path) => this.toGuestPath(path))
}
async prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise<void> {
const guestRoot = this.requireAllowed(path)
const directories = new Set([guestRoot])
for (const file of manifest.files) {
let directory = posix.dirname(posix.join(guestRoot, file.path))
while (directory !== guestRoot && directory.startsWith(`${guestRoot}/`)) {
directories.add(directory)
directory = posix.dirname(directory)
}
}
await this.applyModes([
...[...directories].map((directory) => ({ path: directory, mode: '700' })),
...manifest.files.map((file) => ({
path: posix.join(guestRoot, file.path),
mode: file.executable ? '700' : '600'
}))
])
await this.verifyModes(guestRoot, manifest.files)
}
async observeSkill(
path: string,
files?: readonly SkillInstalledFileMode[]
): Promise<ObservedSkillPackage> {
if (!files) {
return observeSkillPackage(path)
}
const guestRoot = this.requireAllowed(path)
await this.verifyModes(guestRoot, files)
return observeSkillPackage(
path,
SKILL_PACKAGE_OBSERVATION_LIMITS,
new Set(files.filter((file) => file.executable).map((file) => file.path))
)
}
async rename(source: string, target: string): Promise<void> {
await this.run(RENAME_SCRIPT, [this.requireAllowed(source), this.requireAllowed(target)])
}
async remove(path: string): Promise<void> {
await this.run(REMOVE_SCRIPT, [this.requireAllowed(path)])
}
async createAlias(canonicalPath: string, destinationPath: string): Promise<void> {
await this.run(CREATE_ALIAS_SCRIPT, [
this.requireAllowed(canonicalPath),
this.requireAllowed(destinationPath)
])
}
async aliasTargets(canonicalPath: string, destinationPath: string): Promise<boolean> {
return (
(await this.runOutput(ALIAS_TARGET_SCRIPT, [
this.requireAllowed(canonicalPath),
this.requireAllowed(destinationPath)
])) === 'yes'
)
}
private async applyModes(entries: { path: string; mode: string }[]): Promise<void> {
for (let offset = 0; offset < entries.length; offset += BATCH_FILE_COUNT) {
await this.run(
APPLY_MODES_SCRIPT,
entries
.slice(offset, offset + BATCH_FILE_COUNT)
.flatMap((entry) => [entry.mode, entry.path])
)
}
}
private async verifyModes(root: string, files: readonly SkillInstalledFileMode[]): Promise<void> {
const entries = files.map((file) => ({
path: posix.join(root, file.path),
mode: file.executable ? '700' : '600'
}))
for (let offset = 0; offset < entries.length; offset += BATCH_FILE_COUNT) {
await this.run(
VERIFY_MODES_SCRIPT,
entries
.slice(offset, offset + BATCH_FILE_COUNT)
.flatMap((entry) => [entry.mode, entry.path])
)
}
}
private toGuestPath(path: string): string {
const parsed = parseWslUncPath(path)
if (parsed) {
if (
parsed.distro.toLocaleLowerCase('en-US') !== this.distro.toLocaleLowerCase('en-US') ||
!posix.isAbsolute(parsed.linuxPath) ||
parsed.linuxPath.includes('\0')
) {
throw new Error('skill-install-wsl-path-invalid')
}
return posix.normalize(parsed.linuxPath)
}
const drive = path.replace(/\\/g, '/').match(/^([A-Za-z]):\/(.*)$/)
if (!drive || path.includes('\0')) {
throw new Error('skill-install-wsl-path-invalid')
}
return posix.join('/mnt', drive[1].toLocaleLowerCase('en-US'), drive[2])
}
private requireAllowed(path: string): string {
const guestPath = this.toGuestPath(path)
const allowed = this.guestAllowedRoots.some((root) => {
const child = posix.relative(root, guestPath)
return child !== '' && child !== '..' && !child.startsWith('../') && !posix.isAbsolute(child)
})
if (!allowed) {
throw new Error('skill-install-wsl-path-outside-root')
}
return guestPath
}
private async run(script: string, args: string[]): Promise<void> {
await this.runOutput(script, args)
}
private async runOutput(script: string, args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync(
'wsl.exe',
['-d', this.distro, '--exec', 'sh', '-c', script, 'orca-skill', ...args],
{
encoding: 'utf8',
timeout: GUEST_COMMAND_TIMEOUT_MS,
windowsHide: true,
maxBuffer: 64 * 1024
}
)
return stdout.trim()
} catch (error) {
throw Object.assign(new Error('skill-install-wsl-guest-operation-failed'), { cause: error })
}
}
}
export function createWslSkillInstallFilesystem(input: {
distro: string
homeDirectory: string
workspaceDirectory?: string
}): WslSkillInstallFilesystem {
const scopeRoot = input.workspaceDirectory ?? input.homeDirectory
return new WslSkillInstallFilesystem(input.distro, [
join(scopeRoot, '.agents', 'skills'),
join(scopeRoot, '.claude', 'skills')
])
}
@@ -0,0 +1,26 @@
import { execFile } from 'node:child_process'
const DETECTION_SCRIPT = [
'set -eu',
'command -v codex >/dev/null 2>&1 && printf "codex\\n" || true',
'command -v claude >/dev/null 2>&1 && printf "claude\\n" || true'
].join('\n')
export function detectSkillProvidersInWsl(distro: string): Promise<string[]> {
return new Promise((resolve, reject) => {
execFile(
'wsl.exe',
['-d', distro, '--', 'sh', '-c', DETECTION_SCRIPT],
{ encoding: 'utf8', timeout: 10_000, windowsHide: true },
(error, stdout) => {
if (error) {
reject(new Error('skill-install-wsl-provider-detection-failed'))
return
}
resolve(
stdout.split(/\r?\n/u).filter((provider) => provider === 'codex' || provider === 'claude')
)
}
)
})
}
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest'
import { SkillShareDeepLinkState } from './skill-share-deep-link-state'
describe('SkillShareDeepLinkState', () => {
it('queues a startup share until the renderer consumes it once', () => {
const state = new SkillShareDeepLinkState()
expect(state.capture(['orca', 'https://app.orca.dev/skills/share/share_startup'])).toBe(true)
expect(state.consume()).toBe('share_startup')
expect(state.consume()).toBeNull()
})
it('publishes a later second-instance intent and keeps it for renderer recovery', () => {
const state = new SkillShareDeepLinkState()
const publish = vi.fn()
state.capture(['orca', 'https://app.orca.dev/skills/share/share_first'])
expect(state.capture(['orca', 'orca://skills/share/share_second'], publish)).toBe(true)
expect(publish).toHaveBeenCalledWith('share_second')
expect(state.consume()).toBe('share_second')
})
it('ignores untrusted URLs without replacing a pending intent', () => {
const state = new SkillShareDeepLinkState()
state.capture(['orca', 'https://app.orca.dev/skills/share/share_safe'])
expect(state.capture(['orca', 'https://attacker.test/skills/share/share_bad'])).toBe(false)
expect(state.consume()).toBe('share_safe')
})
})
@@ -0,0 +1,21 @@
import { skillShareIdFromArguments } from '../../shared/skill-share-link'
export class SkillShareDeepLinkState {
private pendingShareId: string | null = null
capture(argv: readonly string[], publish?: (shareId: string) => void): boolean {
const shareId = skillShareIdFromArguments(argv)
if (!shareId) {
return false
}
this.pendingShareId = shareId
publish?.(shareId)
return true
}
consume(): string | null {
const shareId = this.pendingShareId
this.pendingShareId = null
return shareId
}
}
+42
View File
@@ -401,6 +401,26 @@ import type {
ShellOpenLocalPathResult
} from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
SkillCloudDownloadGrant,
SkillCloudOperation,
SkillCloudPackageDetails
} from '../shared/skill-cloud-contract'
import type {
SkillInstallPreviewInput,
SkillInstallPreviewOperation,
ManagedSkillInstallListOperation,
SkillPackageVersionInstallInput,
SkillRemoveInput,
SkillRemoveOperation,
SkillShareInstallInput,
SkillShareInstallOperation,
SkillSharePreview,
SkillShareProgress,
SkillSharePublishInput,
SkillSharePublishOperation,
SkillShareResolvedOperation
} from '../shared/skill-sharing-contract'
import type {
SkillFreshnessInventory,
SkillUpdateRun,
@@ -2546,6 +2566,26 @@ export type PreloadApi = {
cancelUpdateRun: () => Promise<void>
acknowledgeUpdateRun: () => Promise<void>
getUpdateRun: () => Promise<SkillUpdateRun>
prepareShare: (input: {
skillId: string
target?: SkillDiscoveryTarget
packageId?: string
}) => Promise<SkillSharePreview>
publishShare: (input: SkillSharePublishInput) => Promise<SkillSharePublishOperation>
cancelShare: (preparationId: string) => Promise<void>
releaseShare: (preparationId: string) => Promise<void>
resolveShare: (shareId: string) => Promise<SkillShareResolvedOperation>
createDownloadGrant: (shareId: string) => Promise<SkillCloudOperation<SkillCloudDownloadGrant>>
installShare: (input: SkillShareInstallInput) => Promise<SkillShareInstallOperation>
installPackageVersion: (
input: SkillPackageVersionInstallInput
) => Promise<SkillShareInstallOperation>
previewInstall: (input: SkillInstallPreviewInput) => Promise<SkillInstallPreviewOperation>
removeInstall: (input: SkillRemoveInput) => Promise<SkillRemoveOperation>
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
listWslDistros: (environmentId?: string) => Promise<string[]>
onShareProgress: (callback: (progress: SkillShareProgress) => void) => () => void
onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void
}
pet: {
@@ -3103,6 +3143,8 @@ export type PreloadApi = {
onOpenSettings: (callback: () => void) => () => void
/** Consumes a one-shot tray/menu-bar "open settings" intent queued before mount. */
consumePendingOpenSettings: () => Promise<boolean>
onOpenSkillShare: (callback: (shareId: string) => void) => () => void
consumePendingSkillShare: () => Promise<string | null>
onOpenSetupGuide: (callback: () => void) => () => void
onOpenFeatureTour: (callback: () => void) => () => void
onOpenCrashReport: (callback: () => void) => () => void
+65
View File
@@ -129,6 +129,26 @@ import type {
ShellOpenLocalPathResult
} from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
SkillCloudDownloadGrant,
SkillCloudOperation,
SkillCloudPackageDetails
} from '../shared/skill-cloud-contract'
import type {
SkillInstallPreviewInput,
SkillInstallPreviewOperation,
ManagedSkillInstallListOperation,
SkillPackageVersionInstallInput,
SkillRemoveInput,
SkillRemoveOperation,
SkillShareInstallInput,
SkillShareInstallOperation,
SkillSharePreview,
SkillShareProgress,
SkillSharePublishInput,
SkillSharePublishOperation,
SkillShareResolvedOperation
} from '../shared/skill-sharing-contract'
import type {
SkillFreshnessInventory,
SkillUpdateRun,
@@ -2496,6 +2516,43 @@ const api = {
cancelUpdateRun: (): Promise<void> => ipcRenderer.invoke('skills:cancelUpdateRun'),
acknowledgeUpdateRun: (): Promise<void> => ipcRenderer.invoke('skills:acknowledgeUpdateRun'),
getUpdateRun: (): Promise<SkillUpdateRun> => ipcRenderer.invoke('skills:getUpdateRun'),
prepareShare: (input: {
skillId: string
target?: SkillDiscoveryTarget
packageId?: string
}): Promise<SkillSharePreview> => ipcRenderer.invoke('skills:prepareShare', input),
publishShare: (input: SkillSharePublishInput): Promise<SkillSharePublishOperation> =>
ipcRenderer.invoke('skills:publishShare', input),
cancelShare: (preparationId: string): Promise<void> =>
ipcRenderer.invoke('skills:cancelShare', preparationId),
releaseShare: (preparationId: string): Promise<void> =>
ipcRenderer.invoke('skills:releaseShare', preparationId),
resolveShare: (shareId: string): Promise<SkillShareResolvedOperation> =>
ipcRenderer.invoke('skills:resolveShare', shareId),
createDownloadGrant: (shareId: string): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> =>
ipcRenderer.invoke('skills:createDownloadGrant', shareId),
installShare: (input: SkillShareInstallInput): Promise<SkillShareInstallOperation> =>
ipcRenderer.invoke('skills:installShare', input),
installPackageVersion: (
input: SkillPackageVersionInstallInput
): Promise<SkillShareInstallOperation> =>
ipcRenderer.invoke('skills:installPackageVersion', input),
previewInstall: (input: SkillInstallPreviewInput): Promise<SkillInstallPreviewOperation> =>
ipcRenderer.invoke('skills:previewInstall', input),
removeInstall: (input: SkillRemoveInput): Promise<SkillRemoveOperation> =>
ipcRenderer.invoke('skills:removeInstall', input),
listManagedInstalls: (environmentId?: string): Promise<ManagedSkillInstallListOperation> =>
ipcRenderer.invoke('skills:listManagedInstalls', environmentId),
getPackage: (packageId: string): Promise<SkillCloudOperation<SkillCloudPackageDetails>> =>
ipcRenderer.invoke('skills:getPackage', packageId),
listWslDistros: (environmentId?: string): Promise<string[]> =>
ipcRenderer.invoke('skills:listWslDistros', environmentId),
onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void =>
callback(progress)
ipcRenderer.on('skills:shareProgress', listener)
return () => ipcRenderer.removeListener('skills:shareProgress', listener)
},
onUpdateRun: (callback: (run: SkillUpdateRun) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, run: SkillUpdateRun): void =>
callback(run)
@@ -3509,6 +3566,14 @@ const api = {
},
consumePendingOpenSettings: (): Promise<boolean> =>
ipcRenderer.invoke('ui:consumePendingOpenSettings'),
onOpenSkillShare: (callback: (shareId: string) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, shareId: string): void =>
callback(shareId)
ipcRenderer.on('ui:openSkillShare', listener)
return () => ipcRenderer.removeListener('ui:openSkillShare', listener)
},
consumePendingSkillShare: (): Promise<string | null> =>
ipcRenderer.invoke('ui:consumePendingSkillShare'),
onOpenSetupGuide: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openSetupGuide', listener)
+4
View File
@@ -82,6 +82,7 @@ import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler'
import { DispatcherClientWriter } from './dispatcher-client-writer'
import { SshPtyConsumerSessionAdapter } from './ssh-pty-consumer-session-adapter'
import { RelayPtySourcePublication } from './relay-pty-source-publication'
import { SKILL_RELAY_CAPABILITIES, SkillInstallHandler } from './skill-install-handler'
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
const SOCK_NAME = 'relay.sock'
@@ -714,8 +715,10 @@ async function main(): Promise<void> {
const gitHandler = new GitHandler(dispatcher, context, watchRegistry)
const _preflightHandler = new PreflightHandler(dispatcher)
const _skillInstallHandler = new SkillInstallHandler(dispatcher)
const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
void _preflightHandler
void _skillInstallHandler
void _externalAutomationsHandler
const _portScanHandler = new PortScanHandler(dispatcher)
@@ -929,6 +932,7 @@ async function main(): Promise<void> {
let graceBranch: RelayGraceBranch | null = null
dispatcher.onRequest('relay.status', async () => ({
capabilities: SKILL_RELAY_CAPABILITIES,
pid: process.pid,
uptimeMs: Date.now() - startedAt,
detached,
+99
View File
@@ -0,0 +1,99 @@
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, vi } from 'vitest'
import type { MethodHandler, RelayDispatcher } from './dispatcher'
import { createSkillPackageArchive } from '../main/skills/skill-package-creation'
import {
SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD,
SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD,
SKILL_SSH_RELAY_INSTALL_METHOD,
SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD
} from '../shared/skill-ssh-relay-contract'
import { SKILL_RELAY_CAPABILITIES, SkillInstallHandler } from './skill-install-handler'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-relay-skill-test-'))
roots.push(root)
const home = join(root, 'home')
const state = join(root, 'state')
const source = join(root, 'source')
await Promise.all([mkdir(home), mkdir(source)])
await writeFile(
join(source, 'SKILL.md'),
'---\nname: relay-skill\ndescription: Relay test\n---\n\n# Relay\n'
)
const archive = await createSkillPackageArchive({
sourceDirectory: source,
archivePath: join(root, 'package.tar.gz'),
packageId: 'package_1',
versionId: 'version_1'
})
const bytes = await readFile(archive.archivePath)
const handlers = new Map<string, MethodHandler>()
const dispatcher = {
onRequest: vi.fn((method: string, handler: MethodHandler) => handlers.set(method, handler))
} as unknown as RelayDispatcher
new SkillInstallHandler(dispatcher, {
homeDirectory: home,
stateDirectory: state,
detectProviders: async () => []
})
const call = (method: string, params: Record<string, unknown>) =>
handlers.get(method)!(params, {
clientId: 1,
isStale: () => false,
signal: new AbortController().signal
})
return { archive, bytes, call, home }
}
describe('SkillInstallHandler', () => {
it('installs a client-mediated package entirely on the SSH host', async () => {
const { archive, bytes, call, home } = await fixture()
const packageIdentity = {
packageId: archive.manifest.packageId,
versionId: archive.manifest.versionId,
packageDigest: archive.manifest.packageDigest,
archiveSha256: archive.archiveSha256,
compressedBytes: bytes.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: bytes.toString('base64')
})
await call(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, { uploadId: begun.uploadId })
const result = (await call(SKILL_SSH_RELAY_INSTALL_METHOD, {
request: {
operationId: 'operation_1',
package: packageIdentity,
ingress: { kind: 'staged-upload', uploadId: begun.uploadId },
destination: { scope: 'global', executionTarget: { kind: 'host' } }
}
})) as { status: string }
expect(result.status).toBe('installed')
expect(
await readFile(join(home, '.agents', 'skills', 'relay-skill', 'SKILL.md'), 'utf8')
).toContain('# Relay')
})
it('advertises separately gateable install, upload, and management capabilities', () => {
expect(SKILL_RELAY_CAPABILITIES).toEqual([
'skills.install.v1',
'skills.upload.v1',
'skills.manage.v1'
])
})
})
+174
View File
@@ -0,0 +1,174 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
SKILL_INSTALL_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../shared/skill-install-capability'
import type { ManagedSkillInstall } 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,
SkillSshInstallParamsSchema,
SkillSshListParamsSchema,
SkillSshPreviewParamsSchema,
SkillSshRemoveParamsSchema,
type SkillSshWorkspaceAuthority
} from '../shared/skill-ssh-relay-contract'
import {
SkillUploadBeginRequestSchema,
SkillUploadChunkRequestSchema,
SkillUploadCommitRequestSchema
} from '../shared/skill-upload-session-contract'
import {
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
getTuiAgentDetectionProbeCommands,
resolveDetectedTuiAgentIds
} from '../shared/tui-agent-detection-commands'
import type { RelayDispatcher } from './dispatcher'
import { isCommandOnPathForRelay } from './preflight-handler'
import type { SkillInstallDestinationAuthority } from '../main/skills/skill-install-destinations'
import {
previewSharedSkillInstall,
removeSharedSkillInstall
} 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 { SkillUploadSessionService } from '../main/skills/skill-upload-session-service'
const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host'
export const SKILL_RELAY_CAPABILITIES = [
SKILL_INSTALL_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY
] as const
export class SkillInstallHandler {
private readonly homeDirectory: string
private readonly stateDirectory: string
private readonly uploads: SkillUploadSessionService
private readonly detectProviders: () => Promise<readonly string[]>
constructor(
private readonly dispatcher: RelayDispatcher,
options: {
homeDirectory?: string
stateDirectory?: string
detectProviders?: () => Promise<readonly string[]>
} = {}
) {
this.homeDirectory = options.homeDirectory ?? homedir()
this.stateDirectory = options.stateDirectory ?? join(this.homeDirectory, '.orca')
this.uploads = new SkillUploadSessionService(
join(this.stateDirectory, 'skill-installs', 'remote-uploads')
)
this.detectProviders = options.detectProviders ?? detectRelaySkillProviders
this.registerHandlers()
}
private registerHandlers(): void {
this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_METHOD, async (params, context) => {
const input = SkillSshInstallParamsSchema.parse(params)
return executeSkillInstallRequest(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 previewSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
detectProviders: this.detectProviders
})
})
this.dispatcher.onRequest(SKILL_SSH_RELAY_REMOVE_METHOD, async (params) => {
const input = SkillSshRemoveParamsSchema.parse(params)
return removeSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
detectProviders: this.detectProviders
})
})
this.dispatcher.onRequest(SKILL_SSH_RELAY_LIST_METHOD, async (params) => {
const input = SkillSshListParamsSchema.parse(params)
return this.listManagedInstalls(input.workspaces)
})
this.dispatcher.onRequest(SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, (params) =>
this.uploads.begin(SkillUploadBeginRequestSchema.parse(params))
)
this.dispatcher.onRequest(SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, (params) =>
this.uploads.append(SkillUploadChunkRequestSchema.parse(params))
)
this.dispatcher.onRequest(SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, (params) =>
this.uploads.commit(SkillUploadCommitRequestSchema.parse(params).uploadId)
)
this.dispatcher.onRequest(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, async (params) => {
await this.uploads.cancel(SkillUploadCommitRequestSchema.parse(params).uploadId)
return { ok: true }
})
}
private authority(workspace?: SkillSshWorkspaceAuthority): SkillInstallDestinationAuthority {
return {
environmentId: SSH_SKILL_ENVIRONMENT_ID,
homeDirectory: this.homeDirectory,
resolveWorktree: async (id) =>
workspace?.kind === 'worktree' && workspace.id === id ? workspace : null,
resolveFolderWorkspace: async (id) =>
workspace?.kind === 'folder' && workspace.id === id ? workspace : null
}
}
private async listManagedInstalls(
workspaces: SkillSshWorkspaceAuthority[]
): Promise<ManagedSkillInstall[]> {
const installs = await listManagedSkillInstalls(join(this.stateDirectory, 'skill-installs'))
return installs.flatMap((install): ManagedSkillInstall[] => {
if (install.scope === 'global') {
return [{ ...install, destination: { scope: 'global', executionTarget: { kind: 'host' } } }]
}
const prefix = `workspace:${SSH_SKILL_ENVIRONMENT_ID}:`
const workspace = workspaces.find(
(candidate) => install.destinationIdentity === `${prefix}${candidate.id}`
)
if (!workspace) {
return []
}
return [
{
...install,
destination:
workspace.kind === 'worktree'
? { scope: 'workspace', worktreeId: workspace.id }
: { scope: 'workspace', folderWorkspaceId: workspace.id }
}
]
})
}
}
export async function detectRelaySkillProviders(): Promise<string[]> {
const runtime = process.platform
const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime)
const found = await Promise.all(
probes.map(async (command) => ({ command, found: await isCommandOnPathForRelay(command) }))
)
return resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
new Set(found.filter((item) => item.found).map((item) => item.command)),
runtime
)
}
@@ -1,4 +1,4 @@
import { BookOpen, Clock, FolderOpen } from 'lucide-react'
import { BookOpen, Clock, FolderOpen, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -25,7 +25,13 @@ function formatUpdatedAt(value: number | null): string {
return value ? dateFormatter.format(new Date(value)) : 'Unknown'
}
export function SkillCard({ skill }: { skill: DiscoveredSkill }): React.JSX.Element {
export function SkillCard({
skill,
onShare
}: {
skill: DiscoveredSkill
onShare?: () => void
}): React.JSX.Element {
const revealSkill = async (): Promise<void> => {
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
if (!result.ok) {
@@ -67,24 +73,37 @@ export function SkillCard({ skill }: { skill: DiscoveredSkill }): React.JSX.Elem
</p>
)}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
onClick={() => {
void revealSkill()
}}
>
<FolderOpen className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
</TooltipContent>
</Tooltip>
<div className="flex shrink-0 items-center gap-1">
{onShare ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" onClick={onShare}>
<Share2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
Share skill
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
void revealSkill()
}}
>
<FolderOpen className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="grid gap-2 text-[11px] text-muted-foreground md:grid-cols-[1fr_auto_auto] md:items-center">
@@ -0,0 +1,137 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
import { useAppStore } from '@/store'
import { SkillInstallDialog } from './SkillInstallDialog'
const DIGEST = 'a'.repeat(64)
const ARCHIVE_SHA = 'b'.repeat(64)
function version(): SkillCloudVersion {
return {
packageId: 'pkg_1',
versionId: 'ver_1',
name: 'private-skill',
description: 'A private skill',
packageDigest: DIGEST,
archiveSha256: ARCHIVE_SHA,
compressedBytes: 128,
createdAt: '2026-08-11T00:00:00.000Z',
releaseNotes: '',
manifest: {
schemaVersion: 1,
packageId: 'pkg_1',
versionId: 'ver_1',
name: 'private-skill',
description: 'A private skill',
createdAt: '2026-08-11T00:00:00.000Z',
files: [
{
path: 'SKILL.md',
size: 1,
executable: false,
classification: 'text',
sha256: DIGEST,
identitySha256: DIGEST
}
],
packageDigest: DIGEST
},
publisher: { userId: 'author_1', organizationId: 'org_1' }
}
}
function installApi(previewInstall: ReturnType<typeof vi.fn>) {
return {
resolveShare: vi
.fn()
.mockResolvedValue({ status: 'ok', value: { id: 'share_1', version: version() } }),
previewInstall,
installShare: vi.fn().mockResolvedValue({
status: 'ok',
value: {
operationId: 'op_1',
status: 'installed',
name: 'private-skill',
packageDigest: DIGEST,
placements: []
}
}),
listWslDistros: vi.fn().mockResolvedValue([])
}
}
async function inspectSkill(): 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')
}
beforeEach(() => {
useAppStore.setState({
runtimeEnvironments: [],
runtimeStatusByEnvironmentId: new Map(),
repos: [],
worktreesByRepo: {},
folderWorkspaces: [],
sshConnectionStates: new Map(),
sshTargetLabels: new Map()
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'api')
})
describe('SkillInstallDialog', () => {
it('preserves a modified install until the user explicitly discards it', async () => {
const previewInstall = vi.fn().mockResolvedValue({
status: 'ok',
value: {
name: 'private-skill',
packageDigest: DIGEST,
destinationIdentity: 'local:global',
currentState: 'modified',
providers: []
}
})
const skills = installApi(previewInstall)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
expect((await screen.findByRole('alert')).textContent).toContain('left it untouched')
expect(skills.installShare).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Discard and replace' }))
await waitFor(() => expect(skills.installShare).toHaveBeenCalledOnce())
expect(skills.installShare).toHaveBeenCalledWith(
expect.objectContaining({ conflictResolution: 'replace-and-discard-local' })
)
})
it('surfaces capability loss after preview selection without attempting installation', async () => {
const previewInstall = vi.fn().mockResolvedValue({
status: 'unsupported',
message: 'Update the selected Orca host to install shared skills.'
})
const skills = installApi(previewInstall)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
expect((await screen.findByRole('alert')).textContent).toContain(
'Update the selected Orca host'
)
expect(skills.installShare).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,373 @@
import { useMemo, useState } from 'react'
import { AlertTriangle, Check, Download, Loader2, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAppStore } from '@/store'
import type {
SkillInstallDestination,
SkillInstallPreview,
SkillInstallResult
} from '../../../../shared/skill-install-contract'
import { parseSkillShareId } from './skill-share-link'
import { skillInstallResultLabel } from './skill-install-result-label'
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
import { summarizeSkillShareVersion, type ResolvedSkillShare } from './skill-share-version-summary'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
export function SkillInstallDialog({
open,
onOpenChange,
initialLink = ''
}: {
open: boolean
onOpenChange: (open: boolean) => void
initialLink?: string
}): React.JSX.Element {
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 [link, setLink] = useState(initialLink)
const [preview, setPreview] = useState<ResolvedSkillShare | null>(null)
const [environmentId, setEnvironmentId] = useState<string>('local')
const [scope, setScope] = useState<'global' | 'workspace'>('global')
const [workspace, setWorkspace] = useState<string>('')
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
null
)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(null)
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 inspect = async (): Promise<void> => {
const shareId = parseSkillShareId(link)
if (!shareId) {
setError('Enter an Orca skill share link.')
return
}
setBusy(true)
setError(null)
setResult(null)
try {
const operation = await window.api.skills.resolveShare(shareId)
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
? 'Reconnect your Orca account to inspect this skill.'
: operation.message
)
return
}
setPreview({ shareId, version: operation.value.version })
} catch (cause) {
console.warn('[skills] share resolution failed:', cause)
setError('This share is unavailable or your account does not have access.')
} finally {
setBusy(false)
}
}
const install = async (discardLocal = false): Promise<void> => {
if (!preview) {
return
}
const choice = workspaceChoices.find((candidate) => candidate.id === workspace)
if (scope === 'workspace' && !choice) {
setError('Choose a workspace.')
return
}
setBusy(true)
setError(null)
try {
const destination: SkillInstallDestination =
scope === 'global'
? environmentId.startsWith('ssh:')
? {
scope: 'global',
executionTarget: {
kind: 'ssh',
connectionId: environmentId.slice('ssh:'.length)
}
}
: { scope: 'global', ...(executionTarget ? { executionTarget } : {}) }
: choice?.kind === 'worktree'
? { scope: 'workspace', worktreeId: choice.id }
: { scope: 'workspace', folderWorkspaceId: choice!.id }
if (!discardLocal) {
const checked = await window.api.skills.previewInstall({
...(environmentId === 'local' || environmentId.startsWith('ssh:')
? {}
: { environmentId }),
name: preview.version.name,
package: {
packageId: preview.version.packageId,
versionId: preview.version.versionId,
packageDigest: preview.version.packageDigest,
archiveSha256: preview.version.archiveSha256,
compressedBytes: preview.version.compressedBytes
},
destination
})
if (checked.status === 'unsupported') {
setError(checked.message)
return
}
setDestinationPreview(checked.value)
if (
['modified', 'unowned', 'external-link', 'name-collision'].includes(
checked.value.currentState
)
) {
return
}
}
const operation = await window.api.skills.installShare({
shareId: preview.shareId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination,
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status === 'unsupported') {
setError(operation.message)
return
}
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
? 'Reconnect your Orca account before installing.'
: operation.message
)
return
}
setResult(operation.value)
} catch (cause) {
console.warn('[skills] install failed:', cause)
setError('Installation failed before Orca could verify the requested version.')
} finally {
setBusy(false)
}
}
const close = (): void => {
setPreview(null)
setDestinationPreview(null)
setError(null)
setLink('')
setScope('global')
setWorkspace('')
setExecutionTarget(null)
onOpenChange(false)
}
const version = preview?.version
const versionSummary = summarizeSkillShareVersion(version)
return (
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
<DialogHeader>
<DialogTitle>Install shared skill</DialogTitle>
<DialogDescription>
Orca verifies access and package identity before changing the selected machine.
</DialogDescription>
</DialogHeader>
{!preview ? (
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault()
void inspect()
}}
>
<div className="space-y-2">
<Label htmlFor="skill-share-link">Orca skill link</Label>
<Input
id="skill-share-link"
value={link}
onChange={(event) => setLink(event.target.value)}
placeholder="https://app.orca.dev/skills/share/…"
className="font-mono text-xs"
autoFocus
/>
<p className="text-xs text-muted-foreground">
Opening the link does not install anything. Review the immutable version first.
</p>
</div>
<Button type="submit" disabled={busy || !link.trim()} className="w-32">
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ShieldCheck className="size-4" />
)}
{busy ? 'Checking…' : 'Inspect skill'}
</Button>
</form>
) : result && result.status !== 'conflict' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<Check className="size-4" />
</div>
<div>
<p className="text-sm font-medium">{skillInstallResultLabel(result)}</p>
<p className="text-xs text-muted-foreground">
{result.placements.length} placement{result.placements.length === 1 ? '' : 's'}{' '}
checked.
</p>
</div>
</div>
{result.status === 'partial' ? (
<div className="space-y-1 text-xs text-muted-foreground">
{result.placements
.filter((item) => item.status === 'failed' || item.status === 'skipped')
.map((item) => (
<p key={`${item.provider}:${item.path}`}>
{item.provider}: {item.errorCategory || item.status}
</p>
))}
</div>
) : null}
</div>
) : (
<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">{version?.name}</h3>
<p className="text-xs leading-5 text-muted-foreground">{version?.description}</p>
</div>
<Badge variant="outline">Immutable version</Badge>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">{version?.manifest.files.length} files</Badge>
<Badge variant="outline">{versionSummary.scriptCount} scripts</Badge>
<Badge variant="outline">{versionSummary.executableCount} executable</Badge>
</div>
<p className="truncate font-mono text-[11px] text-muted-foreground">
SHA-256 {version?.packageDigest}
</p>
{version?.publisher ? (
<p className="text-xs text-muted-foreground">
Published by Orca user {version.publisher.userId}
{version.publisher.organizationId
? ` in organization ${version.publisher.organizationId}`
: ''}
.
</p>
) : null}
<p className="text-xs leading-5 text-muted-foreground">
A skill contains instructions and may include scripts. Treat it as code from its
author.
</p>
</section>
<SkillInstallTargetFields
environmentId={environmentId}
onEnvironmentChange={(value) => {
setEnvironmentId(value)
setDestinationPreview(null)
}}
scope={scope}
onScopeChange={(value) => {
setScope(value)
setDestinationPreview(null)
}}
workspace={workspace}
onWorkspaceChange={(value) => {
setWorkspace(value)
setDestinationPreview(null)
}}
executionTarget={executionTarget}
onExecutionTargetChange={(value) => {
setExecutionTarget(value)
setDestinationPreview(null)
}}
runtimeEnvironments={runtimeEnvironments}
runtimeStatus={runtimeStatus}
sshConnections={sshConnections}
workspaceChoices={workspaceChoices}
/>
{result?.status === 'conflict' ||
(destinationPreview &&
['modified', 'unowned', 'external-link', 'name-collision'].includes(
destinationPreview.currentState
)) ? (
<section className="space-y-2 rounded-md border border-border p-3" role="alert">
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="size-4" /> Local copy needs a decision
</div>
<p className="text-xs leading-5 text-muted-foreground">
Orca found{' '}
{result?.conflict?.kind || destinationPreview?.currentState || 'changed'} content
and left it untouched. Keep it, or explicitly discard and replace it with this
version.
</p>
<Button
type="button"
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void install(true)}
>
Discard and replace
</Button>
</section>
) : null}
</div>
)}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
Close
</Button>
{preview && (!result || result.status === 'conflict') ? (
<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 ? 'Installing…' : 'Install skill'}
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,326 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useAppStore } from '@/store'
import type {
ManagedSkillInstall,
SkillInstallResult
} from '../../../../shared/skill-install-contract'
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
function installKey(install: ManagedSkillInstall): string {
return `${install.destinationIdentity}:${install.name}:${install.packageId}`
}
export function SkillInstallManagementDialog({
open,
onOpenChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
}): React.JSX.Element {
const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
const sshConnectionStates = useAppStore((state) => state.sshConnectionStates)
const sshTargetLabels = useAppStore((state) => state.sshTargetLabels)
const [environmentId, setEnvironmentId] = useState('local')
const [installs, setInstalls] = useState<ManagedSkillInstall[]>([])
const [selectedKey, setSelectedKey] = useState('')
const [details, setDetails] = useState<SkillCloudPackageDetails | null>(null)
const [versionId, setVersionId] = useState('')
const [busy, setBusy] = useState(false)
const [confirmRemove, setConfirmRemove] = useState(false)
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [error, setError] = useState<string | null>(null)
const selected = useMemo(
() => installs.find((install) => installKey(install) === selectedKey) ?? null,
[installs, selectedKey]
)
const load = useCallback(async (): Promise<void> => {
if (!open) {
return
}
setBusy(true)
setError(null)
try {
const operation = await window.api.skills.listManagedInstalls(
environmentId === 'local' ? undefined : environmentId
)
if (operation.status !== 'ok') {
setError(operation.message)
return
}
setInstalls(operation.value)
setSelectedKey('')
setDetails(null)
} catch (cause) {
console.warn('[skills] managed install listing failed:', cause)
setError('Orca could not inspect managed installs on this machine.')
} finally {
setBusy(false)
}
}, [environmentId, open])
useEffect(() => {
void load()
}, [load])
const selectInstall = async (install: ManagedSkillInstall): Promise<void> => {
setSelectedKey(installKey(install))
setBusy(true)
setError(null)
setResult(null)
setConfirmRemove(false)
try {
const operation = await window.api.skills.getPackage(install.packageId)
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
? 'Reconnect your Orca account to load version history.'
: operation.message
)
return
}
setDetails(operation.value)
setVersionId(operation.value.versions[0]?.versionId ?? install.versionId)
} catch (cause) {
console.warn('[skills] package history failed:', cause)
setError('Version history is unavailable for this skill.')
} finally {
setBusy(false)
}
}
const installVersion = async (discardLocal = false): Promise<void> => {
if (!selected || !versionId) {
return
}
setBusy(true)
setError(null)
try {
const operation = await window.api.skills.installPackageVersion({
packageId: selected.packageId,
versionId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination: selected.destination,
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
? 'Reconnect your Orca account before changing versions.'
: operation.message
)
return
}
setResult(operation.value)
if (operation.value.status !== 'conflict') {
await load()
}
} catch (cause) {
console.warn('[skills] version installation failed:', cause)
setError('Orca could not verify the requested version.')
} finally {
setBusy(false)
}
}
const remove = async (discardLocal = false): Promise<void> => {
if (!selected) {
return
}
if (!confirmRemove && !discardLocal) {
setConfirmRemove(true)
return
}
setBusy(true)
setError(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)
return
}
setResult(operation.value)
if (operation.value.status !== 'conflict') {
await load()
}
} catch (cause) {
console.warn('[skills] managed removal failed:', cause)
setError('Orca could not safely remove this skill.')
} finally {
setBusy(false)
}
}
const close = (): void => {
setSelectedKey('')
setDetails(null)
setError(null)
setResult(null)
setConfirmRemove(false)
onOpenChange(false)
}
const destructiveConflict = result?.status === 'conflict' || selected?.state === 'modified'
return (
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Manage installed skills</DialogTitle>
<DialogDescription>
Update, roll back, or safely remove versions installed by Orca.
</DialogDescription>
</DialogHeader>
<Select value={environmentId} onValueChange={setEnvironmentId}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">This computer</SelectItem>
{runtimeEnvironments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
{[...sshTargetLabels.entries()].map(([id, label]) => (
<SelectItem
key={`ssh:${id}`}
value={`ssh:${id}`}
disabled={sshConnectionStates.get(id)?.status !== 'connected'}
>
{label}
{sshConnectionStates.get(id)?.status === 'connected' ? ' · SSH' : ' — disconnected'}
</SelectItem>
))}
</SelectContent>
</Select>
{busy && installs.length === 0 ? <Loader2 className="mx-auto size-5 animate-spin" /> : null}
{!busy && installs.length === 0 ? (
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
No Orca-managed skill installs were found on this machine.
</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>
{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">
Installed version {selected.versionId}
</p>
</div>
<Select value={versionId} onValueChange={setVersionId}>
<SelectTrigger>
<SelectValue placeholder="Choose a version" />
</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" /> Local files were modified
</p>
<p className="text-xs text-muted-foreground">
Orca will preserve them unless you explicitly discard the local changes.
</p>
<Button variant="destructive" size="sm" onClick={() => void installVersion(true)}>
Discard changes and install version
</Button>
<Button variant="destructive" size="sm" onClick={() => void remove(true)}>
Discard changes and remove
</Button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={busy || versionId === selected.versionId}
onClick={() => void installVersion()}
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCcw className="size-4" />
)}
Install selected version
</Button>
<Button
variant={confirmRemove ? 'destructive' : 'outline'}
size="sm"
disabled={busy}
onClick={() => void remove()}
>
<Trash2 className="size-4" /> {confirmRemove ? 'Confirm remove' : 'Remove'}
</Button>
</div>
</section>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { SKILL_INSTALL_CAPABILITY } from '../../../../shared/skill-install-capability'
import type { SkillInstallWorkspaceChoice } from './skill-install-workspace-choices'
export function SkillInstallTargetFields(props: {
environmentId: string
onEnvironmentChange(value: string): void
scope: 'global' | 'workspace'
onScopeChange(value: 'global' | 'workspace'): void
workspace: string
onWorkspaceChange(value: string): void
executionTarget: { kind: 'wsl'; distro: string } | null
onExecutionTargetChange(value: { kind: 'wsl'; distro: string } | null): void
runtimeEnvironments: { id: string; name: string }[]
runtimeStatus: Map<string, { status: { capabilities?: string[] } | null }>
sshConnections: { id: string; label: string; connected: boolean }[]
workspaceChoices: SkillInstallWorkspaceChoice[]
}): React.JSX.Element {
const [wslDistros, setWslDistros] = useState<string[]>([])
useEffect(() => {
let active = true
if (props.environmentId.startsWith('ssh:')) {
setWslDistros([])
return () => {
active = false
}
}
void window.api.skills
.listWslDistros(props.environmentId === 'local' ? undefined : props.environmentId)
.then((distros) => {
if (active) {
setWslDistros(distros)
}
})
.catch(() => {
if (active) {
setWslDistros([])
}
})
return () => {
active = false
}
}, [props.environmentId])
return (
<>
<section className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Machine</Label>
<Select
value={props.environmentId}
onValueChange={(value) => {
props.onEnvironmentChange(value)
props.onWorkspaceChange('')
props.onExecutionTargetChange(null)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">This computer</SelectItem>
{props.runtimeEnvironments.map((environment) => {
const status = props.runtimeStatus.get(environment.id)?.status
const unsupported =
status !== null &&
status !== undefined &&
status.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
return (
<SelectItem key={environment.id} value={environment.id} disabled={unsupported}>
{environment.name}
{unsupported ? ' — update required' : ''}
</SelectItem>
)
})}
{props.sshConnections.map((connection) => (
<SelectItem
key={`ssh:${connection.id}`}
value={`ssh:${connection.id}`}
disabled={!connection.connected}
>
{connection.label}
{!connection.connected ? ' — disconnected' : ' · SSH'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Destination</Label>
<Select
value={props.scope}
onValueChange={(value) => props.onScopeChange(value as 'global' | 'workspace')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global skills</SelectItem>
<SelectItem value="workspace">One workspace</SelectItem>
</SelectContent>
</Select>
</div>
</section>
{props.scope === 'global' && wslDistros.length > 0 ? (
<section className="space-y-2">
<Label>Execution environment</Label>
<Select
value={props.executionTarget?.distro ?? 'host'}
onValueChange={(value) =>
props.onExecutionTargetChange(
value === 'host' ? null : { kind: 'wsl', distro: value }
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="host">Host operating system</SelectItem>
{wslDistros.map((distro) => (
<SelectItem key={distro} value={distro}>
WSL · {distro}
</SelectItem>
))}
</SelectContent>
</Select>
</section>
) : null}
{props.scope === 'workspace' ? (
<section className="space-y-2">
<Label>Workspace</Label>
<Select value={props.workspace} onValueChange={props.onWorkspaceChange}>
<SelectTrigger>
<SelectValue placeholder="Choose a worktree or folder" />
</SelectTrigger>
<SelectContent>
{props.workspaceChoices.map((choice) => (
<SelectItem key={`${choice.kind}:${choice.id}`} value={choice.id}>
{choice.label} · {choice.kind === 'worktree' ? 'Git worktree' : 'Folder'}
</SelectItem>
))}
</SelectContent>
</Select>
{props.workspaceChoices.length === 0 ? (
<p className="text-xs text-muted-foreground">
No workspaces are known on this machine.
</p>
) : null}
</section>
) : null}
</>
)
}
@@ -0,0 +1,381 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Clipboard, FileCode2, Loader2, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { translate } from '@/i18n/i18n'
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
import type {
SkillSharePreview,
SkillShareProgress
} from '../../../../shared/skill-sharing-contract'
import type { DiscoveredSkill } from '../../../../shared/skills'
type SkillShareDialogProps = {
skill: DiscoveredSkill | null
open: boolean
onOpenChange: (open: boolean) => void
}
type SelectableOrgMember = OrcaOrgMember & { userId: string }
function byteLabel(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function operationError(status: string): string {
return status === 'reconnect-required'
? translate(
'auto.components.skills.SkillShareDialog.reconnect',
'Reconnect your Orca account before sharing.'
)
: translate(
'auto.components.skills.SkillShareDialog.unconfigured',
'Connect an Orca Cloud account before sharing.'
)
}
export function SkillShareDialog({
skill,
open,
onOpenChange
}: SkillShareDialogProps): React.JSX.Element {
const [preview, setPreview] = useState<SkillSharePreview | null>(null)
const [members, setMembers] = useState<SelectableOrgMember[]>([])
const [author, setAuthor] = useState('')
const [organization, setOrganization] = useState('')
const [audience, setAudience] = useState<'organization' | 'people'>('organization')
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
const [releaseNotes, setReleaseNotes] = useState('')
const [progress, setProgress] = useState<SkillShareProgress | null>(null)
const [shareUrl, setShareUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [preparing, setPreparing] = useState(false)
const [publishing, setPublishing] = useState(false)
const generation = useRef(0)
useEffect(() => {
if (!open || !skill) {
return
}
const current = ++generation.current
setPreview(null)
setShareUrl(null)
setProgress(null)
setError(null)
setPreparing(true)
void Promise.all([
window.api.skills.prepareShare({ skillId: skill.id }),
window.api.orcaProfiles.authStatus()
])
.then(async ([nextPreview, auth]) => {
if (generation.current !== current) {
await window.api.skills.releaseShare(nextPreview.preparationId)
return
}
setPreview(nextPreview)
const cloud = auth.cloud
setAuthor(cloud?.displayName || cloud?.email || '')
setOrganization(cloud?.activeOrgName || '')
if (!cloud?.activeOrgId) {
setAudience('people')
setMembers([])
return
}
const result = await window.api.orcaProfiles.orgMembersList({ orgId: cloud.activeOrgId })
if (generation.current === current && result.status === 'ok') {
setMembers(
result.roster.members.filter(
(member): member is SelectableOrgMember =>
Boolean(member.userId) && member.userId !== cloud.userId
)
)
}
})
.catch((cause) => {
console.warn('[skills] share preparation failed:', cause)
if (generation.current === current) {
setError(
translate(
'auto.components.skills.SkillShareDialog.prepareFailed',
'Could not prepare this skill for sharing.'
)
)
}
})
.finally(() => {
if (generation.current === current) {
setPreparing(false)
}
})
}, [open, skill])
useEffect(() => {
if (!preview) {
return
}
return window.api.skills.onShareProgress((next) => {
if (next.preparationId === preview.preparationId) {
setProgress(next)
}
})
}, [preview])
const progressPercent = useMemo(() => {
if (!progress || progress.totalBytes === 0) {
return 0
}
return Math.min(100, Math.round((progress.bytesSent / progress.totalBytes) * 100))
}, [progress])
const close = async (): Promise<void> => {
generation.current += 1
if (preview && !shareUrl) {
await window.api.skills.releaseShare(preview.preparationId)
}
onOpenChange(false)
}
const publish = async (): Promise<void> => {
if (!preview) {
return
}
if (audience === 'people' && selectedUserIds.length === 0) {
setError(
translate(
'auto.components.skills.SkillShareDialog.peopleRequired',
'Select at least one teammate.'
)
)
return
}
setPublishing(true)
setError(null)
try {
const result = await window.api.skills.publishShare({
preparationId: preview.preparationId,
releaseNotes,
userIds: audience === 'people' ? selectedUserIds : [],
shareWithOrganization: audience === 'organization'
})
if (result.status !== 'ok') {
setError(operationError(result.status))
return
}
setShareUrl(result.value.share.url)
} catch (cause) {
console.warn('[skills] publish failed:', cause)
setError(
translate(
'auto.components.skills.SkillShareDialog.publishFailed',
'Could not publish this skill. The prepared copy is still available to retry.'
)
)
} finally {
setPublishing(false)
}
}
const copyLink = async (): Promise<void> => {
if (!shareUrl) {
return
}
await window.api.ui.writeClipboardText(shareUrl)
toast.success(translate('auto.components.skills.SkillShareDialog.copied', 'Share link copied'))
}
return (
<Dialog open={open} onOpenChange={(next) => !next && !publishing && void close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{shareUrl
? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready')
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
</DialogTitle>
<DialogDescription>
{shareUrl
? translate(
'auto.components.skills.SkillShareDialog.readyDescription',
'Recipients authenticate with Orca before they can inspect or install it.'
)
: translate(
'auto.components.skills.SkillShareDialog.description',
'Review the exact files, choose who can access them, then publish an immutable version.'
)}
</DialogDescription>
</DialogHeader>
{preparing ? (
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')}
</div>
) : preview && !shareUrl ? (
<div className="space-y-5">
<section className="space-y-2">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<FileCode2 className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-semibold">{preview.name}</h3>
<p className="text-xs leading-5 text-muted-foreground">{preview.description}</p>
</div>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">{preview.fileCount} files</Badge>
<Badge variant="outline">{byteLabel(preview.totalBytes)}</Badge>
<Badge variant="outline">{preview.scriptPaths.length} scripts</Badge>
<Badge variant="outline">{preview.executablePaths.length} executable</Badge>
</div>
<p className="truncate font-mono text-[11px] text-muted-foreground">
SHA-256 {preview.packageDigest}
</p>
</section>
<section className="space-y-2">
<div className="space-y-1">
<Label>Access</Label>
<p className="text-xs text-muted-foreground">
{author
? `Publishing as ${author}${organization ? ` in ${organization}` : ''}.`
: 'A connected Orca Cloud account is required.'}
</p>
</div>
<Tabs
value={audience}
onValueChange={(value) => setAudience(value as typeof audience)}
>
<TabsList aria-label="Skill access">
<TabsTrigger value="organization" disabled={!organization}>
Organization
</TabsTrigger>
<TabsTrigger value="people">Selected people</TabsTrigger>
</TabsList>
<TabsContent value="organization" className="pt-2 text-xs text-muted-foreground">
Everyone currently in {organization || 'the organization'} can access the link.
</TabsContent>
<TabsContent value="people" className="space-y-2 pt-2">
{members.length > 0 ? (
<div className="max-h-32 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
{members.map((member) => {
const checked = selectedUserIds.includes(member.userId)
return (
<label
key={member.userId}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
>
<Checkbox
checked={checked}
onCheckedChange={(next) =>
setSelectedUserIds((current) =>
next
? [...new Set([...current, member.userId])]
: current.filter((id) => id !== member.userId)
)
}
/>
<span className="min-w-0 truncate text-xs">
{member.displayName || member.email}
</span>
</label>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">No teammates are available.</p>
)}
</TabsContent>
</Tabs>
</section>
<section className="space-y-2">
<Label htmlFor="skill-release-notes">Release notes</Label>
<textarea
id="skill-release-notes"
value={releaseNotes}
onChange={(event) => setReleaseNotes(event.target.value)}
maxLength={10_000}
placeholder="What changed in this version?"
className="min-h-20 w-full resize-y rounded-md border border-border bg-input px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</section>
{publishing ? (
<section className="space-y-2" aria-live="polite">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{progress?.phase === 'finalizing' ? 'Validating and publishing…' : 'Uploading…'}
</span>
<span>{progress?.phase === 'finalizing' ? '100%' : `${progressPercent}%`}</span>
</div>
<Progress value={progress?.phase === 'finalizing' ? 100 : progressPercent} />
</section>
) : null}
</div>
) : shareUrl ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-md border border-border p-3">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<Check className="size-4" />
</div>
<p className="min-w-0 flex-1 truncate font-mono text-xs">{shareUrl}</p>
<Button type="button" variant="outline" size="sm" onClick={() => void copyLink()}>
<Clipboard className="size-4" /> Copy link
</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">
Revoking this link blocks future access. It does not remove copies already installed
on recipients’ machines.
</p>
</div>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => void close()} disabled={publishing}>
{shareUrl ? 'Done' : 'Cancel'}
</Button>
{!shareUrl ? (
publishing ? (
<Button
type="button"
variant="secondary"
onClick={() => preview && window.api.skills.cancelShare(preview.preparationId)}
>
Cancel upload
</Button>
) : (
<Button type="button" disabled={!preview || preparing} onClick={() => void publish()}>
<Share2 className="size-4" /> Publish skill
</Button>
)
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowLeft, BookOpen, Loader2, RefreshCw, Search } from 'lucide-react'
import { ArrowLeft, BookOpen, Download, History, Loader2, RefreshCw, Search } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -18,6 +18,9 @@ import { useActiveSkillDiscoveryRuntimeTarget } from '@/hooks/use-active-skill-d
import { useMountedRef } from '@/hooks/useMountedRef'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills'
import { SkillCard } from './SkillCard'
import { SkillShareDialog } from './SkillShareDialog'
import { SkillInstallDialog } from './SkillInstallDialog'
import { SkillInstallManagementDialog } from './SkillInstallManagementDialog'
import { pluralize, sourceLabels } from './skill-display-labels'
import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter'
import { translate } from '@/i18n/i18n'
@@ -74,9 +77,15 @@ function EmptyState({
export default function SkillsPage(): React.JSX.Element {
const closeSkillsPage = useAppStore((s) => s.closeSkillsPage)
const pendingSkillShareId = useAppStore((s) => s.pendingSkillShareId)
const clearPendingSkillShare = useAppStore((s) => s.clearPendingSkillShare)
const runtimeTarget = useActiveSkillDiscoveryRuntimeTarget()
const [result, setResult] = useState<SkillDiscoveryResult | null>(null)
const [loading, setLoading] = useState(true)
const [shareSkill, setShareSkill] = useState<DiscoveredSkill | null>(null)
const [installOpen, setInstallOpen] = useState(false)
const [installLink, setInstallLink] = useState('')
const [managementOpen, setManagementOpen] = useState(false)
const [filters, setFilters] = useState<SkillsFilterState>({
query: '',
sourceKind: 'all',
@@ -120,6 +129,15 @@ export default function SkillsPage(): React.JSX.Element {
void loadSkills()
}, [loadSkills])
useEffect(() => {
if (!pendingSkillShareId) {
return
}
setInstallLink(`https://app.orca.dev/skills/share/${pendingSkillShareId}`)
setInstallOpen(true)
clearPendingSkillShare()
}, [clearPendingSkillShare, pendingSkillShareId])
useEffect(() => {
const hasVisibleOverlay = (): boolean =>
Array.from(
@@ -193,6 +211,20 @@ export default function SkillsPage(): React.JSX.Element {
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setInstallLink('')
setInstallOpen(true)
}}
>
<Download className="size-3.5" /> Install from link
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setManagementOpen(true)}>
<History className="size-3.5" /> Manage installs
</Button>
</header>
<section className="flex shrink-0 flex-col gap-3 border-b border-border px-5 py-4">
@@ -295,7 +327,17 @@ 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 key={skill.id} skill={skill} />
<SkillCard
key={skill.id}
skill={skill}
onShare={
runtimeTarget?.kind === 'local' &&
skill.installed &&
(skill.sourceKind === 'home' || skill.sourceKind === 'repo')
? () => setShareSkill(skill)
: undefined
}
/>
))}
</div>
) : (
@@ -306,6 +348,23 @@ export default function SkillsPage(): React.JSX.Element {
/>
)}
</section>
<SkillShareDialog
skill={shareSkill}
open={shareSkill !== null}
onOpenChange={(nextOpen) => !nextOpen && setShareSkill(null)}
/>
<SkillInstallDialog
key={installLink || 'manual-install'}
open={installOpen}
initialLink={installLink}
onOpenChange={(next) => {
setInstallOpen(next)
if (!next) {
setInstallLink('')
}
}}
/>
<SkillInstallManagementDialog open={managementOpen} onOpenChange={setManagementOpen} />
</main>
)
}
@@ -0,0 +1,18 @@
import type { SkillInstallResult } from '../../../../shared/skill-install-contract'
export function skillInstallResultLabel(result: SkillInstallResult): string {
switch (result.status) {
case 'installed':
return 'Installed and verified.'
case 'updated':
return 'Updated and verified.'
case 'unchanged':
return 'This exact version is already installed.'
case 'partial':
return 'Installed, but one or more agent placements need attention.'
case 'removed':
return 'Removed.'
default:
return 'Installation did not complete.'
}
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace, Repo, Worktree } from '../../../../shared/types'
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
describe('skillInstallWorkspaceChoices', () => {
it('includes SSH worktrees whose legacy host identity is inherited from the repo', () => {
const repo = {
id: 'repo_1',
connectionId: 'ssh_1',
executionHostId: 'ssh:ssh_1'
} as unknown as Repo
const worktree = {
id: 'repo_1::/remote/worktree',
repoId: repo.id,
displayName: 'Remote worktree'
} as Worktree
expect(
skillInstallWorkspaceChoices({
environmentId: 'ssh:ssh_1',
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
folderWorkspaces: []
})
).toEqual([{ id: worktree.id, label: worktree.displayName, kind: 'worktree' }])
})
it('keeps local, paired-runtime, and SSH workspace catalogs isolated', () => {
const folders = [
{ id: 'local', name: 'Local', folderPath: '/local', executionHostId: 'local' },
{
id: 'runtime',
name: 'Runtime',
folderPath: '/runtime',
executionHostId: 'runtime:environment_1'
},
{ id: 'ssh', name: 'SSH', folderPath: '/ssh', executionHostId: 'ssh:ssh_1' }
] as FolderWorkspace[]
const input = { repos: [], worktreesByRepo: {}, folderWorkspaces: folders }
expect(skillInstallWorkspaceChoices({ ...input, environmentId: 'local' })).toHaveLength(1)
expect(skillInstallWorkspaceChoices({ ...input, environmentId: 'environment_1' })).toHaveLength(
1
)
expect(skillInstallWorkspaceChoices({ ...input, environmentId: 'ssh:ssh_1' })).toHaveLength(1)
})
})
@@ -0,0 +1,45 @@
import type { FolderWorkspace, Repo, Worktree } from '../../../../shared/types'
export type SkillInstallWorkspaceChoice = {
id: string
label: string
kind: 'worktree' | 'folder'
}
function belongsToEnvironment(
value: Worktree | FolderWorkspace,
machineId: string,
repo?: Repo
): boolean {
const folder = 'folderPath' in value
const hostId = folder
? value.executionHostId
: (value.hostId ??
repo?.executionHostId ??
(repo?.connectionId ? (`ssh:${repo.connectionId}` as const) : undefined))
const runtimeOwner = folder ? null : value.runtimeOwnerEnvironmentId
if (machineId.startsWith('ssh:')) {
return hostId === machineId
}
if (machineId !== 'local') {
return runtimeOwner === machineId || hostId === `runtime:${machineId}`
}
return !runtimeOwner && (!hostId || hostId === 'local')
}
export function skillInstallWorkspaceChoices(input: {
environmentId: string
worktreesByRepo: Record<string, Worktree[]>
folderWorkspaces: FolderWorkspace[]
repos: readonly Repo[]
}): SkillInstallWorkspaceChoice[] {
const repos = new Map(input.repos.map((repo) => [repo.id, repo]))
const worktrees = Object.values(input.worktreesByRepo)
.flat()
.filter((value) => belongsToEnvironment(value, input.environmentId, repos.get(value.repoId)))
.map((value) => ({ id: value.id, label: value.displayName, kind: 'worktree' as const }))
const folders = input.folderWorkspaces
.filter((value) => belongsToEnvironment(value, input.environmentId))
.map((value) => ({ id: value.id, label: value.name, kind: 'folder' as const }))
return [...worktrees, ...folders].sort((left, right) => left.label.localeCompare(right.label))
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { parseSkillShareId } from './skill-share-link'
describe('parseSkillShareId', () => {
it('accepts durable Orca links and bare identifiers', () => {
expect(parseSkillShareId('share_123')).toBe('share_123')
expect(parseSkillShareId('https://app.orca.dev/skills/share/share_123')).toBe('share_123')
expect(parseSkillShareId('https://share.onorca.dev/skills/share/share_123/')).toBe('share_123')
expect(parseSkillShareId('orca://skills/share/share_123')).toBe('share_123')
})
it('rejects attacker origins and lookalike paths', () => {
expect(parseSkillShareId('https://attacker.test/skills/share/share_123')).toBeNull()
expect(parseSkillShareId('https://app.orca.dev/skills/share/share_123/more')).toBeNull()
expect(parseSkillShareId('javascript:share_123')).toBeNull()
})
})
@@ -0,0 +1 @@
export { parseSkillShareId } from '../../../../shared/skill-share-link'
@@ -0,0 +1,14 @@
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
export type ResolvedSkillShare = { shareId: string; version: SkillCloudVersion }
export function summarizeSkillShareVersion(version: SkillCloudVersion | undefined): {
scriptCount: number
executableCount: number
} {
return {
scriptCount:
version?.manifest.files.filter((file) => file.path.startsWith('scripts/')).length ?? 0,
executableCount: version?.manifest.files.filter((file) => file.executable).length ?? 0
}
}
+15
View File
@@ -1160,6 +1160,12 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.ui.onOpenSkillShare((shareId) => {
useAppStore.getState().openSkillShare(shareId)
})
)
// Why: a tray "Settings…" click can fire before this attaches; consume any queued intent (?. guards stale preload).
void window.api.ui
.consumePendingOpenSettings?.()
@@ -1170,6 +1176,15 @@ export function useIpcEvents(): void {
})
.catch(() => {})
void window.api.ui
.consumePendingSkillShare()
.then((shareId) => {
if (shareId) {
useAppStore.getState().openSkillShare(shareId)
}
})
.catch(() => {})
unsubs.push(
window.api.ui.onOpenSetupGuide?.(() => {
useAppStore.getState().openModal('setup-guide', { telemetrySource: 'help_menu' })
+12
View File
@@ -762,6 +762,9 @@ export type UISlice = {
closeSpacePage: () => void
openSkillsPage: () => void
closeSkillsPage: () => void
pendingSkillShareId: string | null
openSkillShare: (shareId: string) => void
clearPendingSkillShare: () => void
openArtifactsPage: () => void
closeArtifactsPage: () => void
openMobilePage: () => void
@@ -1258,6 +1261,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
previousViewBeforeAutomations: 'terminal',
previousViewBeforeSpace: 'terminal',
previousViewBeforeSkills: 'terminal',
pendingSkillShareId: null,
previousViewBeforeMobile: 'terminal',
previousViewBeforeArtifacts: 'terminal',
setActiveView: (view) => set({ activeView: view }),
@@ -1507,6 +1511,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set((state) => ({
activeView: state.previousViewBeforeSkills
})),
openSkillShare: (shareId) =>
set((state) => ({
activeView: 'skills',
previousViewBeforeSkills:
state.activeView === 'skills' ? state.previousViewBeforeSkills : state.activeView,
pendingSkillShareId: shareId
})),
clearPendingSkillShare: () => set({ pendingSkillShareId: null }),
openArtifactsPage: () =>
set((state) => ({
activeView: 'artifacts',
+19
View File
@@ -2762,6 +2762,8 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
onOpenSettings: () => noopUnsubscribe,
// Why: the web client has no native tray/menu bar, so there's never a queued open-settings intent to consume.
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenSkillShare: () => noopUnsubscribe,
consumePendingSkillShare: () => Promise.resolve(null),
onOpenSetupGuide: () => noopUnsubscribe,
onOpenFeatureTour: () => noopUnsubscribe,
onOpenCrashReport: () => noopUnsubscribe,
@@ -3073,6 +3075,23 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
cancelUpdateRun: () => Promise.resolve(),
acknowledgeUpdateRun: () => Promise.resolve(),
getUpdateRun: () => Promise.resolve({ state: 'idle' as const }),
prepareShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')),
publishShare: () => Promise.reject(new Error('Skill publishing requires the desktop app.')),
cancelShare: () => Promise.resolve(),
releaseShare: () => Promise.resolve(),
resolveShare: () => Promise.reject(new Error('Skill share links require the desktop app.')),
createDownloadGrant: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
installPackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
previewInstall: () => 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.')),
getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
listWslDistros: () => Promise.resolve([]),
onShareProgress: () => () => {},
onUpdateRun: () => () => {}
}
}
+9 -1
View File
@@ -1,4 +1,9 @@
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
import {
SKILL_INSTALL_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from './skill-install-capability'
// Why: declares the Orca runtime RPC compatibility contract. Desktop,
// headless server, CLI, and mobile builds may drift in app version, but
@@ -118,7 +123,10 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY
] as const
export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})
+68
View File
@@ -0,0 +1,68 @@
import type { SkillPackageManifestV1 } from './skill-package-manifest'
export type SkillCloudOptions = {
apiUrl?: string
authToken?: string
}
export type SkillCloudInstallTarget = 'local' | 'remote'
export type SkillCloudOperation<T> =
| { status: 'ok'; value: T }
| { status: 'unconfigured'; message: string }
| { status: 'reconnect-required' }
export type SkillCloudVersion = {
packageId: string
versionId: string
name: string
description: string
packageDigest: string
archiveSha256: string
compressedBytes: number
createdAt: string
releaseNotes: string
manifest: SkillPackageManifestV1
publisher?: { userId: string; organizationId?: string }
}
export type SkillCloudShare = {
id: string
url: string
}
export type SkillCloudDownloadGrant = {
grant: { url: string; expiresAt: string }
version: SkillCloudVersion
}
export type SkillCloudPackageDetails = {
id: string
name: string
description: string
createdAt: string
canManage: boolean
versions: SkillCloudVersion[]
}
export type SkillCloudPublishRequest = SkillCloudOptions & {
archivePath: string
archiveSha256: string
compressedBytes: number
packageId: string
releaseNotes: string
pinnedVersionId?: string
userIds: string[]
shareWithOrganization: boolean
onProgress?: (progress: {
phase: 'uploading' | 'finalizing'
bytesSent: number
totalBytes: number
}) => void
signal?: AbortSignal
}
export type SkillCloudPublishResult = {
version: SkillCloudVersion
share: SkillCloudShare
}
+6
View File
@@ -0,0 +1,6 @@
export const SKILL_INSTALL_CAPABILITY = 'skills.install.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_INSTALL_UPDATE_REQUIRED_MESSAGE =
'Update Orca on the selected machine to install shared skills.'
+201
View File
@@ -0,0 +1,201 @@
import { z } from 'zod'
export const SkillPackageIdentitySchema = z
.object({
packageId: z.string().min(1).max(128),
versionId: z.string().min(1).max(128),
packageDigest: z.string().regex(/^[a-f0-9]{64}$/),
archiveSha256: z.string().regex(/^[a-f0-9]{64}$/),
compressedBytes: z
.number()
.int()
.positive()
.max(40 * 1024 * 1024)
})
.strict()
const SkillInstallIngressSchema = 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: z.string().min(1).max(128) }).strict(),
z.object({ kind: z.literal('local-file'), path: z.string().min(1) }).strict()
])
export const SkillInstallDestinationSchema = z.discriminatedUnion('scope', [
z
.object({
scope: z.literal('global'),
environmentId: z.string().min(1).optional(),
executionTarget: z
.discriminatedUnion('kind', [
z.object({ kind: z.literal('host') }).strict(),
z.object({ kind: z.literal('wsl'), distro: z.string().min(1) }).strict(),
z.object({ kind: z.literal('ssh'), connectionId: z.string().min(1).max(128) }).strict()
])
.optional()
})
.strict(),
z
.object({
scope: z.literal('workspace'),
worktreeId: z.string().min(1).optional(),
folderWorkspaceId: z.string().min(1).optional()
})
.strict()
.refine((value) => Boolean(value.worktreeId) !== Boolean(value.folderWorkspaceId))
])
export const SkillInstallRequestSchema = z
.object({
operationId: z.string().min(1).max(128),
package: SkillPackageIdentitySchema,
ingress: SkillInstallIngressSchema,
destination: SkillInstallDestinationSchema,
conflictResolution: z
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
.optional()
})
.strict()
const SkillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
export const SkillInstallPreviewRequestSchema = z
.object({
package: SkillPackageIdentitySchema,
name: SkillNameSchema,
destination: SkillInstallDestinationSchema
})
.strict()
export const SkillRemoveRequestSchema = z
.object({
operationId: z.string().min(1).max(128),
name: SkillNameSchema,
destination: SkillInstallDestinationSchema,
conflictResolution: z.enum(['replace-and-discard-local', 'cancel']).optional()
})
.strict()
export type SkillInstallRequest = z.infer<typeof SkillInstallRequestSchema>
export type SkillInstallDestination = z.infer<typeof SkillInstallDestinationSchema>
export type SkillPackageIdentity = z.infer<typeof SkillPackageIdentitySchema>
export type SkillInstallPreviewRequest = z.infer<typeof SkillInstallPreviewRequestSchema>
export type SkillRemoveRequest = z.infer<typeof SkillRemoveRequestSchema>
export type SkillInstallPreview = {
name: string
packageDigest: string
destinationIdentity: string
currentState:
| 'missing'
| 'unchanged'
| 'clean-update'
| 'modified'
| 'unowned'
| 'external-link'
| 'name-collision'
providers: {
provider: string
topology: 'canonical-copy' | 'provider-alias' | 'independent-copy'
state: 'ready' | 'missing' | 'conflict'
}[]
}
export type ManagedSkillInstall = {
name: string
packageId: string
versionId: string
packageDigest: string
scope: 'global' | 'workspace'
destinationIdentity: string
destination: SkillInstallDestination
installedAt: string
state: 'unchanged' | 'modified' | 'missing'
}
export const ManagedSkillInstallSchema: z.ZodType<ManagedSkillInstall> = z.object({
name: SkillNameSchema,
packageId: z.string(),
versionId: z.string(),
packageDigest: z.string().regex(/^[a-f0-9]{64}$/),
scope: z.enum(['global', 'workspace']),
destinationIdentity: z.string(),
destination: SkillInstallDestinationSchema,
installedAt: z.string(),
state: z.enum(['unchanged', 'modified', 'missing'])
})
export const ManagedSkillInstallListSchema = z.array(ManagedSkillInstallSchema).max(2048)
export const SkillInstallPreviewSchema: z.ZodType<SkillInstallPreview> = z.object({
name: SkillNameSchema,
packageDigest: z.string().regex(/^[a-f0-9]{64}$/),
destinationIdentity: z.string(),
currentState: z.enum([
'missing',
'unchanged',
'clean-update',
'modified',
'unowned',
'external-link',
'name-collision'
]),
providers: z.array(
z.object({
provider: z.string(),
topology: z.enum(['canonical-copy', 'provider-alias', 'independent-copy']),
state: z.enum(['ready', 'missing', 'conflict'])
})
)
})
export type SkillPlacementResult = {
provider: string
path: string
topology: 'canonical-copy' | 'provider-alias' | 'independent-copy'
status: 'installed' | 'unchanged' | 'removed' | 'skipped' | 'failed'
errorCategory?: string
}
export type SkillInstallResult = {
operationId: string
status: 'installed' | 'updated' | 'unchanged' | 'removed' | 'conflict' | 'partial' | 'failed'
name: string
packageDigest: string
canonicalPath?: string
placements: SkillPlacementResult[]
conflict?: {
kind: 'modified' | 'unowned' | 'external-link' | 'name-collision'
existingDigest?: string
}
errorCategory?: string
}
export const SkillInstallResultSchema: z.ZodType<SkillInstallResult> = z.object({
operationId: z.string(),
status: z.enum(['installed', 'updated', 'unchanged', 'removed', 'conflict', 'partial', 'failed']),
name: z.string(),
packageDigest: z.string(),
canonicalPath: z.string().optional(),
placements: z.array(
z.object({
provider: z.string(),
path: z.string(),
topology: z.enum(['canonical-copy', 'provider-alias', 'independent-copy']),
status: z.enum(['installed', 'unchanged', 'removed', 'skipped', 'failed']),
errorCategory: z.string().optional()
})
),
conflict: z
.object({
kind: z.enum(['modified', 'unowned', 'external-link', 'name-collision']),
existingDigest: z.string().optional()
})
.optional(),
errorCategory: z.string().optional()
})
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
computeSkillPackageDigest,
parseSkillPackageManifest,
validateSkillPackagePath,
type SkillPackageFile
} from './skill-package-manifest'
const file: SkillPackageFile = {
path: 'SKILL.md',
size: 5,
executable: false,
classification: 'text',
sha256: 'a'.repeat(64),
identitySha256: 'b'.repeat(64)
}
function manifest(files: SkillPackageFile[] = [file]): unknown {
return {
schemaVersion: 1,
packageId: 'package_1',
versionId: 'version_1',
name: 'test-skill',
description: 'Test',
createdAt: '2026-08-11T12:00:00.000Z',
files,
packageDigest: computeSkillPackageDigest(files)
}
}
describe('skill package manifest', () => {
it('parses a canonical manifest', () => {
expect(parseSkillPackageManifest(manifest()).name).toBe('test-skill')
})
it.each([
'../SKILL.md',
'/SKILL.md',
'C:/SKILL.md',
'references\\escape.md',
'references//x.md',
'references/con',
'references/trailing. '
])('rejects the non-portable path %s', (path) => {
expect(() => validateSkillPackagePath(path)).toThrow('skill-package-path-invalid')
})
it('rejects case collisions and digest drift', () => {
const collision = [file, { ...file, path: 'skill.md' }]
expect(() => parseSkillPackageManifest(manifest(collision))).toThrow(
'skill-package-case-collision'
)
expect(() =>
parseSkillPackageManifest({ ...(manifest() as object), packageDigest: 'c'.repeat(64) })
).toThrow('skill-package-digest-mismatch')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { createHash } from 'node:crypto'
import { z } from 'zod'
export const SKILL_PACKAGE_SCHEMA_VERSION = 1 as const
export const SKILL_PACKAGE_CONTENT_TYPE = 'application/vnd.orca.skill+tar+gzip'
export const SKILL_PACKAGE_MAX_COMPRESSED_BYTES = 40 * 1024 * 1024
export const SKILL_PACKAGE_MAX_MANIFEST_BYTES = 1024 * 1024
const SHA256_PATTERN = /^[a-f0-9]{64}$/
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/
const WINDOWS_RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
export const SkillPackageFileSchema = z
.object({
path: z.string().min(1).max(1024),
size: z
.number()
.int()
.nonnegative()
.max(4 * 1024 * 1024),
executable: z.boolean(),
classification: z.enum(['text', 'binary']),
sha256: z.string().regex(SHA256_PATTERN),
identitySha256: z.string().regex(SHA256_PATTERN)
})
.strict()
export type SkillPackageFile = z.infer<typeof SkillPackageFileSchema>
export const SkillPackageManifestV1Schema = z
.object({
schemaVersion: z.literal(SKILL_PACKAGE_SCHEMA_VERSION),
packageId: z.string().regex(ID_PATTERN),
versionId: z.string().regex(ID_PATTERN),
name: z.string().regex(SKILL_NAME_PATTERN),
description: z.string().max(4096),
createdAt: z.iso.datetime({ offset: true }),
files: z.array(SkillPackageFileSchema).min(1).max(512),
packageDigest: z.string().regex(SHA256_PATTERN)
})
.strict()
export type SkillPackageManifestV1 = z.infer<typeof SkillPackageManifestV1Schema>
export function validateSkillPackagePath(path: string): void {
if (path !== path.normalize('NFC') || Buffer.byteLength(path, 'utf8') > 1024) {
throw new Error('skill-package-path-invalid')
}
if (path.startsWith('/') || path.includes('\\') || path.includes('\0')) {
throw new Error('skill-package-path-invalid')
}
const segments = path.split('/')
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
throw new Error('skill-package-path-invalid')
}
for (const segment of segments) {
if (
Buffer.byteLength(segment, 'utf8') > 255 ||
[...segment].some((character) => character.charCodeAt(0) <= 0x1f) ||
':*?"<>|'.split('').some((character) => segment.includes(character)) ||
/[ .]$/.test(segment) ||
WINDOWS_RESERVED_SEGMENT.test(segment)
) {
throw new Error('skill-package-path-invalid')
}
}
}
export function computeSkillPackageDigest(files: readonly SkillPackageFile[]): string {
return createHash('sha256')
.update(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
)
)
.digest('hex')
}
export function parseSkillPackageManifest(value: unknown): SkillPackageManifestV1 {
const parsed = SkillPackageManifestV1Schema.safeParse(value)
if (!parsed.success) {
throw new Error('skill-package-manifest-invalid')
}
let previousPath: string | null = null
const foldedPaths = new Set<string>()
let totalBytes = 0
for (const file of parsed.data.files) {
validateSkillPackagePath(file.path)
if (previousPath !== null && file.path <= previousPath) {
throw new Error('skill-package-manifest-path-order')
}
previousPath = file.path
const folded = file.path.toLocaleLowerCase('en-US')
if (foldedPaths.has(folded)) {
throw new Error('skill-package-case-collision')
}
foldedPaths.add(folded)
totalBytes += file.size
if (totalBytes > 32 * 1024 * 1024) {
throw new Error('skill-package-total-size-limit')
}
}
if (!parsed.data.files.some((file) => file.path === 'SKILL.md')) {
throw new Error('skill-package-skill-markdown-required')
}
if (computeSkillPackageDigest(parsed.data.files) !== parsed.data.packageDigest) {
throw new Error('skill-package-digest-mismatch')
}
return parsed.data
}
+38
View File
@@ -0,0 +1,38 @@
const SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
const PRODUCTION_HOSTS = new Set(['app.orca.dev', 'share.onorca.dev'])
export function parseSkillShareId(value: string): string | null {
const trimmed = value.trim()
if (SHARE_ID_PATTERN.test(trimmed)) {
return trimmed
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol === 'orca:') {
const match = `${url.host}${url.pathname}`.match(/^skills\/share\/([A-Za-z0-9_-]{1,128})\/?$/)
return match?.[1] ?? null
}
const developmentHost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
if (url.protocol !== 'https:' && !(developmentHost && url.protocol === 'http:')) {
return null
}
if (!PRODUCTION_HOSTS.has(url.hostname) && !developmentHost) {
return null
}
const match = url.pathname.match(/^\/skills\/share\/([A-Za-z0-9_-]{1,128})\/?$/)
return match?.[1] ?? null
}
export function skillShareIdFromArguments(argv: readonly string[]): string | null {
for (const value of argv) {
const id = parseSkillShareId(value)
if (id && (value.includes('/skills/share/') || value.startsWith('orca:'))) {
return id
}
}
return null
}
+94
View File
@@ -0,0 +1,94 @@
import type {
SkillCloudOperation,
SkillCloudPublishResult,
SkillCloudVersion
} from './skill-cloud-contract'
import type {
SkillInstallDestination,
SkillInstallPreview,
SkillInstallResult,
SkillPackageIdentity,
ManagedSkillInstall
} from './skill-install-contract'
export type SkillSharePreview = {
preparationId: string
packageId: string
versionId: string
name: string
description: string
packageDigest: string
archiveSha256: string
fileCount: number
totalBytes: number
compressedBytes: number
scriptPaths: string[]
executablePaths: string[]
expiresAt: string
}
export type SkillSharePublishInput = {
preparationId: string
releaseNotes: string
userIds: string[]
shareWithOrganization: boolean
}
export type SkillSharePublishOperation = SkillCloudOperation<SkillCloudPublishResult>
export type SkillShareResolvedOperation = SkillCloudOperation<{
id: string
version: SkillCloudVersion
}>
export type SkillShareProgress = {
preparationId: string
phase: 'uploading' | 'finalizing'
bytesSent: number
totalBytes: number
}
export type SkillShareInstallInput = {
shareId: string
versionId?: string
environmentId?: string
destination: SkillInstallDestination
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
}
export type SkillPackageVersionInstallInput = Omit<SkillShareInstallInput, 'shareId'> & {
packageId: string
versionId: string
}
export type SkillShareInstallOperation =
| { status: 'ok'; value: SkillInstallResult }
| { status: 'unconfigured'; message: string }
| { status: 'reconnect-required' }
| { status: 'unsupported'; message: string }
export type SkillInstallPreviewInput = {
environmentId?: string
package: SkillPackageIdentity
name: string
destination: SkillInstallDestination
}
export type SkillInstallPreviewOperation =
| { status: 'ok'; value: SkillInstallPreview }
| { status: 'unsupported'; message: string }
export type SkillRemoveInput = {
environmentId?: string
name: string
destination: SkillInstallDestination
conflictResolution?: 'replace-and-discard-local' | 'cancel'
}
export type SkillRemoveOperation =
| { status: 'ok'; value: SkillInstallResult }
| { status: 'unsupported'; message: string }
export type ManagedSkillInstallListOperation =
| { status: 'ok'; value: ManagedSkillInstall[] }
| { status: 'unsupported'; message: string }
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod'
import {
SkillInstallPreviewRequestSchema,
SkillInstallRequestSchema,
SkillRemoveRequestSchema
} from './skill-install-contract'
export const SKILL_SSH_RELAY_INSTALL_METHOD = 'skills.install' 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
export const SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD = 'skills.beginUpload' as const
export const SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD = 'skills.uploadChunk' as const
export const SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD = 'skills.commitUpload' as const
export const SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD = 'skills.cancelUpload' as const
const SkillSshWorkspaceAuthoritySchema = z.discriminatedUnion('kind', [
z
.object({ kind: z.literal('worktree'), id: z.string().min(1), path: z.string().min(1) })
.strict(),
z.object({ kind: z.literal('folder'), id: z.string().min(1), path: z.string().min(1) }).strict()
])
export const SkillSshInstallParamsSchema = z
.object({
request: SkillInstallRequestSchema,
workspace: SkillSshWorkspaceAuthoritySchema.optional()
})
.strict()
export const SkillSshPreviewParamsSchema = z
.object({
request: SkillInstallPreviewRequestSchema,
workspace: SkillSshWorkspaceAuthoritySchema.optional()
})
.strict()
export const SkillSshRemoveParamsSchema = z
.object({
request: SkillRemoveRequestSchema,
workspace: SkillSshWorkspaceAuthoritySchema.optional()
})
.strict()
export const SkillSshListParamsSchema = z
.object({ workspaces: z.array(SkillSshWorkspaceAuthoritySchema).max(4096) })
.strict()
export type SkillSshWorkspaceAuthority = z.infer<typeof SkillSshWorkspaceAuthoritySchema>
export type SkillSshInstallParams = z.infer<typeof SkillSshInstallParamsSchema>
export type SkillSshPreviewParams = z.infer<typeof SkillSshPreviewParamsSchema>
export type SkillSshRemoveParams = z.infer<typeof SkillSshRemoveParamsSchema>
@@ -0,0 +1,23 @@
import { z } from 'zod'
import { SkillPackageIdentitySchema } from './skill-install-contract'
export const SKILL_UPLOAD_CHUNK_MAX_BYTES = 256 * 1024
export const SkillUploadBeginRequestSchema = z
.object({ package: SkillPackageIdentitySchema })
.strict()
export const SkillUploadChunkRequestSchema = z
.object({
uploadId: z.string().min(1).max(128),
offset: z.number().int().nonnegative(),
bytesBase64: z.string().max(Math.ceil(SKILL_UPLOAD_CHUNK_MAX_BYTES / 3) * 4 + 8)
})
.strict()
export const SkillUploadCommitRequestSchema = z
.object({ uploadId: z.string().min(1).max(128) })
.strict()
export type SkillUploadBeginRequest = z.infer<typeof SkillUploadBeginRequestSchema>
export type SkillUploadChunkRequest = z.infer<typeof SkillUploadChunkRequestSchema>