mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat: add private skill sharing and installer
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -176,7 +176,7 @@ export function registerCoreHandlers(
|
||||
registerTerminalRenderDesyncEvidenceHandler()
|
||||
registerComputerUsePermissionHandlers()
|
||||
registerSettingsHandlers(store, agentAwakeService)
|
||||
registerSkillsHandlers(store)
|
||||
registerSkillsHandlers(store, runtime)
|
||||
if (automations) {
|
||||
registerAutomationHandlers(store, automations)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user