feat(skills): harden shared installation lifecycle

This commit is contained in:
Jinwoo-H
2026-08-11 10:13:40 -04:00
parent c8e5d0d5c4
commit 8963bb3bef
32 changed files with 934 additions and 70 deletions
+4
View File
@@ -24,6 +24,10 @@ Confirm that the review explicitly checked cross-platform compatibility for macO
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
## Agent skill upstream boundary
- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
## Notes
Call out any platform-specific behavior, risks, or follow-up work.
@@ -59,9 +59,9 @@ does not mean the surrounding phase is complete.
- [x] Decide Orca will not depend on the upstream CLI or an unsupported programmatic API.
- [x] Decide V1 accepts Orca package sources only; Git, npm, and community registries remain with
existing tools.
- [ ] Add an architecture decision record covering the reference-only boundary and review it with
- [ ] Review the architecture decision record covering the reference-only boundary with
engineering and legal owners.
- [ ] Add a review check that flags any proposed copied or mechanically translated upstream
- [x] Add a review check that flags any proposed copied or mechanically translated upstream
material so attribution and licensing can be reconsidered before merge.
- [ ] Assign desktop, runtime, Cloud API, Terraform, security, design, and release owners.
- [ ] Confirm the first release ends after WSL and SSH support; keep team reconciliation as a later
@@ -95,12 +95,12 @@ does not mean the surrounding phase is complete.
- [x] Inventory current desktop IPC and remote skill RPC registration.
- [x] Inventory existing SSH execution and file-transfer providers and identify the host-side
installer entry point.
- [ ] Research official documentation for every initially supported agent's global and project
- [x] Research official documentation for every initially supported agent's global and project
skill directories.
- [ ] Verify each provider path with a real installation on every supported platform.
- [ ] Record whether each provider reads `.agents/skills` directly, how it is detected, and which
- [x] Record whether each provider reads `.agents/skills` directly, how it is detected, and which
alias mechanisms it supports.
- [ ] Add a normal reviewed maintenance process for provider registry changes; do not create an
- [x] Add a normal reviewed maintenance process for provider registry changes; do not create an
upstream synchronization job.
## 1. Define package and install contracts
@@ -201,10 +201,10 @@ does not mean the surrounding phase is complete.
- [x] Test executable-mode preservation and Windows's mode limitations.
- [x] Test missing, malformed, and identity-mismatched `SKILL.md` files.
- [ ] Test every size, count, and depth boundary at limit, one below, and one above.
- [ ] Test traversal, absolute paths, drive paths, Unicode/case collisions, duplicate paths, all
- [x] Test traversal, absolute paths, drive paths, Unicode/case collisions, duplicate paths, all
rejected link and special-file types, and encrypted entries.
- [x] Test truncated archives and invalid tar and content checksums.
- [ ] Fuzz archive path normalization and envelope parsing with bounded resources.
- [x] Fuzz archive path normalization and envelope parsing with bounded resources.
### Phase 2 package gate
@@ -231,7 +231,7 @@ does not mean the surrounding phase is complete.
- [x] Add `src/main/skills/skill-package-download.ts`.
- [x] Add `src/main/skills/skill-install-service.ts`.
- [ ] Validate request shape, schema, capability, package identity, scope, destination, and policy
- [x] Validate request shape, schema, capability, package identity, scope, destination, and policy
before download or mutation.
- [x] Recover prior journals for the same destination before planning a new operation.
- [x] Acquire a filesystem-backed cross-process lock keyed by canonical destination.
@@ -303,7 +303,7 @@ does not mean the surrounding phase is complete.
- [x] Add `src/main/skills/skill-provider-destinations.ts`.
- [x] Add data-only records for Orca agent ID, canonical-root support, global/project resolvers,
detection evidence, and platform alias support.
- [ ] Populate only provider paths independently verified from official documentation and real
- [x] Populate only provider paths independently verified from official documentation and real
installations.
- [x] Detect an agent before creating its provider-specific configuration root.
- [x] Avoid modifying roots for agents that consume `.agents/skills` directly.
@@ -356,14 +356,14 @@ does not mean the surrounding phase is complete.
### Recovery failure injection
- [ ] Inject failure before and after every journal transition.
- [x] Inject failure before and after every journal transition.
- [x] Verify partial downloads and extraction staging are deleted.
- [x] Verify a moved destination is restored from backup.
- [x] Verify a placed destination without a receipt is completed or restored according to journal
state.
- [x] Verify a published receipt with an incomplete journal is finalized safely.
- [ ] Verify interrupted placement reconciliation preserves the canonical install and retries.
- [ ] Test cancellation during download, extraction, staging copy, commit, provenance, and
- [x] Verify interrupted placement reconciliation preserves the canonical install and retries.
- [x] Test cancellation during download, extraction, staging copy, commit, provenance, and
placement reconciliation.
## 6. Provision GCP infrastructure
@@ -393,10 +393,11 @@ does not mean the surrounding phase is complete.
- [x] Keep object versioning disabled and record object generations in PostgreSQL.
- [x] Add a one-day deletion lifecycle for `uploads/` quarantine objects only.
- [x] Add exact approved production and development Orca origins to CORS; never use `*`.
- [ ] Allow only required signed POST upload and GET/HEAD download behavior and response headers.
- [x] Allow only required signed POST upload and GET/HEAD download behavior and response headers.
- [x] Keep the bucket off public custom domains.
- [x] Define immutable final keys as
`packages/v1/sha256/<prefix>/<package-digest>/package.tar.gz`.
`packages/v1/sha256/<prefix>/<archive-sha256>/package.tar.gz`, with the logical package digest
verified in object metadata and PostgreSQL.
- [x] Define tenant-bound random quarantine keys as `uploads/<upload-id>/package.tar.gz`.
### Database and secret
@@ -428,13 +429,13 @@ does not mean the surrounding phase is complete.
- [ ] Extend `orca-cloud-api` in `us-central1`; do not create a separate V1 worker service.
- [x] Configure bucket, 40 MiB compressed limit, 15-minute upload TTL, five-minute download TTL,
fixed finalize concurrency, and skill database URL.
- [ ] Reuse existing auth base URL and application CORS configuration.
- [ ] Stream validation with fixed buffers under the existing 512 MiB memory limit.
- [x] Reuse existing auth base URL and application CORS configuration.
- [x] Stream validation with fixed buffers under the existing 512 MiB memory limit.
- [x] Add a small per-instance finalization semaphore and retryable `429` or `503` with
`Retry-After` when saturated.
- [ ] Preserve current service scaling initially and tune only from measured CPU, latency,
- [x] Preserve current service scaling initially and tune only from measured CPU, latency,
database, and error data.
- [ ] Define criteria for splitting finalization into a worker service if it harms existing API
- [x] Define criteria for splitting finalization into a worker service if it harms existing API
traffic.
### Terraform review and apply
@@ -495,7 +496,7 @@ does not mean the surrounding phase is complete.
- [x] Stream once to calculate archive SHA-256 and validate envelope, manifest, paths, limits, and
package digest.
- [x] Promote with an if-absent generation precondition to the content-addressed final key.
- [ ] If the final key exists, verify recorded identity without exposing cross-tenant
- [x] If the final key exists, verify recorded identity without exposing cross-tenant
deduplication.
- [x] Publish the version and complete the upload in a PostgreSQL transaction.
- [x] Delete quarantine bytes after success and rely on lifecycle cleanup for abandonment.
@@ -519,15 +520,15 @@ does not mean the surrounding phase is complete.
- [x] Set only response content type and safe attachment filename overrides.
- [x] Grant no list or write capability.
- [x] Return expected archive identity and byte count beside the grant for runtime verification.
- [ ] Test revocation immediately blocks new grants while already issued URLs expire within five
- [x] Test revocation immediately blocks new grants while already issued URLs expire within five
minutes.
### Cloud tests
- [x] Test organization, user, pinned-link, expiry, and revocation ACL behavior.
- [ ] Test membership removal between preview, grant, and download.
- [x] Test membership removal between preview, grant, and download.
- [x] Test expired, reused, wrong-tenant, wrong-key, wrong-size, and wrong-hash uploads.
- [ ] Test malformed, oversized, and resource-exhausting archives during finalization.
- [x] Test malformed, oversized, and resource-exhausting archives during finalization.
- [x] Test finalization semaphore saturation and retry behavior.
- [x] Test concurrent idempotent mutations and partial database/GCS failures.
- [ ] Test deduplication without cross-tenant timing or response disclosure.
@@ -568,10 +569,10 @@ does not mean the surrounding phase is complete.
- [x] Allow selection of connected machine, Git worktree, or plain folder workspace.
- [x] Show detected-agent coverage and canonical/provider topology before commit.
- [x] Show installed-state conflicts and explicit resolution choices.
- [ ] Show phase progress without exposing grants or local private paths.
- [x] Show phase progress without exposing grants or local private paths.
- [x] Render installed, unchanged, updated, partial, conflict, unsupported, cancelled, and failed
results with actionable recovery.
- [ ] Add incomplete-coverage retry.
- [x] Add incomplete-coverage retry.
### Skills page lifecycle
@@ -618,7 +619,7 @@ does not mean the surrounding phase is complete.
- [x] Hide or disable remote install when `skills.install.v1` is absent.
- [x] Show a specific host-update-required message instead of attempting the RPC.
- [x] Test new client/old server and old client/new server.
- [ ] Test optional-field omission in both directions.
- [x] Test optional-field omission in both directions.
- [x] Test a capability changing after preview but before execution.
- [x] Confirm older servers retain skill discovery and ignore no unknown stream opcode because none
is introduced.
@@ -770,11 +771,11 @@ directory was used for destructive failure injection.
- [x] Add independent server-side flags for upload grants, download grants, and remote
installation.
- [ ] Verify disabling Cloud grants does not break skill discovery or already installed skills.
- [x] Verify disabling Cloud grants does not break skill discovery or already installed skills.
- [x] Document finalization saturation, GCS/IAM signing failure, database outage, corrupt package,
orphan reconciliation, and rollback response.
- [x] Document coordinated PostgreSQL point-in-time and GCS generation/soft-delete restoration.
- [ ] Document package/share deletion, organization departure, legal retention, and audit handling.
- [x] Document package/share deletion, organization departure, legal retention, and audit handling.
- [x] Document how and when to split finalization into its own service.
## 13. Complete automated and end-to-end validation
@@ -7,6 +7,12 @@ Last updated: 2026-08-11.
Implementation checklist:
[Agent skill sharing implementation checklist](./agent-skill-sharing-implementation-checklist.md).
Architecture decision:
[Agent skill sharing upstream boundary](./reference/agent-skill-sharing-upstream-boundary.md).
Provider registry:
[Agent skill provider paths](./reference/agent-skill-provider-paths.md).
## Decision summary
Orca will own a focused skill packaging and installation pipeline for private sharing.
@@ -295,7 +301,7 @@ switch without disrupting existing artifact links.
```text
gs://onorca-cloud-skill-packages/
uploads/<upload-id>/package.tar.gz
packages/v1/sha256/<first-two-hex>/<package-digest>/package.tar.gz
packages/v1/sha256/<first-two-hex>/<archive-sha256>/package.tar.gz
```
Rules:
@@ -306,7 +312,8 @@ Rules:
- The database records the final object's GCS generation, compressed size, archive SHA-256, and
package digest.
- Final objects are never overwritten. Promotion uses a generation-match precondition.
- If a final key already exists, finalization verifies its recorded identity before reusing it.
- If a final key already exists, finalization verifies its archive SHA-256 and logical package
digest metadata before reusing it.
- Internal deduplication never changes tenant-scoped API responses or reveals that another
organization already stored the same digest.
- GCS object versioning remains off; immutable keys plus stored generations prevent accidental
@@ -0,0 +1,38 @@
# Agent skill provider paths
Last verified: 2026-08-11.
V1 supports only providers whose paths are independently established by official documentation.
The registry is deliberately small; it is not copied or synchronized from a community path table.
| Provider | Detection | Global canonical support | Workspace support | Orca placement |
| --- | --- | --- | --- | --- |
| Codex | `codex` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.agents/skills` directly | Reads `.agents/skills` from the current directory through the repository root | Canonical copy only |
| Claude Code | `claude` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.claude/skills` | Reads `.claude/skills` from the launch directory through the repository root, plus nested directories as files are accessed | Relative directory symlink on POSIX, directory junction on Windows, or verified independent-copy fallback |
Codex locations and symlink behavior are documented in the official OpenAI documentation:
[Build skills](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills).
Claude Code locations, precedence, parent traversal, live detection, and symlink behavior are
documented in the official Anthropic documentation:
[Extend Claude with skills](https://code.claude.com/docs/en/skills#where-skills-live).
Codex therefore needs no provider-specific placement. Claude Code does not document
`.agents/skills` as a discovery root, so Orca reconciles its documented `.claude/skills` path back
to the canonical copy. Orca never replaces a path it does not own. If alias creation is unavailable,
the verified copy fallback is tracked in the install receipt so update and removal can detect drift.
## Registry change process
Every registry change requires normal code review and all of the following evidence:
1. Link current official provider documentation for global and workspace paths.
2. Record whether the provider reads `.agents/skills` directly and its documented link behavior.
3. Verify global and folder-workspace discovery on macOS, Linux, native Windows, and WSL where the
provider supports those platforms.
4. Exercise local, paired-runtime, and SSH host-owned path resolution.
5. Test alias denial, broken owned aliases, independent-copy drift, update, rollback, and removal.
6. Update mixed-version capability evidence if the placement contract changes.
Do not add automated upstream path-table synchronization. A provider release that changes discovery
semantics must enter through this review process.
@@ -0,0 +1,43 @@
# Agent skill sharing upstream boundary
Status: proposed for formal engineering and legal review.
Date: 2026-08-11.
## Context
Orca needs private, durable, cross-machine skill sharing with bounded archive ingestion,
host-owned destination resolution, crash-safe transactions, provenance, and mixed-version remote
support. `vercel-labs/skills` exposes a CLI and does not provide the Cloud authorization or local
transaction contract Orca requires.
The behavioral assessment used upstream commit
`c6f69c631292444cc541ac6d91e2226b0ff247da`.
## Decision
Orca implements its package, Cloud, installation, provider-placement, and recovery behavior
independently. The upstream project is a behavioral reference only.
Do not copy or mechanically translate upstream source, tests, fixtures, registry entries, provider
path tables, comments, or documentation. Derive provider paths from official provider
documentation and verify them with real installations. Orca does not depend on the upstream CLI,
npm package, or an unsupported programmatic API.
If a future change proposes incorporating upstream material, stop and review the exact material,
license, attribution, notices, and maintenance implications before implementation or merge.
## Consequences
- Orca owns stability, security, compatibility, and maintenance of this narrower installer.
- There is no automatic upstream synchronization job.
- Similar behavior is acceptable when independently derived from requirements and official
provider contracts; textual or structural copying is not.
- Provider registry changes require normal code review plus official-documentation and real-host
evidence.
- The pull request template requires reviewers to confirm this boundary for relevant changes.
## Review record
Product chose the reference-only approach during planning. Formal engineering and legal reviewers
remain to be named before external rollout.
+36 -4
View File
@@ -10,6 +10,7 @@ import {
SkillInstallDestinationSchema,
type SkillInstallRequest
} from '../../shared/skill-install-contract'
import type { SkillInstallProgress } from '../../shared/skill-sharing-contract'
import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills'
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -17,6 +18,7 @@ import { installSkillOnRemoteRuntime } from '../skills/skill-remote-install-serv
import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target'
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
import { skillInstallFailureFromError } from '../skills/skill-install-operation-error'
import { supportsSkillRuntimeInstall } from '../skills/skill-runtime-capability'
import {
callRuntimeEnvironment,
getRuntimeEnvironmentStatus
@@ -191,23 +193,53 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
ipcMain.handle('skills:createDownloadGrant', (_event, shareId: unknown) =>
runtime.createSkillDownloadGrant(z.string().min(1).max(128).parse(shareId), {})
)
ipcMain.handle('skills:installShare', async (_event, value: unknown) => {
const input = shareInstallSchema.parse(value)
const sendProgress = (
event: Electron.IpcMainInvokeEvent,
progress: SkillInstallProgress
): void => {
if (!event.sender.isDestroyed()) {
event.sender.send('skills:installProgress', progress)
}
}
ipcMain.handle('skills:installShare', async (event, value: unknown) => {
const parsed = shareInstallSchema.parse(value)
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
if (
input.environmentId &&
!(await supportsSkillRuntimeInstall(app.getPath('userData'), input.environmentId))
) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
const grant = await runtime.createSkillDownloadGrant(input.shareId, {
versionId: input.versionId,
installTarget
})
if (grant.status === 'ok') {
sendProgress(event, { operationId: input.operationId, phase: 'installing' })
}
return grant.status === 'ok' ? installGrant(runtime, grant.value, input) : grant
})
ipcMain.handle('skills:installPackageVersion', async (_event, value: unknown) => {
const input = packageVersionInstallSchema.parse(value)
ipcMain.handle('skills:installPackageVersion', async (event, value: unknown) => {
const parsed = packageVersionInstallSchema.parse(value)
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
sendProgress(event, { operationId: input.operationId, phase: 'authorizing' })
if (
input.environmentId &&
!(await supportsSkillRuntimeInstall(app.getPath('userData'), input.environmentId))
) {
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
}
const installTarget = await classifySkillCloudInstallTarget(runtime, input)
const grant = await runtime.createSkillPackageVersionDownloadGrant(
input.packageId,
input.versionId,
{ installTarget }
)
if (grant.status === 'ok') {
sendProgress(event, { operationId: input.operationId, phase: 'installing' })
}
return grant.status === 'ok' ? installGrant(runtime, grant.value, input) : grant
})
ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => {
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -91,6 +91,65 @@ describe('executeSkillInstallRequest', () => {
).rejects.toThrow('skill-install-local-ingress-rejected')
})
it('rejects invalid shape, identity, scope, destination, and ingress policy before download', async () => {
const { request, dependencies, archive, home } = await fixture()
const cases: { input: unknown; error: string }[] = [
{ input: { ...request, unexpected: true }, error: 'Unrecognized key' },
{
input: { ...request, package: { ...request.package, packageDigest: 'invalid' } },
error: 'Invalid string'
},
{
input: {
...request,
destination: {
scope: 'workspace',
worktreeId: 'worktree_1',
folderWorkspaceId: 'folder_1'
}
},
error: 'Invalid input'
},
{
input: { ...request, destination: { scope: 'global', environmentId: 'other' } },
error: 'skill-install-environment-mismatch'
},
{
input: {
...request,
destination: {
scope: 'global',
executionTarget: { kind: 'ssh', connectionId: 'ssh_1' }
}
},
error: 'skill-install-ssh-dispatch-required'
},
{
input: { ...request, ingress: { kind: 'local-file', path: archive.archivePath } },
error: 'skill-install-local-ingress-rejected'
},
{
input: {
...request,
ingress: {
...request.ingress,
url: 'https://untrusted.test/package.tar.gz'
}
},
error: 'skill-download-origin-rejected'
}
]
for (const sample of cases) {
vi.mocked(dependencies.fetcher).mockClear()
await expect(executeSkillInstallRequest(sample.input, dependencies)).rejects.toThrow(
sample.error
)
expect(dependencies.fetcher).not.toHaveBeenCalled()
await expect(lstat(join(home, '.agents'))).rejects.toMatchObject({ code: 'ENOENT' })
}
})
it('returns a structured cancelled result without leaving partial ingress bytes', async () => {
const { request, dependencies } = await fixture()
const controller = new AbortController()
@@ -202,4 +202,83 @@ describe('skill install service', () => {
})
expect(retried.status).toBe('removed')
})
it('preserves the committed canonical install when cancellation reaches provider placement', async () => {
const { root, input } = await fixture()
const controller = new AbortController()
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
const cancellingFilesystem = {
...nativeSkillInstallFilesystem,
rename: async (source: string, target: string): Promise<void> => {
await nativeSkillInstallFilesystem.rename(source, target)
if (target === canonical) {
controller.abort()
}
}
}
const interrupted = await installSharedSkill({
...input,
filesystem: cancellingFilesystem,
signal: controller.signal
})
expect(interrupted.status).toBe('partial')
expect(interrupted.placements).toEqual(
expect.arrayContaining([
expect.objectContaining({ provider: 'agent-skills', status: 'installed' }),
expect.objectContaining({
provider: 'claude',
status: 'skipped',
errorCategory: 'skill-placement-cancelled'
})
])
)
expect(interrupted.placements.some((placement) => placement.provider === 'codex')).toBe(false)
expect(await readFile(join(canonical, 'SKILL.md'), 'utf8')).toContain('# Test')
const retried = await installSharedSkill({ ...input, operationId: 'operation_retry' })
expect(retried.status).toBe('unchanged')
expect(await realpath(join(root, 'home', '.claude', 'skills', 'test-skill'))).toBe(
await realpath(canonical)
)
})
it('cleans an interrupted provider copy and repairs coverage on retry', async () => {
const { root, input } = await fixture()
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
let deniedCopyObservation = false
const interruptedFilesystem = {
...nativeSkillInstallFilesystem,
createAlias: async () => {
throw new Error('injected-alias-denial')
},
observeSkill: async (path: string) => {
if (path === claude && !deniedCopyObservation) {
deniedCopyObservation = true
throw new Error('injected-copy-interruption')
}
return nativeSkillInstallFilesystem.observeSkill(path)
}
}
const interrupted = await installSharedSkill({ ...input, filesystem: interruptedFilesystem })
expect(interrupted).toMatchObject({
status: 'partial',
placements: expect.arrayContaining([
expect.objectContaining({
provider: 'claude',
status: 'failed',
errorCategory: 'skill-placement-create-failed'
})
])
})
await expect(lstat(claude)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(join(canonical, 'SKILL.md'), 'utf8')).toContain('# Test')
const retried = await installSharedSkill({ ...input, operationId: 'operation_retry' })
expect(retried.status).toBe('unchanged')
expect(await realpath(claude)).toBe(await realpath(canonical))
})
})
+3
View File
@@ -107,6 +107,9 @@ export async function installSharedSkill(
detectedProviders: input.detectedProviders
})
for (const destination of destinations) {
if (destination.readsCanonicalRoot) {
continue
}
if (input.signal?.aborted) {
placements.push({
provider: destination.provider,
@@ -234,4 +234,98 @@ describe('skill install transaction', () => {
await expect(lstat(join(root, 'state', 'journals'))).resolves.toBeDefined()
expect(await readdir(join(root, 'state', 'journals'))).toEqual([])
})
it('removes staging bytes when cancellation arrives after the staging move', async () => {
const root = await temporaryDirectory()
const archive = await packageVersion(root, 'version_1', '# First')
const controller = new AbortController()
const cancellingFilesystem = {
...nativeSkillInstallFilesystem,
rename: async (source: string, target: string): Promise<void> => {
await nativeSkillInstallFilesystem.rename(source, target)
if (target.includes('.orca-staging-')) {
controller.abort()
}
}
}
const result = await installLocalSkillPackage(
installInput(root, archive, { filesystem: cancellingFilesystem, signal: controller.signal })
)
expect(result).toMatchObject({
status: 'cancelled',
failure: { category: 'cancelled', retryable: true }
})
await expect(lstat(join(root, 'skills', 'test-skill'))).rejects.toMatchObject({
code: 'ENOENT'
})
expect((await readdir(join(root, 'skills'))).filter((name) => name.includes('.orca-'))).toEqual(
[]
)
})
it('finishes provenance safely when cancellation arrives after canonical commit', async () => {
const root = await temporaryDirectory()
const archive = await packageVersion(root, 'version_1', '# First')
const controller = new AbortController()
const result = await installLocalSkillPackage(
installInput(root, archive, { signal: controller.signal }),
{
onJournalTransition: async (phase, boundary) => {
if (phase === 'receipt-published' && boundary === 'before') {
controller.abort()
}
}
}
)
expect(result.status).toBe('installed')
expect(await readFile(join(root, 'skills', 'test-skill', 'SKILL.md'), 'utf8')).toContain(
'# First'
)
await expect(
readFile(
join(
root,
'state',
'receipts',
`${skillInstallStateKey(join(root, 'skills', 'test-skill'))}.json`
),
'utf8'
)
).resolves.toContain('version_1')
})
it.each(
['prepared', 'backup-created', 'canonical-placed', 'receipt-published', 'complete'].flatMap(
(phase) => ['before', 'after'].map((boundary) => [phase, boundary] as const)
)
)('recovers failure %s the %s journal transition', async (phase, boundary) => {
const root = await temporaryDirectory()
const first = await packageVersion(root, 'version_1', '# First')
const second = await packageVersion(root, 'version_2', '# Second')
await installLocalSkillPackage(installInput(root, first))
await expect(
installLocalSkillPackage(installInput(root, second), {
onJournalTransition: async (currentPhase, currentBoundary) => {
if (currentPhase === phase && currentBoundary === boundary) {
throw new Error(`injected-${phase}-${boundary}`)
}
}
})
).rejects.toThrow(`injected-${phase}-${boundary}`)
const skillMarkdown = await readFile(join(root, 'skills', 'test-skill', 'SKILL.md'), 'utf8')
expect(skillMarkdown.includes('# First') || skillMarkdown.includes('# Second')).toBe(true)
expect((await readdir(join(root, 'skills'))).filter((name) => name.includes('.orca-'))).toEqual(
[]
)
expect(await readdir(join(root, 'state', 'journals'))).toEqual([])
await expect(installLocalSkillPackage(installInput(root, second))).resolves.toMatchObject({
status: expect.stringMatching(/^(updated|unchanged)$/)
})
})
})
+27 -7
View File
@@ -61,6 +61,15 @@ export type LocalSkillInstallPreview = {
currentState: SkillCanonicalState
}
export type SkillInstallJournalBoundary = 'before' | 'after'
export type SkillInstallTransactionDependencies = {
onJournalTransition?: (
phase: SkillInstallJournalV1['phase'],
boundary: SkillInstallJournalBoundary
) => Promise<void>
}
function lockPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
}
@@ -91,6 +100,16 @@ function throwIfCancelled(signal?: AbortSignal): void {
}
}
async function persistJournalTransition(
statePath: string,
journal: SkillInstallJournalV1,
dependencies: SkillInstallTransactionDependencies
): Promise<void> {
await dependencies.onJournalTransition?.(journal.phase, 'before')
await writeSkillStateFile(statePath, journal)
await dependencies.onJournalTransition?.(journal.phase, 'after')
}
export async function previewLocalSkillPackage(
input: LocalSkillInstallInput
): Promise<LocalSkillInstallPreview> {
@@ -113,7 +132,8 @@ export async function previewLocalSkillPackage(
}
export async function installLocalSkillPackage(
input: LocalSkillInstallInput
input: LocalSkillInstallInput,
dependencies: SkillInstallTransactionDependencies = {}
): Promise<SkillInstallResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const extracted = await inspectExtractedPackage(input)
@@ -176,7 +196,6 @@ export async function installLocalSkillPackage(
if (destinationExists && !backupDigest) {
return skillInstallConflictResult(input.operationId, extracted.manifest, state)
}
await filesystem.rename(join(extracted.extractionPath, 'skill'), stagingPath)
journal = {
schemaVersion: 1,
operation: 'install',
@@ -191,7 +210,8 @@ export async function installLocalSkillPackage(
receipt
}
const statePath = skillInstallJournalPath(input.stateDirectory, canonicalPath)
await writeSkillStateFile(statePath, journal)
await persistJournalTransition(statePath, journal, dependencies)
await filesystem.rename(join(extracted.extractionPath, 'skill'), stagingPath)
throwIfCancelled(input.signal)
const commitState = await inspectSkillCanonicalState({
canonicalPath,
@@ -212,12 +232,12 @@ export async function installLocalSkillPackage(
if (destinationExists) {
await filesystem.rename(canonicalPath, backupPath)
journal.phase = 'backup-created'
await writeSkillStateFile(statePath, journal)
await persistJournalTransition(statePath, journal, dependencies)
throwIfCancelled(input.signal)
}
await filesystem.rename(stagingPath, canonicalPath)
journal.phase = 'canonical-placed'
await writeSkillStateFile(statePath, journal)
await persistJournalTransition(statePath, journal, dependencies)
if (
!(await skillInstallDestinationMatches(
canonicalPath,
@@ -230,10 +250,10 @@ export async function installLocalSkillPackage(
}
await writeSkillInstallReceipt(input.stateDirectory, receipt)
journal.phase = 'receipt-published'
await writeSkillStateFile(statePath, journal)
await persistJournalTransition(statePath, journal, dependencies)
await cleanSkillInstallJournalFiles(journal, filesystem)
journal.phase = 'complete'
await writeSkillStateFile(statePath, journal)
await persistJournalTransition(statePath, journal, dependencies)
await rm(statePath, { force: true })
return {
operationId: input.operationId,
+28 -1
View File
@@ -1,4 +1,4 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync, gzipSync } from 'node:zlib'
@@ -227,6 +227,33 @@ describe('skill package creation and extraction', () => {
).rejects.toThrow('skill-package-tar-truncated')
})
it('cancels during streamed extraction and removes partial bytes', async () => {
const root = await temporaryDirectory()
const created = await createSkillPackageArchive({
sourceDirectory: await createSkill(root),
archivePath: join(root, 'package.tar.gz'),
packageId: 'package_1',
versionId: 'version_1'
})
let checks = 0
const signal = {
get aborted() {
checks += 1
return checks >= 3
}
} as AbortSignal
const destinationDirectory = join(root, 'cancelled-extraction')
await expect(
extractSkillPackageArchive({
archivePath: created.archivePath,
destinationDirectory,
signal
})
).rejects.toThrow('skill-install-cancelled')
await expect(lstat(destinationDirectory)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects content checksum and SKILL identity mismatches', async () => {
const root = await temporaryDirectory()
const created = await createSkillPackageArchive({
+31 -1
View File
@@ -1,6 +1,9 @@
import { chmod, cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { createServer } from 'node:net'
import { chmod, cp, link, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import {
describeObservedSkillFile,
@@ -11,6 +14,7 @@ import {
} from './skill-package-identity'
const temporaryDirectories: string[] = []
const execFileAsync = promisify(execFile)
async function temporarySkill(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-'))
@@ -92,6 +96,32 @@ describe('skill package identity', () => {
).rejects.toThrow('skill-package-entry-limit')
})
it.runIf(process.platform !== 'win32')('rejects hardlinks, FIFOs, and Unix sockets', async () => {
const root = await temporarySkill()
const source = join(root, 'SKILL.md')
await writeFile(source, 'skill')
await link(source, join(root, 'hardlink.md'))
await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link')
await rm(join(root, 'hardlink.md'))
const fifo = join(root, 'named-pipe')
await execFileAsync('mkfifo', [fifo])
await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-special-file')
await rm(fifo)
const socket = join(root, 'unix-socket')
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(socket, resolve)
})
try {
await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-special-file')
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
})
it('ignores OS-authored sidecars so a browsed folder still matches its snapshot', async () => {
const pristine = await temporarySkill()
await writeFile(join(pristine, 'SKILL.md'), 'skill\n')
@@ -234,6 +234,9 @@ export async function observeSkillPackage(
}
await visit(absolutePath, depth + 1)
} else if (fileStat.isFile()) {
if (fileStat.nlink !== 1) {
throw new Error('skill-package-link')
}
if (files.length >= limits.maximumFiles) {
throw new Error('skill-package-file-count-limit')
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { parseSkillTarHeader, SKILL_TAR_BLOCK_BYTES } from './skill-package-tar'
function writeOctal(header: Buffer, offset: number, length: number, value: number): void {
header.write(`${value.toString(8).padStart(length - 1, '0')}\0`, offset, length, 'ascii')
}
function refreshChecksum(header: Buffer): void {
header.fill(0x20, 148, 156)
const checksum = header.reduce((sum, byte) => sum + byte, 0)
header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
}
function header(type = '0'): Buffer {
const value = Buffer.alloc(SKILL_TAR_BLOCK_BYTES)
value.write('skill/SKILL.md', 0, 'utf8')
writeOctal(value, 100, 8, 0o644)
writeOctal(value, 108, 8, 0)
writeOctal(value, 116, 8, 0)
writeOctal(value, 124, 12, 0)
writeOctal(value, 136, 12, 0)
value[156] = type.charCodeAt(0)
value.write('ustar\0', 257, 'ascii')
value.write('00', 263, 'ascii')
refreshChecksum(value)
return value
}
describe('skill package tar envelope', () => {
it.each(['1', '2', '3', '4', '5', '6', '7', 'x', 'g', 'L', 'K', 's'])(
'rejects non-regular tar entry type %s',
(type) => {
expect(() => parseSkillTarHeader(header(type))).toThrow('skill-package-tar-entry-type')
}
)
it('requires the deterministic ustar dialect', () => {
const legacy = header()
legacy.fill(0, 257, 265)
refreshChecksum(legacy)
expect(() => parseSkillTarHeader(legacy)).toThrow('skill-package-tar-format-invalid')
})
it('fuzzes fixed-size headers without unbounded parsing or non-Error failures', () => {
let state = 0x51a7e
const randomByte = (): number => {
state = (state * 1664525 + 1013904223) >>> 0
return state & 0xff
}
for (let sample = 0; sample < 5_000; sample += 1) {
const value = Buffer.allocUnsafe(SKILL_TAR_BLOCK_BYTES)
for (let index = 0; index < value.length; index += 1) {
value[index] = randomByte()
}
refreshChecksum(value)
try {
const parsed = parseSkillTarHeader(value)
expect(parsed === null || Buffer.byteLength(parsed.path, 'utf8') <= 256).toBe(true)
} catch (error) {
expect(error).toBeInstanceOf(Error)
}
}
})
})
+3
View File
@@ -224,6 +224,9 @@ export function parseSkillTarHeader(header: Buffer): SkillTarReadEntry | null {
return null
}
validateTarChecksum(header)
if (readString(header, 257, 6) !== 'ustar' || readString(header, 263, 2) !== '00') {
throw new Error('skill-package-tar-format-invalid')
}
const type = header[156]
if (type !== 0 && type !== '0'.charCodeAt(0)) {
throw new Error('skill-package-tar-entry-type')
+6 -1
View File
@@ -3,7 +3,11 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
import type { SkillInstallFilesystem } from './skill-install-filesystem'
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
import { skillInstallStateKey, type SkillInstallReceiptV1 } from './skill-install-provenance'
import {
skillInstallStateKey,
writeSkillInstallReceipt,
type SkillInstallReceiptV1
} from './skill-install-provenance'
export type RemovalMove = {
sourcePath: string
@@ -190,5 +194,6 @@ export async function recoverSkillRemovalTransaction(
await filesystem.rename(move.backupPath, move.sourcePath)
}
}
await writeSkillInstallReceipt(stateDirectory, journal.receipt)
await rm(skillRemovalJournalPath(stateDirectory, canonicalPath), { force: true })
}
@@ -11,6 +11,7 @@ import {
import { installLocalSkillPackage } from './skill-install-transaction'
import {
recoverSkillRemovalTransaction,
removeLocalSharedSkill,
skillRemovalJournalPath,
type SkillRemovalJournalV1
} from './skill-remove-transaction'
@@ -152,4 +153,48 @@ describe('skill removal recovery', () => {
).rejects.toThrow('skill-removal-recovery-conflict')
expect(await readFile(join(backupPath, 'local.md'), 'utf8')).toBe('changed')
})
it.each(
['prepared', 'moving', 'receipt-removed'].flatMap((phase) =>
['before', 'after'].map((boundary) => [phase, boundary] as const)
)
)('recovers failure %s the %s journal transition', async (phase, boundary) => {
const fixture = await installedFixture()
await expect(
removeLocalSharedSkill(
{
operationId: 'remove',
canonicalPath: fixture.canonicalPath,
stateDirectory: fixture.stateDirectory,
allowedProviderRoots: []
},
{
onJournalTransition: async (currentPhase, currentBoundary) => {
if (currentPhase === phase && currentBoundary === boundary) {
throw new Error(`injected-${phase}-${boundary}`)
}
}
}
)
).rejects.toThrow(`injected-${phase}-${boundary}`)
const canonicalExists = await readFile(join(fixture.canonicalPath, 'SKILL.md'), 'utf8')
.then(() => true)
.catch(() => false)
const receipt = await readSkillInstallReceipt(fixture.stateDirectory, fixture.canonicalPath)
expect(Boolean(receipt)).toBe(canonicalExists)
await expect(
readFile(skillRemovalJournalPath(fixture.stateDirectory, fixture.canonicalPath))
).rejects.toMatchObject({ code: 'ENOENT' })
if (canonicalExists) {
await expect(
removeLocalSharedSkill({
operationId: 'retry-remove',
canonicalPath: fixture.canonicalPath,
stateDirectory: fixture.stateDirectory,
allowedProviderRoots: []
})
).resolves.toMatchObject({ status: 'removed' })
}
})
})
+22 -4
View File
@@ -38,6 +38,13 @@ export type LocalSkillRemovalInput = {
filesystem?: SkillInstallFilesystem
}
export type SkillRemovalTransactionDependencies = {
onJournalTransition?: (
phase: SkillRemovalJournalV1['phase'],
boundary: 'before' | 'after'
) => Promise<void>
}
function lockPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
}
@@ -84,8 +91,19 @@ async function inspectCanonicalRemoval(
return observed?.observedDigest === receipt.packageDigest ? 'owned' : 'modified'
}
async function persistRemovalJournal(
path: string,
journal: SkillRemovalJournalV1,
dependencies: SkillRemovalTransactionDependencies
): Promise<void> {
await dependencies.onJournalTransition?.(journal.phase, 'before')
await writeSkillStateFile(path, journal)
await dependencies.onJournalTransition?.(journal.phase, 'after')
}
export async function removeLocalSharedSkill(
input: LocalSkillRemovalInput
input: LocalSkillRemovalInput,
dependencies: SkillRemovalTransactionDependencies = {}
): Promise<SkillInstallResult> {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const releaseLock = await acquireSkillInstallLock({
@@ -178,17 +196,17 @@ export async function removeLocalSharedSkill(
allowedProviderRoots: [...input.allowedProviderRoots]
}
const statePath = skillRemovalJournalPath(input.stateDirectory, input.canonicalPath)
await writeSkillStateFile(statePath, journal)
await persistRemovalJournal(statePath, journal, dependencies)
for (const move of moves) {
journal.movedCount += 1
journal.phase = 'moving'
await writeSkillStateFile(statePath, journal)
await persistRemovalJournal(statePath, journal, dependencies)
await filesystem.rename(move.sourcePath, move.backupPath)
removedPlacements.push({ ...move.placement, status: 'removed' })
}
await removeSkillInstallReceipt(input.stateDirectory, input.canonicalPath)
journal.phase = 'receipt-removed'
await writeSkillStateFile(statePath, journal)
await persistRemovalJournal(statePath, journal, dependencies)
await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem)
return {
operationId: input.operationId,
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
import {
supportsSkillRuntimeInstall,
supportsSkillRuntimeManagement
} from './skill-runtime-capability'
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
getRuntimeEnvironmentStatus: vi.fn()
}))
const status = vi.mocked(getRuntimeEnvironmentStatus)
beforeEach(() => {
status.mockReset()
})
describe('skill runtime capability admission', () => {
it('admits install and management independently', async () => {
status.mockResolvedValue({
ok: true,
result: {
capabilities: ['skills.install.v1'],
runtimeId: 'runtime_1'
}
} as never)
await expect(supportsSkillRuntimeInstall('/state', 'environment_1')).resolves.toBe(true)
await expect(supportsSkillRuntimeManagement('/state', 'environment_1')).resolves.toBe(false)
})
it('fails closed for unavailable and capability-omitting hosts', async () => {
status
.mockResolvedValueOnce({ ok: false, error: { code: 'offline', message: 'offline' } } as never)
.mockResolvedValueOnce({
ok: true,
result: { runtimeId: 'runtime_1', capabilities: [] }
} as never)
await expect(supportsSkillRuntimeInstall('/state', 'environment_1')).resolves.toBe(false)
await expect(supportsSkillRuntimeInstall('/state', 'environment_1')).resolves.toBe(false)
})
})
+14 -1
View File
@@ -1,4 +1,7 @@
import { SKILL_MANAGEMENT_CAPABILITY } from '../../shared/skill-install-capability'
import {
SKILL_INSTALL_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY
} from '../../shared/skill-install-capability'
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
export async function supportsSkillRuntimeManagement(
@@ -10,3 +13,13 @@ export async function supportsSkillRuntimeManagement(
status.ok === true && status.result.capabilities?.includes(SKILL_MANAGEMENT_CAPABILITY) === true
)
}
export async function supportsSkillRuntimeInstall(
userDataPath: string,
environmentId: string
): Promise<boolean> {
const status = await getRuntimeEnvironmentStatus(userDataPath, environmentId, 15_000)
return (
status.ok === true && status.result.capabilities?.includes(SKILL_INSTALL_CAPABILITY) === true
)
}
+2
View File
@@ -416,6 +416,7 @@ import type {
SkillShareInstallInput,
SkillShareInstallOperation,
SkillInstallCancelInput,
SkillInstallProgress,
SkillSharePreview,
SkillShareProgress,
SkillSharePublishInput,
@@ -2598,6 +2599,7 @@ export type PreloadApi = {
}) => Promise<SkillCloudOperation<void>>
deletePackage: (packageId: string) => Promise<SkillCloudOperation<void>>
listWslDistros: (environmentId?: string) => Promise<string[]>
onInstallProgress: (callback: (progress: SkillInstallProgress) => void) => () => void
onShareProgress: (callback: (progress: SkillShareProgress) => void) => () => void
onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void
}
+7
View File
@@ -144,6 +144,7 @@ import type {
SkillShareInstallInput,
SkillShareInstallOperation,
SkillInstallCancelInput,
SkillInstallProgress,
SkillSharePreview,
SkillShareProgress,
SkillSharePublishInput,
@@ -2565,6 +2566,12 @@ const api = {
ipcRenderer.invoke('skills:deletePackage', packageId),
listWslDistros: (environmentId?: string): Promise<string[]> =>
ipcRenderer.invoke('skills:listWslDistros', environmentId),
onInstallProgress: (callback: (progress: SkillInstallProgress) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: SkillInstallProgress): void =>
callback(progress)
ipcRenderer.on('skills:installProgress', listener)
return () => ipcRenderer.removeListener('skills:installProgress', listener)
},
onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, progress: SkillShareProgress): void =>
callback(progress)
@@ -44,6 +44,9 @@ function version(): SkillCloudVersion {
}
function installApi(previewInstall: ReturnType<typeof vi.fn>) {
let progressListener:
| ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void)
| null = null
return {
resolveShare: vi
.fn()
@@ -60,7 +63,15 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
}
}),
cancelInstall: vi.fn().mockResolvedValue({ cancelled: true }),
listWslDistros: vi.fn().mockResolvedValue([])
listWslDistros: vi.fn().mockResolvedValue([]),
onInstallProgress: vi.fn((listener) => {
progressListener = listener
return () => {
progressListener = null
}
}),
emitInstallProgress: (progress: { operationId: string; phase: 'authorizing' | 'installing' }) =>
progressListener?.(progress)
}
}
@@ -202,4 +213,51 @@ describe('SkillInstallDialog', () => {
await screen.findByText('Installation cancelled.')
expect(screen.getByRole('button', { name: 'Retry install' })).toBeDefined()
})
it('announces install phases and allows incomplete provider coverage to retry', async () => {
const previewInstall = vi.fn().mockResolvedValue({
status: 'ok',
value: {
name: 'private-skill',
packageDigest: DIGEST,
destinationIdentity: 'local:global',
currentState: 'missing',
providers: []
}
})
let settleInstall: ((value: unknown) => void) | undefined
const skills = installApi(previewInstall)
skills.installShare.mockImplementation(
() => new Promise((resolve) => (settleInstall = resolve)) as never
)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
await screen.findByText('Authorizing package access…')
const operationId = skills.installShare.mock.calls[0]?.[0].operationId
skills.emitInstallProgress({ operationId, phase: 'installing' })
await screen.findByText('Downloading, verifying, and installing…')
settleInstall?.({
status: 'ok',
value: {
operationId,
status: 'partial',
name: 'private-skill',
packageDigest: DIGEST,
placements: [
{
provider: 'codex',
path: '/redacted-in-ui',
topology: 'provider-alias',
status: 'failed',
errorCategory: 'skill-placement-permission-denied'
}
]
}
})
await screen.findByRole('button', { name: 'Retry install' })
})
})
@@ -25,6 +25,7 @@ import {
SkillShareLinkInputForm
} from './SkillInstallReviewContent'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { useSkillInstallProgress } from './skill-install-progress-state'
export function SkillInstallDialog({
open,
@@ -54,7 +55,7 @@ export function SkillInstallDialog({
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(null)
const [activeOperationId, setActiveOperationId] = useState<string | null>(null)
const installProgress = useSkillInstallProgress()
const workspaceChoices = useMemo(
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
@@ -153,7 +154,7 @@ export function SkillInstallDialog({
}
}
const operationId = crypto.randomUUID()
setActiveOperationId(operationId)
installProgress.begin(operationId)
const operation = await window.api.skills.installShare({
shareId: preview.shareId,
operationId,
@@ -181,17 +182,17 @@ export function SkillInstallDialog({
console.warn('[skills] install failed:', cause)
setError('Installation failed before Orca could verify the requested version.')
} finally {
setActiveOperationId(null)
installProgress.finish()
setBusy(false)
}
}
const cancelInstall = async (): Promise<void> => {
if (!activeOperationId) {
if (!installProgress.activeOperationId) {
return
}
const cancelled = await window.api.skills.cancelInstall({
operationId: activeOperationId,
operationId: installProgress.activeOperationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId })
})
if (!cancelled.cancelled) {
@@ -271,16 +272,22 @@ export function SkillInstallDialog({
{error}
</p>
) : null}
{installProgress.phaseLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{installProgress.phaseLabel}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
Close
</Button>
{busy && activeOperationId ? (
{busy && installProgress.activeOperationId ? (
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
Cancel installation
</Button>
) : null}
{preview && (!result || ['conflict', 'failed', 'cancelled'].includes(result.status)) ? (
{preview &&
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
<Button
type="button"
disabled={busy || (scope === 'workspace' && !workspace)}
@@ -68,6 +68,9 @@ function skillsApi(
versions: SkillCloudVersion[],
details = packageDetails(versions)
) {
let progressListener:
| ((progress: { operationId: string; phase: 'authorizing' | 'installing' }) => void)
| null = null
return {
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: [installed] }),
getPackage: vi.fn().mockResolvedValue({ status: 'ok', value: details }),
@@ -94,7 +97,16 @@ function skillsApi(
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 })
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
cancelInstall: vi.fn().mockResolvedValue({ cancelled: true }),
onInstallProgress: vi.fn((listener) => {
progressListener = listener
return () => {
progressListener = null
}
}),
emitInstallProgress: (progress: { operationId: string; phase: 'authorizing' | 'installing' }) =>
progressListener?.(progress)
}
}
@@ -229,4 +241,41 @@ describe('SkillInstallManagementDialog', () => {
expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1')
expect(screen.getByText('global · ver_2')).toBeTruthy()
})
it('retries incomplete coverage for the currently installed version', async () => {
const skills = skillsApi(install('ver_1'), [
version('ver_2', '2026-08-12T00:00:00.000Z'),
version('ver_1', '2026-08-11T00:00:00.000Z')
])
skills.installPackageVersion.mockResolvedValue({
status: 'ok',
value: {
operationId: 'operation_partial',
status: 'partial',
name: 'private-skill',
packageDigest: DIGEST,
placements: [
{
provider: 'codex',
path: '/redacted-in-ui',
topology: 'provider-alias',
status: 'failed',
errorCategory: 'skill-placement-permission-denied'
}
]
}
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_1')
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
await screen.findByRole('button', { name: 'Retry incomplete coverage' })
fireEvent.click(screen.getByRole('button', { name: 'Retry incomplete coverage' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledTimes(2))
expect(skills.installPackageVersion.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ packageId: 'pkg_1', versionId: 'ver_2' })
)
})
})
@@ -26,6 +26,7 @@ import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-co
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { SkillCloudManagementActions } from './SkillCloudManagementActions'
import { skillInstallResultLabel } from './skill-install-result-label'
import { useSkillInstallProgress } from './skill-install-progress-state'
function installKey(install: ManagedSkillInstall): string {
return `${install.destinationIdentity}:${install.name}:${install.packageId}`
@@ -51,7 +52,7 @@ export function SkillInstallManagementDialog({
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [activeOperationId, setActiveOperationId] = useState<string | null>(null)
const installProgress = useSkillInstallProgress()
const selected = useMemo(
() => installs.find((install) => installKey(install) === selectedKey) ?? null,
@@ -145,7 +146,7 @@ export function SkillInstallManagementDialog({
setError(null)
setNotice(null)
const operationId = crypto.randomUUID()
setActiveOperationId(operationId)
installProgress.begin(operationId)
try {
const operation = await window.api.skills.installPackageVersion({
packageId: selected.packageId,
@@ -166,23 +167,25 @@ export function SkillInstallManagementDialog({
setResult(operation.value)
if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) {
notifyInstalledAgentSkillsChanged()
await load()
if (operation.value.status !== 'partial') {
await load()
}
}
} catch (cause) {
console.warn('[skills] version installation failed:', cause)
setError('Orca could not verify the requested version.')
} finally {
setActiveOperationId(null)
installProgress.finish()
setBusy(false)
}
}
const cancelInstall = async (): Promise<void> => {
if (!activeOperationId) {
if (!installProgress.activeOperationId) {
return
}
const cancelled = await window.api.skills.cancelInstall({
operationId: activeOperationId,
operationId: installProgress.activeOperationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId })
})
if (!cancelled.cancelled) {
@@ -340,7 +343,9 @@ export function SkillInstallManagementDialog({
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={busy || versionId === selected.versionId}
disabled={
busy || (versionId === selected.versionId && result?.status !== 'partial')
}
onClick={() => void installVersion()}
>
{busy ? (
@@ -348,9 +353,11 @@ export function SkillInstallManagementDialog({
) : (
<RotateCcw className="size-4" />
)}
Install selected version
{result?.status === 'partial'
? 'Retry incomplete coverage'
: 'Install selected version'}
</Button>
{activeOperationId ? (
{installProgress.activeOperationId ? (
<Button variant="secondary" size="sm" onClick={() => void cancelInstall()}>
Cancel installation
</Button>
@@ -377,6 +384,11 @@ export function SkillInstallManagementDialog({
{skillInstallResultLabel(result)}
</p>
) : null}
{installProgress.phaseLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{installProgress.phaseLabel}
</p>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
@@ -37,6 +37,10 @@ function discoveryResult(names: string[]): SkillDiscoveryResult {
return { skills: names.map(skill), sources: [], scannedAt: 1 }
}
function skillsApi(discover: ReturnType<typeof vi.fn>) {
return { discover, onInstallProgress: () => () => undefined }
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
@@ -115,7 +119,7 @@ describe('SkillsPage', () => {
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills: { discover }, runtimeEnvironments: { call } }
value: { skills: skillsApi(discover), runtimeEnvironments: { call } }
})
setRuntimeOwner('env-1')
@@ -142,7 +146,7 @@ describe('SkillsPage', () => {
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills: { discover }, runtimeEnvironments: { call } }
value: { skills: skillsApi(discover), runtimeEnvironments: { call } }
})
await renderPage()
@@ -164,7 +168,7 @@ describe('SkillsPage', () => {
const call = vi.fn()
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills: { discover }, runtimeEnvironments: { call } }
value: { skills: skillsApi(discover), runtimeEnvironments: { call } }
})
useAppStore.setState({ runtimeEnvironmentCatalogSettled: false })
@@ -0,0 +1,42 @@
import { useEffect, useRef, useState } from 'react'
const INSTALL_PHASE_LABELS = {
authorizing: 'Authorizing package access…',
installing: 'Downloading, verifying, and installing…'
} as const
export function useSkillInstallProgress(): {
activeOperationId: string | null
phaseLabel: string | null
begin: (operationId: string) => void
finish: () => void
} {
const [activeOperationId, setActiveOperationId] = useState<string | null>(null)
const activeOperationIdRef = useRef<string | null>(null)
const [phase, setPhase] = useState<keyof typeof INSTALL_PHASE_LABELS | null>(null)
useEffect(
() =>
window.api.skills.onInstallProgress((progress) => {
if (progress.operationId === activeOperationIdRef.current) {
setPhase(progress.phase)
}
}),
[]
)
return {
activeOperationId,
phaseLabel: phase ? INSTALL_PHASE_LABELS[phase] : null,
begin: (operationId) => {
activeOperationIdRef.current = operationId
setActiveOperationId(operationId)
setPhase('authorizing')
},
finish: () => {
activeOperationIdRef.current = null
setActiveOperationId(null)
setPhase(null)
}
}
}
+1
View File
@@ -3100,6 +3100,7 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
deletePackage: () =>
Promise.reject(new Error('Skill package management requires the desktop app.')),
listWslDistros: () => Promise.resolve([]),
onInstallProgress: () => () => {},
onShareProgress: () => () => {},
onUpdateRun: () => () => {}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { SkillInstallRequestSchema, SkillInstallResultSchema } from './skill-install-contract'
const packageIdentity = {
packageId: 'package_1',
versionId: 'version_1',
packageDigest: 'a'.repeat(64),
archiveSha256: 'b'.repeat(64),
compressedBytes: 128
}
describe('skill install wire compatibility', () => {
it('accepts an older client request that omits every additive field', () => {
expect(
SkillInstallRequestSchema.parse({
operationId: 'operation_1',
package: packageIdentity,
ingress: {
kind: 'download-grant',
url: 'https://storage.googleapis.com/package',
expiresAt: '2026-08-11T12:00:00.000Z'
},
destination: { scope: 'global' }
})
).toMatchObject({ destination: { scope: 'global' } })
})
it('accepts an older host result that omits additive failure and placement fields', () => {
expect(
SkillInstallResultSchema.parse({
operationId: 'operation_1',
status: 'failed',
name: 'private-skill',
packageDigest: packageIdentity.packageDigest,
placements: [
{
provider: 'claude',
path: '/host-owned/path',
topology: 'provider-alias',
status: 'failed'
}
]
})
).toMatchObject({ status: 'failed', placements: [{ status: 'failed' }] })
})
})
+5
View File
@@ -48,6 +48,11 @@ export type SkillShareProgress = {
totalBytes: number
}
export type SkillInstallProgress = {
operationId: string
phase: 'authorizing' | 'installing'
}
export type SkillShareInstallInput = {
shareId: string
operationId?: string