mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
This reverts commit 757fae28d7.
This commit is contained in:
@@ -17,7 +17,6 @@
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
@@ -29,19 +28,13 @@ Fixes #
|
||||
- [ ] I manually tested these changes locally
|
||||
- [ ] Automated tests added/updated, or explained why not below
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
## AI Disclosure
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
## Review
|
||||
|
||||
## 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
|
||||
|
||||
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -923,87 +923,6 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
skill-sharing-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-${{ matrix.platform }}
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
skill-sharing-linux-floor-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate linux-glibc-2.31
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
container: ubuntu:20.04
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-linux-glibc-2.31
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
# Why: these broader terminal rendering repros are useful release evidence,
|
||||
# but they include heavier app-like flows and must not block publishing.
|
||||
terminal-rendering-release-evidence:
|
||||
@@ -1107,14 +1026,12 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
|
||||
unpacked_dir: dist/linux-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
|
||||
unpacked_dir: dist/linux-arm64-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
@@ -1292,17 +1209,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load packaged node-pty on the Linux floor
|
||||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: >-
|
||||
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
|
||||
--app-dir ${{ matrix.unpacked_dir }}
|
||||
|
||||
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
|
||||
# upload only after the production-signed installer has been returned.
|
||||
- name: Build Windows release artifacts
|
||||
@@ -1990,8 +1896,6 @@ jobs:
|
||||
- cut
|
||||
- build
|
||||
- build-mac
|
||||
- skill-sharing-linux-floor-release-gate
|
||||
- skill-sharing-release-gate
|
||||
- terminal-rendering-golden
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -94,8 +94,6 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/agent-skill-sharing-installation-plan.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
|
||||
@@ -92,7 +92,6 @@ const winSpeechNativeResource = {
|
||||
module.exports = {
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
protocols: [{ name: 'Orca', schemes: ['orca'] }],
|
||||
...(devChannelBuildVersion
|
||||
? { extraMetadata: { version: devChannelBuildVersion } }
|
||||
: localBuildVersion
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
const { join } = require('node:path')
|
||||
|
||||
const resourcesDirectory = process.argv[2]
|
||||
if (!resourcesDirectory) {
|
||||
throw new Error('linux-packaged-node-pty-floor-resources-required')
|
||||
}
|
||||
|
||||
const pty = require(join(resourcesDirectory, 'node_modules', 'node-pty'))
|
||||
const expected = 'orca-node-pty-floor-ok'
|
||||
let output = ''
|
||||
let settled = false
|
||||
const child = pty.spawn('/bin/sh', ['-c', `printf ${expected}; sleep 0.1`], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin' }
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
child.kill()
|
||||
process.stderr.write('packaged node-pty floor smoke timed out\n')
|
||||
process.exitCode = 1
|
||||
}, 5_000)
|
||||
|
||||
child.onData((data) => {
|
||||
output = `${output}${data}`.slice(-256)
|
||||
})
|
||||
child.onExit(({ exitCode }) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (exitCode !== 0 || output !== expected) {
|
||||
process.stderr.write(
|
||||
`packaged node-pty floor smoke failed: exit=${exitCode} bytes=${Buffer.byteLength(output)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
process.stdout.write(`${expected}\n`)
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const FLOOR_IMAGE = 'ubuntu:20.04'
|
||||
const LINUX_EXECUTABLE = 'orca-ide'
|
||||
const FLOOR_PACKAGES = [
|
||||
'ca-certificates',
|
||||
'libasound2',
|
||||
'libatspi2.0-0',
|
||||
'libdrm2',
|
||||
'libgbm1',
|
||||
'libgtk-3-0',
|
||||
'libnss3',
|
||||
'libx11-xcb1',
|
||||
'libxkbcommon0',
|
||||
'libxss1'
|
||||
]
|
||||
|
||||
function workspaceRelativePath(workspaceDirectory, inputPath) {
|
||||
const absolutePath = resolve(workspaceDirectory, inputPath)
|
||||
const relativePath = relative(workspaceDirectory, absolutePath)
|
||||
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
|
||||
throw new Error('linux-packaged-node-pty-floor-app-directory-invalid')
|
||||
}
|
||||
return relativePath.split(sep).join('/')
|
||||
}
|
||||
|
||||
export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }) {
|
||||
const relativeAppDirectory = workspaceRelativePath(workspaceDirectory, appDirectory)
|
||||
const containerAppDirectory = `/workspace/${relativeAppDirectory}`
|
||||
const command = [
|
||||
'export DEBIAN_FRONTEND=noninteractive',
|
||||
'apt-get update -qq',
|
||||
`apt-get install -y -qq ${FLOOR_PACKAGES.join(' ')} >/dev/null`,
|
||||
`ELECTRON_RUN_AS_NODE=1 ${containerAppDirectory}/${LINUX_EXECUTABLE} ` +
|
||||
'/workspace/config/scripts/linux-packaged-node-pty-floor-child.cjs ' +
|
||||
`${containerAppDirectory}/resources`
|
||||
].join(' && ')
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
'--mount',
|
||||
`type=bind,src=${workspaceDirectory},dst=/workspace,readonly`,
|
||||
'--workdir',
|
||||
'/workspace',
|
||||
FLOOR_IMAGE,
|
||||
'/bin/bash',
|
||||
'-lc',
|
||||
command
|
||||
]
|
||||
}
|
||||
|
||||
function parseAppDirectory(argv) {
|
||||
const index = argv.indexOf('--app-dir')
|
||||
const value = index !== -1 ? argv[index + 1] : undefined
|
||||
if (!value || isAbsolute(value) || value.startsWith('-')) {
|
||||
throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir <relative-path>')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function runPackagedNodePtyFloorSmoke({
|
||||
workspaceDirectory = process.cwd(),
|
||||
appDirectory,
|
||||
spawn = spawnSync
|
||||
}) {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('linux-packaged-node-pty-floor-smoke-requires-linux')
|
||||
}
|
||||
const absoluteAppDirectory = resolve(workspaceDirectory, appDirectory)
|
||||
if (!existsSync(resolve(absoluteAppDirectory, LINUX_EXECUTABLE))) {
|
||||
throw new Error(`linux-packaged-node-pty-floor-executable-missing: ${absoluteAppDirectory}`)
|
||||
}
|
||||
const result = spawn(
|
||||
'docker',
|
||||
packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }),
|
||||
{ encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }
|
||||
)
|
||||
if (result.error || result.signal || result.status !== 0) {
|
||||
const detail =
|
||||
result.error?.message ||
|
||||
result.stderr?.trim() ||
|
||||
`status-${result.status}-signal-${result.signal ?? 'none'}`
|
||||
throw new Error(`linux-packaged-node-pty-floor-smoke-failed: ${detail}`)
|
||||
}
|
||||
process.stdout.write(result.stdout)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url
|
||||
if (isMain) {
|
||||
runPackagedNodePtyFloorSmoke({ appDirectory: parseAppDirectory(process.argv.slice(2)) })
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { packagedNodePtyFloorDockerArgs } from './run-linux-packaged-node-pty-floor-smoke.mjs'
|
||||
|
||||
describe('packaged node-pty Linux floor smoke', () => {
|
||||
it('mounts the exact package read-only in an Ubuntu 20.04 container', () => {
|
||||
const args = packagedNodePtyFloorDockerArgs({
|
||||
workspaceDirectory: '/repo',
|
||||
appDirectory: 'dist/linux-arm64-unpacked'
|
||||
})
|
||||
|
||||
expect(args).toContain('type=bind,src=/repo,dst=/workspace,readonly')
|
||||
expect(args).toContain('ubuntu:20.04')
|
||||
expect(args.at(-1)).toContain('/workspace/dist/linux-arm64-unpacked/orca-ide')
|
||||
expect(args.at(-1)).toContain('ELECTRON_RUN_AS_NODE=1')
|
||||
})
|
||||
|
||||
it('rejects an app directory outside the workspace', () => {
|
||||
expect(() =>
|
||||
packagedNodePtyFloorDockerArgs({
|
||||
workspaceDirectory: '/repo',
|
||||
appDirectory: '../outside'
|
||||
})
|
||||
).toThrow('linux-packaged-node-pty-floor-app-directory-invalid')
|
||||
})
|
||||
|
||||
it('loads node-pty from packaged runtime resources', () => {
|
||||
const childSource = readFileSync(
|
||||
new URL('./linux-packaged-node-pty-floor-child.cjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(childSource).toContain("join(resourcesDirectory, 'node_modules', 'node-pty')")
|
||||
expect(childSource).not.toContain("join(resourcesDirectory, 'app.asar'")
|
||||
})
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
|
||||
function stepNamed(job, name) {
|
||||
return job.steps.find((step) => step.name === name)
|
||||
}
|
||||
|
||||
describe('skill-sharing release workflow', () => {
|
||||
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
|
||||
const platform = workflow.jobs['skill-sharing-release-gate']
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const publishNeeds = workflow.jobs['publish-release'].needs
|
||||
|
||||
expect(platform.strategy.matrix.include).toEqual([
|
||||
{ os: 'macos-15', platform: 'mac' },
|
||||
{ os: 'windows-2022', platform: 'windows' }
|
||||
])
|
||||
expect(linux.container).toBe('ubuntu:20.04')
|
||||
expect(publishNeeds).toContain('skill-sharing-release-gate')
|
||||
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
|
||||
})
|
||||
|
||||
it('runs the focused contract and transaction suite with real Windows coverage', () => {
|
||||
const platform = workflow.jobs['skill-sharing-release-gate']
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const platformTest = stepNamed(
|
||||
platform,
|
||||
'Run skill package, transaction, and compatibility suites'
|
||||
)
|
||||
const linuxTest = stepNamed(linux, 'Run skill package, transaction, and compatibility suites')
|
||||
const command = packageJson.scripts['test:skill-sharing:release']
|
||||
|
||||
expect(platformTest.env.ORCA_REAL_WINDOWS_SKILL_TEST).toContain("runner.os == 'Windows'")
|
||||
expect(platformTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
|
||||
expect(linuxTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
|
||||
expect(platformTest.run).toContain('pnpm test:skill-sharing:release')
|
||||
expect(linuxTest.run).toContain('pnpm test:skill-sharing:release')
|
||||
expect(command).toContain('src/main/skills')
|
||||
expect(command).toContain('src/relay/skill-install-handler.test.ts')
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const job = workflow.jobs[jobName]
|
||||
const test = stepNamed(job, 'Run skill package, transaction, and compatibility suites')
|
||||
const archive = stepNamed(job, 'Archive bounded skill-sharing results')
|
||||
|
||||
expect(test.run).toContain('--reporter=json')
|
||||
expect(test.run).toContain('--outputFile=skill-sharing-release-results.json')
|
||||
expect(archive.if).toBe('always()')
|
||||
expect(archive.with['retention-days']).toBe(14)
|
||||
expect(archive.with['if-no-files-found']).toBe('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('loads each exact Linux package on the glibc 2.31 floor', () => {
|
||||
const build = workflow.jobs.build
|
||||
const smoke = stepNamed(build, 'Load packaged node-pty on the Linux floor')
|
||||
const linuxEntries = build.strategy.matrix.include.filter(({ platform }) =>
|
||||
platform.startsWith('linux-')
|
||||
)
|
||||
|
||||
expect(linuxEntries).toEqual([
|
||||
expect.objectContaining({ platform: 'linux-x64', unpacked_dir: 'dist/linux-unpacked' }),
|
||||
expect.objectContaining({
|
||||
platform: 'linux-arm64',
|
||||
unpacked_dir: 'dist/linux-arm64-unpacked'
|
||||
})
|
||||
])
|
||||
expect(smoke.if).toContain("matrix.platform == 'linux-x64'")
|
||||
expect(smoke.with.command).toContain('run-linux-packaged-node-pty-floor-smoke.mjs')
|
||||
expect(smoke.with.command).toContain('${{ matrix.unpacked_dir }}')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
# Administer agent skill sharing
|
||||
|
||||
This guide describes the first-release access, lifecycle, retention, and recovery contract for
|
||||
Orca skill sharing. The operator runbook remains the source of truth for incident commands and
|
||||
environment-specific procedures.
|
||||
|
||||
## Access model
|
||||
|
||||
- Shared bundles are unlisted bearer resources. Orca provides no public browse, search, recipient
|
||||
inventory, or package index.
|
||||
- Anyone with an active, unexpired link can inspect the package and request a short-lived download
|
||||
grant without signing in.
|
||||
- Publishing, package/version management, owned-link inventory, revocation, and deletion require
|
||||
an authenticated package owner with current organization access.
|
||||
- Missing, expired, revoked, deleted, and unauthorized resources return the same non-disclosing
|
||||
response.
|
||||
- Desktop and remote runtimes receive no GCP identity or long-lived storage credential.
|
||||
|
||||
The durable share ID is a credential. Do not put it in tickets, logs, analytics, or support
|
||||
bundles. Use revocation if a link may have reached an unintended recipient.
|
||||
|
||||
## Revocation and deletion
|
||||
|
||||
Revoking a share immediately blocks new resolution and download grants. A generation-bound grant
|
||||
issued before revocation can work until its five-minute expiry. Already installed skills remain on
|
||||
recipient machines.
|
||||
|
||||
Package deletion follows this order:
|
||||
|
||||
1. Mark the package deleted and revoke its active shares.
|
||||
2. Dereference retained versions transactionally.
|
||||
3. Delete only an object generation that no retained version references.
|
||||
4. Reconcile bounded pending deletions after partial database or GCS failures.
|
||||
|
||||
A version cannot be deleted while an active pinned share references it. Deletion uses the exact
|
||||
recorded GCS generation and never overwrites an immutable published key.
|
||||
|
||||
## User and organization departure
|
||||
|
||||
Packages belong to an owner tenant and record the publishing user. In an organization tenant,
|
||||
another current member can manage the package after its publisher leaves; Orca does not rewrite
|
||||
the recorded creator. Removing a user does not automatically revoke the organization's links,
|
||||
delete packages, or remove installed copies.
|
||||
|
||||
Before deleting an organization tenant:
|
||||
|
||||
1. Disable new grants for the tenant.
|
||||
2. Have an authorized operator inventory and revoke active shares.
|
||||
3. Decide whether packages transfer to another authorized owner, remain retained, or are deleted.
|
||||
4. Resolve legal hold, erasure, and audit-retention requirements.
|
||||
5. Apply the coordinated metadata and object lifecycle; do not bypass reference checks.
|
||||
|
||||
The product does not yet encode a universal ownership-transfer or legal-retention policy. Privacy,
|
||||
security, and the organization owner must approve the applicable policy before external rollout.
|
||||
Until that decision is recorded, preserve metadata and soft-deleted generations rather than
|
||||
guessing.
|
||||
|
||||
## Retention contract
|
||||
|
||||
| Data | Default behavior |
|
||||
| -------------------------------------- | --------------------------------------------------------- |
|
||||
| Upload policy and pending upload row | Expires after 15 minutes |
|
||||
| Abandoned `uploads/` quarantine object | Deleted by GCS after one day |
|
||||
| Published immutable package object | No age-based deletion; retained while referenced |
|
||||
| Issued download grant | Expires after five minutes |
|
||||
| Deleted package object | Recoverable through GCS soft delete for seven days |
|
||||
| PostgreSQL metadata | Covered by backups and seven-day point-in-time recovery |
|
||||
| Installed recipient copy | Independent local data; Cloud deletion does not remove it |
|
||||
| Audit event | Follows the approved audit-retention policy |
|
||||
|
||||
Organization retention, legal hold, and erasure requirements take precedence over product rollback
|
||||
retention. Product deletion is not a legal-hold mechanism.
|
||||
|
||||
## Audit and privacy
|
||||
|
||||
Audit records may include package/version IDs, actor IDs, event category, outcome, and timestamp.
|
||||
They must not include skill contents, filenames, manifests, organization membership lists, local
|
||||
paths, durable share URLs, upload policies, download grants, or credentials. Anonymous abuse
|
||||
controls must not persist raw requester IP addresses.
|
||||
|
||||
Normal Cloud logs are limited to route, method, status, duration, and bounded failure categories.
|
||||
Use seeded privacy canaries when validating staging logs and diagnostic exports.
|
||||
|
||||
## Recovery and incident controls
|
||||
|
||||
Upload, download, and remote-install operations have independent kill switches. Disable the
|
||||
narrowest affected operation; existing local discovery and installs continue to work.
|
||||
|
||||
Coordinate PostgreSQL point-in-time recovery with GCS generation recovery. Restore metadata into
|
||||
an isolated database, identify exact referenced generations, restore only matching soft-deleted
|
||||
objects, verify archive and package identities, then transactionally repoint metadata. Keep grants
|
||||
disabled until bearer preview and a generation-bound download pass.
|
||||
|
||||
See the Orca Cloud `docs/skill-sharing-runbook.md` for deployment controls, reconciliation,
|
||||
saturation, signing failures, database outages, and the guarded restore workflow. Security
|
||||
invariants and unresolved release gates are recorded in
|
||||
[Agent skill sharing threat model](./agent-skill-sharing-threat-model.md).
|
||||
@@ -1,38 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Agent skill sharing threat model
|
||||
|
||||
Status: implementation baseline for security and privacy review. This document does not constitute
|
||||
security approval.
|
||||
|
||||
## Scope and trust model
|
||||
|
||||
This model covers private skill packaging, Orca Cloud publication and authorization, durable share
|
||||
resolution, local and remote installation, provider placement, update, rollback, removal, and
|
||||
operator recovery. It applies to macOS, Linux, native Windows, WSL, paired Orca runtimes, and SSH
|
||||
targets.
|
||||
|
||||
Private means unlisted: an unpredictable active share ID is a bearer credential, while publication
|
||||
and management remain access-controlled to authenticated Orca users and organizations. V1 is not
|
||||
end-to-end encrypted from Orca Cloud operators. A skill is code from its author: `SKILL.md` can
|
||||
change agent behavior and packaged scripts may be executed later by a user or agent, although the
|
||||
installer itself never executes package content.
|
||||
|
||||
## Protected assets
|
||||
|
||||
- Skill contents, filenames, manifests, package metadata, release notes, and author identity.
|
||||
- Organization membership, selected-user ACLs, durable share identifiers, and package existence.
|
||||
- Signed upload policies, download grants, authentication tokens, database credentials, and GCP
|
||||
service identities.
|
||||
- Existing local skills, provider configuration, user modifications, provenance, and transaction
|
||||
recovery state.
|
||||
- Cloud package metadata, immutable object generations, audit records, and deletion state.
|
||||
- Availability and cost of Cloud Run, Cloud SQL, GCS, connected runtimes, and local filesystems.
|
||||
|
||||
## Actors and boundaries
|
||||
|
||||
Actors include an authorized publisher, an authorized recipient, an authenticated but unauthorized
|
||||
Orca user, a malicious skill author, a compromised renderer, an untrusted remote RPC caller, an
|
||||
attacker controlling a network endpoint, and an operator with GCP or database access.
|
||||
|
||||
Trust boundaries are:
|
||||
|
||||
1. Source skill directory to the owner-private package staging directory.
|
||||
2. Desktop renderer to the main process and its authenticated Cloud client.
|
||||
3. Orca client to a paired runtime or SSH host across independently versioned protocols.
|
||||
4. Orca Cloud API authorization to short-lived GCS access.
|
||||
5. GCS quarantine to validated immutable publication and PostgreSQL metadata.
|
||||
6. Extracted staging to canonical destination and provider placements.
|
||||
7. Local diagnostic records to a user-reviewed support-bundle upload.
|
||||
8. Terraform and deployment identities to staging and production resources.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- Package identity is deterministic and binds normalized paths, exact bytes, executable state, and
|
||||
immutable package/version IDs.
|
||||
- Publication and installation accept only the `manifest.json` plus `skill/` envelope and never
|
||||
execute archive content.
|
||||
- Archive validation completes before destination mutation.
|
||||
- Destination paths are resolved by the runtime that owns the host or workspace.
|
||||
- Unowned or modified local content is never silently replaced or deleted.
|
||||
- Final GCS objects are immutable, generation-fenced, and reachable only through fresh ACL checks
|
||||
followed by short-lived grants.
|
||||
- No remote caller can turn a desktop-local path into remote filesystem authority.
|
||||
- Interrupted transactions converge to a verified old or requested version.
|
||||
- Logs and support bundles exclude package contents and private authorization or filesystem data.
|
||||
- Cloud sharing, downloading, and remote installation have independent kill switches.
|
||||
|
||||
## Threat register
|
||||
|
||||
| ID | Threat | Required controls and current evidence | Residual release gate |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| TM-01 | Source changes after preview or a link swaps bytes during packaging. | Observe the specific source directory, copy without following links, re-observe staged bytes, compare every identity, and bind preview to the final digest. Source-drift and link/special-file tests cover rejection and cleanup. | Repeat race and permission tests on every supported filesystem. |
|
||||
| TM-02 | Archive traversal, drive paths, links, devices, duplicate paths, Unicode/case collisions, or decompression/resource exhaustion escape staging. | Streaming parser rejects unsafe entry classes and normalized collisions; compressed, extracted, entry, file, depth, and per-file limits are enforced during parsing and extraction. Boundary, malformed, checksum, and fuzz tests run before destination mutation. | Keep package safety suites required in release CI. |
|
||||
| TM-03 | A forged manifest lies about names, bytes, executable state, or package identity. | Parse the manifest before trusting entries, require `skill/SKILL.md`, hash extracted bytes independently, recompute package identity, and compare package/version IDs and digest. | Cross-platform identical-byte digest evidence remains required. |
|
||||
| TM-04 | A client chooses another home, workspace, WSL distro, SSH path, or escapes a destination root. | The executing runtime resolves home and workspace identities, realpath-checks directories, uses platform-native joins, and rejects client-supplied remote paths. `local-file` ingress is trusted in-process only. | Real SSH and mixed-version topology tests remain required. |
|
||||
| TM-05 | Installation overwrites or deletion removes unowned or locally modified content. | Planner distinguishes missing, unchanged, clean, modified, unowned, external-link, broken-link, and collision states. Provenance lives outside installed content. Replacement requires an explicit conflict decision; removal revalidates ownership and digest. | Complete the remaining real junction, external-link, copy-drift, and permission matrix. |
|
||||
| TM-06 | A crash between renames or receipt publication loses both old and new versions. | Same-filesystem staging, durable journals, backups, flushed receipt replacement, bounded recovery, and ownership tokens protect every commit boundary. Failure injection covers every journal transition. | Real process termination and disk/antivirus contention tests remain required. |
|
||||
| TM-07 | A grant leaks through redirects, userinfo, an insecure scheme, DNS/host confusion, or an oversized stream. | Cloud requests reject redirects. Package downloads require configured origins and HTTPS, reject URL credentials and cross-origin redirects, cap redirect count, recheck expiry, stream exact expected bytes, and verify archive/package digests. | Validate approved production origins and exercise malicious network cases in staging. |
|
||||
| TM-08 | Reuse of an upload ID, wrong tenant, wrong key, stale generation, or quarantine object publishes attacker-selected bytes. | Random tenant-bound upload rows, signed POST conditions, expiry, exact metadata/key/type/size validation, generation-fenced reads, streamed validation, and idempotent finalization fail closed. | Staging GCS integration must cover stale generations and lifecycle deletion. |
|
||||
| TM-09 | IDOR, stale organization membership, or a guessed identifier exposes package management data or bearer-protected content. | Management requests authenticate and re-evaluate current ownership. Recipient preview and grants require the exact unpredictable, active bearer share ID and ignore legacy ACL rows. Not-found responses hide unauthorized existence. | Human authorization review and organization-departure policy approval remain required. |
|
||||
| TM-10 | Content-addressed deduplication discloses another tenant's package through response shape or timing. | Tenant-scoped APIs never expose GCS keys, generations, or whether an object already existed; existing-object reuse verifies both archive and logical package identity. Cross-tenant tests prove identical archives use separate tenant-hashed objects and response shapes disclose no reuse. | Keep tenant-isolation and response-disclosure coverage required in release CI. |
|
||||
| TM-11 | A compromised renderer, old client, or arbitrary RPC caller sends credentials, local paths, unknown opcodes, or unsupported operations to a host. | Main owns auth tokens and grants; remote requests use strict schemas and capabilities; package transfer is separate from install; no stream opcode was added; mixed versions fail with update-required results. | Real client-newer/server-newer and SSH parity gates remain required. |
|
||||
| TM-12 | Transfer replay, overlap, disconnect, or abandoned staging consumes disk or commits different bytes. | Session count, idle time, total bytes, and chunk size are bounded. Offsets are monotonic, identical retry is idempotent, changed replay fails, commit hashes the exact staged file, and cancellation/disconnect cleanup is bounded. | Exercise offline-GCS and real disconnects at every transfer boundary. |
|
||||
| TM-13 | Provider aliases or junctions escape canonical storage or point at external content. | POSIX aliases are relative from real parents, Windows junctions use absolute canonical targets, targets are revalidated, and unowned/broken/external links are conflicts. Copy fallback is independently hashed and receipt-owned. Real Windows and WSL coverage includes existing, broken, external, denied, and drifted placement behavior. | Preserve the physical placement matrix in release coverage. |
|
||||
| TM-14 | Skill instructions or scripts are mistaken for trusted Orca code or executed during install. | Share/install previews identify author, organization, scripts, executable files, digest, and version. Installation never runs scripts. Trust copy says the package is code from its author. | Security and design must approve the final trust wording and accessibility behavior. |
|
||||
| TM-15 | Telemetry, logs, or support bundles leak instructions, filenames, paths, ACLs, grants, policies, or credentials. | Desktop install diagnostics map values to bounded categories before tracing. Deployed staging logs contain route templates and bounded request metadata only, and the logging exclusion removes bearer URLs. Support-bundle tests inject private canaries and prove they are absent from collected output. | Preserve deployed-log and support-bundle privacy checks for future changes. |
|
||||
| TM-16 | Permissive staging permissions expose package bytes to another local user. | Package archives, downloads, extraction, relayed uploads, locks, journals, and receipts use owner-private modes on POSIX; Windows uses owner-profile paths and inherited ACLs. Existing POSIX download roots are tightened before use. Real Windows, WSL, Ubuntu-floor, and SSH validation passed. | Preserve owner-private staging checks across supported hosts. |
|
||||
| TM-17 | Deletion, revocation, retention, or user departure leaves unauthorized grants or unrecoverable metadata/blob divergence. | Revocation blocks new grants immediately; existing grants expire within five minutes. Database references govern final deletion, quarantine lifecycle cleans abandonment, and GCS soft delete supplies recovery. Local installs remain independent. | Approve departure/legal-retention policy and exercise coordinated database/GCS recovery. |
|
||||
| TM-18 | Broad IAM, public bucket access, service-account keys, or a compromised deployment identity bypasses application authorization. | Uniform bucket access, public-access prevention, bucket-scoped object access, service-account-scoped signing, skill-secret-only access, Cloud SQL client role, no desktop IAM, and no long-lived keys are Terraform-defined. | Review the staging and production plans and verify live IAM before rollout. |
|
||||
| TM-19 | Unbounded validation or request concurrency causes memory, CPU, database, storage, or egress denial of service. | Fixed streaming buffers, package limits, per-instance finalization semaphore, rate/quota limits, bounded transfer sessions, Cloud Run instance limits, lifecycle cleanup, and independent kill switches constrain work. | Complete load testing, dashboards, alerts, and budget thresholds. |
|
||||
| TM-20 | Operator recovery, diagnostics, or legal workflows bypass tenant isolation or leak content. | Runbooks require generation-specific recovery, coordinated PostgreSQL/GCS restoration, audited lifecycle actions, and no package contents in normal logs. | Security/privacy approval and restricted break-glass procedure remain required. |
|
||||
|
||||
## Required review evidence
|
||||
|
||||
Security and privacy approval must not rely on this document alone. Reviewers need:
|
||||
|
||||
- Package/admission schemas and stable failure categories.
|
||||
- Archive parser, extraction containment, transaction, provenance, and removal tests.
|
||||
- Cloud authorization, object-generation, tenant-isolation, deletion, and recovery tests.
|
||||
- Terraform plans plus live staging IAM, bucket, Cloud Run, Secret Manager, and Cloud SQL evidence.
|
||||
- Real macOS, Linux-floor, Windows, WSL, paired-runtime, mixed-version, and SSH results.
|
||||
- Captured staging logs, metrics, traces, and support bundles with seeded private canaries absent.
|
||||
- Load-test results and independently tested upload, download, and remote-install kill switches.
|
||||
|
||||
Approval owners record findings and accepted residual risks outside this implementation document.
|
||||
The external rollout gate remains closed until those findings are resolved or explicitly accepted.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
# Share and install agent skills
|
||||
|
||||
Orca can put one skill or a bundle of skills behind one unlisted, revocable link. Shared bundles
|
||||
do not appear in search, a catalog, or a public index. Anyone who has an active link can inspect
|
||||
and install its contents without signing in, so treat the link like a credential.
|
||||
|
||||
## Share skills
|
||||
|
||||
Publishing and link management require an Orca account in the desktop app.
|
||||
|
||||
1. Open **Skills** and choose **Share skills**.
|
||||
2. Select one or more skills. One link can contain a large collection, such as 30 skills.
|
||||
3. Review the bundle name, included skills and files, scripts, executable files, digest, account,
|
||||
and optional release notes.
|
||||
4. Choose **Publish skill**, **Publish bundle**, or **Publish new version**, then copy the link.
|
||||
|
||||
Orca publishes an immutable version. Later changes do not silently alter a link's current bytes;
|
||||
publish a new version to update the Cloud package.
|
||||
|
||||
Use **Settings → Share Skills** to copy or revoke active links. Revocation blocks new previews and
|
||||
download grants. A grant issued immediately before revocation can remain usable for up to five
|
||||
minutes, and revocation does not remove copies that recipients already installed.
|
||||
|
||||
## Install from a link
|
||||
|
||||
Opening an Orca skill link shows a preview before changing any files. You can also open **Skills**,
|
||||
choose **Install from link**, and paste the URL.
|
||||
|
||||
1. Verify the author and organization.
|
||||
2. Review the version, release notes, included skills, scripts, executable files, and digest.
|
||||
3. Select all skills or only the ones you want.
|
||||
4. Choose the destination machine and either global or workspace scope.
|
||||
5. Review new, unchanged, updated, and conflicting skills, then choose **Install N skills**.
|
||||
|
||||
Supported destinations include the local machine, paired Orca runtimes, WSL, and SSH hosts. The
|
||||
destination runtime resolves its own home and workspace paths, so folder workspaces and remote
|
||||
filesystems do not borrow paths from the client machine.
|
||||
|
||||
Orca keeps one canonical installed copy and places it where supported agents can discover it.
|
||||
Current provider coverage is documented in
|
||||
[Agent skill provider paths](./agent-skill-provider-paths.md).
|
||||
|
||||
## Conflicts, updates, and rollback
|
||||
|
||||
**Keep local** is the default when an existing skill differs. Orca replaces modified content only
|
||||
after you explicitly choose to discard it.
|
||||
|
||||
Open **Skills → Manage installs** to inspect managed skills and their immutable version history.
|
||||
Installing the latest version performs an update; selecting an older retained version performs a
|
||||
rollback. Both use the same protected install transaction. If a bundle changes between versions,
|
||||
Orca updates only the selected skills that still exist in that version.
|
||||
|
||||
An interrupted install is recovered on restart. If Orca reports a conflict or partial result,
|
||||
review the named skill and retry; completed skills do not need to be installed again.
|
||||
|
||||
## Remove an installed skill
|
||||
|
||||
Use **Skills → Manage installs → Remove**. Orca removes only copies and provider placements that it
|
||||
owns and can verify. Modified or unowned files are preserved and reported. Discarding modified
|
||||
content requires a separate explicit confirmation.
|
||||
|
||||
Removing a local install does not revoke its share or delete its Cloud package. Likewise,
|
||||
revoking or deleting Cloud data does not reach into recipients' machines.
|
||||
|
||||
## Retention and deletion
|
||||
|
||||
- Upload grants expire after 15 minutes.
|
||||
- Abandoned upload bytes are removed from quarantine after one day.
|
||||
- Published versions have no automatic age-based deletion.
|
||||
- Deleting a package revokes its links before unreferenced objects are deleted.
|
||||
- Deleted GCS objects remain operator-recoverable through a seven-day soft-delete window.
|
||||
- Installed copies remain until someone removes them on each destination machine.
|
||||
|
||||
Organization legal or retention requirements can override normal rollback and deletion timing.
|
||||
|
||||
## Trust and privacy
|
||||
|
||||
A skill is code from its author. `SKILL.md` can change agent behavior, and included scripts or
|
||||
executables may run later when a person or agent uses the skill. Orca validates the package and
|
||||
never executes its contents during installation, but you should install only from people you trust
|
||||
and review unexpected scripts or executable files.
|
||||
|
||||
Orca records bounded operational identifiers and outcomes. Normal logs, telemetry, and support
|
||||
bundles exclude skill contents, filenames, manifests, local paths, share URLs, upload policies,
|
||||
download grants, credentials, and access lists.
|
||||
|
||||
If a link no longer works, ask its owner for an active link. Missing, expired, revoked, and deleted
|
||||
links intentionally show the same response so Orca does not disclose private package existence.
|
||||
@@ -25,7 +25,6 @@
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"prepare": "husky",
|
||||
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
|
||||
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
|
||||
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
|
||||
@@ -40,18 +40,18 @@
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
|
||||
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
|
||||
"releaseRevision": 36,
|
||||
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
|
||||
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3944,
|
||||
"size": 3913,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
|
||||
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
|
||||
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
|
||||
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -577,22 +577,6 @@
|
||||
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
|
||||
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3944,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orchestration": [
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
|
||||
terminals, repos, automations, artifacts, worktree comments, and the browser
|
||||
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
|
||||
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
|
||||
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
|
||||
"share HTML/Markdown", "public artifact link", or "control the browser inside
|
||||
Orca". Prefer this over raw `git worktree`, ad hoc
|
||||
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
|
||||
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
|
||||
@@ -272,39 +272,6 @@ ORCA artifacts delete <id> --json
|
||||
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
|
||||
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
|
||||
|
||||
## Skill Sharing
|
||||
|
||||
Agents can publish one or more installed skills behind one unlisted link through the
|
||||
signed-in Orca account. The user must first grant the separate, default-off permission in
|
||||
Settings → Share Skills ("Allow agents and the Orca CLI to publish skill links"). There is
|
||||
no CLI or RPC way to grant it. Manual publishing from the reviewed desktop flow remains
|
||||
available without this agent permission.
|
||||
|
||||
```text
|
||||
ORCA skills installed --json
|
||||
ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> --json
|
||||
```
|
||||
|
||||
- `skills installed` returns safe discovery IDs and names. It does not expose local skill
|
||||
paths in CLI output. Sharing then verifies that each `SKILL.md` declares a portable
|
||||
lowercase name containing only letters, numbers, and hyphens.
|
||||
- Each `--skill` must be an exact discovery ID or an unambiguous installed-skill name.
|
||||
Use IDs when names collide.
|
||||
- Multiple `--skill` flags create one bundle and one link. `--all` and arbitrary paths are
|
||||
intentionally unsupported; name every skill the user asked to publish.
|
||||
- Skill folders can contain scripts, configuration, credentials, or other private files.
|
||||
Treat the permission as authority, not blanket intent: publish only the explicitly
|
||||
requested skills and never widen the selection.
|
||||
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
|
||||
enable the switch in the desktop app if they want this action.
|
||||
- Orca stages one agent-published bundle at a time per host. If another publish is active,
|
||||
wait for it to finish before retrying `agent_skill_sharing_busy`.
|
||||
- Run the command in an Orca terminal on the machine that stores the skills. Forwarded WSL,
|
||||
SSH, and paired-runtime invocations fail before discovery so Orca cannot read from the
|
||||
wrong filesystem.
|
||||
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
|
||||
includes cloud authentication tokens.
|
||||
|
||||
## Built-In Browser
|
||||
|
||||
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
|
||||
@@ -376,7 +343,7 @@ Common recoveries:
|
||||
|
||||
## Next Action
|
||||
|
||||
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, `skills installed/share`, or built-in browser `snapshot`.
|
||||
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
|
||||
|
||||
## Mobile Emulator (iOS Simulator via serve-sim)
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
|
||||
terminals, repos, automations, artifacts, worktree comments, and the browser
|
||||
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
|
||||
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
|
||||
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
|
||||
"share HTML/Markdown", "public artifact link", or "control the browser inside
|
||||
Orca". Prefer this over raw `git worktree`, ad hoc
|
||||
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
|
||||
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -235,11 +235,6 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
keys: ['vm recipe doctor'],
|
||||
load: async () => (await import('./handlers/vm.js')).VM_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'skill-sharing',
|
||||
keys: ['skills installed', 'skills share'],
|
||||
load: async () => (await import('./handlers/skill-sharing.js')).SKILL_SHARING_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { REPEATED_FLAG_SEPARATOR } from '../args'
|
||||
import { RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { SKILL_SHARING_HANDLERS } from './skill-sharing'
|
||||
|
||||
const successMeta = { runtimeId: 'runtime-1' }
|
||||
|
||||
function context(
|
||||
call: ReturnType<typeof vi.fn>,
|
||||
flags = new Map<string, string | boolean>(),
|
||||
options: { isRemote?: boolean; json?: boolean } = {}
|
||||
) {
|
||||
return {
|
||||
client: { call, isRemote: options.isRemote ?? false },
|
||||
cwd: '/repo',
|
||||
flags,
|
||||
json: options.json ?? false
|
||||
} as never
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('skill sharing CLI handlers', () => {
|
||||
it('lists safe installed-skill selectors without local paths', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: {
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-id',
|
||||
name: 'alpha',
|
||||
description: 'Alpha skill',
|
||||
providers: ['codex'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Codex',
|
||||
rootPath: '/secret/root',
|
||||
directoryPath: '/secret/root/alpha',
|
||||
skillFilePath: '/secret/root/alpha/SKILL.md',
|
||||
installed: true,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [],
|
||||
scannedAt: 1
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills installed']!(context(call, new Map(), { json: true }))
|
||||
|
||||
expect(call).toHaveBeenCalledWith('skills.discover', { cwd: '/repo' })
|
||||
const output = String(log.mock.calls[0][0])
|
||||
expect(output).toContain('skill-id')
|
||||
expect(output).not.toContain('/secret/root')
|
||||
})
|
||||
|
||||
it('denies publishing at preflight before invoking skills.share', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: false } },
|
||||
_meta: successMeta
|
||||
})
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'team-skills']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
|
||||
})
|
||||
|
||||
it('publishes multiple explicit skills and prints only public output', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: true } },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'share',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'ok',
|
||||
value: {
|
||||
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
|
||||
version: {
|
||||
packageId: 'pkg_public',
|
||||
versionId: 'ver_public',
|
||||
name: 'team-skills'
|
||||
},
|
||||
selectedSkills: [
|
||||
{ id: 'alpha-id', name: 'alpha', description: null },
|
||||
{ id: 'beta-id', name: 'beta', description: null }
|
||||
]
|
||||
}
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', `alpha${REPEATED_FLAG_SEPARATOR}beta`],
|
||||
['bundle-name', 'team-skills'],
|
||||
['release-notes', 'Initial bundle']
|
||||
]),
|
||||
{ json: true }
|
||||
)
|
||||
)
|
||||
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'skills.share',
|
||||
{
|
||||
skillSelectors: ['alpha', 'beta'],
|
||||
bundleName: 'team-skills',
|
||||
releaseNotes: 'Initial bundle',
|
||||
target: { cwd: '/repo' }
|
||||
},
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
const output = String(log.mock.calls[0][0])
|
||||
expect(output).toContain('shr_public')
|
||||
expect(output).toContain('pkg_public')
|
||||
expect(output).not.toMatch(/token|secret/i)
|
||||
})
|
||||
|
||||
it('normalizes a human-readable bundle name before publishing', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: true } },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'share',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'ok',
|
||||
value: {
|
||||
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
|
||||
version: {
|
||||
packageId: 'pkg_public',
|
||||
versionId: 'ver_public',
|
||||
name: 'team-skills-v2.0'
|
||||
},
|
||||
selectedSkills: [{ id: 'alpha-id', name: 'alpha', description: null }]
|
||||
}
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'Téam Skills -- v2.0']
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'skills.share',
|
||||
{
|
||||
skillSelectors: ['alpha'],
|
||||
bundleName: 'team-skills-v2.0',
|
||||
releaseNotes: '',
|
||||
target: { cwd: '/repo' }
|
||||
},
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a bundle name that cannot produce a valid package name', async () => {
|
||||
const call = vi.fn()
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', '🔥']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: '--bundle-name must contain at least one English letter or number.'
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a forwarded shell', false, '/remote/repo'],
|
||||
['a paired runtime', true, undefined]
|
||||
])('rejects %s before any discovery or publish call', async (_label, isRemote, forwardedCwd) => {
|
||||
if (forwardedCwd) {
|
||||
vi.stubEnv('ORCA_CLI_CWD', forwardedCwd)
|
||||
}
|
||||
const call = vi.fn()
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'alpha']
|
||||
]),
|
||||
{ isRemote }
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_environment' })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives an actionable upgrade error for an older runtime', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'share',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: successMeta
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'alpha']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'update_required' })
|
||||
})
|
||||
})
|
||||
@@ -1,185 +0,0 @@
|
||||
import type {
|
||||
AgentSkillShareOperation,
|
||||
AgentSkillShareResult
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import {
|
||||
AGENT_SKILL_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
|
||||
AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/agent-skill-sharing-gate'
|
||||
import { normalizeSkillBundleName } from '../../shared/skill-bundle-name'
|
||||
import type { SkillCloudOperation } from '../../shared/skill-cloud-contract'
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import {
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
type RuntimeRpcSuccess
|
||||
} from '../runtime-client'
|
||||
|
||||
const SHARE_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
type InstalledSkillSummary = Pick<
|
||||
SkillDiscoveryResult['skills'][number],
|
||||
'id' | 'name' | 'description' | 'providers' | 'sourceKind' | 'sourceLabel'
|
||||
>
|
||||
|
||||
type SharedSkillSummary = {
|
||||
url: string
|
||||
shareId: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleName: string
|
||||
skills: AgentSkillShareResult['selectedSkills']
|
||||
}
|
||||
|
||||
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
|
||||
const value = ctx.flags.get(name)
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function rejectForwardedSkillFilesystem(ctx: HandlerContext, command: string): void {
|
||||
if (!process.env.ORCA_CLI_CWD && !ctx.client.isRemote) {
|
||||
return
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`orca skills ${command} must run on the machine whose installed skills you want to use. Run the command from an Orca terminal on that machine.`
|
||||
)
|
||||
}
|
||||
|
||||
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
|
||||
let enabled: unknown
|
||||
try {
|
||||
const response = await ctx.client.call<{
|
||||
settings?: { agentSkillSharingEnabled?: boolean }
|
||||
}>('settings.get')
|
||||
enabled = response.result?.settings?.agentSkillSharingEnabled
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (enabled === false) {
|
||||
throw new RuntimeClientError(
|
||||
AGENT_SKILL_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
|
||||
{ nextSteps: [...AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function requireCloudOperation<T>(operation: SkillCloudOperation<T>): T {
|
||||
if (operation.status === 'ok') {
|
||||
return operation.value
|
||||
}
|
||||
if (operation.status === 'reconnect-required') {
|
||||
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
|
||||
}
|
||||
throw new RuntimeClientError('authentication_unconfigured', operation.message)
|
||||
}
|
||||
|
||||
function installedSummary(result: SkillDiscoveryResult): InstalledSkillSummary[] {
|
||||
return result.skills.map(({ id, name, description, providers, sourceKind, sourceLabel }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
providers,
|
||||
sourceKind,
|
||||
sourceLabel
|
||||
}))
|
||||
}
|
||||
|
||||
function formatInstalledSkills(skills: InstalledSkillSummary[]): string {
|
||||
if (skills.length === 0) {
|
||||
return 'No installed skills found.'
|
||||
}
|
||||
return skills
|
||||
.map(
|
||||
(skill) =>
|
||||
`${skill.name} (${skill.id})\n ${skill.description ?? 'No description'}\n ${skill.sourceLabel}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function sharedSummary(result: AgentSkillShareResult): SharedSkillSummary {
|
||||
return {
|
||||
url: result.share.url,
|
||||
shareId: result.share.id,
|
||||
packageId: result.version.packageId,
|
||||
versionId: result.version.versionId,
|
||||
bundleName: result.version.name,
|
||||
skills: result.selectedSkills
|
||||
}
|
||||
}
|
||||
|
||||
function formatSharedSkill(result: SharedSkillSummary): string {
|
||||
return `Shared ${result.skills.length} skill${result.skills.length === 1 ? '' : 's'}: ${result.url}`
|
||||
}
|
||||
|
||||
async function callShare(
|
||||
ctx: HandlerContext,
|
||||
params: {
|
||||
skillSelectors: string[]
|
||||
bundleName: string
|
||||
releaseNotes: string
|
||||
target: { cwd: string }
|
||||
}
|
||||
): Promise<RuntimeRpcSuccess<AgentSkillShareOperation>> {
|
||||
try {
|
||||
return await ctx.client.call<AgentSkillShareOperation>('skills.share', params, {
|
||||
timeoutMs: SHARE_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'update_required',
|
||||
'The connected Orca runtime does not support agent skill sharing yet. Update Orca on that machine and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_SHARING_HANDLERS: Record<string, CommandHandler> = {
|
||||
'skills installed': async (ctx) => {
|
||||
rejectForwardedSkillFilesystem(ctx, 'installed')
|
||||
const response = await ctx.client.call<SkillDiscoveryResult>('skills.discover', {
|
||||
cwd: ctx.cwd
|
||||
})
|
||||
const skills = installedSummary(response.result)
|
||||
printResult({ ...response, result: { skills } }, ctx.json, (value) =>
|
||||
formatInstalledSkills(value.skills)
|
||||
)
|
||||
},
|
||||
'skills share': async (ctx) => {
|
||||
rejectForwardedSkillFilesystem(ctx, 'share')
|
||||
const skillSelectors = getRepeatedStringFlag(ctx.flags, 'skill')
|
||||
if (skillSelectors.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Select at least one installed skill with --skill. Run `orca skills installed` to list them.'
|
||||
)
|
||||
}
|
||||
const bundleLabel = stringFlag(ctx, 'bundle-name')
|
||||
if (!bundleLabel) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --bundle-name.')
|
||||
}
|
||||
const bundleName = normalizeSkillBundleName(bundleLabel)
|
||||
if (!bundleName) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--bundle-name must contain at least one English letter or number.'
|
||||
)
|
||||
}
|
||||
await preflightPublishCapability(ctx)
|
||||
const response = await callShare(ctx, {
|
||||
skillSelectors,
|
||||
bundleName,
|
||||
releaseNotes: stringFlag(ctx, 'release-notes') ?? '',
|
||||
target: { cwd: ctx.cwd }
|
||||
})
|
||||
const value = sharedSummary(requireCloudOperation(response.result))
|
||||
printResult({ ...response, result: value }, ctx.json, formatSharedSkill)
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ Accounts:
|
||||
account list List managed Claude and Codex accounts on this Orca host
|
||||
|
||||
Skills:
|
||||
skills installed List installed skill selectors
|
||||
skills share Publish selected skills behind one unlisted link
|
||||
skills list List version-matched skill guides bundled with this Orca CLI
|
||||
skills get Print a version-matched skill guide as Markdown
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
@@ -68,7 +67,6 @@ describe('CLI remote WebSocket transport', () => {
|
||||
clientCapabilities: [
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY
|
||||
]
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('orca skills CLI', () => {
|
||||
'Usage: orca skills get <topic> [--full] [--json]'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'Commands:\n installed List installed skill selectors'
|
||||
'Commands:\n list List version-matched skill guides'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'get Print a version-matched skill guide'
|
||||
@@ -225,7 +225,7 @@ describe('orca skills CLI', () => {
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'update Update already-installed Orca skills'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills installed')
|
||||
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list')
|
||||
expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update')
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectiveAllowedFlags } from '../args'
|
||||
import { formatCommandHelp } from '../help'
|
||||
import { SKILL_COMMAND_SPECS } from './skills'
|
||||
|
||||
function spec(path: string): (typeof SKILL_COMMAND_SPECS)[number] {
|
||||
const found = SKILL_COMMAND_SPECS.find((entry) => entry.path.join(' ') === path)
|
||||
if (!found) {
|
||||
throw new Error(`Missing skill spec: ${path}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
describe('skill command specs', () => {
|
||||
it('requires explicit selectors for sharing and exposes no bulk or path flag', () => {
|
||||
const flags = effectiveAllowedFlags(spec('skills share'))
|
||||
|
||||
expect(flags).toContain('skill')
|
||||
expect(flags).toContain('bundle-name')
|
||||
expect(flags).not.toContain('all')
|
||||
expect(flags).not.toContain('path')
|
||||
expect(formatCommandHelp(spec('skills share'))).toContain(
|
||||
'Only discovered skill directories can be selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,36 +2,6 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const SKILL_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['skills', 'installed'],
|
||||
summary: 'List installed skill selectors',
|
||||
usage: 'orca skills installed [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
'Lists discovery IDs and names without reading skill contents into the CLI.',
|
||||
'Package metadata is validated when the selected skills are shared.',
|
||||
'Use an exact ID or an unambiguous name with `orca skills share --skill <selector>`.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'share'],
|
||||
summary: 'Publish explicitly selected installed skills behind one unlisted link',
|
||||
usage:
|
||||
'orca skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> ' +
|
||||
'[--release-notes <text>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'bundle-name', 'release-notes'],
|
||||
notes: [
|
||||
'Requires the default-off permission in Settings → Share Skills.',
|
||||
'The bundle name may be human-readable; Orca converts it to a portable lowercase package name.',
|
||||
'Selectors are exact discovery IDs or unambiguous names from `orca skills installed`.',
|
||||
'Only discovered skill directories can be selected; arbitrary paths and --all are not supported.',
|
||||
'The resulting link is unlisted. Anyone with it can inspect and install the bundle.'
|
||||
],
|
||||
examples: [
|
||||
'orca skills share --skill frontend --bundle-name "Frontend Skills"',
|
||||
'orca skills share --skill frontend --skill testing --bundle-name "Team Toolkit" --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'list'],
|
||||
summary: 'List version-matched skill guides bundled with this Orca CLI',
|
||||
|
||||
@@ -73,9 +73,6 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
expect(service.getRuntimeConfigDir({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
ubuntuAuthPath
|
||||
)
|
||||
const preparation = await service.prepareForClaudeLaunch()
|
||||
|
||||
expect(preparation).toMatchObject({
|
||||
|
||||
@@ -151,8 +151,8 @@ export class ClaudeRuntimeAuthService {
|
||||
})
|
||||
}
|
||||
|
||||
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
|
||||
return this.getPreparation(target).configDir
|
||||
getRuntimeConfigDir(): string {
|
||||
return this.pathResolver.getRuntimePaths().configDir
|
||||
}
|
||||
|
||||
private initializeLastSyncedState(): void {
|
||||
|
||||
@@ -148,10 +148,6 @@ export class ClaudeAccountService {
|
||||
return this.cancelPendingClaudeLogin?.() ?? false
|
||||
}
|
||||
|
||||
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
|
||||
return this.runtimeAuth.getRuntimeConfigDir(target)
|
||||
}
|
||||
|
||||
private serializeMutation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.mutationQueue.then(fn, fn)
|
||||
this.mutationQueue = next.catch(() => {})
|
||||
|
||||
@@ -125,11 +125,7 @@ function readAnnotationObjects(
|
||||
const raw = view.byteArray(valueRva)
|
||||
if (raw) {
|
||||
// Annotation strings are not NUL-terminated; trim a trailing one anyway.
|
||||
let value = raw.toString('utf8')
|
||||
while (value.endsWith('\0')) {
|
||||
value = value.slice(0, -1)
|
||||
}
|
||||
into[name] = value
|
||||
into[name] = raw.toString('utf8').replace(/\0+$/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-40
@@ -71,8 +71,6 @@ import { resolveConsent } from './telemetry/consent'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/startup-notification-registration'
|
||||
import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime'
|
||||
import { ArtifactCloudService } from './artifacts/artifact-cloud-service'
|
||||
import { SkillCloudService } from './skills/skill-cloud-service'
|
||||
import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery'
|
||||
import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate'
|
||||
import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity'
|
||||
import {
|
||||
@@ -182,8 +180,6 @@ import {
|
||||
} from './startup/single-instance-lock'
|
||||
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
|
||||
import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe'
|
||||
import { parseSkillShareId } from '../shared/skill-share-link'
|
||||
import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state'
|
||||
import {
|
||||
isStartupDiagnosticsEnabled,
|
||||
logStartupDiagnostic,
|
||||
@@ -400,7 +396,6 @@ const expectedRendererReload = createWebContentsTimedFlag()
|
||||
const recoveryReloadInFlight = createWebContentsTimedFlag()
|
||||
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
|
||||
const pendingOpenSettings = createWebContentsTimedFlag()
|
||||
const skillShareDeepLinks = new SkillShareDeepLinkState()
|
||||
let firstWindowStartupServicesReady: Promise<void> = Promise.resolve()
|
||||
let managedWslCliReconciliationReady: Promise<void> = Promise.resolve()
|
||||
let managedWslCliStartupBarrierReady: Promise<void> = Promise.resolve()
|
||||
@@ -701,9 +696,6 @@ function focusExistingWindow(): void {
|
||||
}
|
||||
|
||||
function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
skillShareDeepLinks.capture(argv, (shareId) => {
|
||||
mainWindow?.webContents.send('ui:openSkillShare', shareId)
|
||||
})
|
||||
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
|
||||
if (!shouldActivateDesktopForSecondInstance(argv)) {
|
||||
return
|
||||
@@ -711,16 +703,6 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
|
||||
desktopActivationGate.requestActivation()
|
||||
}
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
if (!parseSkillShareId(url)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
requestDesktopActivation([url])
|
||||
})
|
||||
|
||||
skillShareDeepLinks.capture(process.argv)
|
||||
|
||||
const handleMacAppActivation = createMacAppActivationHandler({
|
||||
getWindow: () => mainWindow,
|
||||
requestActivation: requestDesktopActivation
|
||||
@@ -906,10 +888,6 @@ ipcMain.handle('ui:consumePendingOpenSettings', (event) =>
|
||||
pendingOpenSettings.matches(event.sender.id, { consume: true })
|
||||
)
|
||||
|
||||
ipcMain.handle('ui:consumePendingSkillShare', () => {
|
||||
return skillShareDeepLinks.consume()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'app:startupDiagnostic',
|
||||
(_event, event: string, details?: Record<string, unknown>) => {
|
||||
@@ -2390,21 +2368,6 @@ void app.whenReady().then(async () => {
|
||||
packaged: app.isPackaged,
|
||||
platform: process.platform
|
||||
})
|
||||
const skillTransactionRecovery = recoverPendingSkillTransactions(
|
||||
join(app.getPath('userData'), 'skill-installs')
|
||||
)
|
||||
void skillTransactionRecovery
|
||||
.then((report) => {
|
||||
if (report.scanned || report.failures.length || report.truncated) {
|
||||
console.info('[skills] startup transaction recovery:', {
|
||||
scanned: report.scanned,
|
||||
recovered: report.recovered,
|
||||
failures: report.failures.map((failure) => failure.code),
|
||||
truncated: report.truncated
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => console.warn('[skills] startup transaction recovery failed:', error))
|
||||
// Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track().
|
||||
initCohortClassifier(store)
|
||||
initOnboardingCohortClassifier(store)
|
||||
@@ -2596,8 +2559,7 @@ void app.whenReady().then(async () => {
|
||||
}),
|
||||
buildAgentHookPtyEnv: () =>
|
||||
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
|
||||
orchestrationEnvironmentTransport,
|
||||
skillTransactionRecovery
|
||||
orchestrationEnvironmentTransport
|
||||
})
|
||||
runtime = runtimeService
|
||||
runtimeService.prepareLegacyWorkerTerminalRecovery()
|
||||
@@ -2703,7 +2665,6 @@ void app.whenReady().then(async () => {
|
||||
isArtifactSharingEnabled(store?.getSettings())
|
||||
)
|
||||
)
|
||||
runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData')))
|
||||
runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
|
||||
runtimeService.setCommitMessageAgentEnvironmentResolvers({
|
||||
// Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home.
|
||||
|
||||
@@ -527,7 +527,7 @@ describe('registerCoreHandlers', () => {
|
||||
expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store, undefined)
|
||||
expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime)
|
||||
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
|
||||
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store, runtime)
|
||||
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
|
||||
expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store)
|
||||
|
||||
@@ -175,7 +175,7 @@ export function registerCoreHandlers(
|
||||
registerTerminalRenderDesyncEvidenceHandler()
|
||||
registerComputerUsePermissionHandlers()
|
||||
registerSettingsHandlers(store, agentAwakeService)
|
||||
registerSkillsHandlers(store, runtime)
|
||||
registerSkillsHandlers(store)
|
||||
if (automations) {
|
||||
registerAutomationHandlers(store, automations)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
|
||||
import type {
|
||||
RuntimeOrchestrationEnvelope,
|
||||
RuntimeRpcResponse
|
||||
} from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
sendRemoteRuntimeConnectionRequest,
|
||||
sendRemoteRuntimeSharedControlRequest
|
||||
} from './runtime-environment-request-connections'
|
||||
|
||||
export function sendRemoteRuntimeRequestAbortable(
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
if (signal) {
|
||||
return sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope, signal)
|
||||
}
|
||||
return envelope
|
||||
? sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope)
|
||||
: sendRemoteRuntimeRequest(pairing, method, params, timeoutMs)
|
||||
}
|
||||
|
||||
export function sendRemoteRuntimeConnectionRequestAbortable(
|
||||
environmentId: string,
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
return signal
|
||||
? sendRemoteRuntimeConnectionRequest(environmentId, pairing, method, params, timeoutMs, signal)
|
||||
: sendRemoteRuntimeConnectionRequest(environmentId, pairing, method, params, timeoutMs)
|
||||
}
|
||||
|
||||
export function sendRemoteRuntimeSharedControlRequestAbortable(
|
||||
environmentId: string,
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
if (signal) {
|
||||
return sendRemoteRuntimeSharedControlRequest(
|
||||
environmentId,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
envelope,
|
||||
signal
|
||||
)
|
||||
}
|
||||
return envelope
|
||||
? sendRemoteRuntimeSharedControlRequest(
|
||||
environmentId,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
envelope
|
||||
)
|
||||
: sendRemoteRuntimeSharedControlRequest(environmentId, pairing, method, params, timeoutMs)
|
||||
}
|
||||
@@ -5,8 +5,7 @@ const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
|
||||
export function enqueueRuntimeCall<T>(
|
||||
selector: string,
|
||||
method: string,
|
||||
run: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal)
|
||||
return runtimeCallQueuePool.enqueue(selector, method, run)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ export function sendRemoteRuntimeConnectionRequest<TResult>(
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
const pairingKey = getPairingKey(pairing)
|
||||
let cached = requestConnections.get(environmentId)
|
||||
@@ -41,7 +40,7 @@ export function sendRemoteRuntimeConnectionRequest<TResult>(
|
||||
}
|
||||
requestConnections.set(environmentId, cached)
|
||||
}
|
||||
return cached.connection.request(method, params, timeoutMs, signal)
|
||||
return cached.connection.request(method, params, timeoutMs)
|
||||
}
|
||||
|
||||
export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
|
||||
@@ -57,15 +56,13 @@ export function sendRemoteRuntimeSharedControlRequest<TResult>(
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
signal?: AbortSignal
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
return getSharedControlConnection(environmentId, pairing).request(
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
envelope,
|
||||
signal
|
||||
envelope
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
|
||||
import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store'
|
||||
import { isOrchestrationMutation } from '../../shared/orchestration-rpc-contract'
|
||||
@@ -16,13 +15,10 @@ import { withRemoteRuntimeTailscaleHint } from '../../shared/remote-runtime-tail
|
||||
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
|
||||
import {
|
||||
reconnectRemoteRuntimeSharedControlConnection,
|
||||
sendRemoteRuntimeConnectionRequest,
|
||||
sendRemoteRuntimeSharedControlRequest,
|
||||
subscribeRemoteRuntimeSharedControlRequest
|
||||
} from './runtime-environment-request-connections'
|
||||
import {
|
||||
sendRemoteRuntimeConnectionRequestAbortable,
|
||||
sendRemoteRuntimeRequestAbortable,
|
||||
sendRemoteRuntimeSharedControlRequestAbortable
|
||||
} from './runtime-environment-abortable-requests'
|
||||
import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics'
|
||||
import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard'
|
||||
import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response'
|
||||
@@ -90,8 +86,7 @@ export async function callRuntimeEnvironment(
|
||||
params: unknown,
|
||||
timeoutMs?: number,
|
||||
expectedEnvironmentPairingRevision?: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
options?: { signal?: AbortSignal }
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcResponse<unknown>> {
|
||||
const environment = resolveEnvironment(userDataPath, selector)
|
||||
// Why: connection failures reject (they don't resolve as ok:false), so the
|
||||
@@ -101,84 +96,82 @@ export async function callRuntimeEnvironment(
|
||||
// environment, so a re-pair between enqueue and dispatch can change it.
|
||||
let endpoint = getPreferredPairingOffer(environment).endpoint
|
||||
try {
|
||||
return await enqueueRuntimeCall(
|
||||
environment.id,
|
||||
method,
|
||||
async () => {
|
||||
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
|
||||
const revisionFailure = runtimeEnvironmentRevisionFailure(
|
||||
currentEnvironment,
|
||||
expectedEnvironmentPairingRevision,
|
||||
method
|
||||
)
|
||||
if (revisionFailure) {
|
||||
return revisionFailure
|
||||
}
|
||||
const pairing = getPreferredPairingOffer(currentEnvironment)
|
||||
endpoint = pairing.endpoint
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
const sharedControlEnvelope = shouldUseSharedControlEnvelope(method, params, envelope)
|
||||
if (envelope && !sharedControlEnvelope) {
|
||||
const response = await sendRemoteRuntimeRequestAbortable(
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
envelope,
|
||||
options?.signal
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
}
|
||||
if (shouldUseCachedRequestConnection(method)) {
|
||||
const response = await sendRemoteRuntimeConnectionRequestAbortable(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
options?.signal
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
}
|
||||
if (
|
||||
method !== 'status.get' &&
|
||||
!shouldUseOneShotRequest(method) &&
|
||||
(await waitForPromiseWithSignal(
|
||||
supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs),
|
||||
options?.signal
|
||||
))
|
||||
) {
|
||||
const response = await sendRemoteRuntimeSharedControlRequestAbortable(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
sharedControlEnvelope,
|
||||
options?.signal
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
}
|
||||
// Why: startup/control-plane RPCs use the proven one-shot path so repo
|
||||
// hydration cannot be coupled to a stale terminal-control connection.
|
||||
const response = await sendRemoteRuntimeRequestAbortable(
|
||||
return await enqueueRuntimeCall(environment.id, method, async () => {
|
||||
const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
|
||||
const revisionFailure = runtimeEnvironmentRevisionFailure(
|
||||
currentEnvironment,
|
||||
expectedEnvironmentPairingRevision,
|
||||
method
|
||||
)
|
||||
if (revisionFailure) {
|
||||
return revisionFailure
|
||||
}
|
||||
const pairing = getPreferredPairingOffer(currentEnvironment)
|
||||
endpoint = pairing.endpoint
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
|
||||
const sharedControlEnvelope = shouldUseSharedControlEnvelope(method, params, envelope)
|
||||
if (envelope && !sharedControlEnvelope) {
|
||||
const response = await sendRemoteRuntimeRequest(
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
sharedControlEnvelope,
|
||||
options?.signal
|
||||
envelope
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
},
|
||||
options?.signal
|
||||
)
|
||||
}
|
||||
if (shouldUseCachedRequestConnection(method)) {
|
||||
const response = await sendRemoteRuntimeConnectionRequest(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
}
|
||||
if (
|
||||
method !== 'status.get' &&
|
||||
!shouldUseOneShotRequest(method) &&
|
||||
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
|
||||
) {
|
||||
const response = sharedControlEnvelope
|
||||
? await sendRemoteRuntimeSharedControlRequest(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
sharedControlEnvelope
|
||||
)
|
||||
: await sendRemoteRuntimeSharedControlRequest(
|
||||
currentEnvironment.id,
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs
|
||||
)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
}
|
||||
// Why: startup/control-plane RPCs use the proven one-shot path so repo
|
||||
// hydration cannot be coupled to a stale terminal-control connection.
|
||||
const response = sharedControlEnvelope
|
||||
? await sendRemoteRuntimeRequest(
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
sharedControlEnvelope
|
||||
)
|
||||
: await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
|
||||
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
|
||||
return response
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
if (error instanceof Error) {
|
||||
error.message = withRemoteRuntimeTailscaleHint(error.message, endpoint)
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
skillCloudBundlePackageVersionInstallSchema,
|
||||
skillCloudBundleShareInstallSchema,
|
||||
skillCloudPackageVersionInstallSchema,
|
||||
skillCloudShareInstallSchema
|
||||
} from './skill-cloud-install-ipc-schemas'
|
||||
|
||||
const destination = { scope: 'global' as const }
|
||||
const providers = ['codex', 'claude']
|
||||
|
||||
describe('skill cloud install IPC schemas', () => {
|
||||
it('accepts provider selection for every install entry point', () => {
|
||||
expect(
|
||||
skillCloudShareInstallSchema.parse({ shareId: 'share-1', destination, providers })
|
||||
).toMatchObject({ providers })
|
||||
expect(
|
||||
skillCloudBundleShareInstallSchema.parse({
|
||||
shareId: 'share-1',
|
||||
selectedSkillIds: ['skill-1'],
|
||||
destination,
|
||||
providers
|
||||
})
|
||||
).toMatchObject({ providers })
|
||||
expect(
|
||||
skillCloudPackageVersionInstallSchema.parse({
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
destination,
|
||||
providers
|
||||
})
|
||||
).toMatchObject({ providers })
|
||||
expect(
|
||||
skillCloudBundlePackageVersionInstallSchema.parse({
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
selectedSkillIds: ['skill-1'],
|
||||
destination,
|
||||
providers
|
||||
})
|
||||
).toMatchObject({ providers })
|
||||
})
|
||||
|
||||
it('bounds provider selection', () => {
|
||||
expect(() =>
|
||||
skillCloudBundleShareInstallSchema.parse({
|
||||
shareId: 'share-1',
|
||||
selectedSkillIds: ['skill-1'],
|
||||
destination,
|
||||
providers: Array.from({ length: 65 }, (_, index) => `provider-${index}`)
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { SkillBundleInstallRequestSchema } from '../../shared/skill-bundle-install-contract'
|
||||
import { SkillInstallDestinationSchema } from '../../shared/skill-install-contract'
|
||||
|
||||
export const skillCloudInstallEnvironmentIdSchema = z.string().min(1).max(128)
|
||||
|
||||
const skillCloudInstallProvidersSchema = z.array(z.string().min(1).max(64)).max(64)
|
||||
|
||||
const installDestinationFields = {
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
destination: SkillInstallDestinationSchema,
|
||||
providers: skillCloudInstallProvidersSchema.optional(),
|
||||
conflictResolution: z
|
||||
.enum(['replace-unmodified', 'replace-and-discard-local', 'cancel'])
|
||||
.optional()
|
||||
} as const
|
||||
|
||||
export const skillCloudShareInstallSchema = z
|
||||
.object({
|
||||
shareId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128).optional(),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudBundleShareInstallSchema = z
|
||||
.object({
|
||||
shareId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128).optional(),
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
providers: skillCloudInstallProvidersSchema.optional(),
|
||||
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudPackageVersionInstallSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128),
|
||||
...installDestinationFields
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const skillCloudBundlePackageVersionInstallSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128),
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
|
||||
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
providers: skillCloudInstallProvidersSchema.optional(),
|
||||
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
|
||||
})
|
||||
.strict()
|
||||
@@ -1,305 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability'
|
||||
import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
|
||||
import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
installSkillBundleCloudGrant,
|
||||
installSkillCloudGrant
|
||||
} from '../skills/skill-cloud-grant-installation'
|
||||
import { SkillRemoteInstallCancellation } from '../skills/skill-remote-install-cancellation'
|
||||
import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target'
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import {
|
||||
supportsSkillRuntimeBundleInstall,
|
||||
supportsSkillRuntimeCancellation,
|
||||
supportsSkillRuntimeInstall
|
||||
} from '../skills/skill-runtime-capability'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers'
|
||||
import { sendBundleInstallProgress, sendSkillInstallProgress } from './skill-install-progress-ipc'
|
||||
import {
|
||||
skillCloudBundlePackageVersionInstallSchema,
|
||||
skillCloudBundleShareInstallSchema,
|
||||
skillCloudInstallEnvironmentIdSchema,
|
||||
skillCloudPackageVersionInstallSchema,
|
||||
skillCloudShareInstallSchema
|
||||
} from './skill-cloud-install-ipc-schemas'
|
||||
|
||||
const sharePrepareSchema = z
|
||||
.object({
|
||||
skillIds: z.array(z.string().min(1).max(4096)).min(1).max(512),
|
||||
bundleName: z.string().regex(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/),
|
||||
target: SkillDiscoveryTargetSchema.optional(),
|
||||
packageId: z.string().min(1).max(128).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const sharePublishSchema = z
|
||||
.object({
|
||||
preparationId: z.string().uuid(),
|
||||
releaseNotes: z.string().max(10_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const packageVersionSchema = z
|
||||
.object({
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128)
|
||||
})
|
||||
.strict()
|
||||
|
||||
function registerSharingHandlers(
|
||||
runtime: OrcaRuntimeService,
|
||||
discover: (target?: z.infer<typeof SkillDiscoveryTargetSchema>) => Promise<SkillDiscoveryResult>
|
||||
): void {
|
||||
const preparations = new SkillSharePreparationService(
|
||||
join(app.getPath('userData'), 'skill-share-preparations'),
|
||||
{
|
||||
publishVersion: (request) => runtime.publishSkillPackageVersion(request),
|
||||
createShare: (packageId, request) => runtime.createSkillPackageShare(packageId, request)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('skills:prepareShare', async (_event, value: unknown) => {
|
||||
const input = sharePrepareSchema.parse(value)
|
||||
const result = await discover(input.target)
|
||||
const requested = new Set(input.skillIds)
|
||||
const skills = result.skills.filter((candidate) => requested.has(candidate.id))
|
||||
if (skills.length !== requested.size) {
|
||||
throw new Error('skill-share-source-not-found')
|
||||
}
|
||||
return preparations.prepare({
|
||||
sources: skills.map((skill) => ({ id: skill.name, sourceDirectory: skill.directoryPath })),
|
||||
bundleName: input.bundleName,
|
||||
description:
|
||||
skills.length === 1 ? (skills[0].description ?? '') : `${skills.length} shared skills`,
|
||||
packageId: input.packageId
|
||||
})
|
||||
})
|
||||
ipcMain.handle('skills:publishShare', async (_event, value: unknown) => {
|
||||
const input = sharePublishSchema.parse(value)
|
||||
return preparations.publish(input, (progress) => {
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) {
|
||||
window.webContents.send('skills:shareProgress', progress)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
ipcMain.handle('skills:cancelShare', (_event, id: unknown) => {
|
||||
preparations.cancel(z.string().uuid().parse(id))
|
||||
})
|
||||
ipcMain.handle('skills:releaseShare', async (_event, id: unknown) => {
|
||||
await preparations.release(z.string().uuid().parse(id))
|
||||
})
|
||||
}
|
||||
|
||||
function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
const remoteInstallCancellation = new SkillRemoteInstallCancellation()
|
||||
const installAuthorizedGrant = async (
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input:
|
||||
| z.infer<typeof skillCloudShareInstallSchema>
|
||||
| z.infer<typeof skillCloudPackageVersionInstallSchema>
|
||||
) => {
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
return installSkillCloudGrant(runtime, grant, {
|
||||
...input,
|
||||
operationId: input.operationId ?? randomUUID()
|
||||
})
|
||||
}
|
||||
const operationId = input.operationId ?? randomUUID()
|
||||
const signal = remoteInstallCancellation.begin(operationId)
|
||||
try {
|
||||
return await installSkillCloudGrant(runtime, grant, { ...input, operationId }, signal)
|
||||
} finally {
|
||||
remoteInstallCancellation.finish(operationId, signal)
|
||||
}
|
||||
}
|
||||
const installAuthorizedBundleGrant = async (
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input:
|
||||
| z.infer<typeof skillCloudBundleShareInstallSchema>
|
||||
| z.infer<typeof skillCloudBundlePackageVersionInstallSchema>,
|
||||
onProgress: (progress: SkillBundleInstallProgress) => void
|
||||
) => {
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
return installSkillBundleCloudGrant(
|
||||
runtime,
|
||||
grant,
|
||||
{
|
||||
...input,
|
||||
operationId: input.operationId ?? randomUUID()
|
||||
},
|
||||
undefined,
|
||||
onProgress
|
||||
)
|
||||
}
|
||||
const operationId = input.operationId ?? randomUUID()
|
||||
const signal = remoteInstallCancellation.begin(operationId)
|
||||
try {
|
||||
return await installSkillBundleCloudGrant(
|
||||
runtime,
|
||||
grant,
|
||||
{ ...input, operationId },
|
||||
signal,
|
||||
onProgress
|
||||
)
|
||||
} finally {
|
||||
remoteInstallCancellation.finish(operationId, signal)
|
||||
}
|
||||
}
|
||||
ipcMain.handle('skills:resolveShare', (_event, shareId: unknown) =>
|
||||
runtime.resolveSkillShare(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
ipcMain.handle('skills:createDownloadGrant', (_event, shareId: unknown) =>
|
||||
runtime.createSkillDownloadGrant(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
ipcMain.handle('skills:installShare', async (event, value: unknown) => {
|
||||
const parsed = skillCloudShareInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendSkillInstallProgress(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') {
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:installBundleShare', async (event, value: unknown) => {
|
||||
const parsed = skillCloudBundleShareInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
input.environmentId &&
|
||||
!input.environmentId.startsWith('ssh:') &&
|
||||
!(await supportsSkillRuntimeBundleInstall(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') {
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok'
|
||||
? installAuthorizedBundleGrant(grant.value, input, (progress) =>
|
||||
sendBundleInstallProgress(event, progress)
|
||||
)
|
||||
: grant
|
||||
})
|
||||
ipcMain.handle('skills:installPackageVersion', async (event, value: unknown) => {
|
||||
const parsed = skillCloudPackageVersionInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendSkillInstallProgress(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') {
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:installBundlePackageVersion', async (event, value: unknown) => {
|
||||
const parsed = skillCloudBundlePackageVersionInstallSchema.parse(value)
|
||||
const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() }
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' })
|
||||
if (
|
||||
input.environmentId &&
|
||||
!input.environmentId.startsWith('ssh:') &&
|
||||
!(await supportsSkillRuntimeBundleInstall(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') {
|
||||
sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' })
|
||||
}
|
||||
return grant.status === 'ok'
|
||||
? installAuthorizedBundleGrant(grant.value, input, (progress) =>
|
||||
sendBundleInstallProgress(event, progress)
|
||||
)
|
||||
: grant
|
||||
})
|
||||
ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => {
|
||||
const input = z
|
||||
.object({
|
||||
operationId: z.string().min(1).max(128),
|
||||
environmentId: skillCloudInstallEnvironmentIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.parse(value)
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
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,
|
||||
'skills.cancelInstall',
|
||||
{ operationId: input.operationId },
|
||||
15_000
|
||||
).catch(() => null)
|
||||
const installCancelled =
|
||||
response?.ok === true && response.result && typeof response.result === 'object'
|
||||
? (response.result as { cancelled?: unknown }).cancelled === true
|
||||
: false
|
||||
return { cancelled: transferCancelled || installCancelled }
|
||||
})
|
||||
ipcMain.handle('skills:getPackage', (_event, packageId: unknown) =>
|
||||
runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
ipcMain.handle('skills:listOwnedShares', () => runtime.listOwnedSkillShares({}))
|
||||
ipcMain.handle('skills:revokeShare', (_event, shareId: unknown) =>
|
||||
runtime.revokeSkillShare(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
ipcMain.handle('skills:deletePackageVersion', (_event, value: unknown) => {
|
||||
const input = packageVersionSchema.parse(value)
|
||||
return runtime.deleteSkillPackageVersion(input.packageId, input.versionId, {})
|
||||
})
|
||||
ipcMain.handle('skills:deletePackage', (_event, packageId: unknown) =>
|
||||
runtime.deleteSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
}
|
||||
|
||||
export function registerSkillCloudIpcHandlers(
|
||||
runtime: OrcaRuntimeService,
|
||||
discover: (target?: z.infer<typeof SkillDiscoveryTargetSchema>) => Promise<SkillDiscoveryResult>
|
||||
): void {
|
||||
registerSharingHandlers(runtime, discover)
|
||||
registerCloudInstallHandlers(runtime)
|
||||
registerSkillInstallManagementIpcHandlers(runtime)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callRuntimeEnvironmentMock,
|
||||
handleMock,
|
||||
supportsBundleInstallMock,
|
||||
supportsManagementMock
|
||||
} = vi.hoisted(() => ({
|
||||
callRuntimeEnvironmentMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
supportsBundleInstallMock: vi.fn(),
|
||||
supportsManagementMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/test/user-data' },
|
||||
ipcMain: { handle: handleMock }
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: callRuntimeEnvironmentMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/skill-runtime-capability', () => ({
|
||||
supportsSkillRuntimeBundleInstall: supportsBundleInstallMock,
|
||||
supportsSkillRuntimeManagement: supportsManagementMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', () => ({ listWslDistrosAsync: vi.fn(async () => []) }))
|
||||
|
||||
import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers'
|
||||
|
||||
type IpcHandler = (_event: unknown, value: unknown) => Promise<unknown>
|
||||
|
||||
async function waitForCalls(count: number): Promise<void> {
|
||||
for (
|
||||
let attempt = 0;
|
||||
attempt < 100 && callRuntimeEnvironmentMock.mock.calls.length < count;
|
||||
attempt += 1
|
||||
) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
expect(callRuntimeEnvironmentMock).toHaveBeenCalledTimes(count)
|
||||
}
|
||||
|
||||
describe('skill install management IPC', () => {
|
||||
const handlers = new Map<string, IpcHandler>()
|
||||
|
||||
beforeEach(() => {
|
||||
handlers.clear()
|
||||
callRuntimeEnvironmentMock.mockReset()
|
||||
handleMock.mockReset()
|
||||
handleMock.mockImplementation((channel: string, handler: IpcHandler) => {
|
||||
handlers.set(channel, handler)
|
||||
})
|
||||
supportsBundleInstallMock.mockReset().mockResolvedValue(true)
|
||||
supportsManagementMock.mockReset().mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('bounds remote bundle preview requests', async () => {
|
||||
const pending: (() => void)[] = []
|
||||
let active = 0
|
||||
let peakActive = 0
|
||||
callRuntimeEnvironmentMock.mockImplementation(
|
||||
async (
|
||||
_root,
|
||||
_environment,
|
||||
_method,
|
||||
request: { name: string; package: { packageDigest: string } }
|
||||
) => {
|
||||
active += 1
|
||||
peakActive = Math.max(peakActive, active)
|
||||
await new Promise<void>((resolve) => pending.push(resolve))
|
||||
active -= 1
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
name: request.name,
|
||||
packageDigest: request.package.packageDigest,
|
||||
destinationIdentity: 'global:remote-1',
|
||||
currentState: 'missing',
|
||||
providers: []
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
registerSkillInstallManagementIpcHandlers({} as never)
|
||||
const handler = handlers.get('skills:previewBundleInstall')
|
||||
expect(handler).toBeDefined()
|
||||
const selectedSkills = Array.from({ length: 17 }, (_, index) => ({
|
||||
id: `skill-${index}`,
|
||||
name: `skill-${index}`,
|
||||
digest: 'a'.repeat(64)
|
||||
}))
|
||||
|
||||
const preview = handler!(null, {
|
||||
environmentId: 'remote-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
selectedSkills,
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
|
||||
await waitForCalls(8)
|
||||
expect(peakActive).toBe(8)
|
||||
pending.splice(0).forEach((resolve) => resolve())
|
||||
await waitForCalls(16)
|
||||
expect(peakActive).toBe(8)
|
||||
pending.splice(0).forEach((resolve) => resolve())
|
||||
await waitForCalls(17)
|
||||
expect(peakActive).toBe(8)
|
||||
pending.splice(0).forEach((resolve) => resolve())
|
||||
|
||||
await expect(preview).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
value: { skills: expect.arrayContaining([expect.objectContaining({ name: 'skill-16' })]) }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,218 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability'
|
||||
import {
|
||||
SkillBundleInstallPreviewRequestSchema,
|
||||
SkillBundleInstallPreviewSchema
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
ManagedSkillInstallListSchema,
|
||||
SkillInstallDestinationSchema,
|
||||
SkillInstallPreviewSchema,
|
||||
SkillInstallResultSchema,
|
||||
SkillPackageIdentitySchema
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
supportsSkillRuntimeBundleInstall,
|
||||
supportsSkillRuntimeManagement
|
||||
} from '../skills/skill-runtime-capability'
|
||||
import { listWslDistrosAsync } from '../wsl'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
|
||||
const environmentIdSchema = z.string().min(1).max(128)
|
||||
const skillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
const installPreviewSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
package: SkillPackageIdentitySchema,
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema
|
||||
})
|
||||
.strict()
|
||||
const removeSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z.enum(['replace-and-discard-local', 'cancel']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
type BundlePreviewInput = z.infer<typeof SkillBundleInstallPreviewRequestSchema> & {
|
||||
environmentId?: string
|
||||
}
|
||||
|
||||
const REMOTE_BUNDLE_PREVIEW_CONCURRENCY = 8
|
||||
|
||||
async function previewBundleInstall(runtime: OrcaRuntimeService, input: BundlePreviewInput) {
|
||||
if (!input.environmentId) {
|
||||
return runtime.previewSharedSkillBundleInstallRequest(input)
|
||||
}
|
||||
const environmentId = input.environmentId
|
||||
const previews: z.infer<typeof SkillInstallPreviewSchema>[] = []
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < input.selectedSkills.length;
|
||||
offset += REMOTE_BUNDLE_PREVIEW_CONCURRENCY
|
||||
) {
|
||||
const batch = input.selectedSkills.slice(offset, offset + REMOTE_BUNDLE_PREVIEW_CONCURRENCY)
|
||||
previews.push(
|
||||
...(await Promise.all(
|
||||
batch.map(async (skill) => {
|
||||
const request = {
|
||||
package: {
|
||||
packageId: input.package.packageId,
|
||||
versionId: input.package.versionId,
|
||||
packageDigest: skill.digest,
|
||||
archiveSha256: input.package.archiveSha256,
|
||||
compressedBytes: input.package.compressedBytes
|
||||
},
|
||||
name: skill.name,
|
||||
destination: input.destination
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
environmentId,
|
||||
'skills.previewInstall',
|
||||
request,
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-bundle-preview-remote-${response.error.code}`)
|
||||
}
|
||||
return SkillInstallPreviewSchema.parse(response.result)
|
||||
})
|
||||
))
|
||||
)
|
||||
}
|
||||
return SkillBundleInstallPreviewSchema.parse({
|
||||
packageId: input.package.packageId,
|
||||
versionId: input.package.versionId,
|
||||
bundleDigest: input.package.bundleDigest,
|
||||
destinationIdentity: previews[0]?.destinationIdentity ?? '',
|
||||
skills: input.selectedSkills.map((skill, index) => ({
|
||||
...skill,
|
||||
currentState: previews[index].currentState
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return listWslDistrosAsync()
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
environmentId,
|
||||
'host.wsl.listDistros',
|
||||
{},
|
||||
15_000
|
||||
)
|
||||
return response.ok === true && Array.isArray(response.result)
|
||||
? response.result.filter((distro): distro is string => typeof distro === 'string')
|
||||
: []
|
||||
})
|
||||
ipcMain.handle('skills:previewInstall', async (_event, value: unknown) => {
|
||||
const input = installPreviewSchema.parse(value)
|
||||
const request = { package: input.package, name: input.name, destination: input.destination }
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.previewSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.previewInstall',
|
||||
request,
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-preview-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:previewBundleInstall', async (_event, value: unknown) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
package: SkillBundleInstallPreviewRequestSchema.shape.package,
|
||||
selectedSkills: SkillBundleInstallPreviewRequestSchema.shape.selectedSkills,
|
||||
destination: SkillInstallDestinationSchema
|
||||
})
|
||||
.strict()
|
||||
.parse(value)
|
||||
if (
|
||||
parsed.environmentId &&
|
||||
(!(await supportsSkillRuntimeManagement(app.getPath('userData'), parsed.environmentId)) ||
|
||||
!(await supportsSkillRuntimeBundleInstall(app.getPath('userData'), parsed.environmentId)))
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return { status: 'ok' as const, value: await previewBundleInstall(runtime, parsed) }
|
||||
})
|
||||
ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => {
|
||||
const input = removeSchema.parse(value)
|
||||
const request = {
|
||||
operationId: randomUUID(),
|
||||
name: input.name,
|
||||
destination: input.destination,
|
||||
conflictResolution: input.conflictResolution
|
||||
}
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.removeSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.removeInstall',
|
||||
request,
|
||||
5 * 60_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-remove-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallResultSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:listManagedInstalls', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.listManagedSkillInstalls() }
|
||||
}
|
||||
if (environmentId.startsWith('ssh:')) {
|
||||
const value = await runtime.listManagedSkillInstalls(environmentId.slice('ssh:'.length))
|
||||
return { status: 'ok' as const, value }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'skills.listManagedInstalls',
|
||||
{},
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-list-managed-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: ManagedSkillInstallListSchema.parse(response.result) }
|
||||
})
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import { sendBundleInstallProgress, sendSkillInstallProgress } from './skill-install-progress-ipc'
|
||||
|
||||
function event(destroyed = false): IpcMainInvokeEvent {
|
||||
return {
|
||||
sender: { isDestroyed: () => destroyed, send: vi.fn() }
|
||||
} as unknown as IpcMainInvokeEvent
|
||||
}
|
||||
|
||||
describe('skill install progress IPC', () => {
|
||||
it('projects destination-owned bundle progress without paths or grants', () => {
|
||||
const target = event()
|
||||
sendBundleInstallProgress(target, {
|
||||
operationId: 'operation_1',
|
||||
skillId: 'alpha',
|
||||
skillName: 'alpha',
|
||||
skillIndex: 2,
|
||||
skillCount: 30
|
||||
})
|
||||
|
||||
expect(target.sender.send).toHaveBeenCalledWith('skills:installProgress', {
|
||||
operationId: 'operation_1',
|
||||
phase: 'installing',
|
||||
currentSkill: { id: 'alpha', name: 'alpha', index: 2, total: 30 }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not publish after the invoking renderer is destroyed', () => {
|
||||
const target = event(true)
|
||||
sendSkillInstallProgress(target, { operationId: 'operation_1', phase: 'authorizing' })
|
||||
expect(target.sender.send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract'
|
||||
import type { SkillInstallProgress } from '../../shared/skill-sharing-contract'
|
||||
|
||||
export function sendSkillInstallProgress(
|
||||
event: IpcMainInvokeEvent,
|
||||
progress: SkillInstallProgress
|
||||
): void {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('skills:installProgress', progress)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendBundleInstallProgress(
|
||||
event: IpcMainInvokeEvent,
|
||||
progress: SkillBundleInstallProgress
|
||||
): void {
|
||||
sendSkillInstallProgress(event, {
|
||||
operationId: progress.operationId,
|
||||
phase: 'installing',
|
||||
currentSkill: {
|
||||
id: progress.skillId,
|
||||
name: progress.skillName,
|
||||
index: progress.skillIndex,
|
||||
total: progress.skillCount
|
||||
}
|
||||
})
|
||||
}
|
||||
+10
-16
@@ -1,6 +1,5 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import {
|
||||
SkillDiscoveryTargetSchema,
|
||||
type SkillDiscoveryResult,
|
||||
@@ -20,17 +19,8 @@ import {
|
||||
discoverSkillsOnTarget,
|
||||
resolveSkillDiscoveryTarget
|
||||
} from '../skills/skill-discovery-target'
|
||||
import { registerSkillCloudIpcHandlers } from './skill-cloud-ipc-handlers'
|
||||
|
||||
export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeService): void {
|
||||
const discover = async (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
|
||||
const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined
|
||||
const resolvedTarget = resolveSkillDiscoveryTarget(parsedTarget)
|
||||
return discoverSkillsOnTarget(resolvedTarget, store.getRepos(), {
|
||||
providerRootOverrides: await runtime?.resolveSkillDiscoveryProviderRoots(resolvedTarget),
|
||||
refresh: parsedTarget?.refresh === true
|
||||
})
|
||||
}
|
||||
export function registerSkillsHandlers(store: Store): void {
|
||||
const scanInventory = (): Promise<SkillFreshnessInventory> =>
|
||||
// Why: the update command targets this machine's global homes. WSL and SSH
|
||||
// inventories stay out until their installer rail has an equivalent proof.
|
||||
@@ -65,13 +55,17 @@ export function registerSkillsHandlers(store: Store, runtime?: OrcaRuntimeServic
|
||||
|
||||
ipcMain.handle(
|
||||
'skills:discover',
|
||||
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => discover(target)
|
||||
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
|
||||
const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined
|
||||
return discoverSkillsOnTarget(
|
||||
resolveSkillDiscoveryTarget(parsedTarget),
|
||||
store.getRepos(),
|
||||
// Why: only a caller that knows disk changed may bypass the shared scans.
|
||||
{ refresh: parsedTarget?.refresh === true }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if (runtime) {
|
||||
registerSkillCloudIpcHandlers(runtime, discover)
|
||||
}
|
||||
|
||||
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
|
||||
return scanInventory()
|
||||
})
|
||||
|
||||
@@ -93,20 +93,6 @@ describe('Store', () => {
|
||||
expect(updated.branchPrefix).toBe('git-username')
|
||||
})
|
||||
|
||||
it('persists the agent skill sharing capability as an exact boolean', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.updateSettings({ agentSkillSharingEnabled: true }).agentSkillSharingEnabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
store.updateSettings({ agentSkillSharingEnabled: 'yes' as never }).agentSkillSharingEnabled
|
||||
).toBe(false)
|
||||
expect(
|
||||
store.updateSettings({ agentSkillSharingEnabled: 1 as never }).agentSkillSharingEnabled
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes bot-author overrides on load and every settings write', async () => {
|
||||
writeDataFile({
|
||||
settings: {
|
||||
|
||||
@@ -70,9 +70,6 @@ export function updateSettings(
|
||||
if ('artifactSharingEnabled' in updates) {
|
||||
sanitizedUpdates.artifactSharingEnabled = updates.artifactSharingEnabled === true
|
||||
}
|
||||
if ('agentSkillSharingEnabled' in updates) {
|
||||
sanitizedUpdates.agentSkillSharingEnabled = updates.agentSkillSharingEnabled === true
|
||||
}
|
||||
if ('disabledTuiAgents' in updates) {
|
||||
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
|
||||
}
|
||||
|
||||
@@ -109,11 +109,6 @@ export type { PtyProcessInfo, PtySpawnResult }
|
||||
type PtyProbeOptions = { signal?: AbortSignal }
|
||||
|
||||
export type IPtyProvider = {
|
||||
requestHostRpc?: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
options?: { signal?: AbortSignal; timeoutMs?: number }
|
||||
) => Promise<unknown>
|
||||
/** Fresh local spawns currently route to an in-process, non-persistent fallback. */
|
||||
readonly routesFreshSpawnsToLocalProvider?: true
|
||||
/** Re-probes a degraded durable host before main commits to fallback spawn semantics. */
|
||||
|
||||
@@ -40,9 +40,6 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
private spawnExitRaces = new SshPtySpawnExitRaceTracker()
|
||||
private readonly outputState: SshPtyProviderOutputState
|
||||
|
||||
requestHostRpc: NonNullable<IPtyProvider['requestHostRpc']> = (method, params, options) =>
|
||||
this.mux.request(method, params as Record<string, unknown>, options)
|
||||
|
||||
constructor(
|
||||
connectionId: string,
|
||||
mux: SshChannelMultiplexer,
|
||||
@@ -307,7 +304,9 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
return processes
|
||||
}
|
||||
|
||||
hasPty = (id: string): boolean => this.livePtyIds.has(id)
|
||||
hasPty(id: string): boolean {
|
||||
return this.livePtyIds.has(id)
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
const result = await this.mux.request('pty.getDefaultShell')
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSkillShareRequest } from '../../shared/agent-skill-sharing-contract'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import { extractSkillBundleArchive } from '../skills/skill-bundle-extraction'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ userDataPath: '/tmp' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => mocks.userDataPath), isPackaged: false }
|
||||
}))
|
||||
|
||||
let testRoot = ''
|
||||
|
||||
async function createSkill(id: string, name: string): Promise<DiscoveredSkill> {
|
||||
const directoryPath = join(testRoot, 'skills', id)
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
await writeFile(
|
||||
join(directoryPath, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${name} description\n---\n\n# ${name}\n`,
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(join(directoryPath, 'notes.txt'), name, 'utf8')
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Shared',
|
||||
rootPath: join(testRoot, 'skills'),
|
||||
directoryPath,
|
||||
skillFilePath: join(directoryPath, 'SKILL.md'),
|
||||
installed: true,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
function request(skillSelectors: string[]): AgentSkillShareRequest {
|
||||
return {
|
||||
skillSelectors,
|
||||
bundleName: 'team-skills',
|
||||
releaseNotes: 'Release notes'
|
||||
}
|
||||
}
|
||||
|
||||
async function operationDirectories(): Promise<string[]> {
|
||||
return readdir(join(testRoot, 'agent-skill-share-operations')).catch(() => [])
|
||||
}
|
||||
|
||||
function runtimeWithCloud(options: {
|
||||
isEnabled: () => boolean
|
||||
failPublish?: boolean
|
||||
waitForAbort?: boolean
|
||||
}) {
|
||||
const manifests: string[][] = []
|
||||
let notifyStarted: (() => void) | null = null
|
||||
const started = new Promise<void>((resolve) => {
|
||||
notifyStarted = resolve
|
||||
})
|
||||
const publishVersion = vi.fn(async (input) => {
|
||||
const extractionRoot = join(testRoot, 'extracted')
|
||||
await mkdir(extractionRoot, { recursive: true })
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: input.archivePath,
|
||||
destinationDirectory: join(extractionRoot, randomUUID()),
|
||||
expectedArchiveSha256: input.archiveSha256,
|
||||
expectedPackageId: input.packageId
|
||||
})
|
||||
manifests.push(extracted.manifest.skills.map((skill) => skill.name))
|
||||
if (options.failPublish) {
|
||||
throw new Error('publish-failed')
|
||||
}
|
||||
if (options.waitForAbort) {
|
||||
notifyStarted?.()
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const rejectAbort = (): void => reject(new Error('upload-aborted'))
|
||||
input.signal?.addEventListener('abort', rejectAbort, { once: true })
|
||||
if (input.signal?.aborted) {
|
||||
rejectAbort()
|
||||
}
|
||||
})
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: {
|
||||
packageId: extracted.manifest.packageId,
|
||||
versionId: extracted.manifest.versionId,
|
||||
name: extracted.manifest.bundleName,
|
||||
description: extracted.manifest.description,
|
||||
packageDigest: extracted.manifest.bundleDigest,
|
||||
archiveSha256: input.archiveSha256,
|
||||
compressedBytes: input.compressedBytes,
|
||||
createdAt: extracted.manifest.createdAt,
|
||||
releaseNotes: input.releaseNotes,
|
||||
manifest: extracted.manifest
|
||||
}
|
||||
}
|
||||
})
|
||||
const createShare = vi.fn(async () => ({
|
||||
status: 'ok' as const,
|
||||
value: { id: 'share-id', url: 'https://share.onorca.dev/skills/share/share-id' }
|
||||
}))
|
||||
const runtime = new OrcaRuntimeService({
|
||||
getSettings: () => ({
|
||||
...getDefaultSettings(testRoot),
|
||||
agentSkillSharingEnabled: options.isEnabled()
|
||||
})
|
||||
} as never)
|
||||
runtime.setSkillCloudService({ publishVersion, createShare } as never)
|
||||
return { runtime, publishVersion, createShare, manifests, started }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
testRoot = await mkdtemp(join(tmpdir(), 'orca-agent-skill-share-'))
|
||||
mocks.userDataPath = testRoot
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('agent skill sharing runtime', () => {
|
||||
it('publishes only the selected discovered skills and cleans preparation files', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const beta = await createSkill('beta-id', 'beta')
|
||||
const ignored = await createSkill('ignored-id', 'ignored')
|
||||
const staleOperation = join(testRoot, 'agent-skill-share-operations', 'stale-operation')
|
||||
await mkdir(staleOperation, { recursive: true })
|
||||
await writeFile(join(staleOperation, 'package.tar.gz'), 'crash residue')
|
||||
const { runtime, manifests } = runtimeWithCloud({ isEnabled: () => true })
|
||||
|
||||
const result = await runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id', 'beta']), [
|
||||
alpha,
|
||||
beta,
|
||||
ignored
|
||||
])
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'ok',
|
||||
value: {
|
||||
selectedSkills: [
|
||||
{ id: 'alpha-id', name: 'alpha' },
|
||||
{ id: 'beta-id', name: 'beta' }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(manifests).toEqual([['alpha', 'beta']])
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
it('denies before reading a discovered directory and blocks later publishes when disabled', async () => {
|
||||
let enabled = false
|
||||
const { runtime, publishVersion } = runtimeWithCloud({ isEnabled: () => enabled })
|
||||
const missing = {
|
||||
...(await createSkill('alpha-id', 'alpha')),
|
||||
directoryPath: join(testRoot, 'does-not-exist')
|
||||
}
|
||||
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [missing])
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
|
||||
expect(publishVersion).not.toHaveBeenCalled()
|
||||
|
||||
enabled = true
|
||||
const alpha = await createSkill('alpha-two', 'alpha-two')
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-two']), [alpha])
|
||||
).resolves.toMatchObject({ status: 'ok' })
|
||||
enabled = false
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-two']), [alpha])
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
|
||||
})
|
||||
|
||||
it('cleans preparation files after cloud failure', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const { runtime } = runtimeWithCloud({ isEnabled: () => true, failPublish: true })
|
||||
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [alpha])
|
||||
).rejects.toThrow('publish-failed')
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an actionable error for package-incompatible skill metadata', async () => {
|
||||
const invalid = await createSkill('invalid-id', 'invalid_name')
|
||||
const { runtime, publishVersion } = runtimeWithCloud({ isEnabled: () => true })
|
||||
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['invalid-id']), [invalid])
|
||||
).rejects.toMatchObject({ code: 'agent_skill_not_shareable' })
|
||||
expect(publishVersion).not.toHaveBeenCalled()
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels an upload and cleans preparation files', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const controller = new AbortController()
|
||||
const { runtime, started } = runtimeWithCloud({
|
||||
isEnabled: () => true,
|
||||
waitForAbort: true
|
||||
})
|
||||
const publishing = runtime.publishDiscoveredSkillsFromAgent(
|
||||
request(['alpha-id']),
|
||||
[alpha],
|
||||
controller.signal
|
||||
)
|
||||
await started
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [alpha])
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_busy' })
|
||||
controller.abort()
|
||||
|
||||
await expect(publishing).rejects.toThrow('upload-aborted')
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { fromId: vi.fn(() => null) },
|
||||
webContents: { fromId: vi.fn(() => null) },
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
|
||||
app: { getPath: vi.fn(() => '/tmp'), isPackaged: false }
|
||||
}))
|
||||
|
||||
describe('managed skill startup recovery', () => {
|
||||
it('waits for startup recovery before reading local managed installs', async () => {
|
||||
let finishRecovery!: () => void
|
||||
const recovery = new Promise<void>((resolve) => {
|
||||
finishRecovery = resolve
|
||||
})
|
||||
const runtime = new OrcaRuntimeService(null, undefined, {
|
||||
skillTransactionRecovery: recovery
|
||||
})
|
||||
const listing = runtime.listManagedSkillInstalls()
|
||||
let settled = false
|
||||
void listing.finally(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
finishRecovery()
|
||||
await expect(listing).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -13,10 +13,6 @@ import {
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import { planWorktreeSortOrderUpdates } from '../../shared/worktree/sort-order-update'
|
||||
import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate'
|
||||
import {
|
||||
assertAgentSkillSharingAllowed,
|
||||
isAgentSkillSharingEnabled
|
||||
} from '../../shared/agent-skill-sharing-gate'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
|
||||
@@ -118,7 +114,7 @@ import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-messag
|
||||
import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { resolveWorktreeCreateBase } from '../worktree-create-base'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
|
||||
@@ -151,73 +147,6 @@ import type {
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
import type { ArtifactCloudService } from '../artifacts/artifact-cloud-service'
|
||||
import type {
|
||||
SkillCloudDownloadGrant,
|
||||
SkillCloudOperation,
|
||||
SkillCloudOptions,
|
||||
SkillCloudPackageDetails,
|
||||
SkillCloudPublishRequest,
|
||||
SkillCloudPublishResult,
|
||||
SkillCloudVersion
|
||||
} from '../../shared/skill-cloud-contract'
|
||||
import type { SkillCloudService } from '../skills/skill-cloud-service'
|
||||
import {
|
||||
AGENT_SKILL_NOT_SHAREABLE_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AgentSkillSharingError,
|
||||
type AgentSkillShareOperation,
|
||||
type AgentSkillShareRequest
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import { selectDiscoveredSkills } from '../skills/agent-skill-selection'
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import type {
|
||||
SkillInstallPreview,
|
||||
SkillInstallPreviewRequest,
|
||||
SkillInstallRequest,
|
||||
SkillInstallResult,
|
||||
ManagedSkillInstall,
|
||||
SkillRemoveRequest
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type {
|
||||
SkillBundleInstallPreview,
|
||||
SkillBundleInstallPreviewRequest,
|
||||
SkillBundleInstallProgress,
|
||||
SkillBundleInstallRequest,
|
||||
SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { SkillBundleInstallPreviewSchema } from '../../shared/skill-bundle-install-contract'
|
||||
import { executeSkillInstallRequest } from '../skills/skill-install-request-service'
|
||||
import { executeSkillBundleInstallRequest } from '../skills/skill-bundle-install-request-service'
|
||||
import type { SkillInstallDestinationAuthority } from '../skills/skill-install-destinations'
|
||||
import {
|
||||
previewSharedSkillBundleInstall,
|
||||
previewSharedSkillInstall,
|
||||
removeSharedSkillInstall
|
||||
} from '../skills/skill-install-management-service'
|
||||
import { listManagedSkillInstalls } from '../skills/skill-install-provenance'
|
||||
import { getWslHome, toLinuxPath } from '../wsl'
|
||||
import { WslSkillInstallFilesystem } from '../skills/skill-wsl-install-filesystem'
|
||||
import { nativeSkillInstallFilesystem } from '../skills/skill-install-filesystem'
|
||||
import type { SkillProviderRootOverrides } from '../skills/skill-provider-destinations'
|
||||
import {
|
||||
resolveEnvironmentSkillProviderRoots,
|
||||
resolveWslGrokSkillProviderRoot,
|
||||
withClaudeSkillProviderRoot
|
||||
} from '../skills/skill-provider-runtime-roots'
|
||||
import type {
|
||||
SkillUploadBeginRequest,
|
||||
SkillUploadChunkRequest
|
||||
} from '../../shared/skill-upload-session-contract'
|
||||
import { SkillUploadSessionService } from '../skills/skill-upload-session-service'
|
||||
import type { SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract'
|
||||
import { installSkillBundleOnSshHost } from '../skills/skill-bundle-ssh-relay-service'
|
||||
import {
|
||||
installSkillOnSshHost,
|
||||
listSkillInstallsOnSshHost,
|
||||
previewSkillInstallOnSshHost,
|
||||
removeSkillInstallOnSshHost
|
||||
} from '../skills/skill-ssh-relay-service'
|
||||
import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout'
|
||||
import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy'
|
||||
import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity'
|
||||
@@ -1313,7 +1242,6 @@ type RuntimeStore = {
|
||||
minimaxUsageModels?: GlobalSettings['minimaxUsageModels']
|
||||
prBotAuthorOverrides?: GlobalSettings['prBotAuthorOverrides']
|
||||
artifactSharingEnabled?: GlobalSettings['artifactSharingEnabled']
|
||||
agentSkillSharingEnabled?: GlobalSettings['agentSkillSharingEnabled']
|
||||
terminalQuickCommands?: GlobalSettings['terminalQuickCommands']
|
||||
gitlabProjects?: GlobalSettings['gitlabProjects']
|
||||
mobileAutoRestoreFitMs?: number | null
|
||||
@@ -3497,12 +3425,6 @@ export class OrcaRuntimeService {
|
||||
private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null
|
||||
private automationService: AutomationService | null = null
|
||||
private artifactService: ArtifactCloudService | null = null
|
||||
private skillCloudService: SkillCloudService | null = null
|
||||
private agentSkillShareInProgress = false
|
||||
private skillUploadSessions: SkillUploadSessionService | null = null
|
||||
private readonly skillTransactionRecovery: Promise<unknown>
|
||||
private readonly skillInstallOperations = new Map<string, AbortController>()
|
||||
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
|
||||
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
|
||||
private mobileDictation: {
|
||||
id: string
|
||||
@@ -3554,7 +3476,6 @@ export class OrcaRuntimeService {
|
||||
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
|
||||
agentSessionClaimSigner?: AgentSessionClaimSigner
|
||||
orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport
|
||||
skillTransactionRecovery?: Promise<unknown>
|
||||
}
|
||||
) {
|
||||
this.store = store
|
||||
@@ -3567,7 +3488,6 @@ export class OrcaRuntimeService {
|
||||
this.store?.setMobileClientTabSelections?.(state)
|
||||
})
|
||||
this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null
|
||||
this.skillTransactionRecovery = deps?.skillTransactionRecovery ?? Promise.resolve()
|
||||
if (stats) {
|
||||
this.stats = stats
|
||||
this.agentDetector = new AgentDetector(stats)
|
||||
@@ -3714,7 +3634,6 @@ export class OrcaRuntimeService {
|
||||
// Read-only on purpose: clients preflight the publish capability here, but SettingsUpdate
|
||||
// still omits the key so no RPC caller can grant it to itself.
|
||||
| 'artifactSharingEnabled'
|
||||
| 'agentSkillSharingEnabled'
|
||||
> {
|
||||
if (!this.store?.getSettings) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -3739,8 +3658,7 @@ export class OrcaRuntimeService {
|
||||
minimaxGroupId: settings.minimaxGroupId ?? '',
|
||||
minimaxUsageModels: settings.minimaxUsageModels ?? 'general',
|
||||
prBotAuthorOverrides: settings.prBotAuthorOverrides ?? [],
|
||||
artifactSharingEnabled: isArtifactSharingEnabled(settings),
|
||||
agentSkillSharingEnabled: isAgentSkillSharingEnabled(settings)
|
||||
artifactSharingEnabled: isArtifactSharingEnabled(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4796,704 +4714,6 @@ export class OrcaRuntimeService {
|
||||
this.artifactService = service
|
||||
}
|
||||
|
||||
setSkillCloudService(service: SkillCloudService): void {
|
||||
this.skillCloudService = service
|
||||
}
|
||||
|
||||
assertAgentSkillSharingAllowed(): void {
|
||||
assertAgentSkillSharingAllowed(() => isAgentSkillSharingEnabled(this.store?.getSettings()))
|
||||
}
|
||||
|
||||
async publishDiscoveredSkillsFromAgent(
|
||||
request: AgentSkillShareRequest,
|
||||
discoveredSkills: readonly DiscoveredSkill[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AgentSkillShareOperation> {
|
||||
this.assertAgentSkillSharingAllowed()
|
||||
if (this.agentSkillShareInProgress) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
'Another agent skill bundle is being published. Wait for it to finish and try again.'
|
||||
)
|
||||
}
|
||||
this.agentSkillShareInProgress = true
|
||||
try {
|
||||
return await this.executeAgentSkillShare(request, discoveredSkills, signal)
|
||||
} finally {
|
||||
this.agentSkillShareInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAgentSkillShare(
|
||||
request: AgentSkillShareRequest,
|
||||
discoveredSkills: readonly DiscoveredSkill[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AgentSkillShareOperation> {
|
||||
const selectedSkills = selectDiscoveredSkills(discoveredSkills, request.skillSelectors)
|
||||
const operationRoot = join(app.getPath('userData'), 'agent-skill-share-operations')
|
||||
const cloud = this.requireSkillCloudService()
|
||||
const preparations = new SkillSharePreparationService(operationRoot, {
|
||||
publishVersion: (input) => cloud.publishVersion(input),
|
||||
createShare: (packageId, input) => cloud.createShare(packageId, input)
|
||||
})
|
||||
let preparationId: string | null = null
|
||||
const cancel = (): void => {
|
||||
if (preparationId) {
|
||||
preparations.cancel(preparationId)
|
||||
}
|
||||
}
|
||||
signal?.addEventListener('abort', cancel, { once: true })
|
||||
try {
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('skill-share-cancelled')
|
||||
}
|
||||
const preview = await preparations
|
||||
.prepare({
|
||||
sources: selectedSkills.map((skill) => ({
|
||||
id: skill.name,
|
||||
sourceDirectory: skill.directoryPath
|
||||
})),
|
||||
bundleName: request.bundleName,
|
||||
description:
|
||||
selectedSkills.length === 1
|
||||
? (selectedSkills[0].description ?? '')
|
||||
: `${selectedSkills.length} shared skills`
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
['skill-package-skill-name-required', 'skill-package-skill-name-invalid'].includes(
|
||||
error.message
|
||||
)
|
||||
) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_NOT_SHAREABLE_CODE,
|
||||
'A selected skill cannot be shared. Its SKILL.md must declare a lowercase name containing only letters, numbers, and hyphens.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
preparationId = preview.preparationId
|
||||
if (signal?.aborted) {
|
||||
throw signal.reason ?? new Error('skill-share-cancelled')
|
||||
}
|
||||
this.assertAgentSkillSharingAllowed()
|
||||
const published = await preparations.publish({
|
||||
preparationId,
|
||||
releaseNotes: request.releaseNotes
|
||||
})
|
||||
return published.status === 'ok'
|
||||
? {
|
||||
status: 'ok',
|
||||
value: {
|
||||
...published.value,
|
||||
selectedSkills: selectedSkills.map(({ id, name, description }) => ({
|
||||
id,
|
||||
name,
|
||||
description
|
||||
}))
|
||||
}
|
||||
}
|
||||
: published
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
await preparations.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
publishSkillPackage(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
|
||||
return this.requireSkillCloudService().publish(request)
|
||||
}
|
||||
|
||||
publishSkillPackageVersion(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudVersion>> {
|
||||
return this.requireSkillCloudService().publishVersion(request)
|
||||
}
|
||||
|
||||
createSkillPackageShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
idempotencyKey?: string
|
||||
}
|
||||
) {
|
||||
return this.requireSkillCloudService().createShare(packageId, request)
|
||||
}
|
||||
|
||||
resolveSkillShare(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
|
||||
return this.requireSkillCloudService().resolveShare(shareId, options)
|
||||
}
|
||||
|
||||
createSkillDownloadGrant(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions & {
|
||||
versionId?: string
|
||||
installTarget?: 'local' | 'remote'
|
||||
}
|
||||
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
|
||||
return this.requireSkillCloudService().createDownloadGrant(shareId, options)
|
||||
}
|
||||
|
||||
createSkillPackageVersionDownloadGrant(
|
||||
packageId: string,
|
||||
versionId: string,
|
||||
options: SkillCloudOptions & { installTarget?: 'local' | 'remote' }
|
||||
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
|
||||
return this.requireSkillCloudService().createPackageVersionDownloadGrant(
|
||||
packageId,
|
||||
versionId,
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
getSkillPackage(
|
||||
packageId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<SkillCloudPackageDetails>> {
|
||||
return this.requireSkillCloudService().getPackage(packageId, options)
|
||||
}
|
||||
|
||||
listOwnedSkillShares(options: SkillCloudOptions) {
|
||||
return this.requireSkillCloudService().listOwnedShares(options)
|
||||
}
|
||||
|
||||
revokeSkillShare(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.requireSkillCloudService().revokeShare(shareId, options)
|
||||
}
|
||||
|
||||
deleteSkillPackageVersion(
|
||||
packageId: string,
|
||||
versionId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.requireSkillCloudService().deleteVersion(packageId, versionId, options)
|
||||
}
|
||||
|
||||
deleteSkillPackage(
|
||||
packageId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.requireSkillCloudService().deletePackage(packageId, options)
|
||||
}
|
||||
|
||||
async installSharedSkillRequest(
|
||||
request: SkillInstallRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SkillInstallResult> {
|
||||
if (this.skillInstallOperations.has(request.operationId)) {
|
||||
throw new Error('skill-install-operation-in-progress')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort()
|
||||
if (signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
this.skillInstallOperations.set(request.operationId, controller)
|
||||
try {
|
||||
return await this.executeSharedSkillInstall(request, controller.signal)
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort)
|
||||
if (this.skillInstallOperations.get(request.operationId) === controller) {
|
||||
this.skillInstallOperations.delete(request.operationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async installSharedSkillBundleRequest(
|
||||
request: SkillBundleInstallRequest,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: SkillBundleInstallProgress) => void
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
if (this.skillInstallOperations.has(request.operationId)) {
|
||||
throw new Error('skill-install-operation-in-progress')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort()
|
||||
if (signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
this.skillInstallOperations.set(request.operationId, controller)
|
||||
const reportProgress = (progress: SkillBundleInstallProgress): void => {
|
||||
this.skillInstallProgress.set(request.operationId, progress)
|
||||
try {
|
||||
onProgress?.(progress)
|
||||
} catch {
|
||||
// Why: renderer teardown must not change the host-owned install outcome.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
if (sshTarget) {
|
||||
return installSkillBundleOnSshHost({
|
||||
provider: sshTarget.provider,
|
||||
userDataPath: app.getPath('userData'),
|
||||
request: {
|
||||
...request,
|
||||
destination:
|
||||
request.destination.scope === 'global'
|
||||
? { scope: 'global', executionTarget: { kind: 'host' } }
|
||||
: request.destination
|
||||
},
|
||||
workspace: sshTarget.workspace,
|
||||
requireHttps: app.isPackaged,
|
||||
signal: controller.signal,
|
||||
onProgress: reportProgress
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
const allowedDownloadOrigins = ['https://storage.googleapis.com']
|
||||
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
allowedDownloadOrigins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return await executeSkillBundleInstallRequest(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
|
||||
requireHttps: app.isPackaged,
|
||||
resolveStagedUpload: (uploadId, identity) =>
|
||||
this.requireSkillUploadSessions().take(uploadId, identity),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
resolveProviderRootOverrides: (destination) =>
|
||||
this.resolveSkillProviderRootOverrides(destination),
|
||||
signal: controller.signal,
|
||||
onProgress: reportProgress
|
||||
})
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort)
|
||||
if (this.skillInstallOperations.get(request.operationId) === controller) {
|
||||
this.skillInstallOperations.delete(request.operationId)
|
||||
}
|
||||
this.skillInstallProgress.delete(request.operationId)
|
||||
}
|
||||
}
|
||||
|
||||
getSharedSkillInstallProgress(operationId: string): SkillBundleInstallProgress | null {
|
||||
return this.skillInstallProgress.get(operationId) ?? null
|
||||
}
|
||||
|
||||
cancelSharedSkillInstall(operationId: string): boolean {
|
||||
const operation = this.skillInstallOperations.get(operationId)
|
||||
operation?.abort()
|
||||
return Boolean(operation)
|
||||
}
|
||||
|
||||
private async executeSharedSkillInstall(
|
||||
request: SkillInstallRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<SkillInstallResult> {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
if (sshTarget) {
|
||||
return installSkillOnSshHost({
|
||||
provider: sshTarget.provider,
|
||||
userDataPath: app.getPath('userData'),
|
||||
request: {
|
||||
...request,
|
||||
destination:
|
||||
request.destination.scope === 'global'
|
||||
? { scope: 'global', executionTarget: { kind: 'host' } }
|
||||
: request.destination
|
||||
},
|
||||
workspace: sshTarget.workspace,
|
||||
requireHttps: app.isPackaged,
|
||||
signal
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
const allowedDownloadOrigins = ['https://storage.googleapis.com']
|
||||
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
allowedDownloadOrigins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return executeSkillInstallRequest(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
allowedDownloadOrigins: [...new Set(allowedDownloadOrigins)],
|
||||
requireHttps: app.isPackaged,
|
||||
resolveStagedUpload: (uploadId, identity) =>
|
||||
this.requireSkillUploadSessions().take(uploadId, identity),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
resolveProviderRootOverrides: (destination) =>
|
||||
this.resolveSkillProviderRootOverrides(destination),
|
||||
signal
|
||||
})
|
||||
}
|
||||
|
||||
async previewSharedSkillInstallRequest(
|
||||
request: SkillInstallPreviewRequest
|
||||
): Promise<SkillInstallPreview> {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
if (sshTarget) {
|
||||
return previewSkillInstallOnSshHost({
|
||||
provider: sshTarget.provider,
|
||||
request: {
|
||||
...request,
|
||||
destination:
|
||||
request.destination.scope === 'global'
|
||||
? { scope: 'global', executionTarget: { kind: 'host' } }
|
||||
: request.destination
|
||||
},
|
||||
workspace: sshTarget.workspace
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
return previewSharedSkillInstall(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
resolveProviderRootOverrides: (destination) =>
|
||||
this.resolveSkillProviderRootOverrides(destination)
|
||||
})
|
||||
}
|
||||
|
||||
async previewSharedSkillBundleInstallRequest(
|
||||
request: SkillBundleInstallPreviewRequest
|
||||
): Promise<SkillBundleInstallPreview> {
|
||||
if (await this.resolveSkillSshTarget(request.destination)) {
|
||||
const previews: SkillInstallPreview[] = []
|
||||
for (let offset = 0; offset < request.selectedSkills.length; offset += 8) {
|
||||
const batch = request.selectedSkills.slice(offset, offset + 8)
|
||||
previews.push(
|
||||
...(await Promise.all(
|
||||
batch.map((skill) =>
|
||||
this.previewSharedSkillInstallRequest({
|
||||
package: {
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
packageDigest: skill.digest,
|
||||
archiveSha256: request.package.archiveSha256,
|
||||
compressedBytes: request.package.compressedBytes
|
||||
},
|
||||
name: skill.name,
|
||||
destination: request.destination
|
||||
})
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
return SkillBundleInstallPreviewSchema.parse({
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
bundleDigest: request.package.bundleDigest,
|
||||
destinationIdentity: previews[0]?.destinationIdentity ?? '',
|
||||
skills: request.selectedSkills.map((skill, index) => ({
|
||||
...skill,
|
||||
currentState: previews[index].currentState
|
||||
}))
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
return previewSharedSkillBundleInstall(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
resolveProviderRootOverrides: (destination) =>
|
||||
this.resolveSkillProviderRootOverrides(destination)
|
||||
})
|
||||
}
|
||||
|
||||
async removeSharedSkillInstallRequest(request: SkillRemoveRequest): Promise<SkillInstallResult> {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
if (sshTarget) {
|
||||
return removeSkillInstallOnSshHost({
|
||||
provider: sshTarget.provider,
|
||||
request: {
|
||||
...request,
|
||||
destination:
|
||||
request.destination.scope === 'global'
|
||||
? { scope: 'global', executionTarget: { kind: 'host' } }
|
||||
: request.destination
|
||||
},
|
||||
workspace: sshTarget.workspace
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
return removeSharedSkillInstall(request, {
|
||||
authority: this.skillInstallDestinationAuthority(runtimeId),
|
||||
stateDirectory: app.getPath('userData'),
|
||||
detectProviders: detectInstalledAgentsWithShellPathHydration,
|
||||
resolveProviderRootOverrides: (destination) =>
|
||||
this.resolveSkillProviderRootOverrides(destination)
|
||||
})
|
||||
}
|
||||
|
||||
async listManagedSkillInstalls(connectionId?: string): Promise<ManagedSkillInstall[]> {
|
||||
if (connectionId) {
|
||||
const provider = this.requireSkillSshProvider(connectionId)
|
||||
return listSkillInstallsOnSshHost({
|
||||
provider,
|
||||
connectionId,
|
||||
workspaces: await this.listSkillSshWorkspaces(connectionId)
|
||||
})
|
||||
}
|
||||
await this.skillTransactionRecovery
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const [installs, worktrees] = await Promise.all([
|
||||
listManagedSkillInstalls(join(app.getPath('userData'), 'skill-installs'), {
|
||||
observeReceipt: async (receipt) => {
|
||||
if (!receipt.wslDistro) {
|
||||
return nativeSkillInstallFilesystem.observeSkill(
|
||||
receipt.canonicalPath,
|
||||
receipt.fileModes
|
||||
)
|
||||
}
|
||||
const filesystem = new WslSkillInstallFilesystem(receipt.wslDistro, [
|
||||
dirname(receipt.canonicalPath)
|
||||
])
|
||||
return filesystem.observeSkill(receipt.canonicalPath, receipt.fileModes)
|
||||
}
|
||||
}),
|
||||
this.listResolvedWorktrees()
|
||||
])
|
||||
const folderWorkspaces = this.listFolderWorkspaces()
|
||||
return installs.flatMap((install): ManagedSkillInstall[] => {
|
||||
if (install.scope === 'global') {
|
||||
const wslPrefix = `global:${runtimeId}:wsl:`
|
||||
return [
|
||||
{
|
||||
...install,
|
||||
destination: install.destinationIdentity.startsWith(wslPrefix)
|
||||
? {
|
||||
scope: 'global',
|
||||
executionTarget: {
|
||||
kind: 'wsl',
|
||||
distro: install.destinationIdentity.slice(wslPrefix.length)
|
||||
}
|
||||
}
|
||||
: { scope: 'global' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const worktree = worktrees.find(
|
||||
(candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}`
|
||||
)
|
||||
if (worktree) {
|
||||
return [{ ...install, destination: { scope: 'workspace', worktreeId: worktree.id } }]
|
||||
}
|
||||
const folder = folderWorkspaces.find(
|
||||
(candidate) => install.destinationIdentity === `workspace:${runtimeId}:${candidate.id}`
|
||||
)
|
||||
return folder
|
||||
? [{ ...install, destination: { scope: 'workspace', folderWorkspaceId: folder.id } }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
async skillInstallDestinationUsesSsh(
|
||||
destination: SkillInstallRequest['destination']
|
||||
): Promise<boolean> {
|
||||
return Boolean(await this.resolveSkillSshTarget(destination))
|
||||
}
|
||||
|
||||
async resolveSkillDiscoveryProviderRoots(target: {
|
||||
kind: 'native-host' | 'wsl'
|
||||
distro?: string
|
||||
}): Promise<SkillProviderRootOverrides> {
|
||||
const roots = await this.resolveSkillProviderRootOverrides({
|
||||
scope: 'global',
|
||||
homeDirectory: homedir(),
|
||||
...(target.kind === 'wsl' && target.distro ? { wslDistro: target.distro } : {})
|
||||
})
|
||||
if (target.kind !== 'wsl') {
|
||||
return roots
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(roots).map(([provider, root]) => [provider, toLinuxPath(root)])
|
||||
)
|
||||
}
|
||||
|
||||
private async resolveSkillProviderRootOverrides(destination: {
|
||||
scope: 'global' | 'workspace'
|
||||
homeDirectory: string
|
||||
workspaceDirectory?: string
|
||||
wslDistro?: string
|
||||
}): Promise<SkillProviderRootOverrides> {
|
||||
if (destination.scope !== 'global') {
|
||||
return {}
|
||||
}
|
||||
const wslGrokRoot = destination.wslDistro
|
||||
? await resolveWslGrokSkillProviderRoot(destination.wslDistro)
|
||||
: null
|
||||
const roots: SkillProviderRootOverrides = destination.wslDistro
|
||||
? wslGrokRoot
|
||||
? { grok: wslGrokRoot }
|
||||
: {}
|
||||
: resolveEnvironmentSkillProviderRoots()
|
||||
const claudeConfigDirectory = this.accountServices?.claudeAccounts.getRuntimeConfigDir(
|
||||
destination.wslDistro
|
||||
? { runtime: 'wsl', wslDistro: destination.wslDistro }
|
||||
: { runtime: 'host' }
|
||||
)
|
||||
return withClaudeSkillProviderRoot(roots, claudeConfigDirectory)
|
||||
}
|
||||
|
||||
private skillInstallDestinationAuthority(runtimeId: string): SkillInstallDestinationAuthority {
|
||||
return {
|
||||
environmentId: runtimeId,
|
||||
homeDirectory: homedir(),
|
||||
resolveWorktree: async (id) => {
|
||||
const repo = this.listRepos().find(
|
||||
(candidate) => candidate.id === getRepoIdFromWorktreeId(id)
|
||||
)
|
||||
if (repo?.connectionId) {
|
||||
throw new Error('skill-install-ssh-dispatch-required')
|
||||
}
|
||||
const projectRuntime = this.resolveProjectRuntimeForWorktree(id)
|
||||
const worktree = await this.showManagedWorktree(`id:${id}`)
|
||||
if (worktree.id !== id) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id,
|
||||
path: worktree.path,
|
||||
...(projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl'
|
||||
? { wslDistro: projectRuntime.runtime.distro }
|
||||
: {})
|
||||
}
|
||||
},
|
||||
resolveFolderWorkspace: async (id) => {
|
||||
const workspace = this.listFolderWorkspaces().find((candidate) => candidate.id === id)
|
||||
if (!workspace || workspace.connectionId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id,
|
||||
path: workspace.folderPath,
|
||||
...(parseWslUncPath(workspace.folderPath)?.distro
|
||||
? { wslDistro: parseWslUncPath(workspace.folderPath)!.distro }
|
||||
: {})
|
||||
}
|
||||
},
|
||||
resolveWsl: async (distro) => {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
const homeDirectory = getWslHome(distro)
|
||||
return homeDirectory ? { homeDirectory } : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSkillSshTarget(destination: SkillInstallRequest['destination']): Promise<{
|
||||
provider: IPtyProvider
|
||||
workspace?: SkillSshWorkspaceAuthority
|
||||
} | null> {
|
||||
if (destination.scope === 'global') {
|
||||
return destination.executionTarget?.kind === 'ssh'
|
||||
? { provider: this.requireSkillSshProvider(destination.executionTarget.connectionId) }
|
||||
: null
|
||||
}
|
||||
if (destination.worktreeId) {
|
||||
const repo = this.listRepos().find(
|
||||
(candidate) => candidate.id === getRepoIdFromWorktreeId(destination.worktreeId!)
|
||||
)
|
||||
if (!repo?.connectionId) {
|
||||
return null
|
||||
}
|
||||
const worktree = await this.showManagedWorktree(`id:${destination.worktreeId}`)
|
||||
if (worktree.id !== destination.worktreeId) {
|
||||
throw new Error('skill-install-workspace-not-found')
|
||||
}
|
||||
return {
|
||||
provider: this.requireSkillSshProvider(repo.connectionId),
|
||||
workspace: { kind: 'worktree', id: worktree.id, path: worktree.path }
|
||||
}
|
||||
}
|
||||
const folder = this.listFolderWorkspaces().find(
|
||||
(candidate) => candidate.id === destination.folderWorkspaceId
|
||||
)
|
||||
if (!folder?.connectionId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
provider: this.requireSkillSshProvider(folder.connectionId),
|
||||
workspace: { kind: 'folder', id: folder.id, path: folder.folderPath }
|
||||
}
|
||||
}
|
||||
|
||||
private requireSkillSshProvider(connectionId: string): IPtyProvider {
|
||||
const provider = this.getSshProviderFn?.(connectionId)
|
||||
if (!provider?.requestHostRpc) {
|
||||
throw new Error('skill-install-ssh-relay-unavailable')
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
private async listSkillSshWorkspaces(
|
||||
connectionId: string
|
||||
): Promise<SkillSshWorkspaceAuthority[]> {
|
||||
const repos = new Map(
|
||||
this.listRepos()
|
||||
.filter((repo) => repo.connectionId === connectionId)
|
||||
.map((repo) => [repo.id, repo])
|
||||
)
|
||||
const worktrees = (await this.listResolvedWorktrees())
|
||||
.filter((worktree) => repos.has(getRepoIdFromWorktreeId(worktree.id)))
|
||||
.map(
|
||||
(worktree): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'worktree',
|
||||
id: worktree.id,
|
||||
path: worktree.path
|
||||
})
|
||||
)
|
||||
const folders = this.listFolderWorkspaces()
|
||||
.filter((folder) => folder.connectionId === connectionId)
|
||||
.map(
|
||||
(folder): SkillSshWorkspaceAuthority => ({
|
||||
kind: 'folder',
|
||||
id: folder.id,
|
||||
path: folder.folderPath
|
||||
})
|
||||
)
|
||||
return [...worktrees, ...folders]
|
||||
}
|
||||
|
||||
beginSkillUpload(request: SkillUploadBeginRequest): Promise<{
|
||||
uploadId: string
|
||||
chunkBytes: number
|
||||
acknowledgedOffset: number
|
||||
}> {
|
||||
return this.requireSkillUploadSessions().begin(request)
|
||||
}
|
||||
|
||||
appendSkillUploadChunk(
|
||||
request: SkillUploadChunkRequest
|
||||
): Promise<{ acknowledgedOffset: number }> {
|
||||
return this.requireSkillUploadSessions().append(request)
|
||||
}
|
||||
|
||||
commitSkillUpload(uploadId: string): Promise<{ uploadId: string }> {
|
||||
return this.requireSkillUploadSessions().commit(uploadId)
|
||||
}
|
||||
|
||||
cancelSkillUpload(uploadId: string): Promise<void> {
|
||||
return this.requireSkillUploadSessions().cancel(uploadId)
|
||||
}
|
||||
|
||||
listArtifacts(options: ArtifactListOptions): Promise<ArtifactCloudOperation<ArtifactListPage>> {
|
||||
return this.requireArtifactService().list(options)
|
||||
}
|
||||
@@ -5535,20 +4755,6 @@ export class OrcaRuntimeService {
|
||||
return this.artifactService
|
||||
}
|
||||
|
||||
private requireSkillCloudService(): SkillCloudService {
|
||||
if (!this.skillCloudService) {
|
||||
throw new Error('Skill Cloud service is unavailable.')
|
||||
}
|
||||
return this.skillCloudService
|
||||
}
|
||||
|
||||
private requireSkillUploadSessions(): SkillUploadSessionService {
|
||||
this.skillUploadSessions ??= new SkillUploadSessionService(
|
||||
join(app.getPath('userData'), 'skill-installs', 'remote-uploads')
|
||||
)
|
||||
return this.skillUploadSessions
|
||||
}
|
||||
|
||||
getRuntimeId(): string {
|
||||
return this.runtimeId
|
||||
}
|
||||
|
||||
@@ -14,27 +14,6 @@ class LineageError extends Error {
|
||||
}
|
||||
|
||||
describe('mapRuntimeError', () => {
|
||||
it('preserves the stable skill failure category and retryability across RPC', () => {
|
||||
expect(
|
||||
mapRuntimeError(
|
||||
'req_1',
|
||||
{ runtimeId: 'runtime-1' },
|
||||
new Error('skill-download-transport-failed')
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'skill_install_failure',
|
||||
message: 'skill-download-transport-failed',
|
||||
data: {
|
||||
category: 'transport',
|
||||
code: 'skill-download-transport-failed',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned'])(
|
||||
'preserves the durable terminal tab close failure %s',
|
||||
(code) => {
|
||||
|
||||
@@ -8,18 +8,6 @@ import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
|
||||
import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access'
|
||||
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority'
|
||||
import { ARTIFACT_SHARING_DISABLED_CODE } from '../../../shared/artifact-sharing-gate'
|
||||
import { AGENT_SKILL_SHARING_DISABLED_CODE } from '../../../shared/agent-skill-sharing-gate'
|
||||
import {
|
||||
AGENT_SKILL_NOT_SHAREABLE_CODE,
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE
|
||||
} from '../../../shared/agent-skill-sharing-contract'
|
||||
import {
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
classifySkillInstallFailureCode
|
||||
} from '../../../shared/skill-install-failure'
|
||||
import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget'
|
||||
|
||||
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
|
||||
@@ -112,14 +100,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'waiter_exists',
|
||||
'invalid_argument',
|
||||
GIT_DIFF_TOO_LARGE_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_NOT_SHAREABLE_CODE,
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
|
||||
SKILL_INSTALL_RPC_ERROR_CODE
|
||||
ARTIFACT_SHARING_DISABLED_CODE
|
||||
])
|
||||
|
||||
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
|
||||
@@ -178,16 +159,6 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow
|
||||
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
|
||||
return errorResponse(id, meta, message, message)
|
||||
}
|
||||
const skillInstallFailure = classifySkillInstallFailureCode(message)
|
||||
if (skillInstallFailure) {
|
||||
return errorResponse(
|
||||
id,
|
||||
meta,
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
skillInstallFailure.code,
|
||||
skillInstallFailure
|
||||
)
|
||||
}
|
||||
if (message === 'invalid_terminal_send') {
|
||||
return errorResponse(id, meta, 'invalid_argument', 'Missing terminal send payload')
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { SettingsUpdate } from './client-ui-schemas'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/orca-state', isPackaged: true }
|
||||
}))
|
||||
|
||||
function runtimeWithSharing(agentSkillSharingEnabled: unknown): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService({
|
||||
getSettings: () => ({ ...getDefaultSettings('/tmp'), agentSkillSharingEnabled })
|
||||
} as never)
|
||||
}
|
||||
|
||||
describe('agent skill publish capability cannot be granted over RPC', () => {
|
||||
it('rejects settings.update attempts to enable it', () => {
|
||||
expect(SettingsUpdate.safeParse({ agentSkillSharingEnabled: true }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes the capability read-only through settings.get', () => {
|
||||
expect(runtimeWithSharing(true).getClientSettings().agentSkillSharingEnabled).toBe(true)
|
||||
expect(runtimeWithSharing(false).getClientSettings().agentSkillSharingEnabled).toBe(false)
|
||||
expect(runtimeWithSharing(undefined).getClientSettings().agentSkillSharingEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed for truthy non-booleans', () => {
|
||||
expect(runtimeWithSharing('yes').getClientSettings().agentSkillSharingEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -174,7 +174,6 @@ const TopLevelViewSchema = z.enum([
|
||||
'activity',
|
||||
'automations',
|
||||
'space',
|
||||
'skills',
|
||||
'artifacts',
|
||||
'mobile'
|
||||
])
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcContext } from '../core'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/orca-state', isPackaged: true }
|
||||
}))
|
||||
|
||||
vi.mock('../../../skills/skill-discovery-target', () => ({
|
||||
resolveSkillDiscoveryTarget: vi.fn((target) => ({ kind: 'native-host', cwd: target?.cwd })),
|
||||
discoverSkillsOnTarget: vi.fn(async () => ({ skills: [], sources: [], scannedAt: 1 }))
|
||||
}))
|
||||
|
||||
import { SKILL_METHODS } from './skills'
|
||||
import {
|
||||
discoverSkillsOnTarget,
|
||||
@@ -33,7 +30,6 @@ function makeContext(overrides: {
|
||||
return {
|
||||
runtime: {
|
||||
listRepos: () => [],
|
||||
resolveSkillDiscoveryProviderRoots: async () => ({}),
|
||||
resolveProjectRuntimeForWorktree:
|
||||
overrides.resolveProjectRuntimeForWorktree ?? (() => undefined)
|
||||
}
|
||||
@@ -48,22 +44,6 @@ function discoverMethod() {
|
||||
return method
|
||||
}
|
||||
|
||||
function installMethod() {
|
||||
const method = SKILL_METHODS.find((entry) => entry.name === 'skills.install')
|
||||
if (!method) {
|
||||
throw new Error('skills.install method not registered')
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
function method(name: string) {
|
||||
const value = SKILL_METHODS.find((entry) => entry.name === name)
|
||||
if (!value) {
|
||||
throw new Error(`${name} method not registered`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
describe('skills.discover RPC', () => {
|
||||
it('resolves the project runtime from the owning runtime store when the caller omits it', async () => {
|
||||
const resolveProjectRuntimeForWorktree = vi.fn(() => WSL_RUNTIME)
|
||||
@@ -92,13 +72,11 @@ describe('skills.discover RPC', () => {
|
||||
it('only bypasses the host scan cache when the caller asks for a refresh', async () => {
|
||||
await discoverMethod().handler({ cwd: '/repo' }, makeContext({}))
|
||||
expect(vi.mocked(discoverSkillsOnTarget)).toHaveBeenLastCalledWith(expect.anything(), [], {
|
||||
providerRootOverrides: {},
|
||||
refresh: false
|
||||
})
|
||||
|
||||
await discoverMethod().handler({ cwd: '/repo', refresh: true }, makeContext({}))
|
||||
expect(vi.mocked(discoverSkillsOnTarget)).toHaveBeenLastCalledWith(expect.anything(), [], {
|
||||
providerRootOverrides: {},
|
||||
refresh: true
|
||||
})
|
||||
})
|
||||
@@ -107,243 +85,3 @@ describe('skills.discover RPC', () => {
|
||||
expect(discoverMethod().params?.parse({ cwd: '/repo' })).toEqual({ cwd: '/repo' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills.install RPC', () => {
|
||||
it('routes one bundle request without changing the single-skill method', async () => {
|
||||
const installSharedSkillBundleRequest = vi.fn(async () => ({ status: 'complete' }))
|
||||
const request = {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
selectedSkillIds: ['alpha'],
|
||||
ingress: {
|
||||
kind: 'download-grant' as const,
|
||||
url: 'https://storage.googleapis.com/package',
|
||||
expiresAt: '2026-08-11T12:00:00.000Z'
|
||||
},
|
||||
destination: { scope: 'global' as const },
|
||||
conflictDecisions: []
|
||||
}
|
||||
|
||||
await method('skills.installBundle').handler(request, {
|
||||
runtime: { installSharedSkillBundleRequest }
|
||||
} as unknown as RpcContext)
|
||||
|
||||
expect(installSharedSkillBundleRequest).toHaveBeenCalledWith(request, undefined)
|
||||
})
|
||||
|
||||
it('delegates installation to the executing runtime service', async () => {
|
||||
const installSharedSkillRequest = vi.fn(async () => ({ status: 'installed' }))
|
||||
const runtime = {
|
||||
installSharedSkillRequest
|
||||
}
|
||||
const request = {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
ingress: {
|
||||
kind: 'download-grant' as const,
|
||||
url: 'https://storage.googleapis.com/package',
|
||||
expiresAt: '2026-08-11T12:00:00.000Z'
|
||||
},
|
||||
destination: { scope: 'global' as const }
|
||||
}
|
||||
await installMethod().handler(request, { runtime } as unknown as RpcContext)
|
||||
expect(installSharedSkillRequest).toHaveBeenCalledWith(request, undefined)
|
||||
})
|
||||
|
||||
it('downgrades cancelled results for clients without the additive result capability', async () => {
|
||||
const cancelled = {
|
||||
operationId: 'operation_1',
|
||||
status: 'cancelled' as const,
|
||||
name: 'example',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
placements: [],
|
||||
failure: {
|
||||
category: 'cancelled' as const,
|
||||
code: 'skill-install-cancelled',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
const runtime = { installSharedSkillRequest: vi.fn(async () => cancelled) }
|
||||
const request = {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
ingress: {
|
||||
kind: 'download-grant' as const,
|
||||
url: 'https://storage.googleapis.com/package',
|
||||
expiresAt: '2026-08-11T12:00:00.000Z'
|
||||
},
|
||||
destination: { scope: 'global' as const }
|
||||
}
|
||||
|
||||
await expect(
|
||||
installMethod().handler(request, { runtime, clientCapabilities: [] } as unknown as RpcContext)
|
||||
).resolves.toMatchObject({ status: 'failed', errorCategory: 'skill-install-cancelled' })
|
||||
await expect(
|
||||
installMethod().handler(request, {
|
||||
runtime,
|
||||
clientCapabilities: ['skills.install-result.v2']
|
||||
} as unknown as RpcContext)
|
||||
).resolves.toMatchObject({ status: 'cancelled', failure: { category: 'cancelled' } })
|
||||
})
|
||||
|
||||
it('routes cancellation to the destination runtime by operation ID', async () => {
|
||||
const cancelSharedSkillInstall = vi.fn(() => true)
|
||||
const result = await method('skills.cancelInstall').handler({ operationId: 'operation_1' }, {
|
||||
runtime: { cancelSharedSkillInstall }
|
||||
} as unknown as RpcContext)
|
||||
expect(result).toEqual({ cancelled: true })
|
||||
expect(cancelSharedSkillInstall).toHaveBeenCalledWith('operation_1')
|
||||
})
|
||||
|
||||
it('returns active bundle progress without requiring it from older clients', async () => {
|
||||
const progress = {
|
||||
operationId: 'operation_1',
|
||||
skillId: 'alpha',
|
||||
skillName: 'alpha',
|
||||
skillIndex: 1,
|
||||
skillCount: 2
|
||||
}
|
||||
const getSharedSkillInstallProgress = vi.fn(() => progress)
|
||||
expect(
|
||||
method('skills.getInstallProgress').handler({ operationId: 'operation_1' }, {
|
||||
runtime: { getSharedSkillInstallProgress }
|
||||
} as unknown as RpcContext)
|
||||
).toEqual(progress)
|
||||
})
|
||||
})
|
||||
|
||||
describe('skills.share RPC', () => {
|
||||
const request = {
|
||||
skillSelectors: ['alpha', 'beta'],
|
||||
bundleName: 'team-skills',
|
||||
releaseNotes: '',
|
||||
target: { cwd: '/repo' }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(discoverSkillsOnTarget).mockClear()
|
||||
vi.mocked(resolveSkillDiscoveryTarget).mockImplementation((target) => ({
|
||||
kind: 'native-host',
|
||||
cwd: target?.cwd ?? undefined
|
||||
}))
|
||||
})
|
||||
|
||||
it('checks permission before discovery', async () => {
|
||||
const denial = new Error('denied')
|
||||
const assertAgentSkillSharingAllowed = vi.fn(() => {
|
||||
throw denial
|
||||
})
|
||||
|
||||
await expect(
|
||||
method('skills.share').handler(request, {
|
||||
runtime: { assertAgentSkillSharingAllowed }
|
||||
} as unknown as RpcContext)
|
||||
).rejects.toBe(denial)
|
||||
expect(discoverSkillsOnTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discovers on the executing host and delegates the selected bundle', async () => {
|
||||
const discoveredSkills = [{ id: 'alpha' }, { id: 'beta' }]
|
||||
vi.mocked(discoverSkillsOnTarget).mockResolvedValueOnce({
|
||||
skills: discoveredSkills,
|
||||
sources: [],
|
||||
scannedAt: 1
|
||||
} as never)
|
||||
const publishDiscoveredSkillsFromAgent = vi.fn(async () => ({ status: 'ok' }))
|
||||
const runtime = {
|
||||
assertAgentSkillSharingAllowed: vi.fn(),
|
||||
resolveProjectRuntimeForWorktree: vi.fn(),
|
||||
resolveSkillDiscoveryProviderRoots: vi.fn(async () => ({})),
|
||||
listRepos: vi.fn(() => []),
|
||||
publishDiscoveredSkillsFromAgent
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await method('skills.share').handler(request, { runtime, signal } as unknown as RpcContext)
|
||||
|
||||
expect(discoverSkillsOnTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'native-host', cwd: '/repo' }),
|
||||
[],
|
||||
{ providerRootOverrides: {}, refresh: true }
|
||||
)
|
||||
expect(publishDiscoveredSkillsFromAgent).toHaveBeenCalledWith(request, discoveredSkills, signal)
|
||||
})
|
||||
|
||||
it('rejects WSL before attempting to read Linux paths with native fs', async () => {
|
||||
vi.mocked(resolveSkillDiscoveryTarget).mockReturnValueOnce({
|
||||
kind: 'wsl',
|
||||
distro: 'Ubuntu',
|
||||
homeDir: '/home/alice',
|
||||
cwd: '/repo'
|
||||
})
|
||||
const runtime = {
|
||||
assertAgentSkillSharingAllowed: vi.fn(),
|
||||
resolveProjectRuntimeForWorktree: vi.fn()
|
||||
}
|
||||
|
||||
await expect(
|
||||
method('skills.share').handler(request, { runtime } as unknown as RpcContext)
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' })
|
||||
expect(discoverSkillsOnTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects paired runtime callers before discovery', async () => {
|
||||
const runtime = {
|
||||
assertAgentSkillSharingAllowed: vi.fn()
|
||||
}
|
||||
|
||||
await expect(
|
||||
method('skills.share').handler(request, {
|
||||
runtime,
|
||||
clientKind: 'runtime'
|
||||
} as unknown as RpcContext)
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_unsupported_environment' })
|
||||
expect(discoverSkillsOnTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill management RPC', () => {
|
||||
it('delegates preview and removal to the executing runtime', async () => {
|
||||
const previewSharedSkillInstallRequest = vi.fn(async () => ({ currentState: 'missing' }))
|
||||
const removeSharedSkillInstallRequest = vi.fn(async () => ({ status: 'removed' }))
|
||||
const runtime = { previewSharedSkillInstallRequest, removeSharedSkillInstallRequest }
|
||||
const destination = { scope: 'global' as const }
|
||||
const packageIdentity = {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
}
|
||||
|
||||
await method('skills.previewInstall').handler(
|
||||
{ name: 'example', package: packageIdentity, destination },
|
||||
{ runtime } as unknown as RpcContext
|
||||
)
|
||||
await method('skills.removeInstall').handler(
|
||||
{ operationId: 'operation_1', name: 'example', destination },
|
||||
{ runtime } as unknown as RpcContext
|
||||
)
|
||||
|
||||
expect(previewSharedSkillInstallRequest).toHaveBeenCalledOnce()
|
||||
expect(removeSharedSkillInstallRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { z } from 'zod'
|
||||
import { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
|
||||
import {
|
||||
SkillInstallPreviewRequestSchema,
|
||||
SkillInstallRequestSchema,
|
||||
SkillRemoveRequestSchema
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import {
|
||||
SkillBundleInstallProgressSchema,
|
||||
SkillBundleInstallRequestSchema
|
||||
} from '../../../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SkillUploadBeginRequestSchema,
|
||||
SkillUploadChunkRequestSchema,
|
||||
SkillUploadCommitRequestSchema
|
||||
} from '../../../../shared/skill-upload-session-contract'
|
||||
import {
|
||||
discoverSkillsOnTarget,
|
||||
resolveSkillDiscoveryTarget
|
||||
} from '../../../skills/skill-discovery-target'
|
||||
import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../../../shared/skill-install-capability'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import {
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
|
||||
AgentSkillShareRequestSchema,
|
||||
AgentSkillSharingError
|
||||
} from '../../../../shared/agent-skill-sharing-contract'
|
||||
|
||||
function resolveDiscoveryTarget(
|
||||
params: z.infer<typeof SkillDiscoveryTargetSchema>,
|
||||
runtime: Pick<OrcaRuntimeService, 'resolveProjectRuntimeForWorktree'>
|
||||
) {
|
||||
const target = params.projectRuntime
|
||||
? params
|
||||
: {
|
||||
...params,
|
||||
projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId)
|
||||
}
|
||||
return resolveSkillDiscoveryTarget(target)
|
||||
}
|
||||
|
||||
export const SKILL_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -48,111 +13,15 @@ export const SKILL_METHODS: RpcMethod[] = [
|
||||
// Why: the executing runtime owns WSL project preferences. Remote callers
|
||||
// send worktree identity only; trusting their projectRuntime absence
|
||||
// would scan this host's native filesystem for a WSL-configured project.
|
||||
const resolvedTarget = resolveDiscoveryTarget(params, runtime)
|
||||
return discoverSkillsOnTarget(resolvedTarget, runtime.listRepos(), {
|
||||
providerRootOverrides: await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget),
|
||||
const target = params.projectRuntime
|
||||
? params
|
||||
: {
|
||||
...params,
|
||||
projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId)
|
||||
}
|
||||
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos(), {
|
||||
refresh: params.refresh === true
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.share',
|
||||
params: AgentSkillShareRequestSchema,
|
||||
handler: async (params, { runtime, signal, clientKind }) => {
|
||||
runtime.assertAgentSkillSharingAllowed()
|
||||
if (clientKind !== undefined) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
|
||||
'Publishing skills through a paired client is not supported. Run the command from Orca on the machine that stores the skills.'
|
||||
)
|
||||
}
|
||||
const resolvedTarget = resolveDiscoveryTarget(params.target ?? {}, runtime)
|
||||
if (resolvedTarget.kind !== 'native-host') {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
|
||||
'Publishing skills from a forwarded WSL session is not supported yet. Run the command from Orca on the machine that stores the skills.'
|
||||
)
|
||||
}
|
||||
const discovered = await discoverSkillsOnTarget(resolvedTarget, runtime.listRepos(), {
|
||||
providerRootOverrides: await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget),
|
||||
refresh: true
|
||||
})
|
||||
return runtime.publishDiscoveredSkillsFromAgent(params, discovered.skills, signal)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.install',
|
||||
params: SkillInstallRequestSchema,
|
||||
handler: async (params, { runtime, signal, clientCapabilities }) => {
|
||||
const result = await runtime.installSharedSkillRequest(params, signal)
|
||||
if (
|
||||
result.status === 'cancelled' &&
|
||||
!clientCapabilities?.includes(SKILL_INSTALL_RESULT_V2_CAPABILITY)
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
status: 'failed' as const,
|
||||
errorCategory: result.failure?.code ?? 'skill-install-cancelled',
|
||||
failure: undefined
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.installBundle',
|
||||
params: SkillBundleInstallRequestSchema,
|
||||
handler: (params, { runtime, signal }) =>
|
||||
runtime.installSharedSkillBundleRequest(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.cancelInstall',
|
||||
params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
|
||||
handler: (params, { runtime }) => ({
|
||||
cancelled: runtime.cancelSharedSkillInstall(params.operationId)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.getInstallProgress',
|
||||
params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
|
||||
handler: (params, { runtime }) => {
|
||||
const progress = runtime.getSharedSkillInstallProgress(params.operationId)
|
||||
return progress ? SkillBundleInstallProgressSchema.parse(progress) : null
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.previewInstall',
|
||||
params: SkillInstallPreviewRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.previewSharedSkillInstallRequest(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.removeInstall',
|
||||
params: SkillRemoveRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.removeSharedSkillInstallRequest(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.listManagedInstalls',
|
||||
params: null,
|
||||
handler: (_params, { runtime }) => runtime.listManagedSkillInstalls()
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.beginUpload',
|
||||
params: SkillUploadBeginRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.beginSkillUpload(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.uploadChunk',
|
||||
params: SkillUploadChunkRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.appendSkillUploadChunk(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.commitUpload',
|
||||
params: SkillUploadCommitRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.commitSkillUpload(params.uploadId)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.cancelUpload',
|
||||
params: SkillUploadCommitRequestSchema,
|
||||
handler: (params, { runtime }) => runtime.cancelSkillUpload(params.uploadId)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import {
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import { selectDiscoveredSkills } from './agent-skill-selection'
|
||||
|
||||
function skill(id: string, name: string): DiscoveredSkill {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Shared',
|
||||
rootPath: '/skills',
|
||||
directoryPath: `/skills/${id}`,
|
||||
skillFilePath: `/skills/${id}/SKILL.md`,
|
||||
installed: true,
|
||||
updatedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent skill selection', () => {
|
||||
it('accepts exact IDs and unambiguous names while deduplicating repeats', () => {
|
||||
expect(
|
||||
selectDiscoveredSkills(
|
||||
[skill('id-alpha', 'alpha'), skill('id-beta', 'beta')],
|
||||
['alpha', 'id-beta', 'alpha']
|
||||
).map((entry) => entry.id)
|
||||
).toEqual(['id-alpha', 'id-beta'])
|
||||
})
|
||||
|
||||
it('fails missing selectors with installed-list recovery', () => {
|
||||
expect(() => selectDiscoveredSkills([], ['missing'])).toThrow(
|
||||
expect.objectContaining({ code: AGENT_SKILL_SELECTOR_NOT_FOUND_CODE })
|
||||
)
|
||||
})
|
||||
|
||||
it('requires an ID when names are ambiguous', () => {
|
||||
expect(() =>
|
||||
selectDiscoveredSkills([skill('one', 'same'), skill('two', 'same')], ['same'])
|
||||
).toThrow(expect.objectContaining({ code: AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE }))
|
||||
})
|
||||
|
||||
it('rejects two exact IDs whose bundle folder names would collide', () => {
|
||||
expect(() =>
|
||||
selectDiscoveredSkills([skill('one', 'same'), skill('two', 'same')], ['one', 'two'])
|
||||
).toThrow(expect.objectContaining({ code: AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE }))
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AgentSkillSharingError
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
|
||||
export function selectDiscoveredSkills(
|
||||
skills: readonly DiscoveredSkill[],
|
||||
selectors: readonly string[]
|
||||
): DiscoveredSkill[] {
|
||||
const selected = new Map<string, DiscoveredSkill>()
|
||||
for (const selector of selectors) {
|
||||
const exactId = skills.find((skill) => skill.id === selector)
|
||||
if (exactId) {
|
||||
selected.set(exactId.id, exactId)
|
||||
continue
|
||||
}
|
||||
const named = skills.filter((skill) => skill.name === selector)
|
||||
if (named.length === 0) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
`Installed skill "${selector}" was not found. Run \`orca skills installed\` to list valid selectors.`,
|
||||
{ selector }
|
||||
)
|
||||
}
|
||||
if (named.length > 1) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
`More than one installed skill is named "${selector}". Use its discovery ID from \`orca skills installed\`.`,
|
||||
{ selector, matchingIds: named.map((skill) => skill.id) }
|
||||
)
|
||||
}
|
||||
selected.set(named[0].id, named[0])
|
||||
}
|
||||
const values = [...selected.values()]
|
||||
const byName = new Map<string, DiscoveredSkill[]>()
|
||||
for (const skill of values) {
|
||||
byName.set(skill.name, [...(byName.get(skill.name) ?? []), skill])
|
||||
}
|
||||
const collision = [...byName.entries()].find(([, named]) => named.length > 1)
|
||||
if (collision) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
`The selected skills include more than one installed skill named "${collision[0]}". Publish them in separate bundles.`,
|
||||
{ selector: collision[0], matchingIds: collision[1].map((skill) => skill.id) }
|
||||
)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -204,11 +204,7 @@ describe('skill discovery', () => {
|
||||
await writeFile(join(codexSkills, 'review', 'SKILL.md'), '# review')
|
||||
await mkdir(join(home, '.agents'), { recursive: true })
|
||||
// Shared root is a symlink onto the Codex root: one canonical file, two roots.
|
||||
await symlink(
|
||||
codexSkills,
|
||||
join(home, '.agents', 'skills'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
await symlink(codexSkills, join(home, '.agents', 'skills'), 'dir')
|
||||
|
||||
const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false })
|
||||
|
||||
@@ -227,11 +223,7 @@ describe('skill discovery', () => {
|
||||
await writeFile(join(claudeSkills, 'orchestration', 'SKILL.md'), '# orchestration')
|
||||
// `npx skills add --global` links a provider home onto an existing install.
|
||||
await mkdir(join(home, '.grok'), { recursive: true })
|
||||
await symlink(
|
||||
claudeSkills,
|
||||
join(home, '.grok', 'skills'),
|
||||
process.platform === 'win32' ? 'junction' : 'dir'
|
||||
)
|
||||
await symlink(claudeSkills, join(home, '.grok', 'skills'), 'dir')
|
||||
|
||||
const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false })
|
||||
|
||||
@@ -293,16 +285,7 @@ describe('skill discovery', () => {
|
||||
'/home/test/.omp/agent/skills',
|
||||
'/home/test/.gemini/skills',
|
||||
'/home/test/.gemini/antigravity/skills',
|
||||
'/home/test/.cursor/skills',
|
||||
'/home/test/.factory/skills',
|
||||
'/home/test/.continue/skills',
|
||||
'/home/test/.trae-cn/skills',
|
||||
'/home/test/.augment/skills',
|
||||
'/workspace/current/.factory/skills',
|
||||
'/workspace/current/.continue/skills',
|
||||
'/workspace/current/.trae/skills',
|
||||
'/workspace/current/.grok/skills',
|
||||
'/workspace/current/.augment/skills'
|
||||
'/home/test/.cursor/skills'
|
||||
])
|
||||
)
|
||||
// Why: these live outside ~/.agents/skills, so they must carry the shared
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
SkillScanCoalescer,
|
||||
type SkillScanOutcome
|
||||
} from './skill-scan-coalescer'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
|
||||
export { buildSkillDiscoverySources } from './skill-discovery-sources'
|
||||
|
||||
@@ -208,7 +207,6 @@ export async function discoverSkills(args: {
|
||||
homeDir?: string
|
||||
cwd?: string
|
||||
includeCwd?: boolean
|
||||
providerRootOverrides?: SkillProviderRootOverrides
|
||||
refresh?: boolean
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
const startedAt = Date.now()
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_PLUGIN_MANIFEST_PATH,
|
||||
AGENT_PLUGIN_SCHEMA_V1
|
||||
} from '../../shared/skill-bundle-manifest'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
import { writeSkillTarGzip } from './skill-package-tar'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-test-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createSkill(root: string, name: string, description: string): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(
|
||||
join(directory, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`
|
||||
)
|
||||
await writeFile(join(directory, 'notes.txt'), `${name} notes\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle creation and extraction', () => {
|
||||
it('round trips multiple skills through an Agent Plugins compatible root', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(root, 'alpha-skill', 'Alpha')
|
||||
const beta = await createSkill(root, 'beta-skill', 'Beta')
|
||||
const created = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
description: 'Team skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
})
|
||||
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: created.archivePath,
|
||||
destinationDirectory: join(root, 'extracted'),
|
||||
expectedBundleDigest: created.manifest.bundleDigest
|
||||
})
|
||||
|
||||
expect(extracted.pluginManifest).toEqual({
|
||||
$schema: AGENT_PLUGIN_SCHEMA_V1,
|
||||
name: 'team-skills',
|
||||
version: 'version_1',
|
||||
description: 'Team skills'
|
||||
})
|
||||
expect(extracted.manifest.skills.map((skill) => skill.name)).toEqual([
|
||||
'alpha-skill',
|
||||
'beta-skill'
|
||||
])
|
||||
expect(await readFile(join(extracted.skillsDirectory, 'beta-skill', 'notes.txt'), 'utf8')).toBe(
|
||||
'beta-skill notes\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates deterministic archives regardless of source selection order', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(root, 'alpha-skill', 'Alpha')
|
||||
const beta = await createSkill(root, 'beta-skill', 'Beta')
|
||||
const publication = {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
}
|
||||
const first = await createSkillBundleArchive({
|
||||
...publication,
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(root, 'first.tar.gz')
|
||||
})
|
||||
const second = await createSkillBundleArchive({
|
||||
...publication,
|
||||
sources: [{ sourceDirectory: beta }, { sourceDirectory: alpha }],
|
||||
archivePath: join(root, 'second.tar.gz')
|
||||
})
|
||||
|
||||
expect(first.archiveSha256).toBe(second.archiveSha256)
|
||||
expect(await readFile(first.archivePath)).toEqual(await readFile(second.archivePath))
|
||||
})
|
||||
|
||||
it('packages and extracts thirty selected skills within the shared limits', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const names = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `skill-${String(index).padStart(2, '0')}`
|
||||
)
|
||||
const sources = await Promise.all(
|
||||
names.map(async (name) => ({
|
||||
sourceDirectory: await createSkill(root, name, `Description for ${name}`)
|
||||
}))
|
||||
)
|
||||
const created = await createSkillBundleArchive({
|
||||
sources: sources.toReversed(),
|
||||
archivePath: join(root, 'thirty-skills.tar.gz'),
|
||||
packageId: 'package_30',
|
||||
versionId: 'version_30',
|
||||
bundleName: 'thirty-skills'
|
||||
})
|
||||
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: created.archivePath,
|
||||
destinationDirectory: join(root, 'thirty-skills'),
|
||||
expectedBundleDigest: created.manifest.bundleDigest
|
||||
})
|
||||
|
||||
expect(extracted.manifest.skills.map((skill) => skill.name)).toEqual(names)
|
||||
expect(extracted.manifest.skills).toHaveLength(30)
|
||||
await expect(
|
||||
readFile(join(extracted.skillsDirectory, 'skill-29', 'notes.txt'), 'utf8')
|
||||
).resolves.toBe('skill-29 notes\n')
|
||||
})
|
||||
|
||||
it('rejects conflicting staging roots without changing their imported namespace', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const source = await createSkill(root, 'alpha-skill', 'Alpha')
|
||||
const created = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: source }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
const destination = join(root, 'conflicting-staging')
|
||||
const importedManifest = join(destination, 'dev.orca.skill-sharing', 'manifest.json')
|
||||
await mkdir(join(destination, 'dev.orca.skill-sharing'), { recursive: true })
|
||||
await writeFile(importedManifest, 'unowned\n')
|
||||
|
||||
await expect(
|
||||
extractSkillBundleArchive({
|
||||
archivePath: created.archivePath,
|
||||
destinationDirectory: destination
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'EEXIST' })
|
||||
await expect(readFile(importedManifest, 'utf8')).resolves.toBe('unowned\n')
|
||||
})
|
||||
|
||||
it('rejects unknown top-level extension namespaces and removes fresh staging', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const plugin = Buffer.from(
|
||||
JSON.stringify({ $schema: AGENT_PLUGIN_SCHEMA_V1, name: 'team-skills' })
|
||||
)
|
||||
const unknown = Buffer.from('{}')
|
||||
const archivePath = join(root, 'unknown-extension.tar.gz')
|
||||
await writeSkillTarGzip(archivePath, [
|
||||
{
|
||||
path: AGENT_PLUGIN_MANIFEST_PATH,
|
||||
size: plugin.length,
|
||||
executable: false,
|
||||
bytes: plugin
|
||||
},
|
||||
{
|
||||
path: 'dev.orca.unexpected/manifest.json',
|
||||
size: unknown.length,
|
||||
executable: false,
|
||||
bytes: unknown
|
||||
}
|
||||
])
|
||||
const destination = join(root, 'unknown-extension-staging')
|
||||
|
||||
await expect(
|
||||
extractSkillBundleArchive({ archivePath, destinationDirectory: destination })
|
||||
).rejects.toThrow('skill-bundle-manifest-envelope-invalid')
|
||||
await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects duplicate names and source drift without publishing an archive', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const first = await createSkill(root, 'same-skill', 'First')
|
||||
const secondRoot = join(root, 'other')
|
||||
const second = await createSkill(secondRoot, 'same-skill', 'Second')
|
||||
const archivePath = join(root, 'duplicate.tar.gz')
|
||||
|
||||
await expect(
|
||||
createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: first }, { sourceDirectory: second }],
|
||||
archivePath,
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
).rejects.toThrow('skill-bundle-skill-collision')
|
||||
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
})
|
||||
@@ -1,270 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
AGENT_PLUGIN_MANIFEST_PATH,
|
||||
AGENT_PLUGIN_SCHEMA_V1,
|
||||
ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
computeSkillBundleDigest,
|
||||
parseAgentPluginManifest,
|
||||
parseSkillBundleManifest,
|
||||
type AgentPluginManifestV1,
|
||||
type SkillBundleEntry,
|
||||
type SkillBundleManifestV1
|
||||
} from '../../shared/skill-bundle-manifest'
|
||||
import {
|
||||
computeSkillPackageDigest,
|
||||
validateSkillPackageName
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
import { observeSkillPackage, type ObservedSkillPackage } from './skill-package-identity'
|
||||
import { writeSkillTarGzip, type SkillTarWriteEntry } from './skill-package-tar'
|
||||
import { startSkillPhaseOperation } from './skill-operation-observability'
|
||||
|
||||
export type SkillBundleSource = { id?: string; sourceDirectory: string }
|
||||
|
||||
export type CreatedSkillBundle = {
|
||||
pluginManifest: AgentPluginManifestV1
|
||||
manifest: SkillBundleManifestV1
|
||||
archivePath: string
|
||||
archiveSha256: string
|
||||
compressedBytes: number
|
||||
}
|
||||
|
||||
export type SkillBundleCreationDependencies = { afterSourcesObserved?: () => Promise<void> }
|
||||
|
||||
const SOURCE_OBSERVATION_CONCURRENCY = 4
|
||||
|
||||
function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean {
|
||||
return (
|
||||
left.files.length === right.files.length &&
|
||||
left.files.every((file, index) => {
|
||||
const other = right.files[index]
|
||||
return (
|
||||
file.path === other.path &&
|
||||
file.size === other.size &&
|
||||
file.executable === other.executable &&
|
||||
file.classification === other.classification &&
|
||||
file.exactSha256 === other.exactSha256 &&
|
||||
file.identitySha256 === other.identitySha256
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function bundleEntry(input: {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
observed: ObservedSkillPackage
|
||||
}): SkillBundleEntry {
|
||||
const files = input.observed.files.map((file) => ({
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
executable: file.executable,
|
||||
classification: file.classification,
|
||||
sha256: file.exactSha256,
|
||||
identitySha256: file.identitySha256
|
||||
}))
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
digest: computeSkillPackageDigest(files),
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
async function stageSkill(input: {
|
||||
source: SkillBundleSource
|
||||
sourceObservation: ObservedSkillPackage
|
||||
skillsRoot: string
|
||||
name: string
|
||||
description: string
|
||||
}): Promise<SkillBundleEntry> {
|
||||
const stagedDirectory = join(input.skillsRoot, input.name)
|
||||
await cp(input.source.sourceDirectory, stagedDirectory, {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true
|
||||
})
|
||||
const stagedObservation = await observeSkillPackage(stagedDirectory)
|
||||
if (!observationsMatch(input.sourceObservation, stagedObservation)) {
|
||||
throw new Error('skill-package-source-changed-during-staging')
|
||||
}
|
||||
const stagedSummary = summarizeSkillMarkdown(
|
||||
await readFile(join(stagedDirectory, 'SKILL.md'), 'utf8')
|
||||
)
|
||||
if (
|
||||
stagedSummary.name !== input.name ||
|
||||
(stagedSummary.description ?? '') !== input.description
|
||||
) {
|
||||
throw new Error('skill-package-source-changed-during-staging')
|
||||
}
|
||||
return bundleEntry({
|
||||
id: input.source.id ?? input.name,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
observed: stagedObservation
|
||||
})
|
||||
}
|
||||
|
||||
async function createSkillBundleArchiveUnobserved(
|
||||
input: {
|
||||
sources: readonly SkillBundleSource[]
|
||||
archivePath: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleName: string
|
||||
description?: string
|
||||
createdAt?: string
|
||||
},
|
||||
dependencies: SkillBundleCreationDependencies = {}
|
||||
): Promise<CreatedSkillBundle> {
|
||||
if (input.sources.length === 0) {
|
||||
throw new Error('skill-bundle-empty')
|
||||
}
|
||||
const sourceObservations: ObservedSkillPackage[] = []
|
||||
const summaries: { name: string; description: string }[] = []
|
||||
for (let offset = 0; offset < input.sources.length; offset += SOURCE_OBSERVATION_CONCURRENCY) {
|
||||
const batch = input.sources.slice(offset, offset + SOURCE_OBSERVATION_CONCURRENCY)
|
||||
const observed = await Promise.all(
|
||||
batch.map(async (source) => {
|
||||
const [observation, markdown] = await Promise.all([
|
||||
observeSkillPackage(source.sourceDirectory),
|
||||
readFile(join(source.sourceDirectory, 'SKILL.md'), 'utf8')
|
||||
])
|
||||
const summary = summarizeSkillMarkdown(markdown)
|
||||
if (!summary.name) {
|
||||
throw new Error('skill-package-skill-name-required')
|
||||
}
|
||||
validateSkillPackageName(summary.name)
|
||||
return {
|
||||
observation,
|
||||
summary: { name: summary.name, description: summary.description ?? '' }
|
||||
}
|
||||
})
|
||||
)
|
||||
sourceObservations.push(...observed.map((value) => value.observation))
|
||||
summaries.push(...observed.map((value) => value.summary))
|
||||
}
|
||||
const foldedNames = new Set<string>()
|
||||
for (const summary of summaries) {
|
||||
const foldedName = summary.name.toLocaleLowerCase('en-US')
|
||||
if (foldedNames.has(foldedName)) {
|
||||
throw new Error('skill-bundle-skill-collision')
|
||||
}
|
||||
foldedNames.add(foldedName)
|
||||
}
|
||||
await dependencies.afterSourcesObserved?.()
|
||||
const workDirectory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-'))
|
||||
const skillsRoot = join(workDirectory, 'skills')
|
||||
const verificationDirectory = join(workDirectory, 'verification')
|
||||
const temporaryArchive = `${input.archivePath}.${process.pid}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await mkdir(skillsRoot, { recursive: true })
|
||||
const skills: SkillBundleEntry[] = []
|
||||
for (const [index, source] of input.sources.entries()) {
|
||||
const skill = await stageSkill({
|
||||
source,
|
||||
sourceObservation: sourceObservations[index],
|
||||
skillsRoot,
|
||||
...summaries[index]
|
||||
})
|
||||
skills.push(skill)
|
||||
}
|
||||
skills.sort((left, right) => left.name.localeCompare(right.name, 'en-US'))
|
||||
const manifest = parseSkillBundleManifest({
|
||||
schemaVersion: 1,
|
||||
packageId: input.packageId,
|
||||
versionId: input.versionId,
|
||||
bundleName: input.bundleName,
|
||||
description: input.description ?? '',
|
||||
createdAt: input.createdAt ?? new Date().toISOString(),
|
||||
skills,
|
||||
bundleDigest: computeSkillBundleDigest(skills)
|
||||
})
|
||||
const pluginManifest = parseAgentPluginManifest({
|
||||
$schema: AGENT_PLUGIN_SCHEMA_V1,
|
||||
name: manifest.bundleName,
|
||||
version: manifest.versionId,
|
||||
description: manifest.description
|
||||
})
|
||||
const pluginBytes = Buffer.from(JSON.stringify(pluginManifest), 'utf8')
|
||||
const manifestBytes = Buffer.from(JSON.stringify(manifest), 'utf8')
|
||||
const entries: SkillTarWriteEntry[] = [
|
||||
{
|
||||
path: AGENT_PLUGIN_MANIFEST_PATH,
|
||||
size: pluginBytes.length,
|
||||
executable: false,
|
||||
bytes: pluginBytes
|
||||
},
|
||||
{
|
||||
path: ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
size: manifestBytes.length,
|
||||
executable: false,
|
||||
bytes: manifestBytes
|
||||
},
|
||||
...manifest.skills.flatMap((skill) =>
|
||||
skill.files.map((file) => ({
|
||||
path: `skills/${skill.name}/${file.path}`,
|
||||
size: file.size,
|
||||
executable: file.executable,
|
||||
sourcePath: join(skillsRoot, skill.name, ...file.path.split('/'))
|
||||
}))
|
||||
)
|
||||
]
|
||||
await mkdir(dirname(input.archivePath), { recursive: true })
|
||||
const archiveIdentity = await writeSkillTarGzip(temporaryArchive, entries)
|
||||
await extractSkillBundleArchive({
|
||||
archivePath: temporaryArchive,
|
||||
destinationDirectory: verificationDirectory,
|
||||
expectedArchiveSha256: archiveIdentity.archiveSha256,
|
||||
expectedBundleDigest: manifest.bundleDigest,
|
||||
expectedPackageId: manifest.packageId,
|
||||
expectedVersionId: manifest.versionId
|
||||
})
|
||||
await renameSkillPathWithWindowsRetry(temporaryArchive, input.archivePath)
|
||||
return { pluginManifest, manifest, archivePath: input.archivePath, ...archiveIdentity }
|
||||
} finally {
|
||||
await rm(workDirectory, { recursive: true, force: true })
|
||||
await rm(temporaryArchive, { force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSkillBundleArchive(
|
||||
input: {
|
||||
sources: readonly SkillBundleSource[]
|
||||
archivePath: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleName: string
|
||||
description?: string
|
||||
createdAt?: string
|
||||
},
|
||||
dependencies: SkillBundleCreationDependencies = {}
|
||||
): Promise<CreatedSkillBundle> {
|
||||
const operation = startSkillPhaseOperation({
|
||||
phase: 'package',
|
||||
packageKind: 'bundle',
|
||||
skillCount: input.sources.length
|
||||
})
|
||||
try {
|
||||
const created = await createSkillBundleArchiveUnobserved(input, dependencies)
|
||||
const files = created.manifest.skills.flatMap((skill) => skill.files)
|
||||
operation.complete({
|
||||
status: 'complete',
|
||||
fileCount: files.length,
|
||||
totalBytes: files.reduce((total, file) => total + file.size, 0),
|
||||
compressedBytes: created.compressedBytes
|
||||
})
|
||||
return created
|
||||
} catch (error) {
|
||||
operation.fail(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import {
|
||||
AGENT_PLUGIN_MANIFEST_PATH,
|
||||
ORCA_SKILL_BUNDLE_MANIFEST_PATH,
|
||||
parseAgentPluginManifest,
|
||||
parseSkillBundleManifest,
|
||||
type AgentPluginManifestV1,
|
||||
type SkillBundleManifestV1
|
||||
} from '../../shared/skill-bundle-manifest'
|
||||
import {
|
||||
SKILL_PACKAGE_MAX_MANIFEST_BYTES,
|
||||
type SkillPackageFile
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
import { observeSkillPackage } from './skill-package-identity'
|
||||
import {
|
||||
openSkillTarGzip,
|
||||
parseSkillTarHeader,
|
||||
SKILL_TAR_BLOCK_BYTES,
|
||||
type TarByteReader
|
||||
} from './skill-package-tar'
|
||||
import { SkillInstallOperationError } from './skill-install-operation-error'
|
||||
|
||||
export type SkillBundleExtractionResult = {
|
||||
pluginManifest: AgentPluginManifestV1
|
||||
manifest: SkillBundleManifestV1
|
||||
skillsDirectory: string
|
||||
archiveSha256: string
|
||||
compressedBytes: number
|
||||
}
|
||||
|
||||
const SKILL_VERIFICATION_CONCURRENCY = 4
|
||||
|
||||
async function consumePadding(reader: TarByteReader, size: number): Promise<void> {
|
||||
const padding = (SKILL_TAR_BLOCK_BYTES - (size % SKILL_TAR_BLOCK_BYTES)) % SKILL_TAR_BLOCK_BYTES
|
||||
if (padding > 0 && !(await reader.readExact(padding)).every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-padding-invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonEntry(
|
||||
reader: TarByteReader,
|
||||
expectedPath: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
throwIfCancelled(signal)
|
||||
const header = parseSkillTarHeader(await reader.readExact(SKILL_TAR_BLOCK_BYTES))
|
||||
if (
|
||||
!header ||
|
||||
header.path !== expectedPath ||
|
||||
header.executable ||
|
||||
header.size > SKILL_PACKAGE_MAX_MANIFEST_BYTES
|
||||
) {
|
||||
throw new Error('skill-bundle-manifest-envelope-invalid')
|
||||
}
|
||||
const bytes = await reader.readExact(header.size)
|
||||
throwIfCancelled(signal)
|
||||
await consumePadding(reader, header.size)
|
||||
try {
|
||||
return JSON.parse(bytes.toString('utf8'))
|
||||
} catch {
|
||||
throw new Error('skill-bundle-manifest-invalid')
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFile(
|
||||
reader: TarByteReader,
|
||||
destination: string,
|
||||
expected: SkillPackageFile,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
throwIfCancelled(signal)
|
||||
await mkdir(dirname(destination), { recursive: true, mode: 0o700 })
|
||||
const handle = await open(destination, 'wx', expected.executable ? 0o700 : 0o600)
|
||||
const hash = createHash('sha256')
|
||||
let offset = 0
|
||||
try {
|
||||
while (offset < expected.size) {
|
||||
throwIfCancelled(signal)
|
||||
const bytes = await reader.readExact(Math.min(64 * 1024, expected.size - offset))
|
||||
hash.update(bytes)
|
||||
let written = 0
|
||||
while (written < bytes.length) {
|
||||
const result = await handle.write(bytes, written, bytes.length - written, offset + written)
|
||||
if (result.bytesWritten === 0) {
|
||||
throw new Error('skill-package-extraction-write-failed')
|
||||
}
|
||||
written += result.bytesWritten
|
||||
}
|
||||
offset += bytes.length
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (hash.digest('hex') !== expected.sha256) {
|
||||
throw new Error('skill-package-file-digest-mismatch')
|
||||
}
|
||||
await consumePadding(reader, expected.size)
|
||||
}
|
||||
|
||||
async function requireArchiveEnd(reader: TarByteReader, signal?: AbortSignal): Promise<void> {
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
throwIfCancelled(signal)
|
||||
if (!(await reader.readExact(SKILL_TAR_BLOCK_BYTES)).every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-trailing-entry')
|
||||
}
|
||||
}
|
||||
for (;;) {
|
||||
throwIfCancelled(signal)
|
||||
const block = await reader.readExactOrNull(SKILL_TAR_BLOCK_BYTES)
|
||||
if (!block) {
|
||||
return
|
||||
}
|
||||
if (!block.every((byte) => byte === 0)) {
|
||||
throw new Error('skill-package-tar-trailing-data')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySkill(input: {
|
||||
directory: string
|
||||
manifest: SkillBundleManifestV1['skills'][number]
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
throwIfCancelled(input.signal)
|
||||
const observed = await observeSkillPackage(input.directory, undefined, undefined, input.signal)
|
||||
throwIfCancelled(input.signal)
|
||||
if (
|
||||
observed.observedDigest !== input.manifest.digest ||
|
||||
observed.files.length !== input.manifest.files.length ||
|
||||
observed.files.some((file, index) => {
|
||||
const expected = input.manifest.files[index]
|
||||
return (
|
||||
file.path !== expected.path ||
|
||||
file.exactSha256 !== expected.sha256 ||
|
||||
file.identitySha256 !== expected.identitySha256 ||
|
||||
file.executable !== expected.executable
|
||||
)
|
||||
})
|
||||
) {
|
||||
throw new Error('skill-bundle-extracted-identity-mismatch')
|
||||
}
|
||||
const summary = summarizeSkillMarkdown(await readFile(join(input.directory, 'SKILL.md'), 'utf8'))
|
||||
if (summary.name !== input.manifest.name) {
|
||||
throw new Error('skill-package-skill-name-mismatch')
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractSkillBundleArchive(input: {
|
||||
archivePath: string
|
||||
destinationDirectory: string
|
||||
expectedArchiveSha256?: string
|
||||
expectedBundleDigest?: string
|
||||
expectedPackageId?: string
|
||||
expectedVersionId?: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<SkillBundleExtractionResult> {
|
||||
const archive = await openSkillTarGzip(input.archivePath)
|
||||
let destinationCreated = false
|
||||
try {
|
||||
throwIfCancelled(input.signal)
|
||||
await mkdir(input.destinationDirectory, { mode: 0o700 })
|
||||
destinationCreated = true
|
||||
const pluginManifest = parseAgentPluginManifest(
|
||||
await readJsonEntry(archive.reader, AGENT_PLUGIN_MANIFEST_PATH, input.signal)
|
||||
)
|
||||
const manifest = parseSkillBundleManifest(
|
||||
await readJsonEntry(archive.reader, ORCA_SKILL_BUNDLE_MANIFEST_PATH, input.signal)
|
||||
)
|
||||
if (
|
||||
pluginManifest.name !== manifest.bundleName ||
|
||||
pluginManifest.version !== manifest.versionId ||
|
||||
(input.expectedBundleDigest && input.expectedBundleDigest !== manifest.bundleDigest) ||
|
||||
(input.expectedPackageId && input.expectedPackageId !== manifest.packageId) ||
|
||||
(input.expectedVersionId && input.expectedVersionId !== manifest.versionId)
|
||||
) {
|
||||
throw new Error('skill-bundle-identity-mismatch')
|
||||
}
|
||||
const skillsDirectory = join(input.destinationDirectory, 'skills')
|
||||
await mkdir(skillsDirectory, { mode: 0o700 })
|
||||
for (const skill of manifest.skills) {
|
||||
for (const file of skill.files) {
|
||||
throwIfCancelled(input.signal)
|
||||
const archivePath = `skills/${skill.name}/${file.path}`
|
||||
const header = parseSkillTarHeader(await archive.reader.readExact(SKILL_TAR_BLOCK_BYTES))
|
||||
if (
|
||||
!header ||
|
||||
header.path !== archivePath ||
|
||||
header.size !== file.size ||
|
||||
header.executable !== file.executable
|
||||
) {
|
||||
throw new Error('skill-bundle-file-envelope-mismatch')
|
||||
}
|
||||
await extractFile(
|
||||
archive.reader,
|
||||
join(skillsDirectory, skill.name, ...file.path.split('/')),
|
||||
file,
|
||||
input.signal
|
||||
)
|
||||
}
|
||||
}
|
||||
await requireArchiveEnd(archive.reader, input.signal)
|
||||
const archiveIdentity = await archive.archiveIdentity
|
||||
if (
|
||||
input.expectedArchiveSha256 &&
|
||||
archiveIdentity.archiveSha256 !== input.expectedArchiveSha256
|
||||
) {
|
||||
throw new Error('skill-package-archive-digest-mismatch')
|
||||
}
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < manifest.skills.length;
|
||||
offset += SKILL_VERIFICATION_CONCURRENCY
|
||||
) {
|
||||
const batch = manifest.skills.slice(offset, offset + SKILL_VERIFICATION_CONCURRENCY)
|
||||
await Promise.all(
|
||||
batch.map((skill) =>
|
||||
verifySkill({
|
||||
directory: join(skillsDirectory, skill.name),
|
||||
manifest: skill,
|
||||
signal: input.signal
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
throwIfCancelled(input.signal)
|
||||
return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity }
|
||||
} catch (error) {
|
||||
const failure = input.signal?.aborted
|
||||
? new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE, { cause: error })
|
||||
: error instanceof Error
|
||||
? error
|
||||
: new Error(String(error))
|
||||
archive.abort(failure)
|
||||
await archive.archiveIdentity.catch(() => undefined)
|
||||
if (destinationCreated) {
|
||||
await rm(input.destinationDirectory, { recursive: true, force: true })
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import {
|
||||
type SkillBundleInstallProgress,
|
||||
SkillBundleInstallRequestSchema,
|
||||
type SkillBundleInstallRequest,
|
||||
type SkillBundleInstallResult,
|
||||
type SkillBundlePackageIdentity
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { resolveSkillInstallDestination } from './skill-install-destinations'
|
||||
import type { SkillInstallDestinationAuthority } from './skill-install-destinations'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
|
||||
import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers'
|
||||
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
import { startSkillBundleInstallOperation } from './skill-operation-observability'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
|
||||
type StagedSkillBundle = { archivePath: string; cleanup(): Promise<void> }
|
||||
|
||||
export type SkillBundleInstallRequestDependencies = {
|
||||
authority: SkillInstallDestinationAuthority
|
||||
stateDirectory: string
|
||||
allowedDownloadOrigins: readonly string[]
|
||||
requireHttps: boolean
|
||||
allowTrustedLocalFile?: boolean
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: SkillBundleInstallProgress) => void
|
||||
fetcher?: typeof fetch
|
||||
detectProviders: () => Promise<readonly string[]>
|
||||
resolveProviderRootOverrides?: (destination: {
|
||||
scope: 'global' | 'workspace'
|
||||
homeDirectory: string
|
||||
workspaceDirectory?: string
|
||||
wslDistro?: string
|
||||
}) => Promise<SkillProviderRootOverrides> | SkillProviderRootOverrides
|
||||
resolveStagedUpload?: (
|
||||
uploadId: string,
|
||||
identity: SkillBundlePackageIdentity
|
||||
) => Promise<StagedSkillBundle>
|
||||
}
|
||||
|
||||
async function resolveIngress(
|
||||
request: SkillBundleInstallRequest,
|
||||
dependencies: SkillBundleInstallRequestDependencies
|
||||
): Promise<StagedSkillBundle> {
|
||||
if (request.ingress.kind === 'download-grant') {
|
||||
return downloadSkillPackageGrant({
|
||||
url: request.ingress.url,
|
||||
expiresAt: request.ingress.expiresAt,
|
||||
expectedArchiveSha256: request.package.archiveSha256,
|
||||
expectedCompressedBytes: request.package.compressedBytes,
|
||||
temporaryRoot: join(dependencies.stateDirectory, 'skill-installs', 'downloads'),
|
||||
allowedOrigins: dependencies.allowedDownloadOrigins,
|
||||
requireHttps: dependencies.requireHttps,
|
||||
signal: dependencies.signal,
|
||||
fetcher: dependencies.fetcher
|
||||
})
|
||||
}
|
||||
if (request.ingress.kind === 'staged-upload') {
|
||||
if (!dependencies.resolveStagedUpload) {
|
||||
throw new Error('skill-bundle-staged-upload-unsupported')
|
||||
}
|
||||
return dependencies.resolveStagedUpload(request.ingress.uploadId, request.package)
|
||||
}
|
||||
if (!dependencies.allowTrustedLocalFile || !isAbsolute(request.ingress.path)) {
|
||||
throw new Error('skill-install-local-ingress-rejected')
|
||||
}
|
||||
return { archivePath: request.ingress.path, cleanup: async () => undefined }
|
||||
}
|
||||
|
||||
async function executeParsedSkillBundleInstallRequest(
|
||||
request: SkillBundleInstallRequest,
|
||||
dependencies: SkillBundleInstallRequestDependencies
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
let ingress: StagedSkillBundle | null = null
|
||||
try {
|
||||
const destination = await resolveSkillInstallDestination(
|
||||
request.destination,
|
||||
dependencies.authority
|
||||
)
|
||||
ingress = await resolveIngress(request, dependencies)
|
||||
const detectedProviders = selectedOrDetectedSkillProviders(
|
||||
destination.wslDistro
|
||||
? await detectSkillProvidersInWsl(destination.wslDistro)
|
||||
: await dependencies.detectProviders(),
|
||||
request.providers
|
||||
)
|
||||
const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
|
||||
const filesystem = destination.wslDistro
|
||||
? createWslSkillInstallFilesystem({
|
||||
distro: destination.wslDistro,
|
||||
homeDirectory: destination.homeDirectory,
|
||||
workspaceDirectory: destination.workspaceDirectory,
|
||||
providerRootOverrides
|
||||
})
|
||||
: undefined
|
||||
return await installSkillBundle({
|
||||
operationId: request.operationId,
|
||||
archivePath: ingress.archivePath,
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
bundleDigest: request.package.bundleDigest,
|
||||
selectedSkillIds: request.selectedSkillIds,
|
||||
conflictDecisions: new Map(
|
||||
request.conflictDecisions.map((decision) => [decision.skillId, decision.resolution])
|
||||
),
|
||||
scope: destination.scope,
|
||||
homeDirectory: destination.homeDirectory,
|
||||
workspaceDirectory: destination.workspaceDirectory,
|
||||
orcaStateDirectory: dependencies.stateDirectory,
|
||||
detectedProviders,
|
||||
providerRootOverrides,
|
||||
destinationIdentity: destination.destinationIdentity,
|
||||
hostIdentity: dependencies.authority.environmentId,
|
||||
expectedArchiveSha256: request.package.archiveSha256,
|
||||
filesystem,
|
||||
wslDistro: destination.wslDistro,
|
||||
signal: dependencies.signal,
|
||||
onProgress: dependencies.onProgress
|
||||
})
|
||||
} finally {
|
||||
await ingress?.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSkillBundleInstallRequest(
|
||||
input: unknown,
|
||||
dependencies: SkillBundleInstallRequestDependencies
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
const request = SkillBundleInstallRequestSchema.parse(input)
|
||||
const operation = startSkillBundleInstallOperation(request)
|
||||
try {
|
||||
const result = await executeParsedSkillBundleInstallRequest(request, dependencies)
|
||||
operation.complete(result)
|
||||
return result
|
||||
} catch (error) {
|
||||
operation.fail(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
import { readdirSync, statSync } from 'node:fs'
|
||||
import { 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'
|
||||
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
import { listManagedSkillInstalls } from './skill-install-provenance'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function temporaryDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-install-test-'))
|
||||
temporaryDirectories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function createSkill(root: string, name: string): Promise<string> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(
|
||||
join(directory, 'SKILL.md'),
|
||||
`---\nname: ${name}\ndescription: ${name}\n---\n\n# ${name}\n`
|
||||
)
|
||||
return directory
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('skill bundle installation', () => {
|
||||
it('cancels before extracting bundle bytes and removes staging', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const source = await createSkill(join(root, 'sources'), 'alpha-skill')
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: source }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_cancel',
|
||||
versionId: 'version_cancel',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(
|
||||
installSkillBundle({
|
||||
operationId: 'operation_cancel',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['alpha-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory: join(root, 'home'),
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'local-global',
|
||||
hostIdentity: 'host_1',
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toMatchObject({ data: SKILL_INSTALL_CANCELLED_FAILURE })
|
||||
await expect(readdir(join(root, 'home', '.agents', 'skills'))).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('cancels during bundle extraction and removes its journal and partial bytes', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const source = await createSkill(join(root, 'sources'), 'alpha-skill')
|
||||
const payloadBytes = 256 * 1024
|
||||
await writeFile(join(source, 'payload.bin'), Buffer.alloc(payloadBytes, 0x61))
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: source }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_mid_extract_cancel',
|
||||
versionId: 'version_mid_extract_cancel',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
const destinationRoot = join(root, 'home', '.agents', 'skills')
|
||||
const controller = new AbortController()
|
||||
let observedPartialBytes = false
|
||||
const signal = new Proxy(controller.signal, {
|
||||
get(target, property) {
|
||||
if (property === 'aborted' && !target.aborted) {
|
||||
const extraction = readdirSync(destinationRoot, { withFileTypes: true }).find(
|
||||
(entry) => entry.isDirectory() && entry.name.startsWith('.orca-skill-extract-')
|
||||
)
|
||||
if (extraction) {
|
||||
const size = (() => {
|
||||
try {
|
||||
return statSync(
|
||||
join(destinationRoot, extraction.name, 'skills', 'alpha-skill', 'payload.bin')
|
||||
).size
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
if (size > 0 && size < payloadBytes) {
|
||||
observedPartialBytes = true
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
const value = Reflect.get(target, property, target) as unknown
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
installSkillBundle({
|
||||
operationId: 'operation_mid_extract_cancel',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['alpha-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory: join(root, 'home'),
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'local-global',
|
||||
hostIdentity: 'host_1',
|
||||
signal
|
||||
})
|
||||
).rejects.toMatchObject({ data: SKILL_INSTALL_CANCELLED_FAILURE })
|
||||
|
||||
expect(observedPartialBytes).toBe(true)
|
||||
await expect(readdir(destinationRoot)).resolves.toEqual([])
|
||||
await expect(
|
||||
readdir(join(root, 'state', 'skill-installs', 'extraction-journals'))
|
||||
).resolves.toEqual([])
|
||||
await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('installs a selected subset and keeps an unowned conflict local', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(join(root, 'sources'), 'alpha-skill')
|
||||
const beta = await createSkill(join(root, 'sources'), 'beta-skill')
|
||||
const homeDirectory = join(root, 'home')
|
||||
const existingBeta = join(homeDirectory, '.agents', 'skills', 'beta-skill')
|
||||
await mkdir(existingBeta, { recursive: true })
|
||||
await writeFile(
|
||||
join(existingBeta, 'SKILL.md'),
|
||||
'---\nname: beta-skill\ndescription: Local\n---\n\n# Keep me\n'
|
||||
)
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: alpha }, { sourceDirectory: beta }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
})
|
||||
|
||||
const onProgress = vi.fn((progress: { skillIndex: number }) => {
|
||||
if (progress.skillIndex === 1) {
|
||||
throw new Error('renderer closed')
|
||||
}
|
||||
})
|
||||
const result = await installSkillBundle({
|
||||
operationId: 'operation_1',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['alpha-skill', 'beta-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory,
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'local-global',
|
||||
hostIdentity: 'host_1',
|
||||
onProgress
|
||||
})
|
||||
|
||||
expect(result.status).toBe('partial')
|
||||
expect(result.skills.map((skill) => [skill.name, skill.status])).toEqual([
|
||||
['alpha-skill', 'installed'],
|
||||
['beta-skill', 'kept-local']
|
||||
])
|
||||
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
|
||||
{
|
||||
operationId: 'operation_1',
|
||||
skillId: 'alpha-skill',
|
||||
skillName: 'alpha-skill',
|
||||
skillIndex: 1,
|
||||
skillCount: 2
|
||||
},
|
||||
{
|
||||
operationId: 'operation_1',
|
||||
skillId: 'beta-skill',
|
||||
skillName: 'beta-skill',
|
||||
skillIndex: 2,
|
||||
skillCount: 2
|
||||
}
|
||||
])
|
||||
expect(
|
||||
await readFile(join(homeDirectory, '.agents', 'skills', 'alpha-skill', 'SKILL.md'), 'utf8')
|
||||
).toContain('# alpha-skill')
|
||||
expect(await readFile(join(existingBeta, 'SKILL.md'), 'utf8')).toContain('# Keep me')
|
||||
await expect(listManagedSkillInstalls(join(root, 'state', 'skill-installs'))).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alpha-skill',
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: bundle.manifest.bundleDigest
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('reports partial when a selected provider placement is unowned', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const alpha = await createSkill(join(root, 'sources'), 'alpha-skill')
|
||||
const homeDirectory = join(root, 'home')
|
||||
const providerPath = join(homeDirectory, '.claude', 'skills', 'alpha-skill')
|
||||
await mkdir(providerPath, { recursive: true })
|
||||
await writeFile(join(providerPath, 'SKILL.md'), '# Unowned')
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: alpha }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_provider_partial',
|
||||
versionId: 'version_1',
|
||||
bundleName: 'team-skills'
|
||||
})
|
||||
|
||||
const result = await installSkillBundle({
|
||||
operationId: 'operation_provider_partial',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['alpha-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory,
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: ['claude'],
|
||||
destinationIdentity: 'local-global',
|
||||
hostIdentity: 'host_1'
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'partial',
|
||||
skills: [
|
||||
{
|
||||
name: 'alpha-skill',
|
||||
status: 'installed',
|
||||
placements: expect.arrayContaining([
|
||||
expect.objectContaining({ provider: 'claude', status: 'skipped' })
|
||||
])
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,268 +0,0 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
SkillBundleInstallProgress,
|
||||
SkillBundleInstallResult,
|
||||
SkillBundleSkillResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import { SkillBundleInstallResultSchema } from '../../shared/skill-bundle-install-contract'
|
||||
import type { SkillBundleEntry } from '../../shared/skill-bundle-manifest'
|
||||
import { parseSkillPackageManifest } from '../../shared/skill-package-manifest'
|
||||
import { extractSkillBundleArchive } from './skill-bundle-extraction'
|
||||
import {
|
||||
installSharedExtractedSkill,
|
||||
skillInstallLocalInput,
|
||||
type SkillInstallServiceInput
|
||||
} from './skill-install-service'
|
||||
import {
|
||||
previewLocalExtractedSkillPackage,
|
||||
type LocalExtractedSkillPackage
|
||||
} from './skill-install-transaction'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
import { renameSkillPathWithWindowsRetry } from './skill-filesystem-retry'
|
||||
import {
|
||||
beginSkillExtractionRecovery,
|
||||
finishSkillExtractionRecovery
|
||||
} from './skill-extraction-recovery'
|
||||
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
|
||||
|
||||
export type SkillBundleInstallServiceInput = Omit<
|
||||
SkillInstallServiceInput,
|
||||
'expectedPackageDigest' | 'expectedPackageId' | 'expectedVersionId' | 'conflictResolution'
|
||||
> & {
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleDigest: string
|
||||
selectedSkillIds: readonly string[]
|
||||
conflictDecisions?: ReadonlyMap<
|
||||
string,
|
||||
'keep-local' | 'replace-unmodified' | 'replace-and-discard-local'
|
||||
>
|
||||
onProgress?: (progress: SkillBundleInstallProgress) => void
|
||||
}
|
||||
|
||||
function skillManifest(input: {
|
||||
packageId: string
|
||||
versionId: string
|
||||
createdAt: string
|
||||
skill: SkillBundleEntry
|
||||
}) {
|
||||
return parseSkillPackageManifest({
|
||||
schemaVersion: 1,
|
||||
packageId: input.packageId,
|
||||
versionId: input.versionId,
|
||||
name: input.skill.name,
|
||||
description: input.skill.description,
|
||||
createdAt: input.createdAt,
|
||||
files: input.skill.files,
|
||||
packageDigest: input.skill.digest
|
||||
})
|
||||
}
|
||||
|
||||
function conflictResult(
|
||||
skill: SkillBundleEntry,
|
||||
preview: Awaited<ReturnType<typeof previewLocalExtractedSkillPackage>>
|
||||
): SkillBundleSkillResult {
|
||||
const state = preview.currentState
|
||||
const kind =
|
||||
state.kind === 'modified' ||
|
||||
state.kind === 'unowned' ||
|
||||
state.kind === 'external-link' ||
|
||||
state.kind === 'name-collision'
|
||||
? state.kind
|
||||
: 'modified'
|
||||
return {
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: 'kept-local',
|
||||
placements: [],
|
||||
conflict: {
|
||||
kind,
|
||||
...('digest' in state && state.digest ? { existingDigest: state.digest } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function completedSkillResult(input: {
|
||||
skill: SkillBundleEntry
|
||||
preview: Awaited<ReturnType<typeof previewLocalExtractedSkillPackage>>
|
||||
result: Awaited<ReturnType<typeof installSharedExtractedSkill>>
|
||||
}): SkillBundleSkillResult {
|
||||
const status =
|
||||
input.result.status === 'partial'
|
||||
? input.preview.currentState.kind === 'missing'
|
||||
? 'installed'
|
||||
: input.preview.currentState.kind === 'unchanged'
|
||||
? 'unchanged'
|
||||
: 'updated'
|
||||
: input.result.status === 'conflict'
|
||||
? 'kept-local'
|
||||
: input.result.status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: input.result.status === 'failed'
|
||||
? 'failed'
|
||||
: input.result.status === 'removed'
|
||||
? 'failed'
|
||||
: input.result.status
|
||||
return {
|
||||
skillId: input.skill.id,
|
||||
name: input.skill.name,
|
||||
digest: input.skill.digest,
|
||||
status,
|
||||
...(input.result.canonicalPath ? { canonicalPath: input.result.canonicalPath } : {}),
|
||||
placements: input.result.placements
|
||||
.filter((placement) => placement.status !== 'removed')
|
||||
.map((placement) => ({
|
||||
...placement,
|
||||
status: placement.status === 'removed' ? 'skipped' : placement.status
|
||||
})),
|
||||
...(input.result.conflict ? { conflict: input.result.conflict } : {}),
|
||||
...(input.result.errorCategory ? { errorCategory: input.result.errorCategory } : {}),
|
||||
...(input.result.failure ? { failure: input.result.failure } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function bundleStatus(
|
||||
skills: readonly SkillBundleSkillResult[]
|
||||
): SkillBundleInstallResult['status'] {
|
||||
if (skills.every((skill) => skill.status === 'cancelled')) {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (skills.every((skill) => skill.status === 'failed')) {
|
||||
return 'failed'
|
||||
}
|
||||
return skills.some(
|
||||
(skill) =>
|
||||
['kept-local', 'failed', 'cancelled'].includes(skill.status) ||
|
||||
skill.placements.some(
|
||||
(placement) => placement.status === 'failed' || placement.status === 'skipped'
|
||||
)
|
||||
)
|
||||
? 'partial'
|
||||
: 'complete'
|
||||
}
|
||||
|
||||
export async function installSkillBundle(
|
||||
input: SkillBundleInstallServiceInput
|
||||
): Promise<SkillBundleInstallResult> {
|
||||
const destinationRoot = skillInstallLocalInput({
|
||||
...input,
|
||||
expectedPackageDigest: input.bundleDigest,
|
||||
expectedPackageId: input.packageId,
|
||||
expectedVersionId: input.versionId
|
||||
}).destinationRoot
|
||||
await mkdir(destinationRoot, { recursive: true })
|
||||
const stateDirectory = join(input.orcaStateDirectory, 'skill-installs')
|
||||
const recovery = await beginSkillExtractionRecovery(
|
||||
stateDirectory,
|
||||
destinationRoot,
|
||||
input.wslDistro
|
||||
)
|
||||
const extractionPath = recovery.extractionPath
|
||||
try {
|
||||
const extracted = await extractSkillBundleArchive({
|
||||
archivePath: input.archivePath,
|
||||
destinationDirectory: extractionPath,
|
||||
expectedArchiveSha256: input.expectedArchiveSha256,
|
||||
expectedBundleDigest: input.bundleDigest,
|
||||
expectedPackageId: input.packageId,
|
||||
expectedVersionId: input.versionId,
|
||||
signal: input.signal
|
||||
})
|
||||
const selected = new Set(input.selectedSkillIds)
|
||||
const skills = extracted.manifest.skills.filter((skill) => selected.has(skill.id))
|
||||
if (skills.length !== selected.size) {
|
||||
throw new Error('skill-bundle-selection-invalid')
|
||||
}
|
||||
const results: SkillBundleSkillResult[] = []
|
||||
for (const [index, skill] of skills.entries()) {
|
||||
try {
|
||||
input.onProgress?.({
|
||||
operationId: input.operationId,
|
||||
skillId: skill.id,
|
||||
skillName: skill.name,
|
||||
skillIndex: index + 1,
|
||||
skillCount: skills.length
|
||||
})
|
||||
} catch {
|
||||
// Why: progress observers cannot participate in the install transaction.
|
||||
}
|
||||
if (input.signal?.aborted) {
|
||||
results.push({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: 'cancelled',
|
||||
placements: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
const manifest = skillManifest({
|
||||
packageId: extracted.manifest.packageId,
|
||||
versionId: extracted.manifest.versionId,
|
||||
createdAt: extracted.manifest.createdAt,
|
||||
skill
|
||||
})
|
||||
const wrapper = join(extractionPath, 'selected', skill.id)
|
||||
const extractedSkill: LocalExtractedSkillPackage = {
|
||||
extractionPath: wrapper,
|
||||
manifest,
|
||||
archiveSha256: extracted.archiveSha256
|
||||
}
|
||||
const decision = input.conflictDecisions?.get(skill.id)
|
||||
const serviceInput: SkillInstallServiceInput = {
|
||||
...input,
|
||||
expectedPackageDigest: skill.digest,
|
||||
expectedPackageId: extracted.manifest.packageId,
|
||||
expectedVersionId: extracted.manifest.versionId,
|
||||
sourceBundleDigest: extracted.manifest.bundleDigest,
|
||||
conflictResolution: decision === 'keep-local' ? undefined : decision
|
||||
}
|
||||
const localInput = skillInstallLocalInput(serviceInput)
|
||||
const preview = await previewLocalExtractedSkillPackage(localInput, extractedSkill)
|
||||
const isConflict = !['missing', 'unchanged', 'clean-update'].includes(
|
||||
preview.currentState.kind
|
||||
)
|
||||
if (isConflict && decision !== 'replace-and-discard-local') {
|
||||
results.push(conflictResult(skill, preview))
|
||||
continue
|
||||
}
|
||||
await mkdir(wrapper, { recursive: true, mode: 0o700 })
|
||||
const source = join(extracted.skillsDirectory, skill.name)
|
||||
const target = join(wrapper, 'skill')
|
||||
await (input.filesystem
|
||||
? input.filesystem.rename(source, target)
|
||||
: renameSkillPathWithWindowsRetry(source, target))
|
||||
try {
|
||||
await input.filesystem?.prepareExtractedSkill(target, manifest)
|
||||
const result = await installSharedExtractedSkill(serviceInput, extractedSkill)
|
||||
results.push(completedSkillResult({ skill, preview, result }))
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
results.push({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: failure?.category === 'cancelled' ? 'cancelled' : 'failed',
|
||||
placements: [],
|
||||
...(failure ? { errorCategory: failure.code, failure } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
return SkillBundleInstallResultSchema.parse({
|
||||
operationId: input.operationId,
|
||||
packageId: extracted.manifest.packageId,
|
||||
versionId: extracted.manifest.versionId,
|
||||
bundleDigest: extracted.manifest.bundleDigest,
|
||||
status: bundleStatus(results),
|
||||
skills: results
|
||||
})
|
||||
} finally {
|
||||
await finishSkillExtractionRecovery(
|
||||
stateDirectory,
|
||||
recovery,
|
||||
input.filesystem ?? nativeSkillInstallFilesystem
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract'
|
||||
import { summarizeSkillBundleObservation } from './skill-bundle-observability-summary'
|
||||
|
||||
describe('skill bundle observability summary', () => {
|
||||
it('caps distinct error categories while preserving the aggregate count', () => {
|
||||
const result: SkillBundleInstallResult = {
|
||||
operationId: 'operation',
|
||||
packageId: 'package',
|
||||
versionId: 'version',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
status: 'failed',
|
||||
skills: Array.from({ length: 40 }, (_, index) => ({
|
||||
skillId: `skill-${index}`,
|
||||
name: `skill-${index}`,
|
||||
digest: 'b'.repeat(64),
|
||||
status: 'failed' as const,
|
||||
placements: [],
|
||||
errorCategory: `skill-error-${index}`
|
||||
}))
|
||||
}
|
||||
|
||||
const summary = summarizeSkillBundleObservation(result)
|
||||
|
||||
expect(summary.errorCategories.size).toBe(32)
|
||||
expect([...summary.errorCategories.values()].reduce((total, count) => total + count, 0)).toBe(
|
||||
40
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract'
|
||||
|
||||
const MAX_ERROR_CATEGORIES = 32
|
||||
const SAFE_ERROR_CATEGORY = /^[a-z0-9][a-z0-9._-]{0,63}$/i
|
||||
|
||||
export type SkillBundleObservationSummary = {
|
||||
attributes: Record<string, number>
|
||||
errorCategories: Map<string, number>
|
||||
}
|
||||
|
||||
function increment(attributes: Record<string, number>, key: string): void {
|
||||
attributes[key] = (attributes[key] ?? 0) + 1
|
||||
}
|
||||
|
||||
function recordErrorCategory(categories: Map<string, number>, value: string): void {
|
||||
const category = SAFE_ERROR_CATEGORY.test(value) ? value : 'other'
|
||||
const key =
|
||||
categories.has(category) || (category !== 'other' && categories.size < MAX_ERROR_CATEGORIES - 1)
|
||||
? category
|
||||
: 'other'
|
||||
categories.set(key, (categories.get(key) ?? 0) + 1)
|
||||
}
|
||||
|
||||
export function summarizeSkillBundleObservation(
|
||||
result: SkillBundleInstallResult
|
||||
): SkillBundleObservationSummary {
|
||||
const attributes: Record<string, number> = {
|
||||
skillCount: result.skills.length,
|
||||
placementCount: 0,
|
||||
conflictCount: 0
|
||||
}
|
||||
const errorCategories = new Map<string, number>()
|
||||
for (const skill of result.skills) {
|
||||
increment(attributes, `${skill.status}SkillCount`)
|
||||
if (skill.conflict) {
|
||||
increment(attributes, 'conflictCount')
|
||||
increment(attributes, `${skill.conflict.kind}ConflictCount`)
|
||||
}
|
||||
if (skill.errorCategory) {
|
||||
recordErrorCategory(errorCategories, skill.errorCategory)
|
||||
}
|
||||
for (const placement of skill.placements) {
|
||||
increment(attributes, 'placementCount')
|
||||
increment(attributes, `${placement.topology}PlacementCount`)
|
||||
increment(attributes, `${placement.status}PlacementCount`)
|
||||
if (placement.errorCategory) {
|
||||
recordErrorCategory(errorCategories, placement.errorCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { attributes, errorCategories }
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { open, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { describe, it } from 'vitest'
|
||||
import { createSkillBundleArchive } from './skill-bundle-creation'
|
||||
import { installSkillBundle } from './skill-bundle-install-service'
|
||||
|
||||
const CHILD = process.env.ORCA_SKILL_BUNDLE_PROCESS_CHILD === '1'
|
||||
|
||||
async function createLargeSkill(root: string): Promise<string> {
|
||||
const source = join(root, 'source')
|
||||
await mkdir(source, { recursive: true })
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
'---\nname: crash-bundle-skill\ndescription: Process termination\n---\n\n# Bundle\n'
|
||||
)
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
await writeFile(join(source, `payload-${index}.bin`), Buffer.alloc(4 * 1024 * 1024, index))
|
||||
}
|
||||
await writeFile(join(source, 'payload-final.bin'), Buffer.alloc(3 * 1024 * 1024, 0xff))
|
||||
return source
|
||||
}
|
||||
|
||||
async function markReady(path: string): Promise<void> {
|
||||
const handle = await open(path, 'w', 0o600)
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify({ pid: process.pid })}\n`)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe.runIf(CHILD)('skill bundle process termination child', () => {
|
||||
it('extracts a bundle until the parent terminates this process', async () => {
|
||||
const root = process.env.ORCA_SKILL_BUNDLE_CRASH_ROOT
|
||||
const marker = process.env.ORCA_SKILL_BUNDLE_CRASH_MARKER
|
||||
if (!root || !marker) {
|
||||
throw new Error('missing-bundle-crash-environment')
|
||||
}
|
||||
const source = await createLargeSkill(root)
|
||||
const bundle = await createSkillBundleArchive({
|
||||
sources: [{ sourceDirectory: source }],
|
||||
archivePath: join(root, 'bundle.tar.gz'),
|
||||
packageId: 'package_bundle_crash',
|
||||
versionId: 'version_bundle_crash',
|
||||
bundleName: 'crash-bundle'
|
||||
})
|
||||
await markReady(marker)
|
||||
await installSkillBundle({
|
||||
operationId: 'operation_bundle_crash',
|
||||
archivePath: bundle.archivePath,
|
||||
packageId: bundle.manifest.packageId,
|
||||
versionId: bundle.manifest.versionId,
|
||||
bundleDigest: bundle.manifest.bundleDigest,
|
||||
selectedSkillIds: ['crash-bundle-skill'],
|
||||
expectedArchiveSha256: bundle.archiveSha256,
|
||||
scope: 'global',
|
||||
homeDirectory: join(root, 'home'),
|
||||
orcaStateDirectory: join(root, 'state'),
|
||||
detectedProviders: [],
|
||||
destinationIdentity: 'global:bundle-process-test',
|
||||
hostIdentity: 'bundle-process-test'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,175 +0,0 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { recoverPendingSkillTransactions } from './skill-transaction-startup-recovery'
|
||||
|
||||
const RUN_REAL_PROCESS = process.env.ORCA_REAL_PROCESS_SKILL_TEST === '1'
|
||||
const require = createRequire(import.meta.url)
|
||||
const vitestBin = join(dirname(require.resolve('vitest/package.json')), 'vitest.mjs')
|
||||
const childTest = resolve('src/main/skills/skill-bundle-process-termination-child.test.ts')
|
||||
const roots: string[] = []
|
||||
|
||||
function boundedOutput(child: ChildProcess): { value: () => string } {
|
||||
let output = ''
|
||||
const append = (chunk: Buffer): void => {
|
||||
output = `${output}${chunk.toString('utf8')}`.slice(-8_192)
|
||||
}
|
||||
child.stdout?.on('data', append)
|
||||
child.stderr?.on('data', append)
|
||||
return { value: () => output }
|
||||
}
|
||||
|
||||
async function waitForPid(
|
||||
path: string,
|
||||
child: ChildProcess,
|
||||
output: () => string
|
||||
): Promise<number> {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
const marker = await readFile(path, 'utf8').catch(() => '')
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(marker)
|
||||
const pid =
|
||||
parsed && typeof parsed === 'object' && 'pid' in parsed
|
||||
? (parsed as { pid?: unknown }).pid
|
||||
: null
|
||||
if (typeof pid === 'number' && Number.isInteger(pid) && pid > 0) {
|
||||
return pid
|
||||
}
|
||||
} catch {
|
||||
// The synced marker may not be fully visible on its first read.
|
||||
}
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`bundle-crash-child-exited-${child.exitCode}: ${output()}`)
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 10))
|
||||
}
|
||||
throw new Error(`bundle-crash-marker-timeout: ${output()}`)
|
||||
}
|
||||
|
||||
async function waitForActiveExtraction(stateDirectory: string): Promise<string> {
|
||||
const directory = join(stateDirectory, 'extraction-journals')
|
||||
const deadline = Date.now() + 10_000
|
||||
while (Date.now() < deadline) {
|
||||
const names = await readdir(directory).catch(() => [])
|
||||
const journalName = names.find((name) => name.endsWith('.json'))
|
||||
if (journalName) {
|
||||
const journal = JSON.parse(await readFile(join(directory, journalName), 'utf8')) as {
|
||||
extractionPath?: unknown
|
||||
}
|
||||
if (typeof journal.extractionPath === 'string') {
|
||||
const entries = await readdir(journal.extractionPath).catch(() => [])
|
||||
if (entries.length > 0) {
|
||||
return journal.extractionPath
|
||||
}
|
||||
}
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5))
|
||||
}
|
||||
throw new Error('bundle-extraction-journal-timeout')
|
||||
}
|
||||
|
||||
function processIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(pid: number): Promise<void> {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (Date.now() < deadline) {
|
||||
if (!processIsAlive(pid)) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 20))
|
||||
}
|
||||
throw new Error(`bundle-crash-process-still-running-${pid}`)
|
||||
}
|
||||
|
||||
async function waitForChildExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolveExit, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('bundle-crash-coordinator-kill-failed')),
|
||||
5_000
|
||||
)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolveExit()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startChild(root: string): Promise<{ child: ChildProcess; marker: string }> {
|
||||
const marker = join(root, 'crash-ready')
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
vitestBin,
|
||||
'run',
|
||||
'--config',
|
||||
resolve('config/vitest.config.ts'),
|
||||
'--pool=forks',
|
||||
'--maxWorkers=1',
|
||||
'--no-file-parallelism',
|
||||
childTest
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '0',
|
||||
ORCA_SKILL_BUNDLE_PROCESS_CHILD: '1',
|
||||
ORCA_SKILL_BUNDLE_CRASH_ROOT: root,
|
||||
ORCA_SKILL_BUNDLE_CRASH_MARKER: marker
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
return { child, marker }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.runIf(RUN_REAL_PROCESS)('skill bundle process termination recovery', () => {
|
||||
it('removes a partially extracted bundle and its durable journal after process death', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-bundle-process-crash-'))
|
||||
roots.push(root)
|
||||
const stateDirectory = join(root, 'state', 'skill-installs')
|
||||
const { child, marker } = await startChild(root)
|
||||
const output = boundedOutput(child)
|
||||
const transactionPid = await waitForPid(marker, child, output.value)
|
||||
const extractionPath = await waitForActiveExtraction(stateDirectory)
|
||||
|
||||
process.kill(transactionPid, 'SIGKILL')
|
||||
await waitForProcessExit(transactionPid)
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
await waitForChildExit(child)
|
||||
|
||||
expect(await stat(extractionPath)).toBeTruthy()
|
||||
const report = await recoverPendingSkillTransactions(stateDirectory)
|
||||
|
||||
expect(report.failures).toEqual([])
|
||||
expect(report.orphanedExtractionsRecovered).toBe(1)
|
||||
await expect(stat(extractionPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(readdir(join(stateDirectory, 'extraction-journals'))).resolves.toEqual([])
|
||||
expect(
|
||||
(await readdir(join(root, 'home', '.agents', 'skills')).catch(() => [])).filter((name) =>
|
||||
name.includes('.orca-')
|
||||
)
|
||||
).toEqual([])
|
||||
}, 45_000)
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillBundleInstallRequest } from '../../shared/skill-bundle-install-contract'
|
||||
import { SKILL_PACKAGE_CONTENT_TYPE } from '../../shared/skill-package-manifest'
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
import { installSkillBundleOnSshHost } from './skill-bundle-ssh-relay-service'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function userDataPath(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-bundle-ssh-client-test-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function request(bytes: Buffer): SkillBundleInstallRequest {
|
||||
return {
|
||||
operationId: 'bundle-operation',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
archiveSha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
compressedBytes: bytes.length
|
||||
},
|
||||
selectedSkillIds: ['skill-1'],
|
||||
ingress: {
|
||||
kind: 'download-grant',
|
||||
url: 'https://storage.googleapis.com/test/bundle.tar.gz',
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString()
|
||||
},
|
||||
destination: { scope: 'global', executionTarget: { kind: 'host' } },
|
||||
conflictDecisions: []
|
||||
}
|
||||
}
|
||||
|
||||
function result() {
|
||||
return {
|
||||
operationId: 'bundle-operation',
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
bundleDigest: 'a'.repeat(64),
|
||||
status: 'complete' as const,
|
||||
skills: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('installSkillBundleOnSshHost', () => {
|
||||
it('uses the additive method only when advertised by the SSH host', async () => {
|
||||
const bytes = Buffer.from('private bundle archive')
|
||||
const requestHostRpc = vi.fn(async (method: string) => {
|
||||
if (method === 'relay.status') {
|
||||
return { capabilities: ['skills.install.bundle.v1'] }
|
||||
}
|
||||
if (method === 'skills.installBundle') {
|
||||
return result()
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(bytes),
|
||||
requireHttps: true
|
||||
})
|
||||
).resolves.toEqual(result())
|
||||
})
|
||||
|
||||
it('does not send an unknown method to an older SSH host', async () => {
|
||||
const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] }))
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(Buffer.from('archive')),
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('skill-bundle-ssh-update-required')
|
||||
expect(requestHostRpc).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('polls current-skill progress only when the SSH host advertises it', async () => {
|
||||
const bytes = Buffer.from('private bundle archive')
|
||||
const onProgress = vi.fn()
|
||||
const progress = {
|
||||
operationId: 'bundle-operation',
|
||||
skillId: 'skill-1',
|
||||
skillName: 'alpha',
|
||||
skillIndex: 1,
|
||||
skillCount: 30
|
||||
}
|
||||
const requestHostRpc = vi.fn(async (method: string) => {
|
||||
if (method === 'relay.status') {
|
||||
return {
|
||||
capabilities: ['skills.install.bundle.v1', 'skills.install-progress.v1']
|
||||
}
|
||||
}
|
||||
if (method === 'skills.getInstallProgress') {
|
||||
return progress
|
||||
}
|
||||
if (method === 'skills.installBundle') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
return result()
|
||||
}
|
||||
throw new Error(`unexpected method ${method}`)
|
||||
})
|
||||
|
||||
await installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(bytes),
|
||||
requireHttps: true,
|
||||
onProgress
|
||||
})
|
||||
|
||||
expect(onProgress).toHaveBeenCalledWith(progress)
|
||||
expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([
|
||||
'relay.status',
|
||||
'skills.getInstallProgress',
|
||||
'skills.installBundle'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to client-mediated transfer after direct download fails', async () => {
|
||||
const bytes = Buffer.from('private bundle archive')
|
||||
const requestHostRpc = vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === 'relay.status') {
|
||||
return { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] }
|
||||
}
|
||||
if (method === 'skills.installBundle') {
|
||||
const ingress = (params as { request: SkillBundleInstallRequest }).request.ingress
|
||||
if (ingress.kind === 'download-grant') {
|
||||
throw Object.assign(new Error('skill-download-transport-failed'), { code: -32000 })
|
||||
}
|
||||
return result()
|
||||
}
|
||||
if (method === 'skills.beginUpload') {
|
||||
return { uploadId: 'upload_1', chunkBytes: 256 * 1024 }
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
const chunk = params as { offset: number; bytesBase64: string }
|
||||
return {
|
||||
acknowledgedOffset: chunk.offset + Buffer.from(chunk.bytesBase64, 'base64').length
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
await expect(
|
||||
installSkillBundleOnSshHost({
|
||||
provider: { requestHostRpc } as unknown as IPtyProvider,
|
||||
userDataPath: await userDataPath(),
|
||||
request: request(bytes),
|
||||
requireHttps: true,
|
||||
fetcher: vi.fn(
|
||||
async () =>
|
||||
new Response(bytes, { headers: { 'content-type': SKILL_PACKAGE_CONTENT_TYPE } })
|
||||
) as typeof fetch
|
||||
})
|
||||
).resolves.toEqual(result())
|
||||
expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([
|
||||
'relay.status',
|
||||
'skills.installBundle',
|
||||
'skills.beginUpload',
|
||||
'skills.uploadChunk',
|
||||
'skills.commitUpload',
|
||||
'skills.installBundle',
|
||||
'skills.cancelUpload'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
SkillBundleInstallProgressSchema,
|
||||
SkillBundleInstallResultSchema,
|
||||
type SkillBundleInstallProgress,
|
||||
type SkillBundleInstallRequest,
|
||||
type SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_PROVIDERS_CAPABILITY,
|
||||
SKILL_INSTALL_PROGRESS_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
} from '../../shared/skill-install-capability'
|
||||
import {
|
||||
SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD,
|
||||
SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD,
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
type SkillSshWorkspaceAuthority
|
||||
} from '../../shared/skill-ssh-relay-contract'
|
||||
import type { IPtyProvider } from '../providers/pty-provider-contract'
|
||||
import {
|
||||
SKILL_SSH_REQUEST_TIMEOUT_MS,
|
||||
requireSkillSshRelayClient,
|
||||
retryableSkillSshTransportError,
|
||||
shouldUseSkillSshClientTransfer,
|
||||
skillSshRelayCapabilities
|
||||
} from './skill-ssh-relay-client'
|
||||
import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer'
|
||||
import { retrySkillTransferRpc } from './skill-transfer-rpc-retry'
|
||||
import { startSkillInstallProgressPolling } from './skill-install-progress-polling'
|
||||
import { recordSkillCapabilityAbsence } from './skill-operation-observability'
|
||||
|
||||
export async function installSkillBundleOnSshHost(input: {
|
||||
provider: IPtyProvider
|
||||
userDataPath: string
|
||||
request: SkillBundleInstallRequest
|
||||
workspace?: SkillSshWorkspaceAuthority
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: SkillBundleInstallProgress) => void
|
||||
fetcher?: typeof fetch
|
||||
}): Promise<SkillBundleInstallResult> {
|
||||
const client = requireSkillSshRelayClient(input.provider)
|
||||
const supported = await skillSshRelayCapabilities(client)
|
||||
const request = input.request
|
||||
if (request.providers !== undefined && !supported.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)) {
|
||||
throw new Error('skill-bundle-ssh-update-required')
|
||||
}
|
||||
if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) {
|
||||
recordSkillCapabilityAbsence({
|
||||
capability: SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
destination: 'global-ssh'
|
||||
})
|
||||
throw new Error('skill-bundle-ssh-update-required')
|
||||
}
|
||||
const stopProgress =
|
||||
input.onProgress && supported.includes(SKILL_INSTALL_PROGRESS_CAPABILITY)
|
||||
? startSkillInstallProgressPolling({
|
||||
read: async () => {
|
||||
const value = await client(
|
||||
SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD,
|
||||
{ operationId: request.operationId },
|
||||
{ timeoutMs: 2_000, signal: input.signal }
|
||||
)
|
||||
if (value === null) {
|
||||
return null
|
||||
}
|
||||
const parsed = SkillBundleInstallProgressSchema.safeParse(value)
|
||||
return parsed.success ? parsed.data : null
|
||||
},
|
||||
onProgress: input.onProgress
|
||||
})
|
||||
: null
|
||||
try {
|
||||
try {
|
||||
return SkillBundleInstallResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: () =>
|
||||
client(
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
{ request: request, workspace: input.workspace },
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (
|
||||
request.ingress.kind !== 'download-grant' ||
|
||||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) {
|
||||
recordSkillCapabilityAbsence({
|
||||
capability: SKILL_UPLOAD_CAPABILITY,
|
||||
destination: 'global-ssh'
|
||||
})
|
||||
throw new Error('skill-bundle-ssh-download-unavailable')
|
||||
}
|
||||
return await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableSkillSshTransportError,
|
||||
call: async () => {
|
||||
const uploadId = await transferSkillPackageToSshHost(client, input)
|
||||
try {
|
||||
return SkillBundleInstallResultSchema.parse(
|
||||
await client(
|
||||
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
|
||||
{
|
||||
request: {
|
||||
...request,
|
||||
ingress: { kind: 'staged-upload', uploadId }
|
||||
},
|
||||
workspace: input.workspace
|
||||
},
|
||||
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
stopProgress?.()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
|
||||
|
||||
describe('runSkillCandidateTasks', () => {
|
||||
it('bounds concurrency and preserves task order', async () => {
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const results = await runSkillCandidateTasks(
|
||||
Array.from({ length: 12 }, (_, index) => async () => {
|
||||
active += 1
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 12 - index))
|
||||
active -= 1
|
||||
return index
|
||||
})
|
||||
)
|
||||
|
||||
expect(maxActive).toBe(4)
|
||||
expect(results).toEqual(Array.from({ length: 12 }, (_, index) => index))
|
||||
})
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
downloadSkillPackageGrant: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('./skill-package-download', () => ({
|
||||
downloadSkillPackageGrant: mocks.downloadSkillPackageGrant
|
||||
}))
|
||||
|
||||
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
|
||||
|
||||
describe('transferSkillPackageToRuntime cancellation', () => {
|
||||
let root = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-skill-transfer-cancel-test-'))
|
||||
mocks.callRuntimeEnvironment.mockReset()
|
||||
mocks.downloadSkillPackageGrant.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cancels a committed chunk session when the commit request is interrupted', async () => {
|
||||
const bytes = Buffer.from('package-data')
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, bytes)
|
||||
const downloadCleanup = vi.fn(async () => undefined)
|
||||
const controller = new AbortController()
|
||||
let commitStarted: () => void = () => {}
|
||||
const commitPending = new Promise<void>((resolve) => {
|
||||
commitStarted = resolve
|
||||
})
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup: downloadCleanup })
|
||||
mocks.callRuntimeEnvironment.mockImplementation((...args: unknown[]) => {
|
||||
const method = args[2]
|
||||
if (method === 'skills.beginUpload') {
|
||||
return Promise.resolve({
|
||||
id: 'rpc-begin',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
return Promise.resolve({
|
||||
id: 'rpc-chunk',
|
||||
ok: true,
|
||||
result: { acknowledgedOffset: bytes.length },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
}
|
||||
if (method === 'skills.commitUpload') {
|
||||
const options = args[7] as { signal?: AbortSignal }
|
||||
return new Promise((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), {
|
||||
once: true
|
||||
})
|
||||
commitStarted()
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'rpc-cancel',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
})
|
||||
|
||||
const pending = transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: bytes.length
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
await commitPending
|
||||
|
||||
controller.abort()
|
||||
await expect(pending).rejects.toThrow('skill-install-cancelled')
|
||||
expect(downloadCleanup).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
mocks.callRuntimeEnvironment.mock.calls.filter((call) => call[2] === 'skills.commitUpload')
|
||||
).toHaveLength(1)
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
root,
|
||||
'environment-1',
|
||||
'skills.cancelUpload',
|
||||
{ uploadId: 'upload-1' },
|
||||
15_000
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,455 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
downloadSkillPackageGrant: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
vi.mock('./skill-package-download', () => ({
|
||||
downloadSkillPackageGrant: mocks.downloadSkillPackageGrant
|
||||
}))
|
||||
|
||||
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
|
||||
|
||||
describe('transferSkillPackageToRuntime', () => {
|
||||
let root = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-skill-transfer-test-'))
|
||||
mocks.callRuntimeEnvironment.mockReset()
|
||||
mocks.downloadSkillPackageGrant.mockReset()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cleans the local package after bounded begin retries', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup })
|
||||
mocks.callRuntimeEnvironment.mockRejectedValue(new Error('connection dropped'))
|
||||
|
||||
await expect(
|
||||
transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('connection dropped')
|
||||
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('reuses the operation identity and resumes an acknowledged upload', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
const bytes = Buffer.from('package-data')
|
||||
await writeFile(archivePath, bytes)
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup })
|
||||
const beginRequests: unknown[] = []
|
||||
let beginAttempts = 0
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string, params: unknown) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
beginRequests.push(params)
|
||||
beginAttempts += 1
|
||||
if (beginAttempts === 1) {
|
||||
throw new Error('connection dropped after receiver resumed upload')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-begin',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024, acknowledgedOffset: 4 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
const chunk = params as { offset: number; bytesBase64: string }
|
||||
expect(chunk.offset).toBe(4)
|
||||
expect(Buffer.from(chunk.bytesBase64, 'base64')).toEqual(bytes.subarray(4))
|
||||
return {
|
||||
id: 'rpc-chunk',
|
||||
ok: true,
|
||||
result: { acknowledgedOffset: bytes.length },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'rpc-success',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const transferred = await transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: bytes.length
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
await transferred.cleanup()
|
||||
|
||||
expect(beginRequests).toEqual([
|
||||
expect.objectContaining({ transferId: 'operation-1' }),
|
||||
expect.objectContaining({ transferId: 'operation-1' })
|
||||
])
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects an invalid acknowledgement and cancels the remote session', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const downloadCleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup: downloadCleanup })
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
return {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
return {
|
||||
id: 'rpc-2',
|
||||
ok: true,
|
||||
result: { acknowledgedOffset: 1 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'rpc-3',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('skill-transfer-ack-invalid')
|
||||
|
||||
expect(downloadCleanup).toHaveBeenCalledOnce()
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
root,
|
||||
'environment-1',
|
||||
'skills.cancelUpload',
|
||||
{ uploadId: 'upload-1' },
|
||||
15_000
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels the remote upload when commit fails', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const downloadCleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup: downloadCleanup })
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
return {
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
return {
|
||||
id: 'rpc-2',
|
||||
ok: true,
|
||||
result: { acknowledgedOffset: 12 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.commitUpload') {
|
||||
return {
|
||||
id: 'rpc-3',
|
||||
ok: false,
|
||||
error: { code: 'skill-upload-commit-failed', message: 'commit failed' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'rpc-4',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('skill-transfer-remote-skill-upload-commit-failed')
|
||||
|
||||
expect(downloadCleanup).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
mocks.callRuntimeEnvironment.mock.calls.filter((call) => call[2] === 'skills.commitUpload')
|
||||
).toHaveLength(1)
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
root,
|
||||
'environment-1',
|
||||
'skills.cancelUpload',
|
||||
{ uploadId: 'upload-1' },
|
||||
15_000
|
||||
)
|
||||
})
|
||||
|
||||
it('cancels the session after bounded chunk retry exhaustion', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup })
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
return {
|
||||
id: 'rpc-begin',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
throw new Error('connection dropped')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-cancel',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
).rejects.toThrow('connection dropped')
|
||||
|
||||
expect(
|
||||
mocks.callRuntimeEnvironment.mock.calls.filter((call) => call[2] === 'skills.uploadChunk')
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
mocks.callRuntimeEnvironment.mock.calls.filter((call) => call[2] === 'skills.cancelUpload')
|
||||
).toHaveLength(1)
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries the identical chunk and idempotent commit after lost responses', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup })
|
||||
let chunkAttempts = 0
|
||||
let commitAttempts = 0
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string, params: unknown) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
return {
|
||||
id: 'rpc-begin',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.uploadChunk') {
|
||||
chunkAttempts += 1
|
||||
if (chunkAttempts === 1) {
|
||||
throw new Error('connection dropped after receiver write')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-chunk',
|
||||
ok: true,
|
||||
result: { acknowledgedOffset: 12 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
if (method === 'skills.commitUpload') {
|
||||
commitAttempts += 1
|
||||
if (commitAttempts === 1) {
|
||||
throw new Error('connection dropped after receiver commit')
|
||||
}
|
||||
return {
|
||||
id: 'rpc-commit',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1' },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
expect(method).toBe('skills.cancelUpload')
|
||||
expect(params).toEqual({ uploadId: 'upload-1' })
|
||||
return {
|
||||
id: 'rpc-cancel',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const transferred = await transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true
|
||||
})
|
||||
await transferred.cleanup()
|
||||
|
||||
expect(chunkAttempts).toBe(2)
|
||||
expect(commitAttempts).toBe(2)
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels the begun session when cancellation arrives before chunking', async () => {
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, Buffer.from('package-data'))
|
||||
const cleanup = vi.fn(async () => undefined)
|
||||
const controller = new AbortController()
|
||||
mocks.downloadSkillPackageGrant.mockResolvedValue({ archivePath, cleanup })
|
||||
mocks.callRuntimeEnvironment.mockImplementation(
|
||||
async (_userData: string, _environment: string, method: string) => {
|
||||
if (method === 'skills.beginUpload') {
|
||||
controller.abort()
|
||||
return {
|
||||
id: 'rpc-begin',
|
||||
ok: true,
|
||||
result: { uploadId: 'upload-1', chunkBytes: 256 * 1024 },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'rpc-cancel',
|
||||
ok: true,
|
||||
result: undefined,
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
transferSkillPackageToRuntime({
|
||||
userDataPath: root,
|
||||
environmentId: 'environment-1',
|
||||
transferId: 'operation-1',
|
||||
package: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
},
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
requireHttps: true,
|
||||
signal: controller.signal
|
||||
})
|
||||
).rejects.toThrow('skill-install-cancelled')
|
||||
expect(cleanup).toHaveBeenCalledOnce()
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
root,
|
||||
'environment-1',
|
||||
'skills.cancelUpload',
|
||||
{ uploadId: 'upload-1' },
|
||||
15_000
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { SkillPackageIdentity } from '../../shared/skill-install-contract'
|
||||
import type { SkillBundlePackageIdentity } from '../../shared/skill-bundle-install-contract'
|
||||
import {
|
||||
SKILL_UPLOAD_CHUNK_MAX_BYTES,
|
||||
SkillUploadBeginResultSchema
|
||||
} from '../../shared/skill-upload-session-contract'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import { startSkillPhaseOperation } from './skill-operation-observability'
|
||||
import { retrySkillTransferRpc, throwIfSkillTransferCancelled } from './skill-transfer-rpc-retry'
|
||||
|
||||
const REMOTE_TRANSFER_TIMEOUT_MS = 5 * 60_000
|
||||
const REMOTE_TRANSFER_CLEANUP_TIMEOUT_MS = 15_000
|
||||
|
||||
function allowedOrigins(allowConfiguredOrigins: boolean): string[] {
|
||||
const origins = ['https://storage.googleapis.com']
|
||||
if (allowConfiguredOrigins && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
|
||||
origins.push(
|
||||
...process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
}
|
||||
return [...new Set(origins)]
|
||||
}
|
||||
|
||||
function retryableRemoteTransferError(error: unknown): boolean {
|
||||
return !(error instanceof Error && error.message.startsWith('skill-transfer-remote-'))
|
||||
}
|
||||
|
||||
async function remoteCall<T>(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
method: string,
|
||||
params: unknown,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs = REMOTE_TRANSFER_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
const args = [userDataPath, environmentId, method, params, timeoutMs] as const
|
||||
const response = (await (signal
|
||||
? callRuntimeEnvironment(...args, undefined, undefined, { signal })
|
||||
: callRuntimeEnvironment(...args))) as RuntimeRpcResponse<T>
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-transfer-remote-${response.error.code}`)
|
||||
}
|
||||
return response.result
|
||||
}
|
||||
|
||||
type SkillClientTransferInput = {
|
||||
userDataPath: string
|
||||
environmentId: string
|
||||
transferId: string
|
||||
package: SkillPackageIdentity | SkillBundlePackageIdentity
|
||||
grant: { url: string; expiresAt: string }
|
||||
requireHttps: boolean
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
async function transferSkillPackageToRuntimeUnobserved(
|
||||
input: SkillClientTransferInput
|
||||
): Promise<{ uploadId: string; cleanup(): Promise<void> }> {
|
||||
const downloaded = await downloadSkillPackageGrant({
|
||||
url: input.grant.url,
|
||||
expiresAt: input.grant.expiresAt,
|
||||
expectedArchiveSha256: input.package.archiveSha256,
|
||||
expectedCompressedBytes: input.package.compressedBytes,
|
||||
temporaryRoot: join(input.userDataPath, 'skill-installs', 'client-transfers'),
|
||||
allowedOrigins: allowedOrigins(!input.requireHttps),
|
||||
requireHttps: input.requireHttps,
|
||||
signal: input.signal
|
||||
})
|
||||
let uploadId: string | null = null
|
||||
try {
|
||||
const begun = SkillUploadBeginResultSchema.parse(
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableRemoteTransferError,
|
||||
checkCancellationAfterSuccess: false,
|
||||
call: () =>
|
||||
remoteCall(
|
||||
input.userDataPath,
|
||||
input.environmentId,
|
||||
'skills.beginUpload',
|
||||
{
|
||||
package: input.package,
|
||||
transferId: input.transferId
|
||||
},
|
||||
input.signal
|
||||
)
|
||||
})
|
||||
)
|
||||
uploadId = begun.uploadId
|
||||
throwIfSkillTransferCancelled(input.signal)
|
||||
const chunkBytes = Math.min(begun.chunkBytes, SKILL_UPLOAD_CHUNK_MAX_BYTES)
|
||||
if (begun.acknowledgedOffset > input.package.compressedBytes) {
|
||||
throw new Error('skill-transfer-offset-invalid')
|
||||
}
|
||||
if (!Number.isInteger(chunkBytes) || chunkBytes < 1) {
|
||||
throw new Error('skill-transfer-chunk-size-invalid')
|
||||
}
|
||||
const handle = await open(downloaded.archivePath, 'r')
|
||||
try {
|
||||
let offset = begun.acknowledgedOffset
|
||||
while (offset < input.package.compressedBytes) {
|
||||
const bytes = Buffer.alloc(Math.min(chunkBytes, input.package.compressedBytes - offset))
|
||||
const read = await handle.read(bytes, 0, bytes.length, offset)
|
||||
if (read.bytesRead !== bytes.length) {
|
||||
throw new Error('skill-transfer-source-changed')
|
||||
}
|
||||
const acknowledged = await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableRemoteTransferError,
|
||||
call: () =>
|
||||
remoteCall<{ acknowledgedOffset: number }>(
|
||||
input.userDataPath,
|
||||
input.environmentId,
|
||||
'skills.uploadChunk',
|
||||
{ uploadId, offset, bytesBase64: bytes.toString('base64') },
|
||||
input.signal
|
||||
)
|
||||
})
|
||||
if (acknowledged.acknowledgedOffset !== offset + bytes.length) {
|
||||
throw new Error('skill-transfer-ack-invalid')
|
||||
}
|
||||
offset = acknowledged.acknowledgedOffset
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await retrySkillTransferRpc({
|
||||
signal: input.signal,
|
||||
retryable: retryableRemoteTransferError,
|
||||
call: () =>
|
||||
remoteCall(
|
||||
input.userDataPath,
|
||||
input.environmentId,
|
||||
'skills.commitUpload',
|
||||
{ uploadId },
|
||||
input.signal
|
||||
)
|
||||
})
|
||||
const committedId = uploadId
|
||||
uploadId = null
|
||||
return {
|
||||
uploadId: committedId,
|
||||
cleanup: () =>
|
||||
remoteCall(
|
||||
input.userDataPath,
|
||||
input.environmentId,
|
||||
'skills.cancelUpload',
|
||||
{ uploadId: committedId },
|
||||
undefined,
|
||||
REMOTE_TRANSFER_CLEANUP_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await downloaded.cleanup()
|
||||
if (uploadId) {
|
||||
await remoteCall(
|
||||
input.userDataPath,
|
||||
input.environmentId,
|
||||
'skills.cancelUpload',
|
||||
{ uploadId },
|
||||
undefined,
|
||||
REMOTE_TRANSFER_CLEANUP_TIMEOUT_MS
|
||||
).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function transferSkillPackageToRuntime(
|
||||
input: SkillClientTransferInput
|
||||
): Promise<{ uploadId: string; cleanup(): Promise<void> }> {
|
||||
const operation = startSkillPhaseOperation({
|
||||
phase: 'transfer',
|
||||
transport: 'runtime-rpc',
|
||||
destination: 'remote-runtime',
|
||||
compressedBytes: input.package.compressedBytes
|
||||
})
|
||||
try {
|
||||
const transfer = await transferSkillPackageToRuntimeUnobserved(input)
|
||||
operation.complete({ status: 'complete', compressedBytes: input.package.compressedBytes })
|
||||
return transfer
|
||||
} catch (error) {
|
||||
operation.fail(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { SkillCloudOperation, SkillCloudOptions } from '../../shared/skill-cloud-contract'
|
||||
import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store'
|
||||
import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config'
|
||||
import { runWithFreshOrcaCloudSession } from '../orca-profiles/profile-cloud-session-refresh'
|
||||
import {
|
||||
allowsArtifactCloudAuthOverride,
|
||||
resolveArtifactCloudApiUrl
|
||||
} from '../artifacts/artifact-cloud-config'
|
||||
|
||||
export async function runSkillCloudOperation<T>(input: {
|
||||
userDataPath: string
|
||||
options: SkillCloudOptions
|
||||
operation(token: string, apiUrl: string): Promise<T>
|
||||
}): Promise<SkillCloudOperation<T>> {
|
||||
const apiUrl = resolveArtifactCloudApiUrl(input.options.apiUrl)
|
||||
const active = ensureActiveOrcaProfile(input.userDataPath)
|
||||
const stamp = {
|
||||
profileId: active.profile.id,
|
||||
userId: active.profile.cloud?.userId,
|
||||
cloudProfileId: active.profile.cloud?.cloudProfileId,
|
||||
organizationId: active.profile.cloud?.activeOrgId ?? ''
|
||||
}
|
||||
const assertCurrent = () => {
|
||||
const current = ensureActiveOrcaProfile(input.userDataPath)
|
||||
if (
|
||||
current.profile.id !== stamp.profileId ||
|
||||
current.profile.cloud?.userId !== stamp.userId ||
|
||||
current.profile.cloud?.cloudProfileId !== stamp.cloudProfileId ||
|
||||
(current.profile.cloud?.activeOrgId ?? '') !== stamp.organizationId
|
||||
) {
|
||||
throw new Error('The signed-in Orca account changed during the skill request.')
|
||||
}
|
||||
}
|
||||
const override = input.options.authToken?.trim() || process.env.ORCA_CLOUD_AUTH_TOKEN?.trim()
|
||||
if (override) {
|
||||
if (!allowsArtifactCloudAuthOverride()) {
|
||||
throw new Error('Skill authentication overrides are available only in development builds.')
|
||||
}
|
||||
const value = await input.operation(override, apiUrl)
|
||||
assertCurrent()
|
||||
return { status: 'ok', value }
|
||||
}
|
||||
const config = getOrcaCloudAuthConfig()
|
||||
if (!config.configured) {
|
||||
return { status: 'unconfigured', message: config.setupMessage }
|
||||
}
|
||||
const result = await runWithFreshOrcaCloudSession(
|
||||
config.config,
|
||||
active,
|
||||
input.userDataPath,
|
||||
async (session) => {
|
||||
const value = await input.operation(session.accessToken, apiUrl)
|
||||
assertCurrent()
|
||||
return value
|
||||
}
|
||||
)
|
||||
return result.status === 'ok'
|
||||
? { status: 'ok', value: result.value }
|
||||
: { status: 'reconnect-required' }
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
const DEFAULT_SKILL_CLOUD_TIMEOUT_MS = 60_000
|
||||
|
||||
export function createSkillCloudDeadline(input: {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
timeoutMessage: string
|
||||
}): {
|
||||
signal: AbortSignal
|
||||
cleanup(): void
|
||||
} {
|
||||
const timeoutMs = input.timeoutMs ?? DEFAULT_SKILL_CLOUD_TIMEOUT_MS
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
||||
throw new Error(`${input.timeoutMessage}-invalid`)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const forwardAbort = (): void => controller.abort(input.signal?.reason)
|
||||
if (input.signal?.aborted) {
|
||||
forwardAbort()
|
||||
} else {
|
||||
input.signal?.addEventListener('abort', forwardAbort, { once: true })
|
||||
}
|
||||
const timeout = setTimeout(() => controller.abort(new Error(input.timeoutMessage)), timeoutMs)
|
||||
timeout.unref()
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => {
|
||||
clearTimeout(timeout)
|
||||
input.signal?.removeEventListener('abort', forwardAbort)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('uploadSkillPackageToSignedPolicy', () => {
|
||||
it('allows a slow progressing upload to continue beyond one minute', async () => {
|
||||
vi.useFakeTimers()
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
|
||||
roots.push(root)
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
const archive = Buffer.alloc(3 * 64 * 1024, 0x61)
|
||||
await writeFile(archivePath, archive)
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
let chunksSeen = 0
|
||||
const chunkWaiters = new Set<() => void>()
|
||||
const waitForChunk = async (count: number): Promise<void> => {
|
||||
if (chunksSeen >= count) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = (): void => {
|
||||
if (chunksSeen >= count) {
|
||||
chunkWaiters.delete(check)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
chunkWaiters.add(check)
|
||||
})
|
||||
}
|
||||
const fetcher = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||
markStarted()
|
||||
const body = init?.body as unknown as AsyncIterable<Buffer>
|
||||
for await (const _chunk of body) {
|
||||
chunksSeen += 1
|
||||
chunkWaiters.forEach((notify) => notify())
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 30_000))
|
||||
}
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
const progress = vi.fn()
|
||||
const upload = uploadSkillPackageToSignedPolicy({
|
||||
policy: {
|
||||
url: 'https://storage.googleapis.com/upload',
|
||||
fields: {},
|
||||
expiresAt: new Date(Date.now() + 10 * 60_000).toISOString()
|
||||
},
|
||||
archivePath,
|
||||
expectedBytes: archive.length,
|
||||
fetcher: fetcher as typeof fetch,
|
||||
onProgress: progress
|
||||
})
|
||||
|
||||
await started
|
||||
await waitForChunk(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await waitForChunk(2)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await waitForChunk(3)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(progress).toHaveBeenCalled()
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(29_000)
|
||||
await waitForChunk(4)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await waitForChunk(5)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
await expect(upload).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('streams exact bytes with policy fields and bounded progress', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
|
||||
roots.push(root)
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
const archive = Buffer.from('private-package-bytes')
|
||||
await writeFile(archivePath, archive)
|
||||
let uploaded = Buffer.alloc(0)
|
||||
const fetcher = vi.fn(async (_url: URL | RequestInfo, init?: RequestInit) => {
|
||||
const chunks: Buffer[] = []
|
||||
const body = init?.body as unknown as AsyncIterable<Buffer>
|
||||
for await (const chunk of body) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
uploaded = Buffer.concat(chunks)
|
||||
expect(Number(init?.headers && new Headers(init.headers).get('content-length'))).toBe(
|
||||
uploaded.length
|
||||
)
|
||||
return new Response(null, { status: 204 })
|
||||
}) as typeof fetch
|
||||
const progress = vi.fn()
|
||||
|
||||
await uploadSkillPackageToSignedPolicy({
|
||||
policy: {
|
||||
url: 'https://storage.googleapis.com/upload',
|
||||
fields: { key: 'uploads/private/package.tar.gz', policy: 'opaque-policy' }
|
||||
},
|
||||
archivePath,
|
||||
expectedBytes: archive.length,
|
||||
fetcher,
|
||||
onProgress: progress
|
||||
})
|
||||
|
||||
expect(uploaded.includes(archive)).toBe(true)
|
||||
expect(uploaded.toString('utf8')).toContain('opaque-policy')
|
||||
expect(progress).toHaveBeenLastCalledWith(archive.length)
|
||||
})
|
||||
|
||||
it('rejects insecure destinations and source drift before upload', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
|
||||
roots.push(root)
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, 'bytes')
|
||||
await expect(
|
||||
uploadSkillPackageToSignedPolicy({
|
||||
policy: { url: 'http://storage.test/upload', fields: {} },
|
||||
archivePath,
|
||||
expectedBytes: 5
|
||||
})
|
||||
).rejects.toThrow('skill-cloud-upload-url-invalid')
|
||||
await expect(
|
||||
uploadSkillPackageToSignedPolicy({
|
||||
policy: { url: 'https://storage.test/upload', fields: {} },
|
||||
archivePath,
|
||||
expectedBytes: 4
|
||||
})
|
||||
).rejects.toThrow('skill-cloud-upload-source-changed')
|
||||
await expect(
|
||||
uploadSkillPackageToSignedPolicy({
|
||||
policy: {
|
||||
url: 'https://storage.test/upload',
|
||||
fields: {},
|
||||
expiresAt: 'not-a-date'
|
||||
},
|
||||
archivePath,
|
||||
expectedBytes: 5
|
||||
})
|
||||
).rejects.toThrow('skill-cloud-upload-policy-expiry-invalid')
|
||||
})
|
||||
|
||||
it('bounds a stalled signed upload even without a caller-owned signal', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-cloud-upload-'))
|
||||
roots.push(root)
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
await writeFile(archivePath, 'bytes')
|
||||
const fetcher = vi.fn(
|
||||
async (_url: URL | RequestInfo, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
|
||||
})
|
||||
) as typeof fetch
|
||||
|
||||
const upload = uploadSkillPackageToSignedPolicy({
|
||||
policy: { url: 'https://storage.googleapis.com/upload', fields: {} },
|
||||
archivePath,
|
||||
expectedBytes: 5,
|
||||
fetcher,
|
||||
timeoutMs: 25
|
||||
})
|
||||
const startedAt = Date.now()
|
||||
|
||||
await expect(upload).rejects.toThrow('skill-cloud-upload-timeout')
|
||||
expect(Date.now() - startedAt).toBeLessThan(1_000)
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { Readable } from 'node:stream'
|
||||
import { createSkillCloudDeadline } from './skill-cloud-deadline'
|
||||
|
||||
type SignedPostPolicy = {
|
||||
url: string
|
||||
fields: Record<string, string>
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const DEFAULT_UPLOAD_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
function resolveUploadTimeoutMs(policy: SignedPostPolicy, timeoutMs?: number): number {
|
||||
if (timeoutMs !== undefined) {
|
||||
return timeoutMs
|
||||
}
|
||||
if (!policy.expiresAt) {
|
||||
return DEFAULT_UPLOAD_TIMEOUT_MS
|
||||
}
|
||||
const expiresAt = Date.parse(policy.expiresAt)
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
throw new Error('skill-cloud-upload-policy-expiry-invalid')
|
||||
}
|
||||
return Math.max(1, Math.min(DEFAULT_UPLOAD_TIMEOUT_MS, expiresAt - Date.now()))
|
||||
}
|
||||
|
||||
function quoted(value: string): string {
|
||||
return value
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll('\r', '')
|
||||
.replaceAll('\n', '')
|
||||
}
|
||||
|
||||
function fieldBytes(boundary: string, name: string, value: string): Buffer {
|
||||
return Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="${quoted(name)}"\r\n\r\n${value}\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export async function uploadSkillPackageToSignedPolicy(input: {
|
||||
policy: SignedPostPolicy
|
||||
archivePath: string
|
||||
expectedBytes: number
|
||||
signal?: AbortSignal
|
||||
onProgress?: (bytesSent: number) => void
|
||||
fetcher?: typeof fetch
|
||||
timeoutMs?: number
|
||||
}): Promise<void> {
|
||||
const policyUrl = new URL(input.policy.url)
|
||||
if (policyUrl.protocol !== 'https:' || policyUrl.username || policyUrl.password) {
|
||||
throw new Error('skill-cloud-upload-url-invalid')
|
||||
}
|
||||
const archive = await stat(input.archivePath)
|
||||
if (!archive.isFile() || archive.size !== input.expectedBytes) {
|
||||
throw new Error('skill-cloud-upload-source-changed')
|
||||
}
|
||||
const boundary = `orca-skill-${randomBytes(18).toString('hex')}`
|
||||
const fields = Object.entries(input.policy.fields).map(([name, value]) =>
|
||||
fieldBytes(boundary, name, value)
|
||||
)
|
||||
const fileHeader = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="package.tar.gz"\r\nContent-Type: application/vnd.orca.skill+tar+gzip\r\n\r\n`,
|
||||
'utf8'
|
||||
)
|
||||
const ending = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')
|
||||
const contentLength =
|
||||
fields.reduce((total, field) => total + field.length, 0) +
|
||||
fileHeader.length +
|
||||
archive.size +
|
||||
ending.length
|
||||
const deadline = createSkillCloudDeadline({
|
||||
signal: input.signal,
|
||||
timeoutMs: resolveUploadTimeoutMs(input.policy, input.timeoutMs),
|
||||
timeoutMessage: 'skill-cloud-upload-timeout'
|
||||
})
|
||||
async function* body() {
|
||||
for (const field of fields) {
|
||||
yield field
|
||||
}
|
||||
yield fileHeader
|
||||
let bytesSent = 0
|
||||
for await (const chunk of createReadStream(input.archivePath, { signal: deadline.signal })) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
bytesSent += bytes.length
|
||||
if (bytesSent > input.expectedBytes) {
|
||||
throw new Error('skill-cloud-upload-source-changed')
|
||||
}
|
||||
input.onProgress?.(bytesSent)
|
||||
yield bytes
|
||||
}
|
||||
if (bytesSent !== input.expectedBytes) {
|
||||
throw new Error('skill-cloud-upload-source-changed')
|
||||
}
|
||||
yield ending
|
||||
}
|
||||
try {
|
||||
const response = await (input.fetcher ?? fetch)(policyUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'content-length': String(contentLength)
|
||||
},
|
||||
body: Readable.from(body()) as unknown as BodyInit,
|
||||
duplex: 'half',
|
||||
redirect: 'error',
|
||||
signal: deadline.signal
|
||||
} as RequestInit & { duplex: 'half' })
|
||||
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||
throw new Error('skill-cloud-upload-failed')
|
||||
}
|
||||
} finally {
|
||||
deadline.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRuntimeEnvironmentStatus: vi.fn(),
|
||||
installSkillBundleOnRemoteRuntime: vi.fn(),
|
||||
installSkillOnRemoteRuntime: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/state', isPackaged: true }
|
||||
}))
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
getRuntimeEnvironmentStatus: mocks.getRuntimeEnvironmentStatus
|
||||
}))
|
||||
vi.mock('./skill-remote-install-service', () => ({
|
||||
installSkillBundleOnRemoteRuntime: mocks.installSkillBundleOnRemoteRuntime,
|
||||
installSkillOnRemoteRuntime: mocks.installSkillOnRemoteRuntime
|
||||
}))
|
||||
|
||||
import {
|
||||
installSkillBundleCloudGrant,
|
||||
installSkillCloudGrant
|
||||
} from './skill-cloud-grant-installation'
|
||||
|
||||
const grant = {
|
||||
grant: {
|
||||
url: 'https://storage.googleapis.com/bucket/package.tar.gz',
|
||||
expiresAt: '2099-01-01T00:00:00.000Z'
|
||||
},
|
||||
version: {
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
name: 'private-skill',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 12
|
||||
}
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
|
||||
describe('installSkillCloudGrant', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getRuntimeEnvironmentStatus.mockReset()
|
||||
mocks.installSkillBundleOnRemoteRuntime.mockReset()
|
||||
mocks.installSkillOnRemoteRuntime.mockReset()
|
||||
})
|
||||
|
||||
it('carries cancellation into client-mediated remote installation', async () => {
|
||||
const signal = new AbortController().signal
|
||||
const result = {
|
||||
operationId: 'operation-1',
|
||||
status: 'installed',
|
||||
name: 'private-skill',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
placements: []
|
||||
}
|
||||
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { capabilities: ['skills.install.v1', 'skills.upload.v1'] }
|
||||
})
|
||||
mocks.installSkillOnRemoteRuntime.mockResolvedValue(result)
|
||||
|
||||
await expect(
|
||||
installSkillCloudGrant(
|
||||
{} as OrcaRuntimeService,
|
||||
grant,
|
||||
{
|
||||
operationId: 'operation-1',
|
||||
environmentId: 'environment-1',
|
||||
destination: { scope: 'global' }
|
||||
},
|
||||
signal
|
||||
)
|
||||
).resolves.toEqual({ status: 'ok', value: result })
|
||||
expect(mocks.installSkillOnRemoteRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal })
|
||||
)
|
||||
})
|
||||
|
||||
it('installs selected bundle skills through a capable remote runtime', async () => {
|
||||
const bundleGrant = {
|
||||
...grant,
|
||||
version: {
|
||||
...grant.version,
|
||||
name: 'private-bundle',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleName: 'private-bundle',
|
||||
description: '',
|
||||
createdAt: '2026-08-11T00:00:00.000Z',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-1',
|
||||
name: 'private-skill',
|
||||
description: '',
|
||||
digest: 'a'.repeat(64),
|
||||
files: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
} as unknown as SkillCloudDownloadGrant
|
||||
const result = {
|
||||
operationId: 'operation-1',
|
||||
packageId: 'package-1',
|
||||
versionId: 'version-1',
|
||||
bundleDigest: 'c'.repeat(64),
|
||||
status: 'complete',
|
||||
skills: []
|
||||
}
|
||||
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { capabilities: ['skills.install.bundle.v1', 'skills.upload.v1'] }
|
||||
})
|
||||
mocks.installSkillBundleOnRemoteRuntime.mockResolvedValue(result)
|
||||
|
||||
await expect(
|
||||
installSkillBundleCloudGrant({} as OrcaRuntimeService, bundleGrant, {
|
||||
operationId: 'operation-1',
|
||||
environmentId: 'environment-1',
|
||||
selectedSkillIds: ['skill-1'],
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
).resolves.toEqual({ status: 'ok', value: result })
|
||||
expect(mocks.installSkillBundleOnRemoteRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
request: expect.objectContaining({ selectedSkillIds: ['skill-1'] })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,217 +0,0 @@
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../shared/skill-install-capability'
|
||||
import type {
|
||||
SkillBundleInstallProgress,
|
||||
SkillBundleInstallRequest,
|
||||
SkillBundleInstallResult
|
||||
} from '../../shared/skill-bundle-install-contract'
|
||||
import type {
|
||||
SkillInstallDestination,
|
||||
SkillInstallRequest
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract'
|
||||
import { getRuntimeEnvironmentStatus } from '../ipc/runtime-environment-transport-routing'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
import { recordSkillCapabilityAbsence } from './skill-operation-observability'
|
||||
import {
|
||||
installSkillBundleOnRemoteRuntime,
|
||||
installSkillOnRemoteRuntime
|
||||
} from './skill-remote-install-service'
|
||||
|
||||
export type SkillCloudGrantInstallInput = {
|
||||
operationId: string
|
||||
environmentId?: string
|
||||
destination: SkillInstallDestination
|
||||
providers?: string[]
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
|
||||
}
|
||||
|
||||
export type SkillBundleCloudGrantInstallInput = {
|
||||
operationId: string
|
||||
environmentId?: string
|
||||
selectedSkillIds: string[]
|
||||
destination: SkillInstallDestination
|
||||
providers?: string[]
|
||||
conflictDecisions?: SkillBundleInstallRequest['conflictDecisions']
|
||||
}
|
||||
|
||||
function bundleFailureResult(
|
||||
grant: SkillCloudDownloadGrant,
|
||||
request: SkillBundleInstallRequest,
|
||||
failure: NonNullable<ReturnType<typeof skillInstallFailureFromError>>
|
||||
): SkillBundleInstallResult {
|
||||
const manifest = grant.version.manifest
|
||||
if (!('skills' in manifest)) {
|
||||
throw new Error('skill-bundle-cloud-manifest-required')
|
||||
}
|
||||
return {
|
||||
operationId: request.operationId,
|
||||
packageId: request.package.packageId,
|
||||
versionId: request.package.versionId,
|
||||
bundleDigest: request.package.bundleDigest,
|
||||
status: failure.category === 'cancelled' ? 'cancelled' : 'failed',
|
||||
skills: manifest.skills
|
||||
.filter((skill) => request.selectedSkillIds.includes(skill.id))
|
||||
.map((skill) => ({
|
||||
skillId: skill.id,
|
||||
name: skill.name,
|
||||
digest: skill.digest,
|
||||
status: failure.category === 'cancelled' ? ('cancelled' as const) : ('failed' as const),
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkillBundleCloudGrant(
|
||||
runtime: OrcaRuntimeService,
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input: SkillBundleCloudGrantInstallInput,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (progress: SkillBundleInstallProgress) => void
|
||||
) {
|
||||
const manifest = grant.version.manifest
|
||||
if (!('skills' in manifest)) {
|
||||
throw new Error('skill-bundle-cloud-manifest-required')
|
||||
}
|
||||
const request: SkillBundleInstallRequest = {
|
||||
operationId: input.operationId,
|
||||
package: {
|
||||
packageId: grant.version.packageId,
|
||||
versionId: grant.version.versionId,
|
||||
bundleDigest: manifest.bundleDigest,
|
||||
archiveSha256: grant.version.archiveSha256,
|
||||
compressedBytes: grant.version.compressedBytes
|
||||
},
|
||||
selectedSkillIds: input.selectedSkillIds,
|
||||
ingress: { kind: 'download-grant', ...grant.grant },
|
||||
destination: input.destination,
|
||||
...(input.providers ? { providers: input.providers } : {}),
|
||||
conflictDecisions: input.conflictDecisions ?? []
|
||||
}
|
||||
try {
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.installSharedSkillBundleRequest(request, undefined, onProgress)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
|
||||
if (
|
||||
status.ok !== true ||
|
||||
status.result.capabilities?.includes(SKILL_BUNDLE_INSTALL_CAPABILITY) !== true
|
||||
) {
|
||||
if (status.ok === true) {
|
||||
recordSkillCapabilityAbsence({
|
||||
capability: SKILL_BUNDLE_INSTALL_CAPABILITY,
|
||||
destination: 'remote-runtime'
|
||||
})
|
||||
}
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await installSkillBundleOnRemoteRuntime({
|
||||
userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
request,
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
requireHttps: app.isPackaged,
|
||||
signal,
|
||||
onProgress
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (failure?.category === 'compatibility') {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
if (!failure) {
|
||||
throw error
|
||||
}
|
||||
return { status: 'ok' as const, value: bundleFailureResult(grant, request, failure) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSkillCloudGrant(
|
||||
runtime: OrcaRuntimeService,
|
||||
grant: SkillCloudDownloadGrant,
|
||||
input: SkillCloudGrantInstallInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const request: SkillInstallRequest = {
|
||||
operationId: input.operationId,
|
||||
package: {
|
||||
packageId: grant.version.packageId,
|
||||
versionId: grant.version.versionId,
|
||||
packageDigest: grant.version.packageDigest,
|
||||
archiveSha256: grant.version.archiveSha256,
|
||||
compressedBytes: grant.version.compressedBytes
|
||||
},
|
||||
ingress: {
|
||||
kind: 'download-grant',
|
||||
url: grant.grant.url,
|
||||
expiresAt: grant.grant.expiresAt
|
||||
},
|
||||
destination: input.destination,
|
||||
...(input.providers ? { providers: input.providers } : {}),
|
||||
conflictResolution: input.conflictResolution
|
||||
}
|
||||
try {
|
||||
if (!input.environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.installSharedSkillRequest(request) }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
|
||||
if (
|
||||
status.ok !== true ||
|
||||
status.result.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
|
||||
) {
|
||||
if (status.ok === true) {
|
||||
recordSkillCapabilityAbsence({
|
||||
capability: SKILL_INSTALL_CAPABILITY,
|
||||
destination: 'remote-runtime'
|
||||
})
|
||||
}
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await installSkillOnRemoteRuntime({
|
||||
userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
request,
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
requireHttps: app.isPackaged,
|
||||
signal
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (failure?.category === 'compatibility') {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
if (!failure) {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: {
|
||||
operationId: request.operationId,
|
||||
status: failure.category === 'cancelled' ? ('cancelled' as const) : ('failed' as const),
|
||||
name: grant.version.name,
|
||||
packageDigest: request.package.packageDigest,
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { classifySkillCloudInstallTarget } from './skill-cloud-install-target'
|
||||
|
||||
function runtime(usesSsh: boolean): OrcaRuntimeService {
|
||||
return {
|
||||
skillInstallDestinationUsesSsh: vi.fn().mockResolvedValue(usesSsh)
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
describe('classifySkillCloudInstallTarget', () => {
|
||||
it('treats paired runtimes and SSH-owned workspaces as remote', async () => {
|
||||
await expect(
|
||||
classifySkillCloudInstallTarget(runtime(false), {
|
||||
environmentId: 'environment_1',
|
||||
destination: { scope: 'global' }
|
||||
})
|
||||
).resolves.toBe('remote')
|
||||
await expect(
|
||||
classifySkillCloudInstallTarget(runtime(true), {
|
||||
destination: { scope: 'workspace', worktreeId: 'worktree_1' }
|
||||
})
|
||||
).resolves.toBe('remote')
|
||||
})
|
||||
|
||||
it('keeps native and WSL installs in the local grant lane', async () => {
|
||||
await expect(
|
||||
classifySkillCloudInstallTarget(runtime(false), {
|
||||
destination: { scope: 'global', executionTarget: { kind: 'wsl', distro: 'Ubuntu' } }
|
||||
})
|
||||
).resolves.toBe('local')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { SkillInstallDestination } from '../../shared/skill-install-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
export async function classifySkillCloudInstallTarget(
|
||||
runtime: OrcaRuntimeService,
|
||||
input: { environmentId?: string; destination: SkillInstallDestination }
|
||||
): Promise<'local' | 'remote'> {
|
||||
return input.environmentId || (await runtime.skillInstallDestinationUsesSsh(input.destination))
|
||||
? 'remote'
|
||||
: 'local'
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { skillCloudRequest, type SkillCloudRequestError } from './skill-cloud-request'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
|
||||
describe('skillCloudRequest', () => {
|
||||
it('bounds requests that do not have a caller-owned cancellation signal', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fetcher = vi.fn(
|
||||
async (_input: URL | RequestInfo, init?: RequestInit) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
|
||||
})
|
||||
) as typeof fetch
|
||||
const request = skillCloudRequest({
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
path: '/v1/skill-shares/share_1',
|
||||
fetcher,
|
||||
timeoutMs: 100
|
||||
})
|
||||
const expectation = expect(request).rejects.toThrow('skill-cloud-request-timeout')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await expectation
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sends credentials only to the validated Orca origin', async () => {
|
||||
const fetcher = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
expect(String(input)).toBe('http://127.0.0.1:8787/v1/skill-shares/share_1')
|
||||
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer private-token')
|
||||
expect(init?.redirect).toBe('error')
|
||||
return Response.json({ share: { id: 'share_1' } })
|
||||
}) as typeof fetch
|
||||
await expect(
|
||||
skillCloudRequest({
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
authToken: 'private-token',
|
||||
path: '/v1/skill-shares/share_1',
|
||||
fetcher
|
||||
})
|
||||
).resolves.toEqual({ share: { id: 'share_1' } })
|
||||
})
|
||||
|
||||
it('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({
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
authToken: 'token',
|
||||
path: 'https://attacker.test/v1/skills'
|
||||
})
|
||||
).rejects.toThrow('skill-cloud-request-path-invalid')
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ code: 'skill_share_not_found', private: 'do-not-reflect' }, { status: 404 })
|
||||
) as typeof fetch
|
||||
const request = skillCloudRequest({
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
authToken: 'token',
|
||||
path: '/v1/skill-shares/missing',
|
||||
fetcher
|
||||
})
|
||||
await expect(request).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
code: 'skill_share_not_found',
|
||||
message: 'The skill Cloud request failed.'
|
||||
} satisfies Partial<SkillCloudRequestError>)
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
|
||||
import { createSkillCloudDeadline } from './skill-cloud-deadline'
|
||||
|
||||
export class SkillCloudRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export async function skillCloudRequest<T>(input: {
|
||||
apiUrl?: string
|
||||
authToken?: string
|
||||
path: string
|
||||
method?: 'DELETE' | 'GET' | 'POST' | 'PUT'
|
||||
body?: unknown
|
||||
signal?: AbortSignal
|
||||
idempotencyKey?: string
|
||||
fetcher?: typeof fetch
|
||||
timeoutMs?: number
|
||||
}): Promise<T> {
|
||||
const apiUrl = resolveArtifactCloudApiUrl(input.apiUrl)
|
||||
const url = new URL(input.path, `${apiUrl}/`)
|
||||
if (url.origin !== apiUrl || !url.pathname.startsWith('/v1/')) {
|
||||
throw new Error('skill-cloud-request-path-invalid')
|
||||
}
|
||||
const deadline = createSkillCloudDeadline({
|
||||
signal: input.signal,
|
||||
timeoutMs: input.timeoutMs,
|
||||
timeoutMessage: 'skill-cloud-request-timeout'
|
||||
})
|
||||
try {
|
||||
const response = await (input.fetcher ?? fetch)(url, {
|
||||
method: input.method ?? 'GET',
|
||||
headers: {
|
||||
...(input.authToken ? { authorization: `Bearer ${input.authToken}` } : {}),
|
||||
accept: 'application/json',
|
||||
...(input.body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
...(input.idempotencyKey ? { 'idempotency-key': input.idempotencyKey } : {})
|
||||
},
|
||||
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
|
||||
signal: deadline.signal,
|
||||
redirect: 'error'
|
||||
})
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
const value: unknown = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
const error = value as { code?: unknown; message?: unknown } | null
|
||||
throw new SkillCloudRequestError(
|
||||
response.status,
|
||||
typeof error?.code === 'string' ? error.code : 'skill_cloud_request_failed',
|
||||
typeof error?.message === 'string' ? error.message : 'The skill Cloud request failed.'
|
||||
)
|
||||
}
|
||||
return value as T
|
||||
} finally {
|
||||
deadline.cleanup()
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillCloudVersion } from '../../shared/skill-cloud-contract'
|
||||
import { SkillCloudService } from './skill-cloud-service'
|
||||
|
||||
const { packaged } = vi.hoisted(() => ({ packaged: { value: false } }))
|
||||
const createdPaths: string[] = []
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
get isPackaged() {
|
||||
return packaged.value
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of createdPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
packaged.value = false
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function userDataPath(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), 'orca-skill-cloud-service-'))
|
||||
createdPaths.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
function publishedVersion(archiveSha256: string, compressedBytes: number): SkillCloudVersion {
|
||||
return {
|
||||
packageId: 'pkg_retry',
|
||||
versionId: 'ver_retry',
|
||||
name: 'retry-skill',
|
||||
description: 'Retry publication',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256,
|
||||
compressedBytes,
|
||||
createdAt: '2026-08-12T12:00:00.000Z',
|
||||
releaseNotes: 'retry',
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
packageId: 'pkg_retry',
|
||||
versionId: 'ver_retry',
|
||||
name: 'retry-skill',
|
||||
description: 'Retry publication',
|
||||
createdAt: '2026-08-12T12:00:00.000Z',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function publishRequest(archivePath: string, archiveSha256: string, compressedBytes: number) {
|
||||
return {
|
||||
apiUrl: 'http://127.0.0.1:8787',
|
||||
archivePath,
|
||||
archiveSha256,
|
||||
compressedBytes,
|
||||
packageId: 'pkg_retry',
|
||||
releaseNotes: 'retry'
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the development auth token without opening a profile session', async () => {
|
||||
vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
|
||||
const requests: RequestInit[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => {
|
||||
requests.push(init ?? {})
|
||||
return Response.json({ shares: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
new SkillCloudService(userDataPath()).listOwnedShares({ apiUrl: 'http://127.0.0.1:8787' })
|
||||
).resolves.toEqual({ status: 'ok', value: [] })
|
||||
|
||||
expect(new Headers(requests[0]?.headers).get('authorization')).toBe('Bearer desktop-e2e-token')
|
||||
})
|
||||
|
||||
it('rejects the development auth token in packaged builds', async () => {
|
||||
packaged.value = true
|
||||
vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
|
||||
|
||||
await expect(
|
||||
new SkillCloudService(userDataPath()).listOwnedShares({ apiUrl: 'https://share.onorca.dev' })
|
||||
).rejects.toThrow('available only in development builds')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkillCloudService publication retries', () => {
|
||||
it('reuses a reserved upload after its create response is lost', async () => {
|
||||
vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
|
||||
const root = userDataPath()
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
const archive = Buffer.from('skill archive')
|
||||
const archiveSha256 = 'b'.repeat(64)
|
||||
const version = publishedVersion(archiveSha256, archive.byteLength)
|
||||
writeFileSync(archivePath, archive)
|
||||
let createAttempts = 0
|
||||
let uploads = 0
|
||||
const createKeys: string[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/v1/skill-packages/uploads')) {
|
||||
createAttempts += 1
|
||||
createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '')
|
||||
if (createAttempts === 1) {
|
||||
throw new Error('create response lost')
|
||||
}
|
||||
return Response.json({
|
||||
upload: {
|
||||
id: 'upl_retry',
|
||||
policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' }
|
||||
}
|
||||
})
|
||||
}
|
||||
if (url === 'https://storage.test/upload') {
|
||||
uploads += 1
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.endsWith('/v1/skill-packages/pkg_retry')) {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) {
|
||||
return Response.json({ version })
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
)
|
||||
const service = new SkillCloudService(root)
|
||||
const request = publishRequest(archivePath, archiveSha256, archive.byteLength)
|
||||
|
||||
await expect(service.publishVersion(request)).rejects.toThrow('create response lost')
|
||||
await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version })
|
||||
|
||||
expect(createKeys).toHaveLength(2)
|
||||
expect(new Set(createKeys).size).toBe(1)
|
||||
expect(createKeys[0]).not.toBe('')
|
||||
expect(uploads).toBe(1)
|
||||
})
|
||||
|
||||
it('finds the finalized version when retrying after its response is lost', async () => {
|
||||
vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
|
||||
const root = userDataPath()
|
||||
const archivePath = join(root, 'package.tar.gz')
|
||||
const archive = Buffer.from('skill archive')
|
||||
const archiveSha256 = 'c'.repeat(64)
|
||||
const version = publishedVersion(archiveSha256, archive.byteLength)
|
||||
writeFileSync(archivePath, archive)
|
||||
let createAttempts = 0
|
||||
let uploads = 0
|
||||
let finalizations = 0
|
||||
const createKeys: string[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/v1/skill-packages/uploads')) {
|
||||
createAttempts += 1
|
||||
createKeys.push(new Headers(init?.headers).get('idempotency-key') ?? '')
|
||||
if (createAttempts === 2) {
|
||||
return Response.json(
|
||||
{ code: 'skill_upload_not_pending', message: 'Upload is already finalized.' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
return Response.json({
|
||||
upload: {
|
||||
id: 'upl_retry',
|
||||
policy: { url: 'https://storage.test/upload', fields: {}, expiresAt: '2099-01-01' }
|
||||
}
|
||||
})
|
||||
}
|
||||
if (url === 'https://storage.test/upload') {
|
||||
uploads += 1
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.endsWith('/v1/skill-packages/uploads/upl_retry/finalize')) {
|
||||
finalizations += 1
|
||||
throw new Error('finalize response lost')
|
||||
}
|
||||
if (url.endsWith('/v1/skill-packages/pkg_retry')) {
|
||||
return Response.json({
|
||||
package: {
|
||||
id: 'pkg_retry',
|
||||
name: 'retry-skill',
|
||||
description: 'Retry publication',
|
||||
createdAt: version.createdAt,
|
||||
canManage: true,
|
||||
versions: [version]
|
||||
}
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
)
|
||||
const service = new SkillCloudService(root)
|
||||
const request = publishRequest(archivePath, archiveSha256, archive.byteLength)
|
||||
|
||||
await expect(service.publishVersion(request)).rejects.toThrow('finalize response lost')
|
||||
await expect(service.publishVersion(request)).resolves.toEqual({ status: 'ok', value: version })
|
||||
|
||||
expect(createKeys).toHaveLength(2)
|
||||
expect(new Set(createKeys).size).toBe(1)
|
||||
expect(createKeys[0]).not.toBe('')
|
||||
expect(uploads).toBe(1)
|
||||
expect(finalizations).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
SkillCloudDownloadGrant,
|
||||
SkillCloudInstallTarget,
|
||||
SkillCloudOwnedShare,
|
||||
SkillCloudOperation,
|
||||
SkillCloudOptions,
|
||||
SkillCloudPackageDetails,
|
||||
SkillCloudPublishRequest,
|
||||
SkillCloudPublishResult,
|
||||
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'
|
||||
import { startSkillPhaseOperation } from './skill-operation-observability'
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||
|
||||
function publishUploadIdempotencyKey(request: SkillCloudPublishRequest): string {
|
||||
return (
|
||||
request.idempotencyKey ??
|
||||
createHash('sha256').update(`${request.packageId}\0${request.archiveSha256}`).digest('hex')
|
||||
)
|
||||
}
|
||||
|
||||
function id(value: string): string {
|
||||
if (!ID_PATTERN.test(value)) {
|
||||
throw new Error('skill-cloud-id-invalid')
|
||||
}
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
export class SkillCloudService {
|
||||
constructor(private readonly userDataPath: string) {}
|
||||
|
||||
async publish(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudPublishResult>> {
|
||||
const version = await this.publishVersion(request)
|
||||
if (version.status !== 'ok') {
|
||||
return version
|
||||
}
|
||||
const share = await this.createShare(version.value.packageId, request)
|
||||
return share.status === 'ok'
|
||||
? { status: 'ok', value: { version: version.value, share: share.value } }
|
||||
: share
|
||||
}
|
||||
|
||||
publishVersion(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudVersion>> {
|
||||
return this.withAuth(request, async (token, apiUrl) => {
|
||||
let upload: {
|
||||
upload: {
|
||||
id: string
|
||||
policy: { url: string; fields: Record<string, string>; expiresAt: string }
|
||||
}
|
||||
}
|
||||
try {
|
||||
upload = await skillCloudRequest<typeof upload>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: '/v1/skill-packages/uploads',
|
||||
method: 'POST',
|
||||
body: {
|
||||
expectedArchiveSha256: request.archiveSha256,
|
||||
expectedCompressedBytes: request.compressedBytes
|
||||
},
|
||||
idempotencyKey: publishUploadIdempotencyKey(request),
|
||||
signal: request.signal
|
||||
})
|
||||
} catch (error) {
|
||||
const existing = await skillCloudRequest<{ package: SkillCloudPackageDetails }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(request.packageId)}`,
|
||||
signal: request.signal
|
||||
}).catch(() => null)
|
||||
const published = existing?.package.versions.find(
|
||||
(version) =>
|
||||
version.archiveSha256 === request.archiveSha256 &&
|
||||
version.compressedBytes === request.compressedBytes
|
||||
)
|
||||
if (published) {
|
||||
return published
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const uploadOperation = startSkillPhaseOperation({
|
||||
phase: 'upload',
|
||||
compressedBytes: request.compressedBytes
|
||||
})
|
||||
try {
|
||||
await uploadSkillPackageToSignedPolicy({
|
||||
policy: upload.upload.policy,
|
||||
archivePath: request.archivePath,
|
||||
expectedBytes: request.compressedBytes,
|
||||
signal: request.signal,
|
||||
onProgress: (bytesSent) =>
|
||||
request.onProgress?.({
|
||||
phase: 'uploading',
|
||||
bytesSent,
|
||||
totalBytes: request.compressedBytes
|
||||
})
|
||||
})
|
||||
uploadOperation.complete({
|
||||
status: 'complete',
|
||||
compressedBytes: request.compressedBytes
|
||||
})
|
||||
} catch (error) {
|
||||
uploadOperation.fail(error)
|
||||
throw error
|
||||
}
|
||||
request.onProgress?.({
|
||||
phase: 'finalizing',
|
||||
bytesSent: request.compressedBytes,
|
||||
totalBytes: request.compressedBytes
|
||||
})
|
||||
const finalizationOperation = startSkillPhaseOperation({
|
||||
phase: 'finalization',
|
||||
compressedBytes: request.compressedBytes
|
||||
})
|
||||
let finalized: { version: SkillCloudVersion }
|
||||
try {
|
||||
finalized = await skillCloudRequest<{ version: SkillCloudVersion }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/uploads/${id(upload.upload.id)}/finalize`,
|
||||
method: 'POST',
|
||||
body: { releaseNotes: request.releaseNotes },
|
||||
idempotencyKey: upload.upload.id,
|
||||
signal: request.signal
|
||||
})
|
||||
if (finalized.version.packageId !== request.packageId) {
|
||||
throw new Error('skill-cloud-published-package-mismatch')
|
||||
}
|
||||
finalizationOperation.complete({ status: 'complete' })
|
||||
} catch (error) {
|
||||
finalizationOperation.fail(error)
|
||||
throw error
|
||||
}
|
||||
return finalized.version
|
||||
})
|
||||
}
|
||||
|
||||
createShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & {
|
||||
pinnedVersionId?: string
|
||||
idempotencyKey?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<SkillCloudOperation<SkillCloudShare>> {
|
||||
return this.withAuth(request, async (token, apiUrl) => {
|
||||
const shared = await skillCloudRequest<{ share: SkillCloudShare }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}/shares`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
pinnedVersionId: request.pinnedVersionId
|
||||
},
|
||||
idempotencyKey: request.idempotencyKey ?? randomUUID(),
|
||||
signal: request.signal
|
||||
})
|
||||
return shared.share
|
||||
})
|
||||
}
|
||||
|
||||
resolveShare(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<{ id: string; version: SkillCloudVersion }>> {
|
||||
return this.withoutAuth(options, async (apiUrl) => {
|
||||
const result = await skillCloudRequest<{
|
||||
share: { id: string; version: SkillCloudVersion }
|
||||
}>({ apiUrl, path: `/v1/skill-shares/${id(shareId)}` })
|
||||
return result.share
|
||||
})
|
||||
}
|
||||
|
||||
createDownloadGrant(
|
||||
shareId: string,
|
||||
options: SkillCloudOptions & { versionId?: string; installTarget?: SkillCloudInstallTarget }
|
||||
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
|
||||
return this.withoutAuth(options, (apiUrl) =>
|
||||
skillCloudRequest<SkillCloudDownloadGrant>({
|
||||
apiUrl,
|
||||
path: `/v1/skill-shares/${id(shareId)}/download-grants`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
...(options.versionId ? { versionId: options.versionId } : {}),
|
||||
...(options.installTarget ? { installTarget: options.installTarget } : {})
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
createPackageVersionDownloadGrant(
|
||||
packageId: string,
|
||||
versionId: string,
|
||||
options: SkillCloudOptions & { installTarget?: SkillCloudInstallTarget }
|
||||
): Promise<SkillCloudOperation<SkillCloudDownloadGrant>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
skillCloudRequest<SkillCloudDownloadGrant>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}/versions/${id(versionId)}/download-grants`,
|
||||
method: 'POST',
|
||||
body: options.installTarget ? { installTarget: options.installTarget } : {}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getPackage(
|
||||
packageId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<SkillCloudPackageDetails>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
const result = await skillCloudRequest<{ package: SkillCloudPackageDetails }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}`
|
||||
})
|
||||
return result.package
|
||||
})
|
||||
}
|
||||
|
||||
listOwnedShares(
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
const result = await skillCloudRequest<{ shares: SkillCloudOwnedShare[] }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: '/v1/skill-shares'
|
||||
})
|
||||
return result.shares
|
||||
})
|
||||
}
|
||||
|
||||
revokeShare(shareId: string, options: SkillCloudOptions): Promise<SkillCloudOperation<void>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
skillCloudRequest<void>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-shares/${id(shareId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
deleteVersion(
|
||||
packageId: string,
|
||||
versionId: string,
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<void>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
skillCloudRequest<void>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}/versions/${id(versionId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
deletePackage(packageId: string, options: SkillCloudOptions): Promise<SkillCloudOperation<void>> {
|
||||
return this.withAuth(options, (token, apiUrl) =>
|
||||
skillCloudRequest<void>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/${id(packageId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private withAuth<T>(
|
||||
options: SkillCloudOptions,
|
||||
operation: (token: string, apiUrl: string) => Promise<T>
|
||||
): Promise<SkillCloudOperation<T>> {
|
||||
return runSkillCloudOperation({
|
||||
userDataPath: this.userDataPath,
|
||||
options,
|
||||
operation
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ describe('bounded concurrent skill discovery', () => {
|
||||
const line = String(info.mock.calls.at(0)?.at(0))
|
||||
// `present` is the signal that separates "big tree" from "big root set", and
|
||||
// is not derivable from the other counts.
|
||||
expect(line).toContain('[skills] scan roots=23 present=3 walked=23 skills=3')
|
||||
expect(line).toContain('[skills] scan roots=14 present=3 walked=14 skills=3')
|
||||
expect(line).toContain('home-claude')
|
||||
expect(line).not.toContain(home)
|
||||
expect(line).not.toContain(tmpdir())
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
import type { AgentType } from '../../shared/agent-status-types'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots'
|
||||
|
||||
export type SkillScanRoot = Omit<SkillDiscoverySource, 'exists' | 'skippedReason'>
|
||||
type SkillDiscoveryPathApi = Pick<typeof posix, 'basename' | 'join'>
|
||||
@@ -69,14 +67,11 @@ export function buildSkillDiscoverySources(
|
||||
repos?: Repo[]
|
||||
includeCwd?: boolean
|
||||
pathApi?: SkillDiscoveryPathApi
|
||||
providerRootOverrides?: SkillProviderRootOverrides
|
||||
} = {}
|
||||
): SkillScanRoot[] {
|
||||
const pathApi = args.pathApi ?? { basename, join }
|
||||
const home = args.homeDir ?? homedir()
|
||||
const cwd = args.cwd ?? process.cwd()
|
||||
const providerRootOverrides =
|
||||
args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots())
|
||||
const roots: SkillScanRoot[] = [
|
||||
source(
|
||||
'home-codex',
|
||||
@@ -97,7 +92,7 @@ export function buildSkillDiscoverySources(
|
||||
source(
|
||||
'home-claude',
|
||||
'Claude home',
|
||||
providerRootOverrides.claude ?? pathApi.join(home, '.claude', 'skills'),
|
||||
pathApi.join(home, '.claude', 'skills'),
|
||||
'home',
|
||||
['claude'],
|
||||
'claude'
|
||||
@@ -115,7 +110,7 @@ export function buildSkillDiscoverySources(
|
||||
source(
|
||||
'home-grok',
|
||||
'Grok home',
|
||||
providerRootOverrides.grok ?? pathApi.join(home, '.grok', 'skills'),
|
||||
pathApi.join(home, '.grok', 'skills'),
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'grok'
|
||||
@@ -175,38 +170,6 @@ export function buildSkillDiscoverySources(
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'cursor'
|
||||
),
|
||||
source(
|
||||
'home-droid',
|
||||
'Droid home',
|
||||
pathApi.join(home, '.factory', 'skills'),
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'droid'
|
||||
),
|
||||
source(
|
||||
'home-continue',
|
||||
'Continue home',
|
||||
pathApi.join(home, '.continue', 'skills'),
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'continue'
|
||||
),
|
||||
source(
|
||||
'home-trae',
|
||||
'Trae home',
|
||||
pathApi.join(home, '.trae-cn', 'skills'),
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'trae'
|
||||
),
|
||||
source(
|
||||
'home-aug',
|
||||
'Augment home',
|
||||
pathApi.join(home, '.augment', 'skills'),
|
||||
'home',
|
||||
['agent-skills'],
|
||||
'aug'
|
||||
)
|
||||
]
|
||||
|
||||
@@ -241,46 +204,6 @@ export function buildSkillDiscoverySources(
|
||||
'repo',
|
||||
['claude'],
|
||||
'claude'
|
||||
),
|
||||
source(
|
||||
`repo-droid-${stablePathId(repoPath)}`,
|
||||
`${label} .factory`,
|
||||
pathApi.join(repoPath, '.factory', 'skills'),
|
||||
'repo',
|
||||
['agent-skills'],
|
||||
'droid'
|
||||
),
|
||||
source(
|
||||
`repo-continue-${stablePathId(repoPath)}`,
|
||||
`${label} .continue`,
|
||||
pathApi.join(repoPath, '.continue', 'skills'),
|
||||
'repo',
|
||||
['agent-skills'],
|
||||
'continue'
|
||||
),
|
||||
source(
|
||||
`repo-trae-${stablePathId(repoPath)}`,
|
||||
`${label} .trae`,
|
||||
pathApi.join(repoPath, '.trae', 'skills'),
|
||||
'repo',
|
||||
['agent-skills'],
|
||||
'trae'
|
||||
),
|
||||
source(
|
||||
`repo-grok-${stablePathId(repoPath)}`,
|
||||
`${label} .grok`,
|
||||
pathApi.join(repoPath, '.grok', 'skills'),
|
||||
'repo',
|
||||
['agent-skills'],
|
||||
'grok'
|
||||
),
|
||||
source(
|
||||
`repo-aug-${stablePathId(repoPath)}`,
|
||||
`${label} .augment`,
|
||||
pathApi.join(repoPath, '.augment', 'skills'),
|
||||
'repo',
|
||||
['agent-skills'],
|
||||
'aug'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/sk
|
||||
import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl'
|
||||
import { clearSkillRootScanCache, discoverSkills } from './discovery'
|
||||
import { discoverSkillsInWsl } from './skill-discovery-wsl'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
import { stablePathId } from './skill-discovery-sources'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
import { isSkillRootUnavailableError, SkillScanCoalescer } from './skill-scan-coalescer'
|
||||
@@ -88,55 +87,33 @@ function repoDigest(repos: readonly Repo[]): string {
|
||||
}
|
||||
|
||||
// Keys use exact paths — lowercasing would alias two roots that are distinct on Linux.
|
||||
function scanKey(
|
||||
target: ResolvedSkillDiscoveryTarget,
|
||||
repos: readonly Repo[],
|
||||
providerRootOverrides: SkillProviderRootOverrides | undefined
|
||||
): string {
|
||||
const providerRoots = stablePathId(
|
||||
Object.entries(providerRootOverrides ?? {})
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, root]) => `${provider}\0${root}`)
|
||||
.join('\0')
|
||||
)
|
||||
const targetKey =
|
||||
target.kind === 'wsl'
|
||||
? `wsl\0${target.distro}\0${target.homeDir}\0${target.cwd}`
|
||||
: `native\0${target.cwd ?? ''}\0${target.cwd ? '' : repoDigest(repos)}`
|
||||
return `${targetKey}\0${providerRoots}`
|
||||
function scanKey(target: ResolvedSkillDiscoveryTarget, repos: readonly Repo[]): string {
|
||||
return target.kind === 'wsl'
|
||||
? `wsl\0${target.distro}\0${target.homeDir}\0${target.cwd}`
|
||||
: `native\0${target.cwd ?? ''}\0${target.cwd ? '' : repoDigest(repos)}`
|
||||
}
|
||||
|
||||
export async function discoverSkillsOnTarget(
|
||||
target: ResolvedSkillDiscoveryTarget,
|
||||
repos: readonly Repo[],
|
||||
options: { refresh?: boolean; providerRootOverrides?: SkillProviderRootOverrides } = {}
|
||||
options: { refresh?: boolean } = {}
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const refresh = options.refresh === true
|
||||
try {
|
||||
const outcome = await targetScans.run(
|
||||
scanKey(target, repos, options.providerRootOverrides),
|
||||
scanKey(target, repos),
|
||||
{ ttlMs: target.kind === 'wsl' ? WSL_RESULT_TTL_MS : 0, refresh },
|
||||
async () => {
|
||||
if (target.kind === 'wsl') {
|
||||
return discoverSkillsInWsl({
|
||||
distro: target.distro,
|
||||
homeDir: target.homeDir,
|
||||
cwd: target.cwd,
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
cwd: target.cwd
|
||||
})
|
||||
}
|
||||
return target.cwd
|
||||
? discoverSkills({
|
||||
repos: [],
|
||||
cwd: target.cwd,
|
||||
refresh,
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
: discoverSkills({
|
||||
repos: [...repos],
|
||||
refresh,
|
||||
providerRootOverrides: options.providerRootOverrides
|
||||
})
|
||||
? discoverSkills({ repos: [], cwd: target.cwd, refresh })
|
||||
: discoverSkills({ repos: [...repos], refresh })
|
||||
}
|
||||
)
|
||||
return outcome.value
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type SkillScanRoot
|
||||
} from './skill-discovery-sources'
|
||||
import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl'
|
||||
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
|
||||
|
||||
const MAX_MARKDOWN_BYTES = 256 * 1024
|
||||
const WSL_SCAN_TIMEOUT_MS = 10_000
|
||||
@@ -172,7 +171,6 @@ export async function discoverSkillsInWsl(args: {
|
||||
distro: string
|
||||
homeDir: string
|
||||
cwd: string
|
||||
providerRootOverrides?: SkillProviderRootOverrides
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
// Plugin roots are resolved (in JS) from metadata this first wsl.exe call
|
||||
// reads, then fed to the scan's own wsl.exe call below — two sequential
|
||||
@@ -194,8 +192,7 @@ export async function discoverSkillsInWsl(args: {
|
||||
homeDir: args.homeDir,
|
||||
cwd: args.cwd,
|
||||
repos: [],
|
||||
pathApi: pathPosix,
|
||||
providerRootOverrides: args.providerRootOverrides
|
||||
pathApi: pathPosix
|
||||
}),
|
||||
...pluginRoots
|
||||
]
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { writeSkillStateFile } from './skill-install-provenance'
|
||||
import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from './skill-install-filesystem'
|
||||
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
|
||||
const MAX_PENDING_EXTRACTIONS = 64
|
||||
const MAX_EXTRACTION_JOURNAL_BYTES = 64 * 1024
|
||||
const OWNER_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
export type SkillExtractionJournalV1 = {
|
||||
schemaVersion: 1
|
||||
operation: 'extract'
|
||||
ownerToken: string
|
||||
destinationRoot: string
|
||||
extractionPath: string
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
export type SkillExtractionRecoveryReport = {
|
||||
scanned: number
|
||||
recovered: number
|
||||
failures: { journalKey: string; code: string }[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
function journalPath(stateDirectory: string, ownerToken: string): string {
|
||||
return join(stateDirectory, 'extraction-journals', `${ownerToken}.json`)
|
||||
}
|
||||
|
||||
function normalized(path: string): string {
|
||||
const value = resolve(path)
|
||||
return process.platform === 'win32' ? value.toLocaleLowerCase('en-US') : value
|
||||
}
|
||||
|
||||
function isJournal(value: unknown, ownerToken: string): value is SkillExtractionJournalV1 {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const journal = value as Partial<SkillExtractionJournalV1>
|
||||
return (
|
||||
journal.schemaVersion === 1 &&
|
||||
journal.operation === 'extract' &&
|
||||
journal.ownerToken === ownerToken &&
|
||||
OWNER_TOKEN.test(ownerToken) &&
|
||||
typeof journal.destinationRoot === 'string' &&
|
||||
typeof journal.extractionPath === 'string' &&
|
||||
journal.destinationRoot.length <= 32_768 &&
|
||||
journal.extractionPath.length <= 32_768 &&
|
||||
normalized(dirname(journal.extractionPath)) === normalized(journal.destinationRoot) &&
|
||||
basename(journal.extractionPath) === `.orca-skill-extract-${ownerToken}` &&
|
||||
(journal.wslDistro === undefined ||
|
||||
(typeof journal.wslDistro === 'string' &&
|
||||
journal.wslDistro.length > 0 &&
|
||||
journal.wslDistro.length <= 256))
|
||||
)
|
||||
}
|
||||
|
||||
function filesystemFor(journal: SkillExtractionJournalV1): SkillInstallFilesystem {
|
||||
if (!journal.wslDistro) {
|
||||
return nativeSkillInstallFilesystem
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('skill-transaction-wsl-recovery-unavailable')
|
||||
}
|
||||
return new WslSkillInstallFilesystem(journal.wslDistro, [journal.destinationRoot])
|
||||
}
|
||||
|
||||
export async function beginSkillExtractionRecovery(
|
||||
stateDirectory: string,
|
||||
destinationRoot: string,
|
||||
wslDistro?: string
|
||||
): Promise<SkillExtractionJournalV1> {
|
||||
const ownerToken = randomUUID()
|
||||
const journal: SkillExtractionJournalV1 = {
|
||||
schemaVersion: 1,
|
||||
operation: 'extract',
|
||||
ownerToken,
|
||||
destinationRoot,
|
||||
extractionPath: join(destinationRoot, `.orca-skill-extract-${ownerToken}`),
|
||||
...(wslDistro ? { wslDistro } : {})
|
||||
}
|
||||
await writeSkillStateFile(journalPath(stateDirectory, ownerToken), journal)
|
||||
return journal
|
||||
}
|
||||
|
||||
export async function finishSkillExtractionRecovery(
|
||||
stateDirectory: string,
|
||||
journal: SkillExtractionJournalV1,
|
||||
filesystem: SkillInstallFilesystem
|
||||
): Promise<void> {
|
||||
await filesystem.remove(journal.extractionPath)
|
||||
await rm(journalPath(stateDirectory, journal.ownerToken), { force: true })
|
||||
}
|
||||
|
||||
export async function recoverPendingSkillExtractions(
|
||||
stateDirectory: string
|
||||
): Promise<SkillExtractionRecoveryReport> {
|
||||
const directory = join(stateDirectory, 'extraction-journals')
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
const report: SkillExtractionRecoveryReport = {
|
||||
scanned: 0,
|
||||
recovered: 0,
|
||||
failures: [],
|
||||
truncated: files.length > MAX_PENDING_EXTRACTIONS
|
||||
}
|
||||
for (const entry of files.slice(0, MAX_PENDING_EXTRACTIONS)) {
|
||||
const journalKey = entry.name.slice(0, -'.json'.length)
|
||||
report.scanned += 1
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(
|
||||
(
|
||||
await readNodeFileWithinLimit(join(directory, entry.name), MAX_EXTRACTION_JOURNAL_BYTES)
|
||||
).buffer.toString('utf8')
|
||||
)
|
||||
if (!isJournal(parsed, journalKey)) {
|
||||
throw new Error('skill-extraction-journal-invalid')
|
||||
}
|
||||
await finishSkillExtractionRecovery(stateDirectory, parsed, filesystemFor(parsed))
|
||||
report.recovered += 1
|
||||
} catch (error) {
|
||||
report.failures.push({
|
||||
journalKey,
|
||||
code:
|
||||
error instanceof Error && /^skill-[a-z0-9-]+$/.test(error.message)
|
||||
? error.message
|
||||
: 'skill-extraction-recovery-failed'
|
||||
})
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { rename } from 'node:fs/promises'
|
||||
|
||||
const WINDOWS_RENAME_RETRY_DELAYS_MS = [50, 100, 150, 200, 250]
|
||||
|
||||
export async function renameSkillPathWithWindowsRetry(
|
||||
source: string,
|
||||
target: string
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
await rename(source, target)
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
const retryable = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY'
|
||||
if (
|
||||
process.platform !== 'win32' ||
|
||||
!retryable ||
|
||||
attempt >= WINDOWS_RENAME_RETRY_DELAYS_MS.length
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { devNull, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SkillBundleManifest } from '../../shared/skill-freshness'
|
||||
@@ -50,14 +50,7 @@ describe('observed skill git tree identity', () => {
|
||||
await chmod(join(work, 'nested', 'deep', 'tool.sh'), 0o755)
|
||||
await writeFile(join(work, 'blob.bin'), Buffer.from([0, 1, 2, 253, 254, 255]))
|
||||
|
||||
const globalConfig = join(base, 'global.gitconfig')
|
||||
const systemConfig = join(base, 'system.gitconfig')
|
||||
await Promise.all([writeFile(globalConfig, ''), writeFile(systemConfig, '')])
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_CONFIG_GLOBAL: globalConfig,
|
||||
GIT_CONFIG_SYSTEM: systemConfig
|
||||
}
|
||||
const env = { ...process.env, GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_SYSTEM: devNull }
|
||||
execFileSync('git', ['init', '--quiet', repoShell], { env })
|
||||
const gitDirArgs = ['--git-dir', join(repoShell, '.git'), '--work-tree', work]
|
||||
execFileSync('git', [...gitDirArgs, '-c', 'core.autocrlf=false', 'add', '-A'], {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user