feat(skills): add unlisted share management

This commit is contained in:
Jinwoo-H
2026-08-12 00:26:00 -04:00
parent 6cff2aa507
commit 5a05992e26
34 changed files with 981 additions and 507 deletions
@@ -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/<share-id>/download-grants` after fresh ACL evaluation.
- [x] Implement anonymous `POST /v1/skill-shares/<share-id>/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.
+64 -44
View File
@@ -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/<share-id>
```
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.
+6 -22
View File
@@ -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), {})
)
+2 -8
View File
@@ -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<SkillCloudOperation<void>> {
return this.requireSkillCloudService().replaceAccess(packageId, access, options)
listOwnedSkillShares(options: SkillCloudOptions) {
return this.requireSkillCloudService().listOwnedShares(options)
}
revokeSkillShare(
@@ -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({
+2 -2
View File
@@ -12,7 +12,7 @@ export class SkillCloudRequestError extends Error {
export async function skillCloudRequest<T>(input: {
apiUrl?: string
authToken: string
authToken?: string
path: string
method?: 'DELETE' | 'GET' | 'POST' | 'PUT'
body?: unknown
@@ -28,7 +28,7 @@ export async function skillCloudRequest<T>(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 } : {})
@@ -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)
}
})
})
+21 -20
View File
@@ -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<SkillCloudOperation<SkillCloudShare>> {
@@ -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<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
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<SkillCloudOperation<SkillCloudDownloadGrant>> {
return this.withAuth(options, (token, apiUrl) =>
return this.withoutAuth(options, (apiUrl) =>
skillCloudRequest<SkillCloudDownloadGrant>({
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<SkillCloudOperation<void>> {
return this.withAuth(options, (token, apiUrl) =>
skillCloudRequest<void>({
): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> {
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<SkillCloudOperation<void>> {
@@ -243,4 +236,12 @@ export class SkillCloudService {
operation
})
}
private async withoutAuth<T>(
options: SkillCloudOptions,
operation: (apiUrl: string) => Promise<T>
): Promise<SkillCloudOperation<T>> {
const value = await operation(resolveArtifactCloudApiUrl(options.apiUrl))
return { status: 'ok', value }
}
}
@@ -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)
@@ -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<boolean> {
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
@@ -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
)
})
@@ -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
+2 -5
View File
@@ -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<SkillRemoveOperation>
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
replacePackageAccess: (input: {
packageId: string
userIds: string[]
shareWithOrganization: boolean
}) => Promise<SkillCloudOperation<void>>
listOwnedShares: () => Promise<SkillCloudOperation<SkillCloudOwnedShare[]>>
revokeShare: (shareId: string) => Promise<SkillCloudOperation<void>>
deletePackageVersion: (input: {
packageId: string
+3 -6
View File
@@ -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<SkillCloudOperation<SkillCloudPackageDetails>> =>
ipcRenderer.invoke('skills:getPackage', packageId),
replacePackageAccess: (input: {
packageId: string
userIds: string[]
shareWithOrganization: boolean
}): Promise<SkillCloudOperation<void>> =>
ipcRenderer.invoke('skills:replacePackageAccess', input),
listOwnedShares: (): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> =>
ipcRenderer.invoke('skills:listOwnedShares'),
revokeShare: (shareId: string): Promise<SkillCloudOperation<void>> =>
ipcRenderer.invoke('skills:revokeShare', shareId),
deletePackageVersion: (input: {
@@ -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(<OwnedSkillShareLinks />)
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()
})
})
@@ -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<SkillCloudOwnedShare[]>([])
const [loading, setLoading] = useState(true)
const [busyShareId, setBusyShareId] = useState<string | null>(null)
const [confirmShareId, setConfirmShareId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const generation = useRef(0)
const load = useCallback(async (): Promise<void> => {
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<void> => {
await window.api.ui.writeClipboardText(share.url)
toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied'))
}
const unshare = async (share: SkillCloudOwnedShare): Promise<void> => {
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 (
<section className="space-y-3 py-5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.activeLinksDescription',
'Only people with a link can open it. Unshare a link to block future inspection and installs.'
)}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={loading}
onClick={() => void load()}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{translate('auto.components.settings.shareSkills.refreshLinks', 'Refresh')}
</Button>
</div>
{!loading && shares.length === 0 && !error ? (
<p className="rounded-md border border-border p-3 text-xs text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.noActiveLinks',
'No active links. Publish a skill bundle from Skills to create one.'
)}
</p>
) : null}
{shares.length > 0 ? (
<ul className="space-y-2">
{shares.map((share) => (
<li key={share.id} className="space-y-2 rounded-md border border-border p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{share.name}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{share.url}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => void copy(share)}>
<Clipboard className="size-4" />
{translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
</Button>
<Button
type="button"
size="sm"
variant={confirmShareId === share.id ? 'destructive' : 'outline'}
disabled={busyShareId !== null}
onClick={() => void unshare(share)}
>
{busyShareId === share.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Link2Off className="size-4" />
)}
{confirmShareId === share.id
? translate(
'auto.components.settings.shareSkills.confirmUnshare',
'Confirm unshare'
)
: translate('auto.components.settings.shareSkills.unshare', 'Unshare')}
</Button>
</div>
</li>
))}
</ul>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</section>
)
}
@@ -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}
</SettingsSection>
<SettingsSection
id="share-skills"
title={translate('auto.components.settings.shareSkills.title', 'Share Skills')}
badge="Beta"
description={translate(
'auto.components.settings.shareSkills.description',
'Share one or many skills through unlisted, revocable links.'
)}
searchEntries={getSectionSearchEntries('share-skills')}
>
{isSectionMounted('share-skills') ? <ShareSkillsSettingsPane /> : null}
</SettingsSection>
<SettingsSection
id="git"
title={translate(
@@ -0,0 +1,102 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
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<string, unknown>) => 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(<ShareSkillsSettingsPane />)
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(<ShareSkillsSettingsPane />)
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(<ShareSkillsSettingsPane />)
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()
})
})
@@ -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 (
<div className="divide-y divide-border">
<section className="space-y-2 py-5 first:pt-0">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{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.'
)}
</p>
</section>
{!signedIn ? (
<section className="flex flex-wrap items-center gap-4 py-5">
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">
{translate(
'auto.components.settings.shareSkills.signInTitle',
'Sign in to share skills'
)}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{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.'
)}
</p>
</div>
{!isWebClient ? (
<Button
type="button"
size="sm"
disabled={connecting || authStatus?.configured !== true}
onClick={() => void connect()}
>
{connecting
? translate('auto.components.settings.shareSkills.signingIn', 'Signing in…')
: authStatus?.state === 'reconnect-required'
? translate('auto.components.settings.shareSkills.signInAgain', 'Sign in again')
: translate('auto.components.settings.shareSkills.signIn', 'Sign in to Orca')}
</Button>
) : null}
</section>
) : null}
{signedIn && !isWebClient ? <OwnedSkillShareLinks /> : null}
<section className="space-y-4 py-5">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.shareSkills.howToTitle', 'How to share skills')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.howToDescription',
'Publish one skill or a bundle such as a collection of 30 skills behind one link.'
)}
</p>
</div>
<ol className="space-y-3">
{steps.map((step, index) => (
<li key={step.key} className="flex items-start gap-3">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted/30 text-[11px] font-semibold text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">{step.title}</p>
<p className="text-xs leading-relaxed text-muted-foreground">{step.description}</p>
</div>
</li>
))}
</ol>
{!isWebClient ? (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start whitespace-normal rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-left hover:bg-muted/35 hover:text-foreground"
onClick={openSkillsPage}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<Share2 className="size-4" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium text-foreground">
{translate('auto.components.settings.shareSkills.openSkills', 'Open Skills')}
</span>
<span className="block text-xs font-normal text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.openSkillsDescription',
'Publish a bundle, install from a link, or manage installed and shared skills.'
)}
</span>
</span>
<ArrowRight className="ml-auto size-4 shrink-0 text-muted-foreground" />
</Button>
) : null}
</section>
</div>
)
}
@@ -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')
]
}
])
@@ -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' }))
@@ -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<SkillCloudPackageDetails['management']>
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 <SkillCloudManagementForm key={stateKey} {...props} management={management} />
}
@@ -53,48 +46,22 @@ function SkillCloudManagementForm({
}: SkillCloudManagementActionsProps & {
management: SkillCloudManagement
}): React.JSX.Element {
const [members, setMembers] = useState<IdentifiedMember[]>([])
const [organizationAvailable, setOrganizationAvailable] = useState(false)
const [userIds, setUserIds] = useState<string[]>(management?.userIds ?? [])
const [shareWithOrganization, setShareWithOrganization] = useState(
management?.shareWithOrganization ?? false
)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(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<void> => {
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'
)}
>
<div className="space-y-2">
<h4 className="text-xs font-semibold">
{translate('auto.components.skills.SkillCloudManagementActions.2552c12fea', 'Access')}
</h4>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={shareWithOrganization}
disabled={!organizationAvailable || busyAction !== null}
onCheckedChange={(checked) => setShareWithOrganization(checked === true)}
/>
{translate(
'auto.components.skills.SkillCloudManagementActions.2b8c569ea7',
'Current organization'
)}
</label>
{members.length > 0 ? (
<div className="max-h-28 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
{members.map((member) => (
<label key={member.userId} className="flex items-center gap-2 text-xs">
<Checkbox
checked={userIds.includes(member.userId)}
disabled={busyAction !== null}
onCheckedChange={(checked) =>
setUserIds((current) =>
checked
? [...new Set([...current, member.userId])]
: current.filter((id) => id !== member.userId)
)
}
/>
<span className="truncate">{member.displayName || member.email}</span>
</label>
))}
</div>
) : null}
{unknownUserCount > 0 ? (
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
) : null}
<Button
size="sm"
variant="outline"
disabled={busyAction !== null}
onClick={() =>
void run('access', () =>
window.api.skills.replacePackageAccess({
packageId: details.id,
userIds,
shareWithOrganization
})
)
}
>
{busyAction === 'access' ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{translate(
'auto.components.skills.SkillCloudManagementActions.cc8e4ef6ba',
'Save access'
)}
</Button>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold">
{translate(
@@ -211,8 +107,8 @@ function SkillCloudManagementForm({
</h4>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
{management.shares.length === 0 ? (
@@ -229,8 +125,22 @@ function SkillCloudManagementForm({
className="flex items-center gap-2 rounded-md border border-border p-2"
>
<Badge variant="outline" className="min-w-0 truncate font-mono">
{share.id}
{share.url ?? share.id}
</Badge>
{share.url ? (
<Button
size="sm"
variant="outline"
disabled={busyAction !== null}
onClick={() => void copyShareLink(share.url)}
>
<Clipboard className="size-4" />
{translate(
'auto.components.skills.SkillCloudManagementActions.copyLink',
'Copy link'
)}
</Button>
) : null}
<Button
size="sm"
variant={confirmation === `share:${share.id}` ? 'destructive' : 'outline'}
@@ -140,6 +140,23 @@ afterEach(() => {
})
describe('SkillInstallDialog', () => {
it('describes unavailable bearer links without asking recipients to sign in', async () => {
const skills = installApi(vi.fn())
skills.resolveShare.mockRejectedValue(new Error('skill_share_not_found'))
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
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(
'This share is unavailable. The link may be invalid, expired, or revoked.'
)
expect(screen.queryByText(/sign in|reconnect/i)).toBeNull()
})
it('installs a selected subset from a shared bundle', async () => {
const skills = installApi(vi.fn())
skills.resolveShare.mockResolvedValue({
@@ -87,16 +87,16 @@ export function SkillInstallDialog({
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
operation.status === 'unconfigured'
? operation.message
: 'This share is unavailable. The link may be invalid, expired, or revoked.'
)
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.')
setError('This share is unavailable. The link may be invalid, expired, or revoked.')
} finally {
setBusy(false)
}
@@ -112,7 +112,6 @@ function skillsApi(
placements: []
}
}),
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 }),
@@ -264,8 +263,6 @@ describe('SkillInstallManagementDialog', () => {
const details: SkillCloudPackageDetails = {
...packageDetails(versions),
management: {
userIds: [],
shareWithOrganization: false,
shares: []
}
}
@@ -13,8 +13,7 @@ import type { DiscoveredSkill } from '../../../../shared/skills'
import {
SkillShareDialogHeader,
SkillSharePreparationReview,
SkillSharePublishedLink,
type SelectableOrgMember
SkillSharePublishedLink
} from './SkillShareReviewContent'
import { matchingManagedSkillInstall } from './skill-share-package-selection'
@@ -45,11 +44,7 @@ export function SkillShareDialog({
}: SkillShareDialogProps): React.JSX.Element {
const selectedSkills = useMemo(() => skills ?? (skill ? [skill] : []), [skill, skills])
const [preview, setPreview] = useState<SkillSharePreview | null>(null)
const [members, setMembers] = useState<SelectableOrgMember[]>([])
const [author, setAuthor] = useState('')
const [organization, setOrganization] = useState('')
const [audience, setAudience] = useState<'organization' | 'people'>('organization')
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
const [releaseNotes, setReleaseNotes] = useState('')
const [progress, setProgress] = useState<SkillShareProgress | null>(null)
const [shareUrl, setShareUrl] = useState<string | null>(null)
@@ -104,21 +99,6 @@ export function SkillShareDialog({
setPublishingNewVersion(managedInstall !== null)
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)
@@ -168,24 +148,13 @@ export function SkillShareDialog({
if (!preview) {
return
}
if (audience === 'people' && selectedUserIds.length === 0) {
setError(
translate(
'auto.components.skills.SkillShareDialog.peopleRequired',
'Select at least one teammate.'
)
)
return
}
setPublishing(true)
cancellationRequested.current = false
setError(null)
try {
const result = await window.api.skills.publishShare({
preparationId: preview.preparationId,
releaseNotes,
userIds: audience === 'people' ? selectedUserIds : [],
shareWithOrganization: audience === 'organization'
releaseNotes
})
if (result.status !== 'ok') {
setError(operationError(result.status))
@@ -252,12 +221,6 @@ export function SkillShareDialog({
<SkillSharePreparationReview
preview={preview}
author={author}
organization={organization}
audience={audience}
onAudienceChange={setAudience}
members={members}
selectedUserIds={selectedUserIds}
onSelectedUserIdsChange={setSelectedUserIds}
releaseNotes={releaseNotes}
onReleaseNotesChange={setReleaseNotes}
publishing={publishing}
@@ -1,20 +1,15 @@
import { Check, Clipboard, FileCode2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DialogDescription, 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'
export type SelectableOrgMember = OrcaOrgMember & { userId: string }
export function SkillShareDialogHeader({
published,
publishingNewVersion
@@ -37,8 +32,8 @@ export function SkillShareDialogHeader({
<DialogDescription>
{published
? translate(
'auto.components.skills.SkillShareDialog.readyDescription',
'Recipients authenticate with Orca before they can inspect or install it.'
'auto.components.skills.SkillShareDialog.readyDescriptionV2',
'Anyone with this unlisted link can inspect and install the skills.'
)
: publishingNewVersion
? translate(
@@ -46,8 +41,8 @@ export function SkillShareDialogHeader({
'Review the exact files, then publish an immutable version to the existing Cloud package.'
)
: translate(
'auto.components.skills.SkillShareDialog.description',
'Review the exact files, choose who can access them, then publish an immutable version.'
'auto.components.skills.SkillShareDialog.descriptionV2',
'Review the exact files, then publish an immutable version behind an unlisted link.'
)}
</DialogDescription>
</DialogHeader>
@@ -67,12 +62,6 @@ function byteLabel(bytes: number): string {
export function SkillSharePreparationReview({
preview,
author,
organization,
audience,
onAudienceChange,
members,
selectedUserIds,
onSelectedUserIdsChange,
releaseNotes,
onReleaseNotesChange,
publishing,
@@ -81,12 +70,6 @@ export function SkillSharePreparationReview({
}: {
preview: SkillSharePreview
author: string
organization: string
audience: 'organization' | 'people'
onAudienceChange: (audience: 'organization' | 'people') => void
members: SelectableOrgMember[]
selectedUserIds: string[]
onSelectedUserIdsChange: (userIds: string[]) => void
releaseNotes: string
onReleaseNotesChange: (notes: string) => void
publishing: boolean
@@ -164,14 +147,17 @@ export function SkillSharePreparationReview({
<section className="space-y-2">
<div className="space-y-1">
<Label>
{translate('auto.components.skills.SkillShareReviewContent.78d863235c', 'Access')}
{translate(
'auto.components.skills.SkillShareReviewContent.unlistedLinkTitle',
'Unlisted link'
)}
</Label>
<p className="text-xs text-muted-foreground">
{author
? translate(
'auto.components.skills.SkillShareReviewContent.266527d295',
'Publishing as {{value0}}{{value1}}.',
{ value0: author, value1: organization ? ` in ${organization}` : '' }
'auto.components.skills.SkillShareReviewContent.unlistedPublishingAs',
'Publishing as {{value0}}. Anyone with the link can inspect and install these skills.',
{ value0: author }
)
: translate(
'auto.components.skills.SkillShareReviewContent.c15d90c10b',
@@ -179,81 +165,12 @@ export function SkillSharePreparationReview({
)}
</p>
</div>
<Tabs
value={audience}
onValueChange={(value) => onAudienceChange(value as typeof audience)}
>
<TabsList
aria-label={translate(
'auto.components.skills.SkillShareReviewContent.6817a7f6f8',
'Skill access'
)}
>
<TabsTrigger value="organization" disabled={!organization}>
{translate(
'auto.components.skills.SkillShareReviewContent.0a49d901ab',
'Organization'
)}
</TabsTrigger>
<TabsTrigger value="people">
{translate(
'auto.components.skills.SkillShareReviewContent.f25429e266',
'Selected people'
)}
</TabsTrigger>
</TabsList>
<TabsContent value="organization" className="pt-2 text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillShareReviewContent.0cd4b3e396',
'Everyone currently in'
)}{' '}
{organization ||
translate(
'auto.components.skills.SkillShareReviewContent.fe204e06f0',
'the organization'
)}{' '}
{translate(
'auto.components.skills.SkillShareReviewContent.8188f5d765',
'can access the link.'
)}
</TabsContent>
<TabsContent value="people" className="space-y-2 pt-2">
{members.length > 0 ? (
<div className="max-h-32 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
{members.map((member) => {
const checked = selectedUserIds.includes(member.userId)
return (
<label
key={member.userId}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
>
<Checkbox
checked={checked}
onCheckedChange={(next) =>
onSelectedUserIdsChange(
next
? [...new Set([...selectedUserIds, member.userId])]
: selectedUserIds.filter((id) => id !== member.userId)
)
}
/>
<span className="min-w-0 truncate text-xs">
{member.displayName || member.email}
</span>
</label>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillShareReviewContent.4895d3e0ee',
'No teammates are available.'
)}
</p>
)}
</TabsContent>
</Tabs>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillShareReviewContent.unlistedLinkDetails',
'The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain.'
)}
</p>
</section>
<section className="space-y-2">
@@ -79,7 +79,7 @@ describe('settings navigation metadata', () => {
expect(sections.find((section) => section.id === 'mobile')?.group).toBe('setup')
})
it('places Automations and Artifacts first under Workflows', () => {
it('places Automations, Artifacts, and Share Skills first under Workflows', () => {
const sections = buildSettingsNavigationMetadata({
isMac: false,
isWindows: false,
@@ -88,6 +88,7 @@ describe('settings navigation metadata', () => {
})
const automations = sections.find((section) => section.id === 'automations')
const artifacts = sections.find((section) => section.id === 'artifacts')
const shareSkills = sections.find((section) => section.id === 'share-skills')
const workflowIds = sections
.filter((section) => section.group === 'workflows')
.map((section) => section.id)
@@ -99,7 +100,9 @@ describe('settings navigation metadata', () => {
expect(artifacts?.description).toBe(
'Share HTML and Markdown files with your team and manage their public links.'
)
expect(workflowIds.slice(0, 2)).toEqual(['automations', 'artifacts'])
expect(shareSkills).toMatchObject({ group: 'workflows', badge: 'Beta' })
expect(shareSkills?.searchEntries[0]?.title).toBe('Unlisted skill links')
expect(workflowIds.slice(0, 3)).toEqual(['automations', 'artifacts', 'share-skills'])
})
it('places the Orca account in Set Up on desktop only', () => {
@@ -33,6 +33,7 @@ import {
SquareTerminal,
TextCursorInput,
Files,
Share2,
UserCog,
Wrench
} from 'lucide-react'
@@ -60,6 +61,7 @@ import { getBrowserPaneCombinedSearchEntries } from '@/components/settings/brows
import { getNotificationsPaneSearchEntries } from '@/components/settings/notifications-search'
import { getOrchestrationPaneSearchEntries } from '@/components/settings/orchestration-search'
import { getArtifactsSettingsSearchEntries } from '@/components/settings/artifacts-settings-search'
import { getShareSkillsSettingsSearchEntries } from '@/components/settings/share-skills-settings-search'
import { getAutomationsSettingsSearchEntries } from '@/components/settings/automations-settings-search'
import { getOrcaAccountSettingsSearchEntries } from '@/components/settings/orca-account-settings-search'
import { getLinearAgentSkillPaneSearchEntries } from '@/components/settings/linear-agent-skill-search'
@@ -348,6 +350,18 @@ export function buildSettingsNavigationMetadata({
group: 'workflows',
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
},
{
id: 'share-skills',
title: translate('auto.hooks.useSettingsNavigationMetadata.shareSkillsTitle', 'Share Skills'),
description: translate(
'auto.hooks.useSettingsNavigationMetadata.shareSkillsDescription',
'Share one or many skills through unlisted links and manage access by revoking links.'
),
icon: Share2,
searchEntries: getShareSkillsSettingsSearchEntries(),
group: 'workflows',
badge: translate('auto.hooks.useSettingsNavigationMetadata.40d80bad8a', 'Beta')
},
{
id: 'git',
title: translate(
+51 -4
View File
@@ -901,7 +901,9 @@
"artifactsTitle": "Artifacts",
"artifactsDescription": "Share HTML and Markdown files with your team and manage their public links.",
"automationsTitle": "Automations",
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar."
"automationsDescription": "Schedule agent work and choose whether Automations appears in the sidebar.",
"shareSkillsTitle": "Share Skills",
"shareSkillsDescription": "Share one or many skills through unlisted links and manage access by revoking links."
},
"useAppMenuPaste": {
"pasteTooLarge": "Paste is too large."
@@ -4144,7 +4146,9 @@
"3a51d0f34f": "Cancel upload",
"e9d652ae3d": "Cancelling…",
"30985d4fc0": "Cancel",
"3af85f6add": "Done"
"3af85f6add": "Done",
"readyDescriptionV2": "Anyone with this unlisted link can inspect and install the skills.",
"descriptionV2": "Review the exact files, then publish an immutable version behind an unlisted link."
},
"SkillCard": {
"d25a1b8ae6": "Share skill",
@@ -4165,7 +4169,10 @@
"3d346ddce8": "existing recipient",
"2b8c569ea7": "Current organization",
"2552c12fea": "Access",
"505cd6105c": "Cloud sharing controls"
"505cd6105c": "Cloud sharing controls",
"linkCopied": "Share link copied",
"activeLinkBearerDescription": "Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.",
"copyLink": "Copy link"
},
"SkillInstallDialog": {
"39acb9e8f4": "Install skill",
@@ -4267,7 +4274,10 @@
"2dca0b720b": "Publish new skill version",
"01c5a17e01": "{{value0}} skills",
"01c5a17e02": "Review included skills",
"01c5a17e03": "{{value0}} files · {{value1}}"
"01c5a17e03": "{{value0}} files · {{value1}}",
"unlistedLinkTitle": "Unlisted link",
"unlistedPublishingAs": "Publishing as {{value0}}. Anyone with the link can inspect and install these skills.",
"unlistedLinkDetails": "The link is not searchable or listed publicly. Revoke it to block future access; installed copies remain."
},
"SkillBundleInstallFlow": {
"01c5a13e01": "Close",
@@ -10785,6 +10795,43 @@
"on": "On",
"auto": "Agent",
"off": "Off"
},
"shareSkills": {
"title": "Share Skills",
"description": "Share one or many skills through unlisted, revocable links.",
"selectTitle": "Select one or more skills",
"selectDescription": "Open Skills, choose Share skills, and select the skills to bundle behind one link.",
"reviewTitle": "Review and publish",
"reviewDescription": "Review included files, scripts, and executables before uploading the immutable bundle.",
"copyTitle": "Copy the unlisted link",
"copyDescription": "Anyone with the link can inspect and install all or selected skills without signing in.",
"manageTitle": "Manage or revoke links",
"manageDescription": "Revoking blocks future access. Skills already installed on another machine remain there.",
"linkTitle": "Unlisted skill links",
"linkDescription": "Shared bundles are not searchable or listed in Orca. The link is the credential, so send it only to people you trust.",
"signInTitle": "Sign in to share skills",
"signInWebDescription": "Publishing and link management are available in the Orca desktop app.",
"signInDescription": "Use your Orca account to publish bundles and manage their links. Recipients do not need an account.",
"signingIn": "Signing in…",
"signInAgain": "Sign in again",
"signIn": "Sign in to Orca",
"howToTitle": "How to share skills",
"howToDescription": "Publish one skill or a bundle such as a collection of 30 skills behind one link.",
"openSkills": "Open Skills",
"openSkillsDescription": "Publish a bundle, install from a link, or manage installed and shared skills.",
"searchDescription": "Share one or more skills behind an unlisted, revocable link.",
"linksReconnect": "Sign in again to manage shared links.",
"linksUnavailable": "Shared links are unavailable right now.",
"linkCopied": "Share link copied",
"linkRevoked": "Link revoked",
"revokeFailed": "Orca could not revoke this link.",
"activeLinks": "Active shared links",
"activeLinksDescription": "Only people with a link can open it. Unshare a link to block future inspection and installs.",
"refreshLinks": "Refresh",
"noActiveLinks": "No active links. Publish a skill bundle from Skills to create one.",
"copyLink": "Copy link",
"confirmUnshare": "Confirm unshare",
"unshare": "Unshare"
}
},
"right": {
@@ -39,6 +39,7 @@ const SETTINGS_NAV_TARGETS = [
'agents',
'orchestration',
'artifacts',
'share-skills',
'automations',
'orca-account',
'linear',
+3 -1
View File
@@ -3087,6 +3087,8 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
Promise.reject(new Error('Skill installation requires the desktop app.')),
installPackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
installBundlePackageVersion: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
cancelInstall: () => Promise.resolve({ cancelled: false }),
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
previewBundleInstall: () =>
@@ -3095,7 +3097,7 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
listManagedInstalls: () =>
Promise.reject(new Error('Skill installation requires the desktop app.')),
getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
replacePackageAccess: () =>
listOwnedShares: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
revokeShare: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
+9 -4
View File
@@ -32,6 +32,14 @@ export type SkillCloudShare = {
url: string
}
export type SkillCloudOwnedShare = SkillCloudShare & {
packageId: string
name: string
description: string
createdAt: string
expiresAt?: string
}
export type SkillCloudDownloadGrant = {
grant: { url: string; expiresAt: string }
version: SkillCloudVersion
@@ -45,10 +53,9 @@ export type SkillCloudPackageDetails = {
canManage: boolean
versions: SkillCloudVersion[]
management?: {
userIds: string[]
shareWithOrganization: boolean
shares: {
id: string
url?: string
pinnedVersionId?: string
createdAt: string
expiresAt?: string
@@ -63,8 +70,6 @@ export type SkillCloudPublishRequest = SkillCloudOptions & {
packageId: string
releaseNotes: string
pinnedVersionId?: string
userIds: string[]
shareWithOrganization: boolean
onProgress?: (progress: {
phase: 'uploading' | 'finalizing'
bytesSent: number
-2
View File
@@ -47,8 +47,6 @@ export type SkillSharePreview = {
export type SkillSharePublishInput = {
preparationId: string
releaseNotes: string
userIds: string[]
shareWithOrganization: boolean
}
export type SkillSharePublishOperation = SkillCloudOperation<SkillCloudPublishResult>