diff --git a/.gitignore b/.gitignore index 17bb18529af..51f904626f0 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ebc372c480a..46b919ff9b2 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -92,6 +92,7 @@ const winSpeechNativeResource = { module.exports = { appId, productName: 'Orca', + protocols: [{ name: 'Orca', schemes: ['orca'] }], ...(devChannelBuildVersion ? { extraMetadata: { version: devChannelBuildVersion } } : localBuildVersion diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md new file mode 100644 index 00000000000..cd9848e58b9 --- /dev/null +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -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/` 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 `/.agents/skills/` 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///package.tar.gz`. +- [ ] Define tenant-bound random quarantine keys as `uploads//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//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. diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md new file mode 100644 index 00000000000..f8e375c567c --- /dev/null +++ b/docs/agent-skill-sharing-installation-plan.md @@ -0,0 +1,1191 @@ +# Agent skill sharing and installation plan + +Status: proposed. + +Last updated: 2026-08-11. + +Implementation checklist: +[Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md). + +## Decision summary + +Orca will own a focused skill packaging and installation pipeline for private sharing. + +The community [`vercel-labs/skills`](https://github.com/vercel-labs/skills) project will +remain a behavioral reference, not a runtime dependency or source donor. Its npm package +exposes a CLI rather than a supported programmatic API, and its install/update behavior does +not provide the transaction, provenance, or private-package semantics Orca needs. + +Orca will: + +1. Package a local skill into an immutable, content-addressed artifact. +2. Store private artifacts behind Orca Cloud authorization and durable share records. +3. Resolve a share into a short-lived download grant after checking access. +4. Execute installation on the machine that will use the skill. +5. Validate, stage, commit, and verify the package without silently overwriting local work. +6. Install once into `.agents/skills` and add aliases only for detected agents that need + provider-specific paths. +7. Record Orca-owned provenance independently from the community CLI's lockfiles. + +The first release will support Orca package sources only. Arbitrary Git, npm, and community +registry sources remain delegated to existing tools until there is a product need to own +that larger package-manager surface. + +## Research baseline + +The upstream assessment used `vercel-labs/skills` commit +`c6f69c631292444cc541ac6d91e2226b0ff247da`. + +Useful upstream behavior: + +- A canonical `.agents/skills/` location. +- A data set of agent-specific global and project paths. +- Relative symlink handling, Windows junction behavior, and copy fallback. +- Archive path, entry-count, and extracted-byte limits. +- Tests for aliases, broken links, and provider-specific placement. + +Behavior Orca will not inherit: + +- Deleting the destination before the replacement is ready. +- Updating without checking whether installed files were modified. +- Direct read/modify/write lockfile updates without durable publication. +- Reinstalling the canonical directory once per selected agent. +- Treating an expiring signed artifact URL as package identity. +- Coupling installation to prompts, terminal output, telemetry, and process exits. + +Orca will not copy upstream source, tests, fixtures, or registry entries. Provider paths will be +implemented independently from official provider documentation and verified installations. Under +that constraint, Orca does not incorporate upstream copyrightable material and needs no upstream +attribution. Revisit this decision if implementation work ever proposes copying material. + +## Goals + +- Share a private skill with a teammate through a durable, intuitive link. +- Install on the local computer or any compatible connected Orca runtime. +- Support global scope and folder-workspace/project scope. +- Work on macOS, Linux, and Windows. +- Preserve executable bits and all files in the skill package. +- Prevent archive traversal, symlink escapes, special files, case collisions, and resource + exhaustion. +- Never silently replace an unowned or locally modified skill. +- Recover from interruption during package download, extraction, placement, alias creation, + or provenance publication. +- Keep share authorization separate from short-lived blob access. +- Support immutable versions, updates, removal, and eventual team-wide reconciliation. +- Remain compatible with independently updated desktop clients and remote Orca servers. +- Treat ordinary folder workspaces as first-class; Git is not required. + +## Non-goals for the first release + +- Reimplementing all `skills` CLI source parsing and provider integrations. +- Supporting all upstream agent paths on day one. +- Publishing private package bytes through a public bearer URL. +- Automatically installing a shared skill on every organization machine. +- Merging local modifications with a newer shared package version. +- Treating installation as a security sandbox. A skill contains instructions and scripts and + must be presented to the user as code from its author. +- End-to-end encryption from Orca Cloud operators in the initial trust model. Private means + access-controlled to authorized Orca users and organizations. Application-level package + encryption can be added later without changing package identity. + +## Product experience + +### Sharing + +The user opens an installed or workspace skill and chooses **Share skill**. + +Orca shows: + +- Skill name and description. +- Package file count and total size. +- Included scripts and executable files. +- Current author identity and organization. +- Access choice: organization or selected people. +- Optional version label and release notes. + +After confirmation, Orca validates and packages the exact bytes shown in the preview, uploads +them, and returns a durable URL such as: + +```text +https://app.orca.dev/skills/share/ +``` + +The URL identifies a revocable share record, not a blob-storage object or bearer grant. Every +recipient still authenticates and passes the share ACL. Revocation, organization membership +changes, version selection, and audit history therefore remain effective after the link has been +copied. + +### Installing + +Opening a share shows: + +- Author and organization. +- Skill description, version, file summary, and package digest. +- Whether an installed skill with the same name already exists. +- Destination machine. +- Global or workspace scope. +- Detected agents that will receive coverage. +- Any conflict that requires a decision. + +The default action installs on the current machine globally. Connected machines and folder +workspaces are selectable. The action reports one of: + +- Installed. +- Already installed. +- Updated. +- Installed with incomplete agent coverage. +- Needs a conflict decision. +- Unsupported because the selected runtime must be updated. + +The UI must follow `docs/STYLEGUIDE.md` and use existing design tokens and shadcn primitives. + +## Target architecture + +```text +Skill folder + | + v +Package builder -- validates and creates immutable artifact + | + v +Orca Cloud -- private blob + version metadata + ACL + durable share + | + v +Download grant -- short-lived and scoped to one immutable digest + | + v +Destination runtime + | + +-- package ingress + +-- package inspection + +-- install planning + +-- staged transaction + +-- provider placement reconciliation + +-- provenance publication + +-- post-install discovery +``` + +Cloud download, client-mediated transfer, and a local package file all terminate at the same +validated package-ingress boundary. Installation logic does not know which transport supplied +the bytes. + +## Package format + +Use a versioned tar archive so executable modes can be represented without a platform-specific +side channel. The archive contains an envelope rather than placing Orca metadata inside the +installed skill: + +```text +manifest.json +skill/ + SKILL.md + scripts/ + references/ + assets/ +``` + +Conceptual manifest: + +```ts +type SkillPackageManifestV1 = { + schemaVersion: 1 + packageId: string + versionId: string + name: string + description: string + createdAt: string + files: Array<{ + path: string + size: number + executable: boolean + sha256: string + }> + packageDigest: string +} +``` + +Rules: + +- `packageId` is stable across versions; `versionId` identifies one immutable publication. +- `packageDigest` is derived from normalized paths, executable state, classification, and file + identity using Orca's existing skill-package identity rules. +- `skill/SKILL.md` is required and must parse successfully. +- The installed folder receives only the contents of `skill/`. +- Paths use `/` in the manifest and are converted with the executing host's path API. +- Names are normalized once during publication. Installation never silently renames a conflict. +- V1 rejects symlinks and special files. A later package-builder feature may safely dereference + internal links after proving they remain within the source root. +- Existing limits remain the starting contract: depth 16, 2,048 entries, 512 files, 4 MiB per + file, and 32 MiB total extracted bytes. Compressed download size also receives an explicit cap. +- Archive and manifest schema changes are additive or introduced as a new schema version. + +The package builder observes the source, copies it into a private staging directory, observes +the staged copy again, and only packages it if the two identities agree. The preview shown to +the user is bound to that final digest. + +## GCP deployment plan + +### Existing production baseline + +Read-only inspection of the active `onorca-cloud` project on 2026-08-11 found: + +| Resource | Existing state relevant to skill sharing | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cloud Run | `orca-cloud-api`, `orca-cloud-auth`, `orca-cloud-relay`, and `orca-cloud-relay-fence` run in `us-central1` | +| API service | `orca-cloud-api` uses its own service account, scales from 0 to 20 instances, has a 300-second timeout, and already owns artifact-sharing endpoints | +| Object storage | `onorca-cloud-artifacts` is a `US` bucket with uniform bucket-level access, public-access prevention, exact CORS for `https://share.onorca.dev`, and seven-day soft delete | +| Database | `orca-cloud-auth-db` is PostgreSQL 17 in `us-central1`, regional/high-availability, with backups and point-in-time recovery | +| Database contents | The instance currently contains `orca_auth` and `orca_relay` databases; there is no skill database | +| IAM | `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` owns objects in the existing artifact bucket | +| APIs | Cloud Run, Cloud Storage, Cloud SQL Admin, IAM Credentials, Secret Manager, Logging, Monitoring, and Pub/Sub are enabled | +| Excluded services | Firestore and Cloud Asset Inventory are disabled; Cloud Tasks is not enabled | + +The GCP inspection was read-only. Commands that offered to enable disabled APIs were declined, and +no resources or IAM policies were changed. + +### V1 infrastructure decision + +Use the existing project, API service, authentication service, PostgreSQL instance, deployment +pipeline, and Terraform state. Add a dedicated package bucket and database instead of putting +private skill packages into the existing general artifact namespace. + +The V1 control plane remains in `orca-cloud-api`. Package validation is bounded and streaming, so a +separate worker service is not justified at the initial 40 MiB compressed-package ceiling. Protect +the existing API workload with a small per-instance package-finalization semaphore and return a +retryable response when that lane is full. + +Split package processing into a dedicated Cloud Run service later if finalize latency or CPU usage +interferes with artifact sharing. Do not reduce the entire API service's current concurrency merely +to accommodate one endpoint family. + +Firestore is not required. Skill metadata belongs in PostgreSQL beside the existing Orca Cloud +identity model. Cloud Tasks and Pub/Sub are not required for V1 because upload expiry is enforced +by a GCS lifecycle rule and package finalization stays synchronous and bounded. + +### Required GCP changes + +All production changes are declared in the existing Orca Cloud Terraform configuration. Do not +create durable resources manually with `gcloud`. + +| Change | Proposed production value | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| GCS bucket | `onorca-cloud-skill-packages`, subject to Terraform name validation | +| Location | `US`, matching existing shared-artifact storage; revisit per-region buckets when data residency becomes a product requirement | +| Access | Uniform bucket-level access and public-access prevention enforced | +| Soft delete | Seven days, matching the current artifact bucket | +| Object versioning | Disabled; package keys are immutable and GCS generations are recorded | +| Temporary lifecycle | Delete `uploads/` objects after one day | +| CORS | Exact approved Orca share/app origins; no wildcard origin | +| Database | `orca_skills` on `orca-cloud-auth-db` | +| Database principal | Dedicated `orca_skills_app` user with access only to `orca_skills` | +| Database secret | `orca-cloud-skills-database-url` in Secret Manager | +| Cloud Run service | Extend `orca-cloud-api`; mount the existing Cloud SQL instance and inject only the skill database secret | +| Package signer | Reuse `orca-cloud-api@onorca-cloud.iam.gserviceaccount.com` for V1 | +| Metrics/logging | Extend existing Cloud Logging and Monitoring configuration with skill endpoint dashboards and alerts | + +The bucket is separate because skill packages need different authorization, retention, signed +download, and lifecycle rules from public/shared artifacts. It also provides a clean IAM kill +switch without disrupting existing artifact links. + +### Bucket layout + +```text +gs://onorca-cloud-skill-packages/ + uploads//package.tar.gz + packages/v1/sha256///package.tar.gz +``` + +Rules: + +- `uploads/` is quarantine storage and is never downloadable through a share. +- Upload IDs are random, single-use, tenant-bound, and expire in PostgreSQL as well as GCS. +- Final package keys are derived from the validated package digest. +- The database records the final object's GCS generation, compressed size, archive SHA-256, and + package digest. +- Final objects are never overwritten. Promotion uses a generation-match precondition. +- If a final key already exists, finalization verifies its recorded identity before reusing it. +- Internal deduplication never changes tenant-scoped API responses or reveals that another + organization already stored the same digest. +- GCS object versioning remains off; immutable keys plus stored generations prevent accidental + replacement, while seven-day soft delete provides operator recovery. +- Deleting the final database reference deletes the object only after a transaction proves no + package version still references it. GCS soft delete then supplies the recovery window. + +### Upload implementation + +Use a V4 signed POST policy rather than a signed unrestricted PUT. The POST policy enforces: + +- Exact quarantine object key. +- `application/vnd.orca.skill+tar+gzip` content type. +- A content-length range capped at 40 MiB. +- Required upload ID and expected archive SHA-256 metadata. +- A 15-minute expiration. + +The compressed limit is slightly above the 32 MiB extracted-file limit to allow tar metadata and +incompressible input. The package builder still enforces the lower extracted-content limits. + +Upload sequence: + +1. `orca-cloud-api` authenticates the user through the existing auth integration. +2. The API checks organization membership, package quota, per-user rate limits, and concurrent + upload limits. +3. It inserts a pending upload row and returns a signed POST policy for the unique quarantine key. +4. The client uploads directly to GCS; package bytes do not traverse Cloud Run. +5. The client calls finalize with the upload ID and package manifest identity. +6. The API reads GCS object metadata and rejects unexpected size, content type, key, generation, + or tenant binding. +7. The API streams the object once to calculate archive SHA-256 and validate the archive envelope, + manifest, path set, file limits, and package digest. It never buffers the whole package. +8. The API promotes the validated object to its immutable content-addressed key with an + if-absent generation precondition. +9. A PostgreSQL transaction publishes the immutable package version and marks the upload complete. +10. The quarantine object is deleted. The one-day lifecycle remains the backstop for abandoned + uploads. + +Run at most a small fixed number of finalizations concurrently per API instance. A full lane +returns `429` or `503` with `Retry-After`; it does not queue unbounded package bytes or work. + +### Download grants + +After resolving a share and rechecking its ACL, `orca-cloud-api` creates a V4 signed GET URL with: + +- Exact final object key and stored generation. +- Five-minute expiration. +- Response content type and attachment filename. +- No list or write permission. + +The URL is the short-lived grant. The durable share URL is never signed directly and cannot access +GCS. Revocation cannot invalidate an already-issued GCS URL, so the five-minute lifetime is the +maximum revocation delay. + +The destination runtime accepts download URLs only from the configured GCS origin for the Orca +skill bucket, rejects credential-bearing redirects to any other host, enforces the expected byte +count while streaming, and verifies both archive SHA-256 and package digest before installation. + +### IAM + +Apply least privilege at the dedicated bucket and service-account level: + +- Grant the API service account `roles/storage.objectUser` on the skill-package bucket, not a + project-wide storage role. +- Grant the API service account `roles/cloudsql.client` for the existing instance. +- Allow the API service account to use IAM Credentials `signBlob` for itself so it can generate V4 + policies and URLs without a downloaded service-account key. The Terraform binding is scoped to + that service account. +- Grant Secret Manager accessor only for `orca-cloud-skills-database-url` and any future + skill-specific secret. +- Keep bucket IAM free of `allUsers` and `allAuthenticatedUsers`. +- Do not give the desktop, remote runtime, or user identity direct bucket IAM. They receive only a + time-bound signed operation after application authorization. +- Keep the existing deployment service account's impersonation/deploy permissions unchanged + except where Terraform needs to manage the new resources. + +No long-lived GCP key is shipped in Orca or stored on a remote runtime. + +### PostgreSQL schema + +Create migrations for: + +- `skill_packages`: stable package identity, owner organization, slug/name, creator, timestamps, + and deletion state. +- `skill_package_versions`: immutable version, package digest, archive SHA-256, GCS object key and + generation, byte counts, manifest JSON, release notes, creator, and publication state. +- `skill_package_uploads`: tenant/user binding, quarantine key and generation, expected identities, + expiration, finalization state, and failure category. +- `skill_package_acl`: organization or user principal, permission, creator, and timestamps. +- `skill_share_records`: unpredictable share ID, package ID, optional pinned version, expiration, + revocation state, creator, and timestamps. +- `skill_package_audit_events`: bounded security/lifecycle event metadata without package contents + or signed URLs. + +Required constraints and indexes include: + +- Unique package slug within its owning organization. +- Unique immutable version ID and package digest identity. +- Unique final GCS object key/generation pair. +- Share lookup by unpredictable ID and active/revoked state. +- ACL lookup by package and principal. +- Pending-upload lookup by owner and expiration. +- Foreign keys that prevent removing a blob reference while a published version uses it. + +Every share resolution, grant, update lookup, and revoke mutation begins with an authenticated +principal and evaluates package ownership plus ACL in the same request. The share ID locates the +record but does not itself authorize access. + +### Cloud Run changes + +Add skill endpoints to `orca-cloud-api` and configure: + +```text +ORCA_SKILL_PACKAGE_BUCKET=onorca-cloud-skill-packages +ORCA_SKILL_PACKAGE_MAX_COMPRESSED_BYTES=41943040 +ORCA_SKILL_PACKAGE_UPLOAD_TTL_SECONDS=900 +ORCA_SKILL_PACKAGE_DOWNLOAD_TTL_SECONDS=300 +ORCA_SKILL_PACKAGE_FINALIZE_CONCURRENCY= +ORCA_SKILLS_DATABASE_URL= +``` + +Use the existing auth base URL and CORS-origin configuration. Do not add a second authentication +implementation for skills. + +The API streams upload validation with fixed buffers under its existing 512 MiB memory limit. Load +test the finalize lane before choosing its fixed concurrency. Keep the current Cloud Run instance +scaling bounds initially, then adjust only from observed CPU, latency, database connection, and +error metrics. + +Recommended endpoint family: + +```text +POST /v1/skill-packages/uploads +POST /v1/skill-packages/uploads//finalize +POST /v1/skill-packages +GET /v1/skill-packages/ +GET /v1/skill-packages//versions +POST /v1/skill-packages//shares +DELETE /v1/skill-shares/ +GET /v1/skill-shares/ +POST /v1/skill-shares//download-grants +``` + +Finalize may create the package/version in one call; the separate package endpoint remains useful +when publishing a new version under an existing stable package identity. All mutating endpoints +accept idempotency keys. + +### CORS and public routing + +- Route the endpoint family through the existing Orca Cloud API hostname and deployment path. +- Add only exact production and approved development app/share origins to the skill bucket's CORS + policy. +- Allow `POST` for signed upload policy submission and `GET`/`HEAD` for signed download. +- Expose only required response headers such as `Content-Type`, `Content-Length`, `ETag`, + `x-goog-generation`, and `x-goog-hash`. +- Do not expose the GCS bucket through a public custom domain. +- Keep Cloud Run ingress and authentication consistent with existing API endpoints; package + authorization remains mandatory at the application layer. + +### Retention, deletion, and recovery + +- Pending upload database rows expire after 15 minutes and are safe to delete after one day. +- GCS deletes quarantine objects after one day regardless of database cleanup success. +- Published objects have no age-based deletion rule. +- Package deletion first marks metadata deleted and revokes shares, then deletes an object only + when no retained version references it. +- Seven-day GCS soft delete allows operator recovery from accidental object deletion. +- PostgreSQL backups and point-in-time recovery cover metadata; restoration procedures must restore + database rows and object generations consistently. +- Organization retention and legal deletion requirements override product rollback retention. + +### Monitoring and operational gates + +Add dashboards and alerts for: + +- Upload grant, finalize, share resolution, and download-grant request rate. +- Authorization denials and rate-limit outcomes. +- Finalize duration, semaphore saturation, archive rejection category, and digest mismatch. +- Cloud Run 5xx rate, CPU, memory, instance count, and request latency for skill routes. +- GCS quarantine bytes, published bytes, object count, and lifecycle deletion failures. +- PostgreSQL connection count, storage, query latency, migration status, and failed transactions. +- Signed URL creation failures and IAM Credentials errors. +- Orphan metadata/object reconciliation count. + +Budget alerts cover GCS stored bytes and egress, Cloud Run CPU/request growth, and Cloud SQL storage. +Logs contain package/version IDs and error categories, not manifests, filenames, share URLs, signed +policies, signed URLs, or ACL membership. + +### Provisioning and deployment sequence + +1. Confirm the existing Orca Cloud Terraform worktree and staging environment are current. +2. If there is no isolated staging GCP project, create or designate one before testing package + upload; do not exercise new lifecycle/IAM behavior first in `onorca-cloud` production. +3. Add the bucket, lifecycle, CORS, IAM, database, database user, secret, Cloud SQL attachment, API + configuration, dashboards, alerts, and budget thresholds to Terraform. +4. Run Terraform formatting, validation, and a reviewed plan. The expected production plan contains + no replacement of the existing artifact bucket, Cloud SQL instance, or Cloud Run services. +5. Apply in staging and run upload/finalize/download tests, including expired policies, ACL denial, + oversized uploads, corrupt archives, deduplication, revocation, and object cleanup. +6. Apply the PostgreSQL migrations before routing package traffic to the new API handlers. +7. Deploy `orca-cloud-api` with endpoints disabled by a server-side feature flag. +8. Run a staging desktop-to-runtime installation and verify logs contain no grants or private + contents. +9. Apply the reviewed production Terraform plan. +10. Run migrations, deploy the API, and enable internal accounts only. +11. Observe at least one complete soft-delete, upload-expiry, update, rollback, and revoke journey. +12. Expand the feature flag gradually, retaining separate kill switches for upload grants, + download grants, and remote installation. + +Production verification uses read-only commands such as: + +```bash +gcloud storage buckets describe gs://onorca-cloud-skill-packages --project=onorca-cloud +gcloud run services describe orca-cloud-api --region=us-central1 --project=onorca-cloud +gcloud sql databases list --instance=orca-cloud-auth-db --project=onorca-cloud +gcloud iam service-accounts get-iam-policy \ + orca-cloud-api@onorca-cloud.iam.gserviceaccount.com --project=onorca-cloud +``` + +Never print Secret Manager payloads or signed policies/URLs during verification. + +## Installation scopes and destinations + +### Global scope + +The canonical root is resolved on the destination host: + +```text +/.agents/skills/ +``` + +Provider-specific aliases are created only for detected agents that do not consume the canonical +root. + +### Workspace scope + +The canonical root is: + +```text +/.agents/skills/ +``` + +The workspace may be a Git worktree or a plain folder. Resolution uses the workspace identity +owned by the executing runtime; a client-provided path alone is not trusted for a remote target. + +Project installation does not create or modify a checked-in desired-state lockfile in V1. A +separate team-sync feature may later add an explicit, reviewable project manifest. + +### WSL scope + +WSL is a distinct execution target. Home resolution, path operations, package extraction, and +installation run inside the selected distro. The Windows client must not construct Linux home +paths or mutate the distro through translated Windows paths. + +### SSH-only scope + +An SSH target without an Orca runtime cannot execute the runtime RPC directly. Before GA, add a +host-side installation command invoked through the existing SSH execution and file-transfer +providers. The desktop uploads the immutable package through SFTP, invokes the bundled Orca +installer on the SSH host, and receives the same structured result as runtime RPC. + +If the required host component is unavailable, Orca reports the limitation and provides a local +package download or command; it never installs into the desktop's home as a fallback. + +## Provider destination strategy + +Orca owns a small data-only registry for agents Orca supports. Each entry describes: + +- Orca agent ID. +- Whether it reads the universal `.agents/skills` root. +- Global provider-specific path resolver when required. +- Project provider-specific path resolver when required. +- Detection evidence required before creating the path. +- Supported alias mechanism by platform. + +The registry is authored from official agent documentation and Orca's own verified installations. +Upstream may reveal that a compatibility case exists, but its implementation, path table, and +tests are not copied or transformed into Orca code. + +Policy: + +1. Always create one canonical copy. +2. Do nothing else for universal agents. +3. Create an alias for a detected non-universal agent. +4. On Windows, prefer a directory junction when supported. +5. Fall back to a verified independent copy only when aliases are unavailable. +6. Record every placement and its topology. +7. Never create configuration roots for dozens of undetected agents. +8. Never replace an unowned provider-specific copy without an explicit decision. + +Provider releases and official documentation are reviewed periodically. Orca's registry changes +only through normal review and platform tests; no upstream synchronization script owns it. + +## Installer components + +Create narrow modules with explicit responsibilities: + +### Shared contracts + +- `src/shared/skill-package-manifest.ts`: package manifest schema and canonical validation. +- `src/shared/skill-install-contract.ts`: request, preview, conflict, placement, and result types. +- `src/shared/skill-install-capability.ts`: runtime capability name and compatibility message. + +### Main/runtime implementation + +- `src/main/skills/skill-package-creation.ts`: source observation, stable staging, and archive + creation. +- `src/main/skills/skill-package-extraction.ts`: bounded archive inspection and extraction. +- `src/main/skills/skill-package-download.ts`: grant validation, host allowlisting, streaming, and + compressed-byte limits. +- `src/main/skills/skill-install-destinations.ts`: global, workspace, WSL, and provider path + resolution. +- `src/main/skills/skill-provider-destinations.ts`: Orca-owned provider registry. +- `src/main/skills/skill-install-planner.ts`: current-state inspection and conflict decisions. +- `src/main/skills/skill-install-transaction.ts`: locking, staging, commit journal, rollback, and + recovery. +- `src/main/skills/skill-placement-reconciliation.ts`: aliases, junctions, and verified copy + fallback. +- `src/main/skills/skill-install-provenance.ts`: bounded receipts and recovery data. +- `src/main/skills/skill-install-service.ts`: orchestration and structured results. + +Reuse `observeSkillPackage` and the existing topology/freshness logic directly. Reuse the design +of plugin staging, provenance, lock serialization, and Windows rename retries, but do not import +plugin-domain modules into the skill domain. Promote a primitive only when both domains can name +and test the same contract accurately. + +### Cloud client + +- Skill upload grant creation and finalization. +- Share creation, resolution, revocation, and access management. +- Download grant creation. +- Package catalog and version lookup. + +Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider +neutral. + +### Runtime and IPC + +- Extend the existing skill RPC registration with preview/install/update/remove methods. +- Register the same operations through desktop IPC. +- Route every request to the runtime that owns the selected machine and workspace. +- Keep package transfer separate from installation so direct download and chunked relay converge + on one staged file. + +### Renderer + +- Share preview and access dialog. +- Share completion dialog with copyable durable link. +- Install preview with destination, scope, coverage, and conflict state. +- Install progress and structured outcome. +- Installed version, update, removal, and incomplete-coverage actions on the Skills page. + +## Install request and result contract + +Conceptual request: + +```ts +type SkillInstallRequest = { + operationId: string + package: { + packageId: string + versionId: string + packageDigest: string + compressedBytes: number + } + ingress: + | { kind: 'download-grant'; url: string; expiresAt: string } + | { kind: 'staged-upload'; uploadId: string } + | { kind: 'local-file'; path: string } + destination: + | { scope: 'global'; environmentId?: string } + | { scope: 'workspace'; worktreeId?: string; folderWorkspaceId?: string } + conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel' +} +``` + +`local-file.path` is accepted only across an in-process trusted boundary. It is not exposed as an +arbitrary remote RPC path. + +Conceptual result: + +```ts +type SkillInstallResult = { + operationId: string + status: 'installed' | 'updated' | 'unchanged' | 'conflict' | 'partial' | 'failed' + name: string + packageDigest: string + canonicalPath?: string + placements: Array<{ + provider: string + path: string + topology: 'canonical-copy' | 'provider-alias' | 'independent-copy' + status: 'installed' | 'unchanged' | 'skipped' | 'failed' + errorCategory?: string + }> + conflict?: { + kind: 'modified' | 'unowned' | 'external-link' | 'name-collision' + existingDigest?: string + } + errorCategory?: string +} +``` + +The response never includes a download grant or credentials. + +## Installation algorithm + +### 1. Admission + +- Validate all request fields and package identifiers. +- Resolve the destination through host-owned runtime state. +- Confirm the requested scope is writable and contained in an allowed home or workspace root. +- Reject an expired grant before network access. +- Deduplicate retries using `operationId` and the package/destination identity. + +### 2. Lock + +- Acquire a cross-process lock scoped to canonical destination and skill name. +- Use an atomic lock-directory or exclusive-file creation supported on all target platforms. +- Record a random owner token and start time. +- Recover a stale lock only after validating its journal and owner liveness policy. +- Bound lock wait time and return a retryable busy result. + +An in-process promise chain alone is insufficient because the desktop, headless runtime, CLI, or +SSH helper may run concurrently. + +### 3. Ingress + +- Stream the archive into a bounded temporary file. +- For grants, require HTTPS when the configured cloud endpoint is HTTPS and restrict redirects and + final hosts to approved Orca storage origins. +- Abort when compressed bytes exceed the manifest contract. +- Hash while streaming and compare the archive identity supplied by Cloud. +- Delete partial bytes on cancellation, expiration, disconnect, or failure. + +### 4. Extract and inspect + +- Create extraction staging on the same filesystem as the canonical destination. +- Reject absolute paths, `..`, drive prefixes, NUL bytes, links, devices, FIFOs, sockets, + encrypted entries, duplicate normalized paths, and case-fold collisions. +- Enforce compressed, extracted, entry, file, depth, and per-file limits during extraction. +- Validate `manifest.json` before trusting file metadata. +- Observe `skill/` with `observeSkillPackage` and compare every file and the package digest. +- Parse `SKILL.md` and require its identity to agree with the package name. + +### 5. Inspect current state + +Classify the canonical destination and all requested provider placements. + +Canonical outcomes: + +- Missing: install is allowed. +- Same requested digest: no-op; continue to placement repair. +- Matches Orca receipt and remains unmodified: update is allowed. +- Differs from its Orca receipt: return a modified conflict. +- Exists without Orca provenance: return an unowned conflict. +- External or broken link: return a topology conflict. + +Provider outcomes: + +- Correct alias or matching Orca-owned fallback copy: no-op. +- Missing: create after canonical commit. +- Broken Orca-owned alias: repair. +- Unowned or modified placement: leave untouched and report incomplete coverage unless the user + explicitly chooses replacement. + +The preview and final install repeat current-state inspection. A state change between preview and +commit invalidates the preview and requires a new decision. + +### 6. Prepare commit + +- Copy extracted `skill/` into a hidden sibling staging directory. +- Preserve executable modes. +- Observe the copy and require the expected digest again. +- Write a durable transaction journal before moving the current destination. +- Keep the replacement and backup on the destination filesystem so directory renames do not cross + volumes. + +### 7. Commit canonical copy + +- If an existing destination is approved for replacement, rename it to the journal's backup path. +- Rename the verified staging directory into the canonical path. +- Use bounded Windows retry behavior for antivirus/indexer `EPERM`, `EACCES`, and `EBUSY` races. +- Observe the installed path and require the requested digest. +- On failure, restore the backup and retain enough journal state for startup recovery. + +Directory replacement is crash-recoverable rather than assumed to be a single atomic overwrite on +every platform. + +### 8. Reconcile provider placements + +- Create aliases relative to their real parent directory on POSIX. +- Use directory junctions with absolute targets on Windows. +- Detect parent directories that are already symlinked to canonical storage. +- If an alias mechanism fails, create and verify an independent copy when policy allows. +- Reconcile each placement idempotently. + +Canonical success is retained when one provider placement fails. The result is `partial`, and the +UI offers retry. Rolling back a valid universal installation because one optional alias failed +would make recovery less reliable. + +### 9. Publish provenance + +Write a bounded, versioned receipt outside the installed skill folder containing: + +- Package and version IDs. +- Expected package digest. +- Scope and destination identity. +- Canonical path identity. +- Provider placements and topologies. +- Previous version identity for interrupted-update recovery. +- Installation timestamp and Orca host/runtime identity. + +Do not store credentials, download URLs, share bearer values, or package contents. + +Serialize receipt updates, write them durably through a temporary file and rename, and support +reconstruction from per-install provenance if the aggregate index is corrupt. + +After the receipt is durable, mark the journal complete and delete transactional backups and +staging bytes. + +### 10. Verify and publish result + +- Run skill discovery on the destination target. +- Confirm the canonical skill and successful provider placements are observable. +- Return observed status and error categories rather than relying on process exit alone. +- Invalidate renderer skill caches and refresh the Skills page. + +## Update behavior + +1. Resolve the latest accessible immutable version from Cloud. +2. Compare its digest with the receipt. +3. Observe installed bytes before offering the update. +4. If bytes match the receipt, use the normal installation transaction. +5. If bytes were modified, offer: + - Keep local version. + - Export local bytes as a new package version when authorized. + - Replace and discard local changes after explicit confirmation. +6. Reconcile all recorded aliases and fallback copies. +7. Preserve the previous package version in Cloud; rollback is a normal install of that immutable + version. + +Do not invoke `npx skills update` for Orca-owned packages or write entries that cause the community +CLI to claim ownership of them. + +## Removal behavior + +1. Read Orca provenance and observe every recorded placement. +2. Remove only aliases that still point to the recorded canonical path. +3. Remove independent copies only when their bytes still match their recorded digest, unless the + user explicitly confirms discarding modifications. +4. Remove the canonical copy only when it is Orca-owned, unmodified, and no retained placement or + receipt depends on it. +5. Publish the provenance change durably. +6. Leave an unowned or changed destination untouched and report what remains. + +Cloud package deletion and local removal are separate actions. Revoking a share prevents new +downloads but does not silently delete already installed local files. + +## Remote, SSH, and mixed-version compatibility + +Add a static runtime capability: + +```text +skills.install.v1 +``` + +Clients check it before sending installation RPCs. Older servers continue supporting discovery +but do not show remote install actions. A missing capability produces an update-required message. + +Adding new RPC methods and a static capability does not require a runtime protocol bump. New +fields remain optional until all supported peers understand them. No new terminal stream opcode is +needed. + +Recommended RPC surface: + +- `skills.install.preview` +- `skills.install.beginUpload` +- `skills.install.uploadChunk` +- `skills.install.commitUpload` +- `skills.install.cancelUpload` +- `skills.install.execute` +- `skills.install.update` +- `skills.install.remove` + +Upload sessions are bounded by count, bytes, idle lifetime, and chunk size. Offsets are monotonic; +retries either repeat an acknowledged chunk idempotently or restart the upload. Disconnect and +cancellation release staging bytes. + +The runtime that executes installation owns: + +- Home and config-directory resolution. +- Workspace/folder identity resolution. +- WSL distro selection. +- Agent detection and provider paths. +- Filesystem mutation and recovery. + +The calling client owns: + +- User authentication and share authorization. +- Destination selection. +- Obtaining a grant or providing package chunks. +- Rendering preview, progress, conflicts, and results. + +## Security and privacy controls + +- Treat `SKILL.md` and packaged scripts as executable code for trust messaging. +- Show author, organization, version, digest, file summary, and executable files before install. +- Require authentication for private share resolution and every download grant. +- Bind grants to one package version, digest, maximum byte count, and short expiration. +- Validate grant and redirect hosts to prevent server-side request forgery. +- Never execute package scripts during installation. +- Reject links and special files before they can be published or extracted. +- Keep package staging owner-only where platform permissions support it. +- Avoid logging file contents, share tokens, signed URLs, or organization-private names. +- Audit authorization and lifecycle events without storing skill contents in telemetry. +- Apply organization deletion and retention policy to metadata and blobs. +- Rate-limit uploads, downloads, share resolution, and remote transfer sessions. +- Use constant-shape authorization failures so private package existence is not disclosed. + +## Failure and recovery model + +The transaction journal records enough state to classify interrupted operations: + +- Download only: delete partial archive. +- Extracted but not committed: delete staging. +- Existing destination moved to backup: restore backup. +- New destination placed but receipt absent: verify new digest, then either finish receipt + publication or restore backup according to journal state. +- Receipt published but journal incomplete: verify receipt and installed digest, then finalize. +- Alias reconciliation interrupted: preserve canonical installation and retry reconciliation. + +Recovery runs before a new operation for the same destination and during bounded startup cleanup. +It never removes an unknown path based only on a filename pattern; the journal owner token, +destination containment, and expected identities must all agree. + +## Testing strategy + +### Package tests + +- Deterministic manifest and digest generation. +- Source changes during packaging. +- CRLF/LF identity behavior. +- Executable mode preservation. +- Missing or malformed `SKILL.md`. +- Excessive depth, entries, files, individual size, total size, and compressed size. +- Traversal, absolute paths, Windows drive paths, Unicode/case collisions, duplicate paths, + symlinks, hardlinks, devices, FIFOs, sockets, and encrypted entries. +- Truncated and checksum-invalid archives. + +### Transaction tests + +- Fresh install, identical reinstall, clean update, and explicit replacement. +- Modified and unowned conflicts. +- Existing canonical or provider paths that are files, directories, links, or broken links. +- Failure before and after every journal transition. +- Backup restoration and idempotent recovery. +- Concurrent desktop, runtime, and CLI attempts. +- Cancellation during download, extraction, copy, commit, and alias reconciliation. +- Antivirus-style Windows rename contention. +- Permission failures and read-only destinations. +- Alias failure with verified copy fallback. +- Independent copy drift during update and removal. + +### Target matrix + +- macOS ARM64 and x64 behavior where available. +- Linux at the supported Ubuntu 20.04/glibc 2.31 floor. +- Windows with junction success, junction denial, copy fallback, and long paths. +- WSL with multiple distros and distro-owned home resolution. +- Local Git worktree and plain folder workspace. +- Paired remote runtimes in both client-newer and server-newer combinations. +- SSH-only macOS/Linux/Windows targets where supported by existing providers. +- Remote target with no outbound Cloud connectivity using chunked transfer. + +### Cloud tests + +- Organization/user/link ACLs and revocation. +- Expired grants and membership removed between preview and download. +- Upload finalization digest mismatch. +- Blob deduplication without cross-tenant information leakage. +- Quota and rate-limit behavior. +- Durable share resolving to the intended immutable version. +- Version update and rollback. + +### End-to-end journeys + +1. Share on machine A, install globally on machine B, launch a detected agent, and discover the + skill. +2. Share on machine A, install into a folder workspace on a connected remote runtime, and discover + it only in that workspace. +3. Modify the installed copy, publish an update, and prove Orca refuses silent replacement. +4. Disconnect during commit, reconnect, recover, and obtain either the complete old or complete + new version. +5. Revoke a share, prove new installation fails, and prove an existing local installation remains. + +## Observability + +Record bounded operational metrics: + +- Package byte/file counts and stage durations. +- Download versus client-mediated transfer selection. +- Install outcomes by category. +- Conflict categories. +- Alias, junction, and copy-fallback rates. +- Recovery and rollback counts. +- Runtime capability absence. +- Error categories by operating system and target kind. + +Do not record skill contents, file names beyond approved aggregate categories, private share URLs, +download grants, access lists, or raw local paths. + +User-facing logs show phase, destination label, placement outcome, and actionable error. Sensitive +network values are redacted at creation rather than scrubbed after logging. + +## Delivery phases + +### Phase 0: contracts and vertical spike + +Deliver: + +- Package manifest schema. +- Local package builder and bounded extractor. +- A programmatic install call from a validated staging directory into a temporary canonical root. +- Fresh install and modified-conflict tests on macOS, Linux, and Windows CI. +- Decision record confirming upstream is behavioral-reference-only and no material is copied. + +Exit criteria: + +- No `npx` or Node installation dependency outside Orca's own runtime. +- Same bytes produce the same digest on every target platform. +- Invalid archive classes fail before destination mutation. + +Estimate: 2–3 engineer-days for the spike, followed by design review. + +### Phase 1: production local installer + +Deliver: + +- Cross-process lock. +- Durable journal and recovery. +- Canonical install, update, and removal. +- Provenance receipts and aggregate index recovery. +- Provider registry for Orca's primary detected agents. +- POSIX aliases, Windows junctions, and verified copy fallback. +- Structured preview/result contracts. +- CLI-only developer harness for deterministic integration tests. + +Exit criteria: + +- Failure injection at every commit boundary preserves either the previous or requested complete + package. +- Local modifications are never silently replaced. +- Folder workspaces and global scope pass the platform matrix. + +Estimate: 5–8 engineer-days. + +### Phase 2: private Cloud sharing + +Deliver: + +- Reviewed Terraform for the dedicated private GCS bucket, lifecycle, CORS, bucket-scoped IAM, + service-account signing, `orca_skills` database, database secret, Cloud SQL attachment, + monitoring, and budgets. +- PostgreSQL migrations for package, version, upload, ACL, share, and audit records. +- Upload/finalize APIs using bounded V4 signed POST policies and private content-addressed GCS + objects. +- Version, ACL, share, revocation, five-minute download grant, quota, and audit behavior. +- Share preview and access UI. +- Install preview and local-machine installation UI. +- Durable links and short-lived grants. +- Update availability and version rollback. + +Exit criteria: + +- A copied durable link remains inaccessible unless the authenticated recipient passes the share + ACL. +- Revocation immediately blocks new grants. +- The installed receipt identifies the immutable package without persisting a grant. +- Production provisioning changes are Terraform-owned and do not replace existing artifact, + Cloud SQL, or Cloud Run resources. + +Estimate: 1–2 engineer-weeks across desktop and Cloud work. + +### Phase 3: paired Orca runtime installation + +Deliver: + +- `skills.install.v1` capability. +- Runtime preview/install/update/remove methods. +- Direct runtime download. +- Bounded client-mediated upload fallback. +- Destination-machine and remote-workspace selection. +- Mixed-version UI and compatibility tests. + +Exit criteria: + +- Installation executes on the selected host and uses that host's home, workspace, and detected + agents. +- Older hosts are not called and receive a clear update-required state. +- A host without outbound internet installs through the authenticated chunked transfer path. + +Estimate: 4–7 engineer-days. + +### Phase 4: WSL and SSH completion + +Deliver: + +- Distro-owned package ingress and installer execution. +- SSH package upload and host-side structured installer invocation. +- Cross-target cancellation and cleanup. +- WSL/SSH provider detection and path coverage. +- Real-host end-to-end coverage. + +Exit criteria: + +- No desktop path is substituted for a WSL, SSH, or runtime path. +- SSH and WSL return the same result contract as native installation. +- Failure and cancellation leave no untracked staging bytes outside bounded recovery retention. + +Estimate: 4–7 engineer-days, depending on host-helper availability. + +### Phase 5: team library and reconciliation + +Deliver: + +- Organization skill library and version history. +- “Install on another machine” and multi-machine progress. +- Optional desired-version policy for selected personal or organization machines. +- Drift and missing-install reconciliation. +- Explicit project desired-state manifest if validated by product usage. +- Direct machine-to-machine transfer as an alternative to Cloud persistence if required. + +Exit criteria: + +- Reconciliation remains opt-in and never overwrites local modifications. +- Offline machines converge after reconnect without sharing durable credentials. +- Organization removal, user departure, and package retention have documented semantics. + +Estimate: separate product milestone after first-release usage data. + +## Release gates + +Before enabling Cloud or remote installation by default: + +- Threat-model review covers package ingestion, grants, SSRF, archive extraction, local path + containment, and instruction/script trust. +- Cross-platform CI covers the package and transaction suites. +- Real Windows validates junction and copy fallback. +- Real WSL and SSH validate host-owned paths. +- Mixed-version remote tests cover old client/new server and new client/old server. +- Download/upload cancellation and app/runtime crash recovery are exercised. +- Telemetry and diagnostic bundles are verified not to contain grants or private contents. +- Share deletion, revocation, organization departure, and retention behavior are documented. +- The UI identifies the author and executable content before installation. +- A kill switch can disable new Cloud grants and remote installs without affecting discovery or + already installed skills. + +## Effort summary + +For one engineer familiar with Orca: + +| Scope | Estimate | +| ----------------------------------- | -------------------------------------------: | +| Disposable canonical-copy prototype | 1–2 days | +| Production local installer | 5–8 days | +| Cloud sharing and local install UX | 1–2 weeks | +| Paired runtime support | 4–7 days | +| WSL and SSH completion | 4–7 days | +| Polished first release | Approximately 3–5 engineer-weeks | +| Full community CLI parity | 6–10 weeks plus ongoing registry maintenance | + +The recommended first release ends after Phase 4. Phase 5 should follow observed demand rather +than delaying private sharing for organization-wide policy features. + +## Implementation principles + +- One validated installer core, regardless of source or destination transport. +- One canonical copy, with provider placements reconciled separately. +- Immutable Cloud versions and mutable local installations are distinct concepts. +- A durable share is authorization metadata, not a permanent blob URL. +- Installed bytes, not child-process exit codes, determine success. +- Unknown or modified local state fails closed. +- Remote hosts own their paths and mutations. +- Upstream behavior informs compatibility but does not define Orca's internal architecture. diff --git a/src/main/index.ts b/src/main/index.ts index f49e07988e9..e7fb9336b0c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 = Promise.resolve() let managedWslCliReconciliationReady: Promise = Promise.resolve() let managedWslCliStartupBarrierReady: Promise = 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) => { @@ -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. diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index f372cb3f772..3d62e97764c 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -176,7 +176,7 @@ export function registerCoreHandlers( registerTerminalRenderDesyncEvidenceHandler() registerComputerUsePermissionHandlers() registerSettingsHandlers(store, agentAwakeService) - registerSkillsHandlers(store) + registerSkillsHandlers(store, runtime) if (automations) { registerAutomationHandlers(store, automations) } diff --git a/src/main/ipc/skill-cloud-ipc-handlers.ts b/src/main/ipc/skill-cloud-ipc-handlers.ts new file mode 100644 index 00000000000..07cdcd6deda --- /dev/null +++ b/src/main/ipc/skill-cloud-ipc-handlers.ts @@ -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 | z.infer +) { + 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) => Promise +): 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) => Promise +): void { + registerSharingHandlers(runtime, discover) + registerCloudInstallHandlers(runtime) + registerInstallManagementHandlers(runtime) +} diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index e2d12ca57ba..2a5c77e3c38 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -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 => { + const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined + return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos()) + } const scanInventory = (): Promise => // 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 => { - const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined - return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos()) - } + async (_event, target?: SkillDiscoveryTarget): Promise => discover(target) ) + if (runtime) { + registerSkillCloudIpcHandlers(runtime, discover) + } + ipcMain.handle('skills:freshnessInventory', async (): Promise => { return scanInventory() }) diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 398729efe17..01ac4b08105 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -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 /** 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. */ diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 2bc52fe491d..066945d225d 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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 => this.mux.request(method, params as Record, options) + constructor( connectionId: string, mux: SshChannelMultiplexer, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6c0f90c6f6b..375f1f326a9 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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> { + return this.requireSkillCloudService().publish(request) + } + + resolveSkillShare( + shareId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().resolveShare(shareId, options) + } + + createSkillDownloadGrant( + shareId: string, + options: SkillCloudOptions & { + versionId?: string + installTarget?: 'local' | 'remote' + } + ): Promise> { + return this.requireSkillCloudService().createDownloadGrant(shareId, options) + } + + createSkillPackageVersionDownloadGrant( + packageId: string, + versionId: string, + options: SkillCloudOptions & { installTarget?: 'local' | 'remote' } + ): Promise> { + return this.requireSkillCloudService().createPackageVersionDownloadGrant( + packageId, + versionId, + options + ) + } + + getSkillPackage( + packageId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().getPackage(packageId, options) + } + + replaceSkillPackageAccess( + packageId: string, + access: { userIds: string[]; shareWithOrganization: boolean }, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().replaceAccess(packageId, access, options) + } + + revokeSkillShare( + shareId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().revokeShare(shareId, options) + } + + deleteSkillPackageVersion( + packageId: string, + versionId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().deleteVersion(packageId, versionId, options) + } + + deleteSkillPackage( + packageId: string, + options: SkillCloudOptions + ): Promise> { + return this.requireSkillCloudService().deletePackage(packageId, options) + } + + async installSharedSkillRequest( + request: SkillInstallRequest, + signal?: AbortSignal + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.requireSkillUploadSessions().cancel(uploadId) + } + listArtifacts(options: ArtifactListOptions): Promise> { 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 } diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index a4335ec2e62..3a9c2a779ef 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 346198aeffc..de42ab7dff2 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -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) }) ] diff --git a/src/main/skills/skill-client-mediated-transfer.test.ts b/src/main/skills/skill-client-mediated-transfer.test.ts new file mode 100644 index 00000000000..834a3bb3855 --- /dev/null +++ b/src/main/skills/skill-client-mediated-transfer.test.ts @@ -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 + ) + }) +}) diff --git a/src/main/skills/skill-client-mediated-transfer.ts b/src/main/skills/skill-client-mediated-transfer.ts new file mode 100644 index 00000000000..f59a801b23e --- /dev/null +++ b/src/main/skills/skill-client-mediated-transfer.ts @@ -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( + userDataPath: string, + environmentId: string, + method: string, + params: unknown +): Promise { + const response = (await callRuntimeEnvironment( + userDataPath, + environmentId, + method, + params, + 5 * 60_000 + )) as RuntimeRpcResponse + 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 }> { + 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) + } + } +} diff --git a/src/main/skills/skill-cloud-auth.ts b/src/main/skills/skill-cloud-auth.ts new file mode 100644 index 00000000000..a63342d56a3 --- /dev/null +++ b/src/main/skills/skill-cloud-auth.ts @@ -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(input: { + userDataPath: string + options: SkillCloudOptions + operation(token: string, apiUrl: string): Promise +}): Promise> { + 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' } +} diff --git a/src/main/skills/skill-cloud-direct-upload.test.ts b/src/main/skills/skill-cloud-direct-upload.test.ts new file mode 100644 index 00000000000..4ee0789c301 --- /dev/null +++ b/src/main/skills/skill-cloud-direct-upload.test.ts @@ -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 + 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') + }) +}) diff --git a/src/main/skills/skill-cloud-direct-upload.ts b/src/main/skills/skill-cloud-direct-upload.ts new file mode 100644 index 00000000000..d733fb13803 --- /dev/null +++ b/src/main/skills/skill-cloud-direct-upload.ts @@ -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 +} + +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 { + 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') + } +} diff --git a/src/main/skills/skill-cloud-install-target.test.ts b/src/main/skills/skill-cloud-install-target.test.ts new file mode 100644 index 00000000000..e0b26edbe84 --- /dev/null +++ b/src/main/skills/skill-cloud-install-target.test.ts @@ -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') + }) +}) diff --git a/src/main/skills/skill-cloud-install-target.ts b/src/main/skills/skill-cloud-install-target.ts new file mode 100644 index 00000000000..31b738c127c --- /dev/null +++ b/src/main/skills/skill-cloud-install-target.ts @@ -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' +} diff --git a/src/main/skills/skill-cloud-request.test.ts b/src/main/skills/skill-cloud-request.test.ts new file mode 100644 index 00000000000..476889ede57 --- /dev/null +++ b/src/main/skills/skill-cloud-request.test.ts @@ -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) + }) +}) diff --git a/src/main/skills/skill-cloud-request.ts b/src/main/skills/skill-cloud-request.ts new file mode 100644 index 00000000000..96c9a665281 --- /dev/null +++ b/src/main/skills/skill-cloud-request.ts @@ -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(input: { + apiUrl?: string + authToken: string + path: string + method?: 'DELETE' | 'GET' | 'POST' | 'PUT' + body?: unknown + signal?: AbortSignal + idempotencyKey?: string + fetcher?: typeof fetch +}): Promise { + 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 +} diff --git a/src/main/skills/skill-cloud-service.ts b/src/main/skills/skill-cloud-service.ts new file mode 100644 index 00000000000..ac4d44df898 --- /dev/null +++ b/src/main/skills/skill-cloud-service.ts @@ -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> { + return this.withAuth(request, async (token, apiUrl) => { + const upload = await skillCloudRequest<{ + upload: { + id: string + policy: { url: string; fields: Record; expiresAt: string } + } + }>({ + apiUrl, + authToken: token, + path: '/v1/skill-packages/uploads', + method: 'POST', + body: { + expectedArchiveSha256: request.archiveSha256, + expectedCompressedBytes: request.compressedBytes + }, + idempotencyKey: randomUUID(), + signal: request.signal + }) + 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> { + 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> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + 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> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + 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> { + 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> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + apiUrl, + authToken: token, + path: `/v1/skill-packages/${id(packageId)}/access`, + method: 'PUT', + body: access, + idempotencyKey: randomUUID() + }) + ) + } + + revokeShare(shareId: string, options: SkillCloudOptions): Promise> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + apiUrl, + authToken: token, + path: `/v1/skill-shares/${id(shareId)}`, + method: 'DELETE' + }) + ) + } + + deleteVersion( + packageId: string, + versionId: string, + options: SkillCloudOptions + ): Promise> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + apiUrl, + authToken: token, + path: `/v1/skill-packages/${id(packageId)}/versions/${id(versionId)}`, + method: 'DELETE' + }) + ) + } + + deletePackage(packageId: string, options: SkillCloudOptions): Promise> { + return this.withAuth(options, (token, apiUrl) => + skillCloudRequest({ + apiUrl, + authToken: token, + path: `/v1/skill-packages/${id(packageId)}`, + method: 'DELETE' + }) + ) + } + + private withAuth( + options: SkillCloudOptions, + operation: (token: string, apiUrl: string) => Promise + ): Promise> { + return runSkillCloudOperation({ + userDataPath: this.userDataPath, + options, + operation + }) + } +} diff --git a/src/main/skills/skill-filesystem-retry.ts b/src/main/skills/skill-filesystem-retry.ts new file mode 100644 index 00000000000..b6a1a7c561d --- /dev/null +++ b/src/main/skills/skill-filesystem-retry.ts @@ -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 { + 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((resolve) => + setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt]) + ) + } + } +} diff --git a/src/main/skills/skill-install-destinations.test.ts b/src/main/skills/skill-install-destinations.test.ts new file mode 100644 index 00000000000..a2f9b87a0b1 --- /dev/null +++ b/src/main/skills/skill-install-destinations.test.ts @@ -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') + }) +}) diff --git a/src/main/skills/skill-install-destinations.ts b/src/main/skills/skill-install-destinations.ts new file mode 100644 index 00000000000..7a15d488f04 --- /dev/null +++ b/src/main/skills/skill-install-destinations.ts @@ -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 + resolveFolderWorkspace(id: string): Promise + 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 { + 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 { + 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 } : {}) + } +} diff --git a/src/main/skills/skill-install-filesystem.ts b/src/main/skills/skill-install-filesystem.ts new file mode 100644 index 00000000000..2c5169aca9b --- /dev/null +++ b/src/main/skills/skill-install-filesystem.ts @@ -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 + observeSkill( + path: string, + files?: readonly SkillInstalledFileMode[] + ): Promise + rename(source: string, target: string): Promise + remove(path: string): Promise + createAlias?(canonicalPath: string, destinationPath: string): Promise + aliasTargets?(canonicalPath: string, destinationPath: string): Promise +} + +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 }) +} diff --git a/src/main/skills/skill-install-lock.ts b/src/main/skills/skill-install-lock.ts new file mode 100644 index 00000000000..8a77b8c7887 --- /dev/null +++ b/src/main/skills/skill-install-lock.ts @@ -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 { + 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> { + 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((resolve) => setTimeout(resolve, LOCK_RETRY_MS)) + } + } +} diff --git a/src/main/skills/skill-install-management-service.test.ts b/src/main/skills/skill-install-management-service.test.ts new file mode 100644 index 00000000000..620b84cf2f6 --- /dev/null +++ b/src/main/skills/skill-install-management-service.test.ts @@ -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' } }) + }) +}) diff --git a/src/main/skills/skill-install-management-service.ts b/src/main/skills/skill-install-management-service.ts new file mode 100644 index 00000000000..62aee2a6616 --- /dev/null +++ b/src/main/skills/skill-install-management-service.ts @@ -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 +} + +function canonicalPath( + destination: Awaited>, + name: string +): string { + return join( + destination.scope === 'global' ? destination.homeDirectory : destination.workspaceDirectory!, + '.agents', + 'skills', + name + ) +} + +export async function previewSharedSkillInstall( + request: SkillInstallPreviewRequest, + dependencies: ManagementDependencies +): Promise { + 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 { + 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 + }) +} diff --git a/src/main/skills/skill-install-planner.ts b/src/main/skills/skill-install-planner.ts new file mode 100644 index 00000000000..f3dea4683d6 --- /dev/null +++ b/src/main/skills/skill-install-planner.ts @@ -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 { + 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 => + 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' } +} diff --git a/src/main/skills/skill-install-provenance.test.ts b/src/main/skills/skill-install-provenance.test.ts new file mode 100644 index 00000000000..61b67551217 --- /dev/null +++ b/src/main/skills/skill-install-provenance.test.ts @@ -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' + }) + ]) + }) +}) diff --git a/src/main/skills/skill-install-provenance.ts b/src/main/skills/skill-install-provenance.ts new file mode 100644 index 00000000000..685f24448e2 --- /dev/null +++ b/src/main/skills/skill-install-provenance.ts @@ -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 { + 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 + 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[]> { + 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 | 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 => install !== null) + .sort((left, right) => right.installedAt.localeCompare(left.installedAt)) +} + +export async function readSkillInstallReceipt( + stateDirectory: string, + canonicalPath: string +): Promise { + 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 { + await writeSkillStateFile(skillInstallReceiptPath(stateDirectory, receipt.canonicalPath), receipt) +} + +export async function removeSkillInstallReceipt( + stateDirectory: string, + canonicalPath: string +): Promise { + await rm(skillInstallReceiptPath(stateDirectory, canonicalPath), { force: true }) +} diff --git a/src/main/skills/skill-install-recovery.ts b/src/main/skills/skill-install-recovery.ts new file mode 100644 index 00000000000..0935e05db04 --- /dev/null +++ b/src/main/skills/skill-install-recovery.ts @@ -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 { + 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 + 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 { + 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 { + try { + return (await filesystem.observeSkill(path, files)).observedDigest === digest + } catch { + return false + } +} + +export async function cleanSkillInstallJournalFiles( + journal: SkillInstallJournalV1, + filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem +): Promise { + 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 { + 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') +} diff --git a/src/main/skills/skill-install-request-service.test.ts b/src/main/skills/skill-install-request-service.test.ts new file mode 100644 index 00000000000..984ff4f6f3b --- /dev/null +++ b/src/main/skills/skill-install-request-service.test.ts @@ -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') + }) +}) diff --git a/src/main/skills/skill-install-request-service.ts b/src/main/skills/skill-install-request-service.ts new file mode 100644 index 00000000000..ee27b8446eb --- /dev/null +++ b/src/main/skills/skill-install-request-service.ts @@ -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 +} + +export type SkillInstallRequestDependencies = { + authority: SkillInstallDestinationAuthority + stateDirectory: string + allowedDownloadOrigins: readonly string[] + requireHttps: boolean + allowTrustedLocalFile?: boolean + signal?: AbortSignal + fetcher?: typeof fetch + detectProviders: () => Promise + resolveStagedUpload?: ( + uploadId: string, + identity: SkillInstallRequest['package'] + ) => Promise +} + +async function resolveIngress( + request: SkillInstallRequest, + dependencies: SkillInstallRequestDependencies +): Promise { + if (request.ingress.kind === 'download-grant') { + return downloadSkillPackageGrant({ + url: request.ingress.url, + expiresAt: request.ingress.expiresAt, + expectedArchiveSha256: request.package.archiveSha256, + expectedCompressedBytes: request.package.compressedBytes, + temporaryRoot: join(dependencies.stateDirectory, 'skill-installs', 'downloads'), + allowedOrigins: dependencies.allowedDownloadOrigins, + requireHttps: dependencies.requireHttps, + signal: dependencies.signal, + fetcher: dependencies.fetcher + }) + } + if (request.ingress.kind === 'staged-upload') { + if (!dependencies.resolveStagedUpload) { + throw new Error('skill-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 { + 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() + } +} diff --git a/src/main/skills/skill-install-service.test.ts b/src/main/skills/skill-install-service.test.ts new file mode 100644 index 00000000000..ee17163b89c --- /dev/null +++ b/src/main/skills/skill-install-service.test.ts @@ -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') + }) +}) diff --git a/src/main/skills/skill-install-service.ts b/src/main/skills/skill-install-service.ts new file mode 100644 index 00000000000..562aa788e51 --- /dev/null +++ b/src/main/skills/skill-install-service.ts @@ -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 { + 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 { + 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 + }) +} diff --git a/src/main/skills/skill-install-transaction.test.ts b/src/main/skills/skill-install-transaction.test.ts new file mode 100644 index 00000000000..28ee816ae1b --- /dev/null +++ b/src/main/skills/skill-install-transaction.test.ts @@ -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 { + 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>, + overrides: Partial = {} +): 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') + }) +}) diff --git a/src/main/skills/skill-install-transaction.ts b/src/main/skills/skill-install-transaction.ts new file mode 100644 index 00000000000..d63eebe4be6 --- /dev/null +++ b/src/main/skills/skill-install-transaction.ts @@ -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 { + 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 { + 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() + } +} diff --git a/src/main/skills/skill-package-creation.test.ts b/src/main/skills/skill-package-creation.test.ts new file mode 100644 index 00000000000..1c0db8e100d --- /dev/null +++ b/src/main/skills/skill-package-creation.test.ts @@ -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 { + const directory = await mkdtemp(join(tmpdir(), 'orca-skill-package-test-')) + temporaryDirectories.push(directory) + return directory +} + +async function createSkill(root: string): Promise { + 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') + }) +}) diff --git a/src/main/skills/skill-package-creation.ts b/src/main/skills/skill-package-creation.ts new file mode 100644 index 00000000000..b31cc5a687e --- /dev/null +++ b/src/main/skills/skill-package-creation.ts @@ -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 { + 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) + } +} diff --git a/src/main/skills/skill-package-download.test.ts b/src/main/skills/skill-package-download.test.ts new file mode 100644 index 00000000000..2d30e3c5ea7 --- /dev/null +++ b/src/main/skills/skill-package-download.test.ts @@ -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 { + 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): typeof fetch { + return vi.fn(async (input) => implementation(String(input))) as typeof fetch +} + +async function input(overrides: Record = {}) { + 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() + }) +}) diff --git a/src/main/skills/skill-package-download.ts b/src/main/skills/skill-package-download.ts new file mode 100644 index 00000000000..bc30302636e --- /dev/null +++ b/src/main/skills/skill-package-download.ts @@ -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 +} + +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, + 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, + expiresAt: number +): Promise { + 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 { + 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 + } +} diff --git a/src/main/skills/skill-package-extraction.ts b/src/main/skills/skill-package-extraction.ts new file mode 100644 index 00000000000..127c926aec8 --- /dev/null +++ b/src/main/skills/skill-package-extraction.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts index 5610479a4f7..11419fab771 100644 --- a/src/main/skills/skill-package-identity.ts +++ b/src/main/skills/skill-package-identity.ts @@ -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 ): Promise { 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 { diff --git a/src/main/skills/skill-package-tar.ts b/src/main/skills/skill-package-tar.ts new file mode 100644 index 00000000000..2025f6949d1 --- /dev/null +++ b/src/main/skills/skill-package-tar.ts @@ -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 { + 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 + private current: Buffer = Buffer.alloc(0) + private currentOffset = 0 + private consumed = 0 + + constructor(stream: Readable) { + this.iterator = stream[Symbol.asyncIterator]() as AsyncIterator + } + + async readExact(length: number): Promise { + const bytes = await this.readExactOrNull(length) + if (!bytes) { + throw new Error('skill-package-tar-truncated') + } + return bytes + } + + async readExactOrNull(length: number): Promise { + 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 diff --git a/src/main/skills/skill-placement-reconciliation.ts b/src/main/skills/skill-placement-reconciliation.ts new file mode 100644 index 00000000000..0d80953714d --- /dev/null +++ b/src/main/skills/skill-placement-reconciliation.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' + } + } +} diff --git a/src/main/skills/skill-provider-destinations.ts b/src/main/skills/skill-provider-destinations.ts new file mode 100644 index 00000000000..9a37ec70216 --- /dev/null +++ b/src/main/skills/skill-provider-destinations.ts @@ -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(['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 +} diff --git a/src/main/skills/skill-remote-install-service.test.ts b/src/main/skills/skill-remote-install-service.test.ts new file mode 100644 index 00000000000..f9e5510a512 --- /dev/null +++ b/src/main/skills/skill-remote-install-service.test.ts @@ -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() + }) +}) diff --git a/src/main/skills/skill-remote-install-service.ts b/src/main/skills/skill-remote-install-service.ts new file mode 100644 index 00000000000..a77f1828644 --- /dev/null +++ b/src/main/skills/skill-remote-install-service.ts @@ -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> { + return (await callRuntimeEnvironment( + userDataPath, + environmentId, + 'skills.install', + request, + 5 * 60_000 + )) as RuntimeRpcResponse +} + +function isDownloadTransportFailure(response: RuntimeRpcResponse): 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 { + 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) + } +} diff --git a/src/main/skills/skill-removable-placement.ts b/src/main/skills/skill-removable-placement.ts new file mode 100644 index 00000000000..827282763d8 --- /dev/null +++ b/src/main/skills/skill-removable-placement.ts @@ -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 { + 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 { + 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 +} diff --git a/src/main/skills/skill-remove-transaction.ts b/src/main/skills/skill-remove-transaction.ts new file mode 100644 index 00000000000..611dc84da69 --- /dev/null +++ b/src/main/skills/skill-remove-transaction.ts @@ -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 + 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 { + 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 { + return Boolean(await lstat(path).catch(() => null)) +} + +export async function recoverSkillRemovalTransaction( + stateDirectory: string, + canonicalPath: string, + filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem +): Promise { + 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 { + 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() + } +} diff --git a/src/main/skills/skill-runtime-capability.ts b/src/main/skills/skill-runtime-capability.ts new file mode 100644 index 00000000000..e281afbd4f1 --- /dev/null +++ b/src/main/skills/skill-runtime-capability.ts @@ -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 { + const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000) + return ( + status.ok === true && status.result.capabilities?.includes(SKILL_MANAGEMENT_CAPABILITY) === true + ) +} diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts new file mode 100644 index 00000000000..ef008736514 --- /dev/null +++ b/src/main/skills/skill-share-preparation-service.ts @@ -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() + + constructor( + private readonly root: string, + private readonly cloud: Pick + ) {} + + async prepare(input: { + sourceDirectory: string + packageId?: string + }): Promise { + 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 { + 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 { + 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 { + 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 { + 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))) + } +} diff --git a/src/main/skills/skill-ssh-relay-service.test.ts b/src/main/skills/skill-ssh-relay-service.test.ts new file mode 100644 index 00000000000..405236eae1f --- /dev/null +++ b/src/main/skills/skill-ssh-relay-service.test.ts @@ -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 { + 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 }).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() + }) +}) diff --git a/src/main/skills/skill-ssh-relay-service.ts b/src/main/skills/skill-ssh-relay-service.ts new file mode 100644 index 00000000000..45f037729e6 --- /dev/null +++ b/src/main/skills/skill-ssh-relay-service.ts @@ -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 + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[0] +): Promise { + if (input.request.ingress.kind !== 'download-grant') { + throw new Error('skill-install-ssh-ingress-invalid') + } + const downloaded = await downloadSkillPackageGrant({ + url: input.request.ingress.url, + expiresAt: input.request.ingress.expiresAt, + expectedArchiveSha256: input.request.package.archiveSha256, + expectedCompressedBytes: input.request.package.compressedBytes, + temporaryRoot: join(input.userDataPath, 'skill-installs', 'ssh-transfers'), + allowedOrigins: allowedOrigins(input.requireHttps), + requireHttps: input.requireHttps, + signal: input.signal, + fetcher: input.fetcher + }) + let uploadId: string | null = null + try { + const begun = (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) + } + } +} diff --git a/src/main/skills/skill-upload-session-service.test.ts b/src/main/skills/skill-upload-session-service.test.ts new file mode 100644 index 00000000000..d250aa3d2fe --- /dev/null +++ b/src/main/skills/skill-upload-session-service.test.ts @@ -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' + ) + }) +}) diff --git a/src/main/skills/skill-upload-session-service.ts b/src/main/skills/skill-upload-session-service.ts new file mode 100644 index 00000000000..9db60e35078 --- /dev/null +++ b/src/main/skills/skill-upload-session-service.ts @@ -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() + private initialized: Promise | 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 }> { + 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 { + 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 { + 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 { + 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 { + 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') + } +} diff --git a/src/main/skills/skill-wsl-install-filesystem.test.ts b/src/main/skills/skill-wsl-install-filesystem.test.ts new file mode 100644 index 00000000000..2916805da69 --- /dev/null +++ b/src/main/skills/skill-wsl-install-filesystem.test.ts @@ -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() + }) +}) diff --git a/src/main/skills/skill-wsl-install-filesystem.ts b/src/main/skills/skill-wsl-install-filesystem.ts new file mode 100644 index 00000000000..92122adea49 --- /dev/null +++ b/src/main/skills/skill-wsl-install-filesystem.ts @@ -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 { + 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 { + 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 { + await this.run(RENAME_SCRIPT, [this.requireAllowed(source), this.requireAllowed(target)]) + } + + async remove(path: string): Promise { + await this.run(REMOVE_SCRIPT, [this.requireAllowed(path)]) + } + + async createAlias(canonicalPath: string, destinationPath: string): Promise { + await this.run(CREATE_ALIAS_SCRIPT, [ + this.requireAllowed(canonicalPath), + this.requireAllowed(destinationPath) + ]) + } + + async aliasTargets(canonicalPath: string, destinationPath: string): Promise { + return ( + (await this.runOutput(ALIAS_TARGET_SCRIPT, [ + this.requireAllowed(canonicalPath), + this.requireAllowed(destinationPath) + ])) === 'yes' + ) + } + + private async applyModes(entries: { path: string; mode: string }[]): Promise { + 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 { + 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 { + await this.runOutput(script, args) + } + + private async runOutput(script: string, args: string[]): Promise { + 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') + ]) +} diff --git a/src/main/skills/skill-wsl-provider-detection.ts b/src/main/skills/skill-wsl-provider-detection.ts new file mode 100644 index 00000000000..e3794e27b18 --- /dev/null +++ b/src/main/skills/skill-wsl-provider-detection.ts @@ -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 { + 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') + ) + } + ) + }) +} diff --git a/src/main/startup/skill-share-deep-link-state.test.ts b/src/main/startup/skill-share-deep-link-state.test.ts new file mode 100644 index 00000000000..128c1bc5a7c --- /dev/null +++ b/src/main/startup/skill-share-deep-link-state.test.ts @@ -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') + }) +}) diff --git a/src/main/startup/skill-share-deep-link-state.ts b/src/main/startup/skill-share-deep-link-state.ts new file mode 100644 index 00000000000..e49d6fb2df9 --- /dev/null +++ b/src/main/startup/skill-share-deep-link-state.ts @@ -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 + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ea5ba93a99c..f474d620a47 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 acknowledgeUpdateRun: () => Promise getUpdateRun: () => Promise + prepareShare: (input: { + skillId: string + target?: SkillDiscoveryTarget + packageId?: string + }) => Promise + publishShare: (input: SkillSharePublishInput) => Promise + cancelShare: (preparationId: string) => Promise + releaseShare: (preparationId: string) => Promise + resolveShare: (shareId: string) => Promise + createDownloadGrant: (shareId: string) => Promise> + installShare: (input: SkillShareInstallInput) => Promise + installPackageVersion: ( + input: SkillPackageVersionInstallInput + ) => Promise + previewInstall: (input: SkillInstallPreviewInput) => Promise + removeInstall: (input: SkillRemoveInput) => Promise + listManagedInstalls: (environmentId?: string) => Promise + getPackage: (packageId: string) => Promise> + listWslDistros: (environmentId?: string) => Promise + 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 + onOpenSkillShare: (callback: (shareId: string) => void) => () => void + consumePendingSkillShare: () => Promise onOpenSetupGuide: (callback: () => void) => () => void onOpenFeatureTour: (callback: () => void) => () => void onOpenCrashReport: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index e8152ea1184..126f14aed87 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('skills:cancelUpdateRun'), acknowledgeUpdateRun: (): Promise => ipcRenderer.invoke('skills:acknowledgeUpdateRun'), getUpdateRun: (): Promise => ipcRenderer.invoke('skills:getUpdateRun'), + prepareShare: (input: { + skillId: string + target?: SkillDiscoveryTarget + packageId?: string + }): Promise => ipcRenderer.invoke('skills:prepareShare', input), + publishShare: (input: SkillSharePublishInput): Promise => + ipcRenderer.invoke('skills:publishShare', input), + cancelShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:cancelShare', preparationId), + releaseShare: (preparationId: string): Promise => + ipcRenderer.invoke('skills:releaseShare', preparationId), + resolveShare: (shareId: string): Promise => + ipcRenderer.invoke('skills:resolveShare', shareId), + createDownloadGrant: (shareId: string): Promise> => + ipcRenderer.invoke('skills:createDownloadGrant', shareId), + installShare: (input: SkillShareInstallInput): Promise => + ipcRenderer.invoke('skills:installShare', input), + installPackageVersion: ( + input: SkillPackageVersionInstallInput + ): Promise => + ipcRenderer.invoke('skills:installPackageVersion', input), + previewInstall: (input: SkillInstallPreviewInput): Promise => + ipcRenderer.invoke('skills:previewInstall', input), + removeInstall: (input: SkillRemoveInput): Promise => + ipcRenderer.invoke('skills:removeInstall', input), + listManagedInstalls: (environmentId?: string): Promise => + ipcRenderer.invoke('skills:listManagedInstalls', environmentId), + getPackage: (packageId: string): Promise> => + ipcRenderer.invoke('skills:getPackage', packageId), + listWslDistros: (environmentId?: string): Promise => + 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 => 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 => + ipcRenderer.invoke('ui:consumePendingSkillShare'), onOpenSetupGuide: (callback: () => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent) => callback() ipcRenderer.on('ui:openSetupGuide', listener) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index cf4b4808668..a50b48609d6 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -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 { 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 { let graceBranch: RelayGraceBranch | null = null dispatcher.onRequest('relay.status', async () => ({ + capabilities: SKILL_RELAY_CAPABILITIES, pid: process.pid, uptimeMs: Date.now() - startedAt, detached, diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts new file mode 100644 index 00000000000..68c2aa253f0 --- /dev/null +++ b/src/relay/skill-install-handler.test.ts @@ -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() + 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) => + 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' + ]) + }) +}) diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts new file mode 100644 index 00000000000..72ec9f0d8a3 --- /dev/null +++ b/src/relay/skill-install-handler.ts @@ -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 + + constructor( + private readonly dispatcher: RelayDispatcher, + options: { + homeDirectory?: string + stateDirectory?: string + detectProviders?: () => Promise + } = {} + ) { + 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 { + 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 { + 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 + ) +} diff --git a/src/renderer/src/components/skills/SkillCard.tsx b/src/renderer/src/components/skills/SkillCard.tsx index 6563f62faf1..6efd0eff371 100644 --- a/src/renderer/src/components/skills/SkillCard.tsx +++ b/src/renderer/src/components/skills/SkillCard.tsx @@ -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 => { 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

)} - - - - - - {translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')} - - +
+ {onShare ? ( + + + + + + Share skill + + + ) : null} + + + + + + {translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')} + + +
diff --git a/src/renderer/src/components/skills/SkillInstallDialog.test.tsx b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx new file mode 100644 index 00000000000..96cee577cb2 --- /dev/null +++ b/src/renderer/src/components/skills/SkillInstallDialog.test.tsx @@ -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) { + 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 { + 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( 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( 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() + }) +}) diff --git a/src/renderer/src/components/skills/SkillInstallDialog.tsx b/src/renderer/src/components/skills/SkillInstallDialog.tsx new file mode 100644 index 00000000000..b0c6dc66500 --- /dev/null +++ b/src/renderer/src/components/skills/SkillInstallDialog.tsx @@ -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(null) + const [environmentId, setEnvironmentId] = useState('local') + const [scope, setScope] = useState<'global' | 'workspace'>('global') + const [workspace, setWorkspace] = useState('') + const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>( + null + ) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [result, setResult] = useState(null) + const [destinationPreview, setDestinationPreview] = useState(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 => { + 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 => { + 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 ( + !next && !busy && close()}> + + + Install shared skill + + Orca verifies access and package identity before changing the selected machine. + + + + {!preview ? ( +
{ + event.preventDefault() + void inspect() + }} + > +
+ + setLink(event.target.value)} + placeholder="https://app.orca.dev/skills/share/…" + className="font-mono text-xs" + autoFocus + /> +

+ Opening the link does not install anything. Review the immutable version first. +

+
+ +
+ ) : result && result.status !== 'conflict' ? ( +
+
+
+ +
+
+

{skillInstallResultLabel(result)}

+

+ {result.placements.length} placement{result.placements.length === 1 ? '' : 's'}{' '} + checked. +

+
+
+ {result.status === 'partial' ? ( +
+ {result.placements + .filter((item) => item.status === 'failed' || item.status === 'skipped') + .map((item) => ( +

+ {item.provider}: {item.errorCategory || item.status} +

+ ))} +
+ ) : null} +
+ ) : ( +
+
+
+
+

{version?.name}

+

{version?.description}

+
+ Immutable version +
+
+ {version?.manifest.files.length} files + {versionSummary.scriptCount} scripts + {versionSummary.executableCount} executable +
+

+ SHA-256 {version?.packageDigest} +

+ {version?.publisher ? ( +

+ Published by Orca user {version.publisher.userId} + {version.publisher.organizationId + ? ` in organization ${version.publisher.organizationId}` + : ''} + . +

+ ) : null} +

+ A skill contains instructions and may include scripts. Treat it as code from its + author. +

+
+ + { + 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 + )) ? ( +
+
+ Local copy needs a decision +
+

+ Orca found{' '} + {result?.conflict?.kind || destinationPreview?.currentState || 'changed'} content + and left it untouched. Keep it, or explicitly discard and replace it with this + version. +

+ +
+ ) : null} +
+ )} + + {error ? ( +

+ {error} +

+ ) : null} + + + {preview && (!result || result.status === 'conflict') ? ( + + ) : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx new file mode 100644 index 00000000000..f30f7be1d0f --- /dev/null +++ b/src/renderer/src/components/skills/SkillInstallManagementDialog.tsx @@ -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([]) + const [selectedKey, setSelectedKey] = useState('') + const [details, setDetails] = useState(null) + const [versionId, setVersionId] = useState('') + const [busy, setBusy] = useState(false) + const [confirmRemove, setConfirmRemove] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const selected = useMemo( + () => installs.find((install) => installKey(install) === selectedKey) ?? null, + [installs, selectedKey] + ) + + const load = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 ( + !next && !busy && close()}> + + + Manage installed skills + + Update, roll back, or safely remove versions installed by Orca. + + + + + {busy && installs.length === 0 ? : null} + {!busy && installs.length === 0 ? ( +

+ No Orca-managed skill installs were found on this machine. +

+ ) : null} +
+ {installs.map((install) => ( + + ))} +
+ + {selected && details ? ( +
+
+

{selected.name}

+

+ Installed version {selected.versionId} +

+
+ + {destructiveConflict ? ( +
+

+ Local files were modified +

+

+ Orca will preserve them unless you explicitly discard the local changes. +

+ + +
+ ) : null} +
+ + +
+
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/skills/SkillInstallTargetFields.tsx b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx new file mode 100644 index 00000000000..fe5967215ee --- /dev/null +++ b/src/renderer/src/components/skills/SkillInstallTargetFields.tsx @@ -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 + sshConnections: { id: string; label: string; connected: boolean }[] + workspaceChoices: SkillInstallWorkspaceChoice[] +}): React.JSX.Element { + const [wslDistros, setWslDistros] = useState([]) + + 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 ( + <> +
+
+ + +
+
+ + +
+
+ + {props.scope === 'global' && wslDistros.length > 0 ? ( +
+ + +
+ ) : null} + + {props.scope === 'workspace' ? ( +
+ + + {props.workspaceChoices.length === 0 ? ( +

+ No workspaces are known on this machine. +

+ ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/skills/SkillShareDialog.tsx b/src/renderer/src/components/skills/SkillShareDialog.tsx new file mode 100644 index 00000000000..f5f913b0fc4 --- /dev/null +++ b/src/renderer/src/components/skills/SkillShareDialog.tsx @@ -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(null) + const [members, setMembers] = useState([]) + const [author, setAuthor] = useState('') + const [organization, setOrganization] = useState('') + const [audience, setAudience] = useState<'organization' | 'people'>('organization') + const [selectedUserIds, setSelectedUserIds] = useState([]) + const [releaseNotes, setReleaseNotes] = useState('') + const [progress, setProgress] = useState(null) + const [shareUrl, setShareUrl] = useState(null) + const [error, setError] = useState(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 => { + generation.current += 1 + if (preview && !shareUrl) { + await window.api.skills.releaseShare(preview.preparationId) + } + onOpenChange(false) + } + + const publish = async (): Promise => { + 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 => { + if (!shareUrl) { + return + } + await window.api.ui.writeClipboardText(shareUrl) + toast.success(translate('auto.components.skills.SkillShareDialog.copied', 'Share link copied')) + } + + return ( + !next && !publishing && void close()}> + + + + {shareUrl + ? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready') + : translate('auto.components.skills.SkillShareDialog.title', 'Share skill')} + + + {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.' + )} + + + + {preparing ? ( +
+ + {translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')} +
+ ) : preview && !shareUrl ? ( +
+
+
+
+ +
+
+

{preview.name}

+

{preview.description}

+
+
+
+ {preview.fileCount} files + {byteLabel(preview.totalBytes)} + {preview.scriptPaths.length} scripts + {preview.executablePaths.length} executable +
+

+ SHA-256 {preview.packageDigest} +

+
+ +
+
+ +

+ {author + ? `Publishing as ${author}${organization ? ` in ${organization}` : ''}.` + : 'A connected Orca Cloud account is required.'} +

+
+ setAudience(value as typeof audience)} + > + + + Organization + + Selected people + + + Everyone currently in {organization || 'the organization'} can access the link. + + + {members.length > 0 ? ( +
+ {members.map((member) => { + const checked = selectedUserIds.includes(member.userId) + return ( + + ) + })} +
+ ) : ( +

No teammates are available.

+ )} +
+
+
+ +
+ +