diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index f7669bc03c1..ebd40d4dea2 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -2,16 +2,19 @@ Status: implementation and validation in progress. -Last updated: 2026-08-11. +Last updated: 2026-08-12. Implementation baselines captured by this checklist update: -- Orca implementation: `6ba6107dba` on `skills-share` (pushed; no PR). -- Orca Cloud: `be00db10` on `main` (opaque audience IDs in `stablyai/orca-cloud#317`, with the - skill OIDC smoke and release-metadata convergence from `#313` and `#314`). +- Orca implementation: `skills-share` (bearer-link changes follow baseline `6cff2aa507`; no PR). +- Orca Cloud: isolated `feat/skill-bundles` branch (bearer-link changes follow baseline `d7d1026`). + Production remains untouched. Validated so far: +- Final bearer-link changes pass 143 Orca Cloud API tests with one opt-in skip; 58 focused Orca + tests; four mixed-version wire tests; desktop/node/web typecheck; localization catalog, + extraction, and coverage; changed-code quality; max-lines; and diff checks. - Local Node and web typechecks, changed-code quality gates, 94 skill-domain files with 770 tests passed and 3 skipped, 134 Orca Cloud API tests with one opt-in integration skip, the full Cloud monorepo test/typecheck/lint/build gates, and isolated Terraform formatting and validation. @@ -68,12 +71,14 @@ Validated so far: - Auth deploy run `31535179937` promoted revision `orca-cloud-auth-staging-00017-dug` at 100% traffic. Health, JWKS, exact GitHub OIDC claim constraints, and zero deployment-window errors passed. -- API deploy run `31535438327` promoted revision `orca-cloud-api-staging-00042-hef` at 100% +- The historical ACL-era API deploy run `31535438327` promoted revision + `orca-cloud-api-staging-00042-hef` at 100% traffic with `authenticatedSmoke: true` and `skillSmoke: true`. The smoke covered artifact lifecycle; skill upload/finalize/download; two immutable versions; recipient and outsider authorization; local and remote grants; rollback selection; expiry; revocation; package deletion; and signed-object cleanup. -- Cloud PR `#317` passed both required checks and merged as `be00db10`. Deploy run `31546194596` +- Cloud PR `#317` passed both required checks and merged as `be00db10`. Its historical ACL-era + deploy run `31546194596` promoted revision `orca-cloud-api-staging-00048-xom` at 100% traffic with `authenticatedSmoke: true` and `skillSmoke: true`; canonical health and the immutable image digest independently matched after promotion. @@ -94,10 +99,11 @@ Validated so far: Independent GCP reads verified Cloud SQL policy `NEVER`, every MIG target at zero, and both managed Cloud Run minimums at zero. -Rollout gate: staging infrastructure, OIDC identity exchange, route registration, and the -authenticated artifact/skill lifecycle smoke passed. The shared staging data plane is back in its -low-cost asleep state. Production remains untouched; physical staging install journeys, load, -quarantine lifecycle, and soft-delete recovery gates remain. +Rollout gate: staging infrastructure and OIDC owner identity exchange passed. The earlier +authenticated skill lifecycle smoke predates bearer-link semantics and is not acceptance evidence +for anonymous recipient access. The shared staging data plane is back in its low-cost asleep state. +Production remains untouched; the anonymous bearer lifecycle, physical staging install journeys, +load, quarantine lifecycle, and soft-delete recovery gates remain. Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md). @@ -109,8 +115,9 @@ does not mean the surrounding phase is complete. - [ ] A user can select one or many private local skills, publish one immutable Skill Bundle, and receive one durable Orca share URL. -- [ ] An authorized recipient can inspect a bundle, choose all or a subset of its skills, and - install them globally or into a Git worktree or plain folder workspace. +- [ ] Anyone with an active unlisted link can inspect a bundle without signing in, choose all or a + subset of its skills, and install them globally or into a Git worktree or plain folder + workspace. - [ ] Installation works on local macOS, Linux, native Windows, WSL, paired Orca runtimes, and supported SSH targets. - [ ] The portable archive root conforms to Agent Plugins 1.0.0 for skills-only packages and keeps @@ -126,8 +133,9 @@ does not mean the surrounding phase is complete. - [x] Mixed-version clients and remote hosts fail safely through capability negotiation. - [ ] Cloud resources are Terraform-owned, monitored, recoverable, and protected by independent upload, download, and remote-install kill switches. -- [ ] The existing Skills page supports multi-select sharing, selective installation, per-skill - conflicts/results, and installed/shared bundle management. +- [x] The existing Skills page supports multi-select sharing, selective installation, per-skill + conflicts/results, and installed-bundle management; Settings → Share Skills provides the + authenticated owner inventory for copying and revoking active links. ## 0. Confirm scope and ownership @@ -158,20 +166,28 @@ does not mean the surrounding phase is complete. ### Product semantics -- [x] Confirm private means authorized Orca users and organizations, not end-to-end encryption - from Orca Cloud operators. +- [x] Confirm private means unlisted and protected by an unpredictable revocable bearer link, not + end-to-end encryption from Orca Cloud operators. - [x] Confirm a published immutable version persists until package or version deletion; it has no age-based object expiry. - [x] Confirm an unfinished upload grant expires after 15 minutes and its quarantine object is deleted after one day. -- [x] Confirm a durable share URL identifies an authorization record, not a GCS object or bearer - credential. +- [x] Confirm a durable share URL contains a bearer credential for a revocable authorization + record, not a GCS object or permanent blob grant. - [x] Confirm a signed download grant lasts five minutes and is the maximum share-revocation lag. - [x] Confirm updates create immutable versions and never mutate existing package objects. - [x] Confirm rollback installs a selected prior immutable version. - [x] Confirm revoking or deleting a Cloud share does not silently remove existing local installs. -- [x] 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. +- [x] Confirm sharing and updating use an artifact-like flow—preview, upload, unlisted durable + link, update, revoke/delete—while retaining skill-specific version, trust, and install + semantics. +- [x] Confirm recipients do not sign in; publishing and managing owned links still require sign-in. +- [x] Confirm revocation blocks future resolution/download grants but does not remove installed + copies. +- [x] Add a Settings section named **Share Skills**, parallel to **Artifacts**, without adding any + index or discoverability surface. +- [x] Treat the authenticated owner-only active-link inventory as management, not recipient + discoverability; never expose it anonymously or through browse/search APIs. - [x] Confirm V1 has no checked-in workspace desired-state lockfile and no automatic fleet-wide installation. - [x] Confirm one skill is a one-item bundle and a bundle may contain large selections such as 30 @@ -618,12 +634,13 @@ does not mean the surrounding phase is complete. - [x] Add bounded `skill_package_audit_events` without contents, filenames, or signed values. - [x] Add unique package slug per organization, immutable version and digest constraints, and tenant-scoped archive and final GCS key/generation constraints. -- [x] Add active-share, ACL-principal, and pending-upload expiration indexes. +- [x] Add active-share, ACL-principal, and pending-upload expiration indexes. The ACL index remains + legacy storage and is not used for bearer resolution. - [x] Add foreign keys preventing blob-reference deletion while a published version uses it. - [x] Accept and stream-validate both the pre-release single-skill envelope and the new Agent Plugins skills-only bundle envelope during the desktop migration. -- [x] Store the detailed bundle manifest in the existing immutable version record without changing - object, share, or ACL semantics; expose it only after exact-link authorization or to owners. +- [x] Store the detailed bundle manifest in the existing immutable version record; expose it only + after exact-link bearer authorization or to owners. - [x] Keep contained skills out of separate database rows and search indexes. Selective inspection reads the validated manifest attached to the exact immutable version. - [ ] Keep detailed file identity in the in-archive manifest rather than duplicating it into GCS @@ -633,12 +650,14 @@ does not mean the surrounding phase is complete. ### Authorization and lifecycle model -- [x] Begin every share resolution, grant, update lookup, and revoke mutation with an authenticated - principal. -- [x] Evaluate package ownership and ACL in the same request. -- [x] Ensure share ID locates a record but never authorizes access by itself. -- [x] Return constant-shape authorization failures that do not disclose package existence. -- [x] Recheck organization membership for every grant, including after preview. +- [x] Allow share resolution and share-scoped download grants without an authenticated principal. +- [x] Treat an unpredictable active, unexpired share ID as the bearer authorization; do not apply + legacy package ACLs to recipients. +- [x] Return the same non-disclosing `404` for missing, expired, revoked, or deleted shares. +- [x] Keep authenticated owner checks on upload, finalize, package/version management, share + creation, revocation, and deletion. +- [x] Apply distributed per-IP abuse limits to anonymous resolution and grant requests without + persisting raw requester addresses. - [ ] Apply organization retention and legal deletion rules over product rollback retention. - [x] Make package deletion revoke shares before dereferencing immutable objects. - [x] Delete an object only after a transaction proves no retained version references it. @@ -664,20 +683,22 @@ does not mean the surrounding phase is complete. - [x] Delete quarantine bytes after success and rely on lifecycle cleanup for abandonment. - [x] Make all mutating endpoints accept idempotency keys. -### Package, version, ACL, and share APIs +### Package, version, and share APIs - [x] Implement package creation and version publication under a stable package identity. - [x] Implement package details and paginated version history. -- [x] Implement organization and selected-user access management. +- [x] Stop accepting organization/selected-user audience fields for new bearer shares; retain + legacy ACL storage only as migration-compatible data. - [x] Implement durable share creation with optional pinned version and expiry. -- [x] Implement share resolution with authenticated metadata preview. +- [x] Implement anonymous bearer-link metadata preview. - [x] Implement immediate share revocation for new grant requests. - [x] Implement Cloud package/version deletion with retention and reference checks. - [x] Implement version update lookup and rollback selection. ### Download grants -- [x] Implement `POST /v1/skill-shares//download-grants` after fresh ACL evaluation. +- [x] Implement anonymous `POST /v1/skill-shares//download-grants` after fresh active-link + evaluation. - [x] Generate a five-minute V4 signed GET URL for the exact immutable key and stored generation. - [x] Set only response content type and safe attachment filename overrides. - [x] Grant no list or write capability. @@ -687,8 +708,11 @@ does not mean the surrounding phase is complete. ### Cloud tests -- [x] Test organization, user, pinned-link, expiry, and revocation ACL behavior. -- [x] Test membership removal between preview, grant, and download. +- [x] Test anonymous latest/pinned preview and download grants without an Authorization header. +- [x] Test missing, expired, revoked, and deleted shares all fail without disclosure. +- [x] Test authenticated owner management remains protected. +- [x] Test anonymous per-IP rate limiting without recording raw IP addresses. +- [x] Test the authenticated owner-only active-link inventory and ensure responses are not cached. - [x] Test expired, reused, wrong-tenant, wrong-key, wrong-size, and wrong-hash uploads. - [x] Test malformed, oversized, and resource-exhausting archives during finalization. - [x] Test finalization semaphore saturation and retry behavior. @@ -704,8 +728,8 @@ does not mean the surrounding phase is complete. ### Cloud client -- [x] Add typed calls for upload grants, GCS upload, finalization, package/version catalog, - ACL/access management, share creation/resolution/revocation, and download grants. +- [x] Make share resolution and share-scoped download-grant calls anonymous while keeping upload, + package/version management, share creation, revocation, and deletion authenticated. - [x] Keep desktop contracts storage-provider-neutral. - [x] Implement bounded progress, cancellation, retry, and idempotency behavior. - [x] Redact signed policies, URLs, credentials, private paths, and package contents at creation. @@ -714,23 +738,26 @@ does not mean the surrounding phase is complete. - [x] Read and apply `docs/STYLEGUIDE.md`; use canonical CSS tokens and shadcn primitives. - [x] Add **Share skill** to eligible installed and workspace skills. -- [x] Show name, description, author, organization, file count, total size, scripts, and executable - files before upload. -- [x] Let the author choose organization or selected-person access. +- [x] Show name, description, author, file count, total size, scripts, executable files, and the + unlisted-link warning before upload. +- [x] Remove organization and selected-person audience controls from bearer-link publishing. - [x] Accept optional release label and notes. - [x] Package the exact previewed bytes and invalidate preview after source drift. - [x] Show bounded upload and finalization progress with cancellation. - [x] Return a copyable durable Orca URL after publication. -- [x] Add access editing, version publishing, unshare, and package deletion actions. +- [x] Remove access editing from the bearer-share workflow; keep version publishing, revoke, and + package deletion actions. - [x] Make clear that unsharing blocks future installs but does not remove installed copies. -- [ ] Add **Share skills**, **Install from link**, and unified **Manage** to the existing Skills page - header. The first two are present; management still covers installed packages only. +- [x] Add **Share skills**, **Install from link**, and installed-package **Manage** to the existing + Skills page header; expose active link copy/revocation in Settings → **Share Skills** so links + remain manageable even when the source was never a managed install. - [x] Add selection mode whose selection survives search/filter changes and an explicit **Select all results** action; never select all implicitly. - [x] Keep the per-card share action as a one-skill bundle shortcut. - [ ] Disable ineligible skills with visible reasons and block duplicate skill names before review. - [x] Review bundle name, skill/file/byte counts, script/executable warnings, expandable per-skill - details, audience, and release notes. + details, unlisted-link behavior, and release notes. +- [x] Add Settings → **Share Skills** with artifact-like explanation and a route to the Skills page. - [ ] Show named preparation, upload, verification, and link-publication progress and return one durable link for the bundle. diff --git a/docs/agent-skill-sharing-installation-plan.md b/docs/agent-skill-sharing-installation-plan.md index 4a444264dfb..52d225f7ed3 100644 --- a/docs/agent-skill-sharing-installation-plan.md +++ b/docs/agent-skill-sharing-installation-plan.md @@ -2,7 +2,7 @@ Status: implementation and validation in progress. -Last updated: 2026-08-11. +Last updated: 2026-08-12. Implementation checklist: [Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md). @@ -27,8 +27,9 @@ Orca will: 1. Package one or many local skills into one immutable, content-addressed artifact and durable link. -2. Store private artifacts behind Orca Cloud authorization and durable share records. -3. Resolve a share into a short-lived download grant after checking access. +2. Store private artifacts behind unlisted, revocable Orca Cloud share records. +3. Treat possession of an active share link as recipient authorization, then resolve it into a + short-lived download grant. 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. Let the recipient install all or a selected subset, with conflicts and results reported per @@ -38,6 +39,8 @@ Orca will: 8. Record bundle/version provenance independently from the community CLI's lockfiles. 9. Keep every share unlisted: bundles are reachable only through their durable link and never appear in search, browse, organization-library, or marketplace surfaces. +10. Give authenticated owners a private active-link inventory for copy and revocation; this is a + management surface, not recipient discovery. The portable archive root follows Agent Plugins 1.0.0 for a skills-only package. Orca does not present Skill Bundles as a new executable plugin system: MCP servers, hooks, processes, connectors, @@ -50,12 +53,14 @@ that larger package-manager surface. ## Current execution status -The last pushed Orca baseline is `41a7b45c4e`; current Skill Bundle work remains uncommitted on -`skills-share` and no Orca pull request exists. Orca Cloud implementation is merged on `main` -through `be00db10`. The authenticated OIDC smoke landed in `stablyai/orca-cloud#313`; the narrow -Auth release-metadata convergence follow-up landed in `#314`; the finalization and lifecycle fixes -landed in `#317`. Staging infrastructure and skill routes are deployed and validated; production -remains untouched. Local, Windows, WSL, paired-runtime, and +The Orca implementation is on `skills-share`; bearer-link changes follow baseline `6cff2aa507` and +no Orca pull request exists. Cloud bundle ingestion and bearer-link work are isolated on +`feat/skill-bundles` after baseline `d7d1026`. The authenticated OIDC smoke landed in +`stablyai/orca-cloud#313`; the narrow Auth release-metadata convergence +follow-up landed in `#314`; the finalization and lifecycle fixes landed in `#317`. Staging +infrastructure exists, but its earlier authenticated recipient smoke predates the anonymous +bearer-link model and must be replaced before release. Production remains untouched. Local, +Windows, WSL, paired-runtime, and Docker-backed SSH validation are substantially complete; the implementation checklist records the exact evidence and remaining physical-host and failure-recovery gates. @@ -71,10 +76,11 @@ rejected before transfer. The original single-skill path remains active for lega bundle management migrates. Cloud bundle validation is pushed on isolated branch `feat/skill-bundles` at `d7d1026`. It accepts -both envelopes during migration, stores the detailed bundle manifest with the immutable version, -and leaves object storage, ACLs, shares, and download grants unchanged. The full API suite passes -136 tests with one skipped. It is not merged or deployed until the desktop publisher emits the new -format and the staging smoke is updated. +both envelopes during migration and stores the detailed bundle manifest with the immutable +version. Local bearer-link changes make active share IDs the recipient credential, keep owner +operations authenticated, retain legacy ACL rows only as migration storage, and prevent concrete +share URLs from entering request logs. It is not merged or deployed until the final gates and +anonymous staging smoke pass. The latest resilience pass adds bounded convergence after a lost final install response for both paired runtimes and SSH. A staged retry creates a new upload instead of reusing a consumed upload. @@ -167,7 +173,7 @@ attribution. Revisit this decision if implementation work ever proposes copying - 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. +- Publishing package bytes through a permanent or directly addressable blob 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 @@ -196,8 +202,8 @@ The review dialog shows: - Bundle name and selected skill list. - Skill, file, and byte counts. - Included scripts and executable files, with an expandable review for each skill. -- Current author identity and organization. -- Access choice: organization or selected people. +- Current author identity. +- A clear warning that anyone with the unlisted link can inspect and install the bundle. - Optional version label and release notes. After confirmation, Orca validates and packages the exact bytes shown in the preview. Progress is @@ -208,10 +214,11 @@ one durable URL such as: 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. +The URL identifies a revocable bearer share record, not a blob-storage object or permanent GCS +grant. Recipients do not sign in: possession of an active, unexpired link authorizes inspection and +a short-lived download grant. Revocation blocks future resolution and grants, while already +installed copies remain. Publishing, listing owned shares, version management, revocation, and +deletion still require the owner to sign in. ### Installing @@ -232,7 +239,12 @@ An incompatible selected runtime receives an update-required result before trans **Manage** separates: - Installed bundles: update, rollback, install on another machine, inspect skills, and remove. -- Shared bundles: copy link, change access, revoke, publish version, and delete the Cloud package. +- Shared bundles: copy link, revoke, publish version, and delete the Cloud package. + +Settings includes a **Share Skills** section, parallel to **Artifacts**, explaining unlisted-link +behavior, retention, revocation, and the fact that installed copies are unaffected. It links to the +Skills page for publishing and management; it does not introduce a searchable catalog or a second +sharing workflow. Bundle update or removal never silently destroys locally modified skills. @@ -247,7 +259,7 @@ Selected skill folders Package builder -- validates and creates immutable artifact | v -Orca Cloud -- private blob + version metadata + ACL + durable share +Orca Cloud -- private blob + version metadata + revocable bearer share | v Download grant -- short-lived and scoped to one immutable digest @@ -336,7 +348,8 @@ Rules: than a top-level custom field or GCS metadata alone. It therefore survives export, SSH/remote transfer, and offline validation and can describe every skill and file. - GCS custom metadata stays compact and operational. PostgreSQL stores only the package, share, - version, ACL, and lifecycle records needed to resolve a known link or manage an owned bundle. + version, ownership, and lifecycle records needed to resolve a known link or manage an owned + bundle. Legacy ACL records may remain during migration but never gate bearer-link access. - Orca constructs a fresh staging root. If imported content already contains `dev.orca.skill-sharing`, Orca accepts only an appropriate recognized schema and rejects unknown, malformed, or conflicting contents; it never overwrites the namespace silently. @@ -481,7 +494,7 @@ returns `429` or `503` with `Retry-After`; it does not queue unbounded package b ### Download grants -After resolving a share and rechecking its ACL, `orca-cloud-api` creates a V4 signed GET URL with: +After resolving an active bearer share, `orca-cloud-api` creates a V4 signed GET URL with: - Exact final object key and stored generation. - Five-minute expiration. @@ -527,7 +540,8 @@ Create migrations for: bundle manifest needed to inspect a known share without downloading its private archive. - `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_package_acl`: legacy organization or user access records retained for migration until a + later schema cleanup; these do not authorize or deny bearer-share resolution. - `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 @@ -540,13 +554,15 @@ Required constraints and indexes include: - Tenant-scoped archive identity and final GCS object key/generation pair. - Share lookup by unpredictable ID and active/revoked state. - No package, version, or contained-skill search/list index; share links are intentionally unlisted. -- ACL lookup by package and principal. +- Owner lookup for authenticated management operations. - 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. +Share resolution and share-scoped download grants accept no user credential: an unpredictable, +active, unexpired share ID is the bearer credential. Anonymous requests receive the same +non-disclosing `404` for missing, expired, revoked, or deleted shares and remain subject to +distributed per-IP abuse limits. Package/version management, share creation, owner listing, +revocation, and deletion require an authenticated owner. ### Cloud Run changes @@ -650,7 +666,7 @@ policies, signed URLs, or ACL membership. 7. Verify the migration-ready event, zero error logs, ordinary artifact behavior, and `404` skill route boundary before changing any control. 8. Enable route registration for the staging identity and run upload/finalize/download tests, - including expired policies, ACL denial, oversized uploads, corrupt archives, deduplication, + including expired policies, anonymous bearer resolution, oversized uploads, corrupt archives, deduplication, revocation, and object cleanup. 9. Run a staging desktop-to-runtime installation and verify logs contain no grants or private contents. @@ -796,11 +812,13 @@ and test the same contract accurately. ### Cloud client - Skill upload grant creation and finalization. -- Share creation, resolution, revocation, and access management. +- Authenticated share creation and revocation; anonymous share resolution. - Download grant creation. - Owned-package management and exact package/version lookup by ID; no browse or search API. -- Manager-only package details include current user/organization access and active, unexpired - share records; non-managers never receive that management metadata. +- Authenticated owner-only active-link inventory for cross-device copy and revocation; no public or + recipient inventory. +- Manager-only package details include ownership and active, unexpired share records; anonymous + recipients never receive management metadata. Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider neutral. @@ -815,10 +833,12 @@ neutral. ### Renderer -- Multi-select bundle share review and access dialog. +- Multi-select bundle share review and unlisted-link warning. - Share completion dialog with copyable durable link. -- Access editing, active-link revocation, immutable-version deletion, and Cloud-package deletion - with explicit confirmation and copy explaining that installed copies remain local. +- Active-link copy/revocation, immutable-version deletion, and Cloud-package deletion with explicit + confirmation and copy explaining that installed copies remain local. +- Settings → Share Skills owner inventory so newly published links remain manageable even when the + source skill has no managed-install receipt. - Selective install preview with destination, scope, coverage, and per-skill conflict state. - Aggregate install progress and grouped per-skill outcomes. - Installed/shared bundle management, update, rollback, removal, and incomplete-coverage actions on @@ -1174,8 +1194,8 @@ destination containment, and expected identities must all agree. ### Cloud tests -- Organization/user/link ACLs and revocation. -- Expired grants and membership removed between preview and download. +- Anonymous active-link resolution, expiry, revocation, and non-disclosing missing-link failures. +- Owner authentication for package and share management. - Upload finalization digest mismatch. - Blob deduplication without cross-tenant information leakage. - Quota and rate-limit behavior. @@ -1261,19 +1281,19 @@ 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. +- PostgreSQL migrations for package, version, upload, share, ownership, and audit records, with + legacy ACL storage tolerated during migration. - 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. +- Version, bearer-share, revocation, five-minute download grant, quota, and audit behavior. +- Share preview and owner-management 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. +- A copied active durable link can be inspected and installed without recipient sign-in. - 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, @@ -1376,7 +1396,7 @@ than delaying private sharing for organization-wide policy features. - 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. +- An unpredictable durable share ID is a revocable bearer credential, 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. diff --git a/src/main/ipc/skill-cloud-ipc-handlers.ts b/src/main/ipc/skill-cloud-ipc-handlers.ts index 46868cb1c22..c8bbed66f64 100644 --- a/src/main/ipc/skill-cloud-ipc-handlers.ts +++ b/src/main/ipc/skill-cloud-ipc-handlers.ts @@ -16,6 +16,7 @@ import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-t import { SkillSharePreparationService } from '../skills/skill-share-preparation-service' import { supportsSkillRuntimeBundleInstall, + supportsSkillRuntimeCancellation, supportsSkillRuntimeInstall } from '../skills/skill-runtime-capability' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' @@ -40,17 +41,7 @@ const sharePrepareSchema = z const sharePublishSchema = z .object({ preparationId: z.string().uuid(), - releaseNotes: z.string().max(10_000), - userIds: z.array(z.string().min(1).max(255)).max(100), - shareWithOrganization: z.boolean() - }) - .strict() - -const replaceAccessSchema = z - .object({ - packageId: z.string().min(1).max(128), - userIds: z.array(z.string().min(1).max(255)).max(100), - shareWithOrganization: z.boolean() + releaseNotes: z.string().max(10_000) }) .strict() @@ -258,6 +249,9 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { return { cancelled: runtime.cancelSharedSkillInstall(input.operationId) } } const transferCancelled = remoteInstallCancellation.cancel(input.operationId) + if (!(await supportsSkillRuntimeCancellation(app.getPath('userData'), input.environmentId))) { + return { cancelled: transferCancelled } + } const response = await callRuntimeEnvironment( app.getPath('userData'), input.environmentId, @@ -274,17 +268,7 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('skills:getPackage', (_event, packageId: unknown) => runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {}) ) - ipcMain.handle('skills:replacePackageAccess', (_event, value: unknown) => { - const input = replaceAccessSchema.parse(value) - return runtime.replaceSkillPackageAccess( - input.packageId, - { - userIds: input.userIds, - shareWithOrganization: input.shareWithOrganization - }, - {} - ) - }) + ipcMain.handle('skills:listOwnedShares', () => runtime.listOwnedSkillShares({})) ipcMain.handle('skills:revokeShare', (_event, shareId: unknown) => runtime.revokeSkillShare(z.string().min(1).max(128).parse(shareId), {}) ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 444bd1b3901..67650b0ba98 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4597,8 +4597,6 @@ export class OrcaRuntimeService { packageId: string, request: SkillCloudOptions & { pinnedVersionId?: string - userIds: string[] - shareWithOrganization: boolean idempotencyKey?: string } ) { @@ -4641,12 +4639,8 @@ export class OrcaRuntimeService { return this.requireSkillCloudService().getPackage(packageId, options) } - replaceSkillPackageAccess( - packageId: string, - access: { userIds: string[]; shareWithOrganization: boolean }, - options: SkillCloudOptions - ): Promise> { - return this.requireSkillCloudService().replaceAccess(packageId, access, options) + listOwnedSkillShares(options: SkillCloudOptions) { + return this.requireSkillCloudService().listOwnedShares(options) } revokeSkillShare( diff --git a/src/main/skills/skill-cloud-request.test.ts b/src/main/skills/skill-cloud-request.test.ts index 476889ede57..f39c1187408 100644 --- a/src/main/skills/skill-cloud-request.test.ts +++ b/src/main/skills/skill-cloud-request.test.ts @@ -21,6 +21,18 @@ describe('skillCloudRequest', () => { ).resolves.toEqual({ share: { id: 'share_1' } }) }) + it('omits authorization for bearer-link requests', async () => { + const fetcher = vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => { + expect(new Headers(init?.headers).has('authorization')).toBe(false) + return Response.json({ share: { id: 'share_1' } }) + }) as typeof fetch + await skillCloudRequest({ + apiUrl: 'http://127.0.0.1:8787', + path: '/v1/skill-shares/share_1', + fetcher + }) + }) + it('rejects non-API paths and returns structured errors without response contents', async () => { await expect( skillCloudRequest({ diff --git a/src/main/skills/skill-cloud-request.ts b/src/main/skills/skill-cloud-request.ts index 96c9a665281..c1bf4c805be 100644 --- a/src/main/skills/skill-cloud-request.ts +++ b/src/main/skills/skill-cloud-request.ts @@ -12,7 +12,7 @@ export class SkillCloudRequestError extends Error { export async function skillCloudRequest(input: { apiUrl?: string - authToken: string + authToken?: string path: string method?: 'DELETE' | 'GET' | 'POST' | 'PUT' body?: unknown @@ -28,7 +28,7 @@ export async function skillCloudRequest(input: { const response = await (input.fetcher ?? fetch)(url, { method: input.method ?? 'GET', headers: { - authorization: `Bearer ${input.authToken}`, + ...(input.authToken ? { authorization: `Bearer ${input.authToken}` } : {}), accept: 'application/json', ...(input.body === undefined ? {} : { 'content-type': 'application/json' }), ...(input.idempotencyKey ? { 'idempotency-key': input.idempotencyKey } : {}) diff --git a/src/main/skills/skill-cloud-service.test.ts b/src/main/skills/skill-cloud-service.test.ts new file mode 100644 index 00000000000..bb19e3b0a34 --- /dev/null +++ b/src/main/skills/skill-cloud-service.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SkillCloudService } from './skill-cloud-service' + +vi.mock('electron', () => ({ app: { isPackaged: false } })) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('SkillCloudService bearer links', () => { + it('resolves and grants downloads without an Orca session', async () => { + const requests: RequestInit[] = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + requests.push(init ?? {}) + return String(input).endsWith('/download-grants') + ? Response.json({ + grant: { url: 'https://storage.test/package', expiresAt: '2026-08-11T00:05:00Z' }, + version: { versionId: 'ver_1' } + }) + : Response.json({ share: { id: 'share_1', version: { versionId: 'ver_1' } } }) + }) + ) + const service = new SkillCloudService('/unused') + const options = { apiUrl: 'http://127.0.0.1:8787' } + + await expect(service.resolveShare('share_1', options)).resolves.toMatchObject({ status: 'ok' }) + await expect(service.createDownloadGrant('share_1', options)).resolves.toMatchObject({ + status: 'ok' + }) + + expect(requests).toHaveLength(2) + for (const request of requests) { + expect(new Headers(request.headers).has('authorization')).toBe(false) + } + }) +}) diff --git a/src/main/skills/skill-cloud-service.ts b/src/main/skills/skill-cloud-service.ts index 6d89cebd0ff..0ffdbef36aa 100644 --- a/src/main/skills/skill-cloud-service.ts +++ b/src/main/skills/skill-cloud-service.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import type { SkillCloudDownloadGrant, SkillCloudInstallTarget, + SkillCloudOwnedShare, SkillCloudOperation, SkillCloudOptions, SkillCloudPackageDetails, @@ -10,6 +11,7 @@ import type { SkillCloudShare, SkillCloudVersion } from '../../shared/skill-cloud-contract' +import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config' import { runSkillCloudOperation } from './skill-cloud-auth' import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload' import { skillCloudRequest } from './skill-cloud-request' @@ -97,8 +99,6 @@ export class SkillCloudService { packageId: string, request: SkillCloudOptions & { pinnedVersionId?: string - userIds: string[] - shareWithOrganization: boolean idempotencyKey?: string } ): Promise> { @@ -109,9 +109,7 @@ export class SkillCloudService { path: `/v1/skill-packages/${id(packageId)}/shares`, method: 'POST', body: { - pinnedVersionId: request.pinnedVersionId, - userIds: request.userIds, - shareWithOrganization: request.shareWithOrganization + pinnedVersionId: request.pinnedVersionId }, idempotencyKey: request.idempotencyKey ?? randomUUID() }) @@ -123,10 +121,10 @@ export class SkillCloudService { shareId: string, options: SkillCloudOptions ): Promise> { - return this.withAuth(options, async (token, apiUrl) => { + return this.withoutAuth(options, async (apiUrl) => { const result = await skillCloudRequest<{ share: { id: string; version: SkillCloudVersion } - }>({ apiUrl, authToken: token, path: `/v1/skill-shares/${id(shareId)}` }) + }>({ apiUrl, path: `/v1/skill-shares/${id(shareId)}` }) return result.share }) } @@ -135,10 +133,9 @@ export class SkillCloudService { shareId: string, options: SkillCloudOptions & { versionId?: string; installTarget?: SkillCloudInstallTarget } ): Promise> { - return this.withAuth(options, (token, apiUrl) => + return this.withoutAuth(options, (apiUrl) => skillCloudRequest({ apiUrl, - authToken: token, path: `/v1/skill-shares/${id(shareId)}/download-grants`, method: 'POST', body: { @@ -179,21 +176,17 @@ export class SkillCloudService { }) } - replaceAccess( - packageId: string, - access: { userIds: string[]; shareWithOrganization: boolean }, + listOwnedShares( options: SkillCloudOptions - ): Promise> { - return this.withAuth(options, (token, apiUrl) => - skillCloudRequest({ + ): Promise> { + return this.withAuth(options, async (token, apiUrl) => { + const result = await skillCloudRequest<{ shares: SkillCloudOwnedShare[] }>({ apiUrl, authToken: token, - path: `/v1/skill-packages/${id(packageId)}/access`, - method: 'PUT', - body: access, - idempotencyKey: randomUUID() + path: '/v1/skill-shares' }) - ) + return result.shares + }) } revokeShare(shareId: string, options: SkillCloudOptions): Promise> { @@ -243,4 +236,12 @@ export class SkillCloudService { operation }) } + + private async withoutAuth( + options: SkillCloudOptions, + operation: (apiUrl: string) => Promise + ): Promise> { + const value = await operation(resolveArtifactCloudApiUrl(options.apiUrl)) + return { status: 'ok', value } + } } diff --git a/src/main/skills/skill-runtime-capability.test.ts b/src/main/skills/skill-runtime-capability.test.ts index e191eb4c6b1..3059a89032e 100644 --- a/src/main/skills/skill-runtime-capability.test.ts +++ b/src/main/skills/skill-runtime-capability.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing' import { + supportsSkillRuntimeCancellation, supportsSkillRuntimeInstall, supportsSkillRuntimeManagement } from './skill-runtime-capability' @@ -29,6 +30,21 @@ describe('skill runtime capability admission', () => { await expect(supportsSkillRuntimeManagement('/state', 'environment_1')).resolves.toBe(false) }) + it('admits cancellation only when the host advertises it', async () => { + status + .mockResolvedValueOnce({ + ok: true, + result: { capabilities: ['skills.install.v1'], runtimeId: 'runtime_1' } + } as never) + .mockResolvedValueOnce({ + ok: true, + result: { capabilities: ['skills.install-cancel.v1'], runtimeId: 'runtime_1' } + } as never) + + await expect(supportsSkillRuntimeCancellation('/state', 'environment_1')).resolves.toBe(false) + await expect(supportsSkillRuntimeCancellation('/state', 'environment_1')).resolves.toBe(true) + }) + it('fails closed for unavailable and capability-omitting hosts', async () => { status .mockResolvedValueOnce({ ok: false, error: { code: 'offline', message: 'offline' } } as never) diff --git a/src/main/skills/skill-runtime-capability.ts b/src/main/skills/skill-runtime-capability.ts index 66dacdd9680..c03b726931d 100644 --- a/src/main/skills/skill-runtime-capability.ts +++ b/src/main/skills/skill-runtime-capability.ts @@ -1,5 +1,6 @@ import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_CANCEL_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY } from '../../shared/skill-install-capability' @@ -26,6 +27,17 @@ export async function supportsSkillRuntimeBundleInstall( ) } +export async function supportsSkillRuntimeCancellation( + userDataPath: string, + environmentId: string +): Promise { + const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000) + return ( + status.ok === true && + status.result.capabilities?.includes(SKILL_INSTALL_CANCEL_CAPABILITY) === true + ) +} + export async function supportsSkillRuntimeInstall( userDataPath: string, environmentId: string diff --git a/src/main/skills/skill-share-preparation-service.test.ts b/src/main/skills/skill-share-preparation-service.test.ts index 4d972b5df2b..cfb96fd6d83 100644 --- a/src/main/skills/skill-share-preparation-service.test.ts +++ b/src/main/skills/skill-share-preparation-service.test.ts @@ -70,9 +70,7 @@ describe('SkillSharePreparationService', () => { const preview = await service.prepare({ sourceDirectory: source }) const input = { preparationId: preview.preparationId, - releaseNotes: 'retry', - userIds: ['user_2'], - shareWithOrganization: false + releaseNotes: 'retry' } await expect(service.publish(input)).rejects.toThrow('response lost') @@ -88,18 +86,18 @@ describe('SkillSharePreparationService', () => { expect(idempotencyKey).toBe(createShare.mock.calls[1]?.[1].idempotencyKey) }) - it('keeps the finalized version when the audience must be corrected', async () => { + it('keeps the finalized version while sign-in is reconnected', async () => { const { root, source } = await createSource() const publishVersion = vi.fn(async (request: { packageId: string }) => ({ status: 'ok' as const, - value: publishedVersion(request.packageId, 'version_audience') + value: publishedVersion(request.packageId, 'version_reconnect') })) const createShare = vi .fn() - .mockRejectedValueOnce(new Error('skill_package_organization_required')) + .mockResolvedValueOnce({ status: 'reconnect-required' }) .mockResolvedValueOnce({ status: 'ok', - value: { id: 'share_people', url: 'https://share.test/skills/share/share_people' } + value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' } }) const service = new SkillSharePreparationService(join(root, 'preparations'), { publishVersion, @@ -108,24 +106,14 @@ describe('SkillSharePreparationService', () => { const preview = await service.prepare({ sourceDirectory: source }) await expect( - service.publish({ - preparationId: preview.preparationId, - releaseNotes: 'retry', - userIds: [], - shareWithOrganization: true - }) - ).rejects.toThrow('skill_package_organization_required') + service.publish({ preparationId: preview.preparationId, releaseNotes: 'retry' }) + ).resolves.toEqual({ status: 'reconnect-required' }) await expect( - service.publish({ - preparationId: preview.preparationId, - releaseNotes: 'retry', - userIds: ['user_2'], - shareWithOrganization: false - }) - ).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_people' } } }) + service.publish({ preparationId: preview.preparationId, releaseNotes: 'retry' }) + ).resolves.toMatchObject({ status: 'ok', value: { share: { id: 'share_retry' } } }) expect(publishVersion).toHaveBeenCalledOnce() - expect(createShare.mock.calls[0]?.[1].idempotencyKey).not.toBe( + expect(createShare.mock.calls[0]?.[1].idempotencyKey).toBe( createShare.mock.calls[1]?.[1].idempotencyKey ) }) diff --git a/src/main/skills/skill-share-preparation-service.ts b/src/main/skills/skill-share-preparation-service.ts index bfccd47c186..0c12695ebde 100644 --- a/src/main/skills/skill-share-preparation-service.ts +++ b/src/main/skills/skill-share-preparation-service.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import type { @@ -21,13 +21,8 @@ type Preparation = { publishedVersion: SkillCloudVersion | null } -function shareIdempotencyKey(preparationId: string, input: SkillSharePublishInput): string { - const audience = JSON.stringify({ - shareWithOrganization: input.shareWithOrganization, - userIds: [...new Set(input.userIds)].sort() - }) - const digest = createHash('sha256').update(audience).digest('hex').slice(0, 32) - return `${preparationId}_${digest}` +function shareIdempotencyKey(preparationId: string): string { + return `${preparationId}_share` } function preview(id: string, value: Preparation): SkillSharePreview { @@ -133,8 +128,6 @@ export class SkillSharePreparationService { 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 }) @@ -146,9 +139,7 @@ export class SkillSharePreparationService { preparation.publishedVersion = version } const shared = await this.cloud.createShare(version.packageId, { - userIds: input.userIds, - shareWithOrganization: input.shareWithOrganization, - idempotencyKey: shareIdempotencyKey(input.preparationId, input) + idempotencyKey: shareIdempotencyKey(input.preparationId) }) if (shared.status !== 'ok') { return shared diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 9a8e282bf2e..1af3f1dde2a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -403,6 +403,7 @@ import type { import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' import type { SkillCloudDownloadGrant, + SkillCloudOwnedShare, SkillCloudOperation, SkillCloudPackageDetails } from '../shared/skill-cloud-contract' @@ -2602,11 +2603,7 @@ export type PreloadApi = { removeInstall: (input: SkillRemoveInput) => Promise listManagedInstalls: (environmentId?: string) => Promise getPackage: (packageId: string) => Promise> - replacePackageAccess: (input: { - packageId: string - userIds: string[] - shareWithOrganization: boolean - }) => Promise> + listOwnedShares: () => Promise> revokeShare: (shareId: string) => Promise> deletePackageVersion: (input: { packageId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index d5407193dc7..01daf004189 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -131,6 +131,7 @@ import type { import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' import type { SkillCloudDownloadGrant, + SkillCloudOwnedShare, SkillCloudOperation, SkillCloudPackageDetails } from '../shared/skill-cloud-contract' @@ -2567,12 +2568,8 @@ const api = { ipcRenderer.invoke('skills:listManagedInstalls', environmentId), getPackage: (packageId: string): Promise> => ipcRenderer.invoke('skills:getPackage', packageId), - replacePackageAccess: (input: { - packageId: string - userIds: string[] - shareWithOrganization: boolean - }): Promise> => - ipcRenderer.invoke('skills:replacePackageAccess', input), + listOwnedShares: (): Promise> => + ipcRenderer.invoke('skills:listOwnedShares'), revokeShare: (shareId: string): Promise> => ipcRenderer.invoke('skills:revokeShare', shareId), deletePackageVersion: (input: { diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx new file mode 100644 index 00000000000..2706ce10e31 --- /dev/null +++ b/src/renderer/src/components/settings/OwnedSkillShareLinks.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OwnedSkillShareLinks } from './OwnedSkillShareLinks' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ toast: { success: vi.fn() } })) + +afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') +}) + +describe('OwnedSkillShareLinks', () => { + it('copies and revokes owner-only shared links', async () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined) + const revokeShare = vi.fn().mockResolvedValue({ status: 'ok', value: undefined }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ui: { writeClipboardText }, + skills: { + listOwnedShares: vi.fn().mockResolvedValue({ + status: 'ok', + value: [ + { + id: 'share_1', + url: 'https://share.onorca.dev/skills/share/share_1', + packageId: 'package_1', + name: 'team-skills', + description: 'Team bundle', + createdAt: '2026-08-12T00:00:00.000Z' + } + ] + }), + revokeShare + } + } + }) + const user = userEvent.setup() + render() + + await screen.findByText('team-skills') + await user.click(screen.getByRole('button', { name: 'Copy link' })) + expect(writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/skills/share/share_1') + + await user.click(screen.getByRole('button', { name: 'Unshare' })) + expect(revokeShare).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Confirm unshare' })) + await waitFor(() => expect(revokeShare).toHaveBeenCalledWith('share_1')) + expect(screen.queryByText('team-skills')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx b/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx new file mode 100644 index 00000000000..1bf3c89a0fd --- /dev/null +++ b/src/renderer/src/components/settings/OwnedSkillShareLinks.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Clipboard, Link2Off, Loader2, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +function inventoryError(status: string): string { + return status === 'reconnect-required' + ? translate( + 'auto.components.settings.shareSkills.linksReconnect', + 'Sign in again to manage shared links.' + ) + : translate( + 'auto.components.settings.shareSkills.linksUnavailable', + 'Shared links are unavailable right now.' + ) +} + +export function OwnedSkillShareLinks(): React.JSX.Element { + const [shares, setShares] = useState([]) + const [loading, setLoading] = useState(true) + const [busyShareId, setBusyShareId] = useState(null) + const [confirmShareId, setConfirmShareId] = useState(null) + const [error, setError] = useState(null) + const generation = useRef(0) + + const load = useCallback(async (): Promise => { + const current = ++generation.current + setLoading(true) + setError(null) + try { + const operation = await window.api.skills.listOwnedShares() + if (generation.current !== current) { + return + } + if (operation.status !== 'ok') { + setError(inventoryError(operation.status)) + return + } + setShares(operation.value) + } catch { + if (generation.current === current) { + setError( + translate( + 'auto.components.settings.shareSkills.linksUnavailable', + 'Shared links are unavailable right now.' + ) + ) + } + } finally { + if (generation.current === current) { + setLoading(false) + } + } + }, []) + + useEffect(() => { + void load() + return () => { + generation.current += 1 + } + }, [load]) + + const copy = async (share: SkillCloudOwnedShare): Promise => { + await window.api.ui.writeClipboardText(share.url) + toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied')) + } + + const unshare = async (share: SkillCloudOwnedShare): Promise => { + if (confirmShareId !== share.id) { + setConfirmShareId(share.id) + return + } + setBusyShareId(share.id) + setError(null) + try { + const operation = await window.api.skills.revokeShare(share.id) + if (operation.status !== 'ok') { + setError(inventoryError(operation.status)) + return + } + setShares((current) => current.filter((candidate) => candidate.id !== share.id)) + setConfirmShareId(null) + toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked')) + } catch { + setError( + translate( + 'auto.components.settings.shareSkills.revokeFailed', + 'Orca could not revoke this link.' + ) + ) + } finally { + setBusyShareId(null) + } + } + + return ( +
+
+
+

+ {translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')} +

+

+ {translate( + 'auto.components.settings.shareSkills.activeLinksDescription', + 'Only people with a link can open it. Unshare a link to block future inspection and installs.' + )} +

+
+ +
+ + {!loading && shares.length === 0 && !error ? ( +

+ {translate( + 'auto.components.settings.shareSkills.noActiveLinks', + 'No active links. Publish a skill bundle from Skills to create one.' + )} +

+ ) : null} + + {shares.length > 0 ? ( +
    + {shares.map((share) => ( +
  • +
    +

    {share.name}

    +

    {share.url}

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

+ {error} +

+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 393f70a6b35..161048e58c9 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -55,6 +55,7 @@ import { PluginsSettingsSection } from './PluginsSettingsSection' import { AgentsPane } from './AgentsPane' import { OrchestrationPane } from './OrchestrationPane' import { ArtifactsSettingsPane } from './ArtifactsSettingsPane' +import { ShareSkillsSettingsPane } from './ShareSkillsSettingsPane' import { AutomationsSettingsPane } from './AutomationsSettingsPane' import { OrcaAccountSettingsPane } from './OrcaAccountSettingsPane' import { LinearAgentSkillPane } from './LinearAgentSkillPane' @@ -1456,6 +1457,19 @@ function Settings(): React.JSX.Element { ) : null} + + {isSectionMounted('share-skills') ? : null} + + ({ + connect: vi.fn(), + fetchAuthStatus: vi.fn(), + openSkillsPage: vi.fn(), + state: { + orcaProfileAuthStatus: { configured: true, state: 'connected' } as Record< + string, + unknown + > | null, + orcaProfileConnecting: false, + isWebClient: false + } +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/web-client-location', () => ({ + isWebClientLocation: () => mocks.state.isWebClient +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + ...mocks.state, + connectCurrentOrcaProfile: mocks.connect, + fetchOrcaProfileAuthStatus: mocks.fetchAuthStatus, + openSkillsPage: mocks.openSkillsPage + }) +})) + +import { ShareSkillsSettingsPane } from './ShareSkillsSettingsPane' + +describe('ShareSkillsSettingsPane', () => { + beforeEach(() => { + mocks.connect.mockReset() + mocks.fetchAuthStatus.mockReset() + mocks.openSkillsPage.mockReset() + mocks.state.orcaProfileAuthStatus = { configured: true, state: 'connected' } + mocks.state.orcaProfileConnecting = false + mocks.state.isWebClient = false + Object.defineProperty(window, 'api', { + configurable: true, + value: { + skills: { + listOwnedShares: vi.fn().mockResolvedValue({ status: 'ok', value: [] }), + revokeShare: vi.fn() + } + } + }) + }) + + afterEach(() => { + cleanup() + Reflect.deleteProperty(window, 'api') + }) + + it('explains unlisted multi-skill links and opens Skills', async () => { + const user = userEvent.setup() + render() + + expect(screen.getByText('Unlisted skill links')).toBeInTheDocument() + expect(screen.getByText('Select one or more skills')).toBeInTheDocument() + expect( + screen.getByText( + 'Anyone with the link can inspect and install all or selected skills without signing in.' + ) + ).toBeInTheDocument() + expect(screen.getByText(/installed on another machine remain there/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /Open Skills/ })) + expect(mocks.openSkillsPage).toHaveBeenCalledOnce() + }) + + it('offers owner sign-in while explaining recipients stay signed out', async () => { + const user = userEvent.setup() + mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } + render() + + expect(screen.getByText('Sign in to share skills')).toBeInTheDocument() + expect(screen.getByText(/Recipients do not need an account/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Sign in to Orca' })) + expect(mocks.connect).toHaveBeenCalledOnce() + }) + + it('does not offer desktop publishing from the web client', () => { + mocks.state.isWebClient = true + mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' } + render() + + expect(screen.getByText(/available in the Orca desktop app/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Open Skills/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sign in to Orca' })).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx new file mode 100644 index 00000000000..e6e0d1a39d2 --- /dev/null +++ b/src/renderer/src/components/settings/ShareSkillsSettingsPane.tsx @@ -0,0 +1,175 @@ +import { useEffect } from 'react' +import { ArrowRight, Share2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { isWebClientLocation } from '@/lib/web-client-location' +import { useAppStore } from '@/store' +import { OwnedSkillShareLinks } from './OwnedSkillShareLinks' + +type HowToStep = { key: string; title: string; description: string } + +export function ShareSkillsSettingsPane(): React.JSX.Element { + const openSkillsPage = useAppStore((state) => state.openSkillsPage) + const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) + const connecting = useAppStore((state) => state.orcaProfileConnecting) + const connect = useAppStore((state) => state.connectCurrentOrcaProfile) + const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus) + const signedIn = authStatus?.state === 'connected' + const isWebClient = isWebClientLocation() + + useEffect(() => { + if (!authStatus) { + void fetchAuthStatus() + } + }, [authStatus, fetchAuthStatus]) + + const steps: HowToStep[] = [ + { + key: 'select', + title: translate( + 'auto.components.settings.shareSkills.selectTitle', + 'Select one or more skills' + ), + description: translate( + 'auto.components.settings.shareSkills.selectDescription', + 'Open Skills, choose Share skills, and select the skills to bundle behind one link.' + ) + }, + { + key: 'review', + title: translate('auto.components.settings.shareSkills.reviewTitle', 'Review and publish'), + description: translate( + 'auto.components.settings.shareSkills.reviewDescription', + 'Review included files, scripts, and executables before uploading the immutable bundle.' + ) + }, + { + key: 'share', + title: translate('auto.components.settings.shareSkills.copyTitle', 'Copy the unlisted link'), + description: translate( + 'auto.components.settings.shareSkills.copyDescription', + 'Anyone with the link can inspect and install all or selected skills without signing in.' + ) + }, + { + key: 'manage', + title: translate( + 'auto.components.settings.shareSkills.manageTitle', + 'Manage or revoke links' + ), + description: translate( + 'auto.components.settings.shareSkills.manageDescription', + 'Revoking blocks future access. Skills already installed on another machine remain there.' + ) + } + ] + + return ( +
+
+

+ {translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links')} +

+

+ {translate( + 'auto.components.settings.shareSkills.linkDescription', + 'Shared bundles are not searchable or listed in Orca. The link is the credential, so send it only to people you trust.' + )} +

+
+ + {!signedIn ? ( +
+
+

+ {translate( + 'auto.components.settings.shareSkills.signInTitle', + 'Sign in to share skills' + )} +

+

+ {isWebClient + ? translate( + 'auto.components.settings.shareSkills.signInWebDescription', + 'Publishing and link management are available in the Orca desktop app.' + ) + : translate( + 'auto.components.settings.shareSkills.signInDescription', + 'Use your Orca account to publish bundles and manage their links. Recipients do not need an account.' + )} +

+
+ {!isWebClient ? ( + + ) : null} +
+ ) : null} + + {signedIn && !isWebClient ? : null} + +
+
+

+ {translate('auto.components.settings.shareSkills.howToTitle', 'How to share skills')} +

+

+ {translate( + 'auto.components.settings.shareSkills.howToDescription', + 'Publish one skill or a bundle such as a collection of 30 skills behind one link.' + )} +

+
+ +
    + {steps.map((step, index) => ( +
  1. + + {index + 1} + +
    +

    {step.title}

    +

    {step.description}

    +
    +
  2. + ))} +
+ + {!isWebClient ? ( + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/share-skills-settings-search.ts b/src/renderer/src/components/settings/share-skills-settings-search.ts new file mode 100644 index 00000000000..b4d82271eeb --- /dev/null +++ b/src/renderer/src/components/settings/share-skills-settings-search.ts @@ -0,0 +1,21 @@ +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export const getShareSkillsSettingsSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links'), + description: translate( + 'auto.components.settings.shareSkills.searchDescription', + 'Share one or more skills behind an unlisted, revocable link.' + ), + keywords: [ + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordSkills', 'skills'), + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordShare', 'share'), + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordBundle', 'bundle'), + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordLink', 'link'), + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordUnlisted', 'unlisted'), + ...translateSearchKeyword('auto.components.settings.shareSkills.keywordRevoke', 'revoke') + ] + } +]) diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx index c07bde26e48..b8a5a1f5975 100644 --- a/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx +++ b/src/renderer/src/components/skills/SkillCloudManagementActions.test.tsx @@ -13,11 +13,10 @@ const details: SkillCloudPackageDetails = { canManage: true, versions: [], management: { - userIds: ['legacy_user'], - shareWithOrganization: false, shares: [ { id: 'share_1', + url: 'https://share.test/skills/share/share_1', pinnedVersionId: 'ver_1', createdAt: '2026-08-11T00:00:00.000Z' } @@ -27,35 +26,18 @@ const details: SkillCloudPackageDetails = { function setup() { const skills = { - replacePackageAccess: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }), revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }), deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }), deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }) } + const writeClipboardText = vi.fn().mockResolvedValue(undefined) const onChanged = vi.fn().mockResolvedValue(undefined) const onPackageDeleted = vi.fn() Object.defineProperty(window, 'api', { configurable: true, value: { skills, - orcaProfiles: { - authStatus: vi.fn().mockResolvedValue({ - cloud: { userId: 'viewer', activeOrgId: 'org_1' } - }), - orgMembersList: vi.fn().mockResolvedValue({ - status: 'ok', - roster: { - members: [ - { - userId: 'user_2', - email: 'teammate@example.com', - displayName: 'Teammate', - role: 'member' - } - ] - } - }) - } + ui: { writeClipboardText } } }) render( @@ -66,7 +48,7 @@ function setup() { onPackageDeleted={onPackageDeleted} /> ) - return { skills, onChanged, onPackageDeleted } + return { skills, onChanged, onPackageDeleted, writeClipboardText } } afterEach(() => { @@ -76,26 +58,10 @@ afterEach(() => { }) describe('SkillCloudManagementActions', () => { - it('saves organization and selected-user access while preserving unknown recipients', async () => { - const { skills, onChanged } = setup() - const organization = await screen.findByRole('checkbox', { name: 'Current organization' }) - await waitFor(() => expect((organization as HTMLButtonElement).disabled).toBe(false)) - fireEvent.click(organization) - fireEvent.click(screen.getByRole('checkbox', { name: 'Teammate' })) - fireEvent.click(screen.getByRole('button', { name: 'Save access' })) - - await waitFor(() => expect(skills.replacePackageAccess).toHaveBeenCalledOnce()) - expect(skills.replacePackageAccess).toHaveBeenCalledWith({ - packageId: 'pkg_1', - userIds: ['legacy_user', 'user_2'], - shareWithOrganization: true - }) - expect(onChanged).toHaveBeenCalledOnce() - }) - it('requires confirmation before revoking an active link', async () => { const { skills } = setup() - expect(screen.getByText(/Copies already installed on any machine remain there\./)).toBeTruthy() + expect(screen.getByText(/leaves installed copies unchanged/)).toBeTruthy() + expect(screen.queryByText('Access')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Unshare' })) expect(skills.revokeShare).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Confirm unshare' })) @@ -103,6 +69,14 @@ describe('SkillCloudManagementActions', () => { await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('share_1')) }) + it('copies an active unlisted link', async () => { + const { writeClipboardText } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Copy link' })) + await waitFor(() => + expect(writeClipboardText).toHaveBeenCalledWith('https://share.test/skills/share/share_1') + ) + }) + it('requires confirmation before deleting the selected immutable version', async () => { const { skills } = setup() fireEvent.click(screen.getByRole('button', { name: 'Delete selected Cloud version' })) diff --git a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx b/src/renderer/src/components/skills/SkillCloudManagementActions.tsx index 4998ab362b6..839e954b294 100644 --- a/src/renderer/src/components/skills/SkillCloudManagementActions.tsx +++ b/src/renderer/src/components/skills/SkillCloudManagementActions.tsx @@ -1,16 +1,14 @@ -import { useEffect, useMemo, useState } from 'react' -import { Loader2, Link2Off, Save, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { Clipboard, Link2Off, Trash2 } 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 type { OrcaOrgMember } from '../../../../shared/orca-profiles' import type { SkillCloudOperation, SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract' import { translate } from '@/i18n/i18n' -type IdentifiedMember = OrcaOrgMember & { userId: string } type SkillCloudManagement = NonNullable type SkillCloudManagementActionsProps = { details: SkillCloudPackageDetails @@ -35,12 +33,7 @@ export function SkillCloudManagementActions( if (!props.details.canManage || !management) { return null } - const stateKey = JSON.stringify([ - props.details.id, - management.userIds, - management.shareWithOrganization, - management.shares - ]) + const stateKey = JSON.stringify([props.details.id, management.shares]) return } @@ -53,48 +46,22 @@ function SkillCloudManagementForm({ }: SkillCloudManagementActionsProps & { management: SkillCloudManagement }): React.JSX.Element { - const [members, setMembers] = useState([]) - const [organizationAvailable, setOrganizationAvailable] = useState(false) - const [userIds, setUserIds] = useState(management?.userIds ?? []) - const [shareWithOrganization, setShareWithOrganization] = useState( - management?.shareWithOrganization ?? false - ) const [busyAction, setBusyAction] = useState(null) const [confirmation, setConfirmation] = useState(null) const [message, setMessage] = useState(null) - useEffect(() => { - let active = true - setOrganizationAvailable(false) - setMembers([]) - void window.api.orcaProfiles - .authStatus() - .then(async (auth) => { - const orgId = auth.cloud?.activeOrgId - if (!active || !orgId) { - return - } - setOrganizationAvailable(true) - const result = await window.api.orcaProfiles.orgMembersList({ orgId }) - if (active && result.status === 'ok') { - setMembers( - result.roster.members.filter( - (member): member is IdentifiedMember => - Boolean(member.userId) && member.userId !== auth.cloud?.userId - ) - ) - } - }) - .catch(() => undefined) - return () => { - active = false + const copyShareLink = async (url?: string): Promise => { + if (!url) { + return } - }, []) - - const unknownUserCount = useMemo( - () => userIds.filter((id) => !members.some((member) => member.userId === id)).length, - [members, userIds] - ) + await window.api.ui.writeClipboardText(url) + toast.success( + translate( + 'auto.components.skills.SkillCloudManagementActions.linkCopied', + 'Share link copied' + ) + ) + } const run = async ( action: string, @@ -131,77 +98,6 @@ function SkillCloudManagementForm({ 'Cloud sharing controls' )} > -
-

- {translate('auto.components.skills.SkillCloudManagementActions.2552c12fea', 'Access')} -

- - {members.length > 0 ? ( -
- {members.map((member) => ( - - ))} -
- ) : null} - {unknownUserCount > 0 ? ( -

- {unknownUserCount}{' '} - {translate( - 'auto.components.skills.SkillCloudManagementActions.3d346ddce8', - 'existing recipient' - )} - {unknownUserCount === 1 ? '' : 's'}{' '} - {translate( - 'auto.components.skills.SkillCloudManagementActions.eb603f7888', - 'outside the current roster will be preserved.' - )} -

- ) : null} - -
-

{translate( @@ -211,8 +107,8 @@ function SkillCloudManagementForm({

{translate( - 'auto.components.skills.SkillCloudManagementActions.c7f2b69122', - 'Unsharing blocks future installs. Copies already installed on any machine remain there.' + 'auto.components.skills.SkillCloudManagementActions.activeLinkBearerDescription', + 'Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.' )}

{management.shares.length === 0 ? ( @@ -229,8 +125,22 @@ function SkillCloudManagementForm({ className="flex items-center gap-2 rounded-md border border-border p-2" > - {share.id} + {share.url ?? share.id} + {share.url ? ( + + ) : null}