feat(skills): complete private bundle sharing

This commit is contained in:
Jinwoo-H
2026-08-13 21:36:03 -04:00
parent 2a0b7b2acf
commit ccd6b40981
164 changed files with 8685 additions and 2687 deletions
@@ -9,13 +9,30 @@ Implementation baselines captured by this checklist update:
- Orca implementation: `skills-share` through `607e31dd04`; no PR.
- Orca Cloud: bundle smoke PR `#329` merged as `eddb144afe`; generation-aware recovery PR `#330`
merged as `8045c85dad`; encrypted physical-host credential PR `#336` merged as `8fce3298ef`;
kill-switch discovery PR `#342` merged as `c2bef2ff20fb`; production remains untouched.
kill-switch discovery PR `#342` merged as `c2bef2ff20fb`.
Windows device-name validation PR `#343` merged as `dbb14a658cbc`; explicit disabled production
bootstrap PR `#352` merged as `2a23f6ace5`; publisher-identity omission PR `#355` merged as
`b3213bd34d1b224d8a3b11527eceaac883965400`; production remains untouched.
`b3213bd34d1b224d8a3b11527eceaac883965400`; production enablement PR `#357`, bounded OIDC smoke
PR `#358`, monitor IAM PR `#359`, and monitor release-ownership PRs `#360`/`#361` are merged
through `d8605edde72229c5d00d22c8bd6853a13c1a4aaa`.
Validated so far:
- Current release hardening cancels bundle extraction after partial bytes exist and removes the
extraction directory, durable journal, and managed-install state. Four repeated focused runs and
the real SIGKILL recovery test passed. Signed GCS uploads use the earlier of signed-policy expiry
and a 15-minute cap, including their archive read stream, so slow valid uploads can finish while
stalled uploads remain bounded.
- Local and headless managed-skill operations now wait for the one startup transaction-recovery
promise before reading or changing install state. Codex uses its documented global
`~/.agents/skills` root, and provider removal derives allowed roots from the registry so aliases
remain removable after an agent is no longer detected. The combined focused suite passed 74
tests across 12 files, including fake-timer slow-upload and controlled-recovery regressions.
- The reported desktop install failure was a strict main-process IPC schema rejecting the
renderer's additive `providers` selection before bundle verification. All four install schemas
now accept the bounded field, and focused schema/provider tests pass. The original production
link resolves as a valid one-skill bundle and returns a fresh same-version local download grant;
the signed URL and private metadata were not logged or printed during verification.
- The final reliability hardening at `607e31dd04` adds a 60-second Cloud request deadline;
cancellable, journaled bundle extraction; bounded package creation, verification, and preview
fanout; atomic share-preparation admission and abandoned-archive cleanup; process-owned download
@@ -288,12 +305,14 @@ Validated so far:
Rollout gate: staging infrastructure, OIDC owner identity exchange, anonymous bearer lifecycle,
owner management, browser-free desktop bundle lifecycle, privacy-safe logging, published-object
recovery, bounded finalization load, and guarded rollback-capable deployment passed. Production
remains untouched. Native Windows, real Ubuntu 24.04 WSL filesystems, physical Ubuntu 20.04 and
infrastructure and the unlisted-link API are live with all four controls enabled after successful
authenticated candidate and canonical lifecycle smoke. Native Windows, real Ubuntu 24.04 WSL
filesystems, physical Ubuntu 20.04 and
macOS ARM64 SSH, and paired non-Windows staging passed. On 2026-08-12, the owner accepted the
remaining live WSL-to-staging journey as duplicate evidence after the combined 449-test physical
Windows/WSL run and native-Windows staging lifecycle; this does not treat WSL semantics as
identical to macOS. Supported Windows SSH and the quarantine lifecycle deletion remain. The shared
staging data plane is asleep.
identical to macOS. The user-driven signed-in desktop and real-host production journey, supported
Windows SSH, and the quarantine lifecycle deletion remain. The shared staging data plane is asleep.
Source plan: [Agent skill sharing and installation plan](./agent-skill-sharing-installation-plan.md).
@@ -313,7 +332,8 @@ does not mean the surrounding phase is complete.
- [x] The portable archive root conforms to Agent Plugins 1.0.0 for skills-only packages and keeps
Orca integrity metadata in `dev.orca.skill-sharing/manifest.json`.
- [x] One canonical `.agents/skills/<skill-name>` copy is installed for each selected skill;
provider-specific placements are reconciled only for detected agents.
provider-specific placements are reconciled for explicitly selected agents, defaulting to
detected agents when no selection is supplied.
- [x] Install, update, rollback, and removal preserve modified or unowned local content unless the
user explicitly approves replacement.
- [x] Package ingestion rejects unsafe archives before any destination mutation.
@@ -1536,15 +1556,32 @@ disconnect boundaries remain separate gates below.
### Production infrastructure and launch
- [ ] Apply the approved production Terraform plan and verify no unrelated replacement.
- [ ] Run production database migrations before routing skill traffic.
- [ ] Deploy the API with all skill flags disabled.
- [ ] After every first-release gate passes, enable the feature for all accounts in one launch.
- [x] Apply the approved production Terraform plan and verify no unrelated replacement. The saved
skill-only plan added 40 resources with zero updates, replacements, or deletions; the
storage-monitor follow-up added one narrow IAM binding with zero changes or deletions. The
final hardening used two reviewed targeted phases: 13 additions plus one WIF update, followed
by exactly five broad-IAM deletions. A separate targeted apply made nine in-place
observability updates. No full plan or unrelated Relay drift was applied.
- [x] Run production database migrations before routing skill traffic. Disabled-route bootstrap
run `31648015091` completed the skill schema migration before any skill route was enabled.
- [x] Deploy the API with all skill flags disabled. Bootstrap revision `orca-cloud-api-00016-yem`
received 100% traffic and returned `404` for the disabled skill route while Artifact and Auth
health remained green.
- [x] After every first-release gate passes, enable the feature for all accounts in one launch.
Production run `31650178315` initially enabled upload, download, remote install, and sharing
together. Hardening run `31661421728` then passed candidate and canonical authenticated
artifact plus skill smoke and promoted `orca-cloud-api-00025-qup` at 100% traffic with digest
`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`.
- [ ] Verify one complete share, local install, remote install, update, rollback, revoke, local
removal, Cloud deletion, upload expiry, and soft-delete recovery journey.
removal, Cloud deletion, upload expiry, and soft-delete recovery journey. Production CI has
verified the server-side share/version/grant/revoke/delete lifecycle; the signed-in desktop
and real-host production journey remains manual.
- [ ] Verify upload, download, and remote-install kill switches independently.
- [ ] Review error budgets, cost, authorization denials, saturation, orphan counts, and support
signals.
signals. The hardened production revision has zero post-deploy error logs. All eight skill
alerts route to the enabled dedicated `Orca skill sharing alerts` channel, and aggregate
monitor execution `orca-cloud-skill-storage-monitor-lfd26` passed on the serving API digest
with zero overdue objects. Sustained-usage review remains post-launch.
### Launch operations
+44 -17
View File
@@ -53,22 +53,27 @@ that larger package-manager surface.
## Current execution status
The Orca implementation is on `skills-share` through `607e31dd04`; no Orca pull request exists.
Cloud bundle ingestion and bearer-link work merged through `stablyai/orca-cloud#320` as `0579cc1a71`;
the bundle desktop smoke update merged through `#329` as `eddb144afe`, generation-aware GCS
recovery merged through `#330` as `8045c85dad`, and the bounded finalization load gate plus cleanup
hardening merged through `#332`-`#335` as `bb8bf8b9ac`. The encrypted physical-host credential
handoff merged through `#336` as `8fce3298ef`. The isolated migration coexistence and database
restore drill merged through `#350` as `c22007384a`.
The explicit default-off production bootstrap merged through `#352` as `2a23f6ace5`; it performs
no deployment unless deliberately selected, and all production skill controls remain disabled.
The authenticated OIDC smoke landed in `#313`; the narrow Auth release-metadata convergence
follow-up landed in `#314`; the finalization and lifecycle fixes landed in `#317`. The final
anonymous bearer-link candidate and canonical smoke now pass in staging. Production remains
untouched. Local, Windows, WSL, paired-runtime, Docker-backed SSH, browser-free staging desktop,
physical Ubuntu 20.04 SSH, published-object recovery, and bounded finalization-load validation are
substantially complete; the implementation checklist records the exact evidence and remaining
physical-host and lifecycle gates.
The Orca implementation is on `skills-share` through `2a0b7b2acf`; no Orca pull request exists.
The local working tree also contains the install-IPC compatibility fix and the in-progress Skills
UI overhaul. Cloud bundle ingestion, bearer links, recovery, load testing, production bootstrap,
OIDC smoke, and monitor ownership merged through `stablyai/orca-cloud#361`. Final infrastructure
hardening merged in `#364` as `57c4958979fc32ed4753959cea45edd3a7c8775c`.
Production is live. Two reviewed targeted Terraform phases added the dedicated app deploy identity,
restricted both GitHub WIF providers to exact workflows, added resource-scoped API/Auth/monitor and
Relay bindings, then removed five broad legacy bindings. A separate exact plan updated only the
bearer request-log exclusion and all eight skill alert policies; the alerts use the dedicated
`Orca skill sharing alerts` email channel. No full production plan was applied because it contains
unrelated Relay drift.
Production deploy run `31661421728` promoted `orca-cloud-api-00025-qup` at 100% traffic from merged
`main`, using immutable digest
`sha256:e4f044105ff2345574bdceb36eec1629761428cc2edf9d743939390b857d61e0`. Candidate and canonical
authenticated artifact and skill smoke passed, `/health` and `/ready` are green, and the direct
unlisted landing route returns only generic install copy plus the Orca deep link with `no-store`,
`no-referrer`, and restrictive CSP headers. Monitor execution
`orca-cloud-skill-storage-monitor-lfd26` passed on the same digest and emitted only aggregate
inventory. The implementation checklist records the remaining desktop and physical-host gates.
The final reliability hardening gives every skill Cloud request a default deadline, makes bundle
extraction cancellable and recoverable through the durable extraction journal, bounds package and
@@ -266,7 +271,29 @@ candidate/canonical smoke passed. Its unrelated post-promotion storage-monitor i
MIGs stable and reached at target zero, and the API at minimum scale zero while revision
`00060-qay` retained 100% traffic.
Production remains untouched.
Production deployment completed on 2026-08-12. The exact skill-only Terraform apply created the
private bucket, database, secret, least-privilege identities, request-log exclusion, metrics,
alerts, and dashboard with 40 additions and zero updates, replacements, or deletions. Disabled-route
bootstrap run `31648015091` migrated the schema and promoted `orca-cloud-api-00016-yem` while all
four controls remained false. Cloud PR `#357` enabled all four controls in one launch. Cloud PR
`#358` replaced the missing long-lived production smoke secret with an exchange restricted to the
exact production `main` workflow, GitHub environment, dispatch event, repository/owner IDs, and
audience; issued principals expire in ten minutes and have no refresh credentials. Auth run
`31649381596` passed candidate and canonical health. API run `31650178315` passed candidate and
canonical authenticated artifact plus skill lifecycle smoke, promoted
`orca-cloud-api-00022-maq` to 100% traffic, and completed the aggregate monitor image update. All
four skill controls are true, the serving revision reported zero deployment-window errors, and
both API and Auth health are green.
Cloud PR `#359` codified the deploy identity's `roles/iam.serviceAccountUser` grant only on the
read-only skill storage monitor identity. Its exact production apply added one resource with zero
changes or deletions, and the repeat targeted plan was zero-diff. Manual execution
`orca-cloud-skill-storage-monitor-bmm4q` completed successfully. Cloud PRs `#360` and `#361` keep
Terraform ownership of the monitor shape while assigning its immutable image and Cloud Run client
metadata to the release workflow; the exact production monitor plan is zero-diff after the live
image advance. The development Orca instance
`skill-sharing-manual` now points at `login.onorca.dev` and `cloud-api.onorca.dev`; sign-in and the
desktop/real-host production lifecycle remain user-driven validation.
A final renderer lifecycle review fenced managed-install inventory by request generation so a
slower previous machine cannot overwrite the newly selected machine's list. It also moved bundle
@@ -3526,6 +3526,9 @@ 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(): string {
return this.pathResolver.getRuntimePaths().configDir
getRuntimeConfigDir(target?: ClaudeAccountSelectionTarget): string {
return this.getPreparation(target).configDir
}
private initializeLastSyncedState(): void {
+4
View File
@@ -148,6 +148,10 @@ 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(() => {})
+6 -2
View File
@@ -2360,7 +2360,10 @@ void app.whenReady().then(async () => {
packaged: app.isPackaged,
platform: process.platform
})
void recoverPendingSkillTransactions(join(app.getPath('userData'), 'skill-installs'))
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:', {
@@ -2563,7 +2566,8 @@ void app.whenReady().then(async () => {
}),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {},
orchestrationEnvironmentTransport
orchestrationEnvironmentTransport,
skillTransactionRecovery
})
runtime = runtimeService
runtimeService.prepareLegacyWorkerTerminalRecovery()
@@ -0,0 +1,54 @@
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()
})
})
@@ -4,10 +4,13 @@ import { SkillInstallDestinationSchema } from '../../shared/skill-install-contra
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()
@@ -29,6 +32,7 @@ export const skillCloudBundleShareInstallSchema = z
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
destination: SkillInstallDestinationSchema,
providers: skillCloudInstallProvidersSchema.optional(),
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
})
.strict()
@@ -49,6 +53,7 @@ export const skillCloudBundlePackageVersionInstallSchema = z
environmentId: skillCloudInstallEnvironmentIdSchema.optional(),
selectedSkillIds: SkillBundleInstallRequestSchema.shape.selectedSkillIds,
destination: SkillInstallDestinationSchema,
providers: skillCloudInstallProvidersSchema.optional(),
conflictDecisions: SkillBundleInstallRequestSchema.shape.conflictDecisions.optional()
})
.strict()
+6 -1
View File
@@ -24,7 +24,12 @@ 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
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos())
const resolvedTarget = resolveSkillDiscoveryTarget(parsedTarget)
return discoverSkillsOnTarget(
resolvedTarget,
store.getRepos(),
await runtime?.resolveSkillDiscoveryProviderRoots(resolvedTarget)
)
}
const scanInventory = (): Promise<SkillFreshnessInventory> =>
// Why: the update command targets this machine's global homes. WSL and SSH
@@ -0,0 +1,32 @@
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([])
})
})
+71 -4
View File
@@ -169,9 +169,15 @@ import {
removeSharedSkillInstall
} from '../skills/skill-install-management-service'
import { listManagedSkillInstalls } from '../skills/skill-install-provenance'
import { getWslHome } from '../wsl'
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
@@ -3302,6 +3308,7 @@ export class OrcaRuntimeService {
private artifactService: ArtifactCloudService | null = null
private skillCloudService: SkillCloudService | null = null
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()
@@ -3355,6 +3362,7 @@ export class OrcaRuntimeService {
getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus
agentSessionClaimSigner?: AgentSessionClaimSigner
orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport
skillTransactionRecovery?: Promise<unknown>
}
) {
this.store = store
@@ -3367,6 +3375,7 @@ 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)
@@ -4740,6 +4749,7 @@ export class OrcaRuntimeService {
onProgress: reportProgress
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
@@ -4756,6 +4766,8 @@ export class OrcaRuntimeService {
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination),
signal: controller.signal,
onProgress: reportProgress
})
@@ -4800,6 +4812,7 @@ export class OrcaRuntimeService {
signal
})
}
await this.skillTransactionRecovery
const allowedDownloadOrigins = ['https://storage.googleapis.com']
if (!app.isPackaged && process.env.ORCA_SKILL_PACKAGE_DOWNLOAD_ORIGINS) {
allowedDownloadOrigins.push(
@@ -4816,6 +4829,8 @@ export class OrcaRuntimeService {
resolveStagedUpload: (uploadId, identity) =>
this.requireSkillUploadSessions().take(uploadId, identity),
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination),
signal
})
}
@@ -4838,10 +4853,13 @@ export class OrcaRuntimeService {
workspace: sshTarget.workspace
})
}
await this.skillTransactionRecovery
return previewSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4881,11 +4899,14 @@ export class OrcaRuntimeService {
}))
})
}
await this.skillTransactionRecovery
const runtimeId = this.getStatus().runtimeId
return previewSharedSkillBundleInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4905,10 +4926,13 @@ export class OrcaRuntimeService {
workspace: sshTarget.workspace
})
}
await this.skillTransactionRecovery
return removeSharedSkillInstall(request, {
authority: this.skillInstallDestinationAuthority(runtimeId),
stateDirectory: app.getPath('userData'),
detectProviders: detectInstalledAgentsWithShellPathHydration
detectProviders: detectInstalledAgentsWithShellPathHydration,
resolveProviderRootOverrides: (destination) =>
this.resolveSkillProviderRootOverrides(destination)
})
}
@@ -4921,6 +4945,7 @@ export class OrcaRuntimeService {
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'), {
@@ -4979,6 +5004,48 @@ export class OrcaRuntimeService {
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,
@@ -30,6 +30,7 @@ function makeContext(overrides: {
return {
runtime: {
listRepos: () => [],
resolveSkillDiscoveryProviderRoots: async () => ({}),
resolveProjectRuntimeForWorktree:
overrides.resolveProjectRuntimeForWorktree ?? (() => undefined)
}
+6 -1
View File
@@ -35,7 +35,12 @@ export const SKILL_METHODS: RpcMethod[] = [
...params,
projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId)
}
return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos())
const resolvedTarget = resolveSkillDiscoveryTarget(target)
return discoverSkillsOnTarget(
resolvedTarget,
runtime.listRepos(),
await runtime.resolveSkillDiscoveryProviderRoots(resolvedTarget)
)
}
}),
defineMethod({
+10 -1
View File
@@ -218,7 +218,16 @@ describe('skill discovery', () => {
'/home/test/.omp/agent/skills',
'/home/test/.gemini/skills',
'/home/test/.gemini/antigravity/skills',
'/home/test/.cursor/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'
])
)
// Why: these live outside ~/.agents/skills, so they must carry the shared
+17 -13
View File
@@ -18,6 +18,8 @@ import {
type SkillScanRoot
} from './skill-discovery-sources'
import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources'
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
export { buildSkillDiscoverySources } from './skill-discovery-sources'
@@ -187,8 +189,8 @@ type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string }
async function scanRoot(root: SkillScanRoot): Promise<ScannedSkill[]> {
const maxDepth = root.sourceKind === 'plugin' ? 9 : 4
const skillFiles = await findSkillFiles(root.path, maxDepth)
const skills = await Promise.all(
skillFiles.map(async (skillFilePath): Promise<ScannedSkill | null> => {
const skills = await runSkillCandidateTasks(
skillFiles.map((skillFilePath) => async (): Promise<ScannedSkill | null> => {
// Why: path identity belongs to the scanning host; canonicalizing before
// returning prevents symlinked roots from becoming duplicate picker rows.
const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath)
@@ -225,6 +227,7 @@ export async function discoverSkills(args: {
homeDir?: string
cwd?: string
includeCwd?: boolean
providerRootOverrides?: SkillProviderRootOverrides
}): Promise<SkillDiscoveryResult> {
const homeDir = args.homeDir ?? homedir()
const roots = [
@@ -236,21 +239,22 @@ export async function discoverSkills(args: {
: [])
]
const sources: SkillDiscoverySource[] = []
const skillGroups = await Promise.all(
roots.map(async (root) => {
const rootResults = await runSkillCandidateTasks(
roots.map((root) => async () => {
const exists = await pathExists(root.path)
sources.push({
...root,
providers: [...root.providers],
exists,
skippedReason: exists ? undefined : 'missing'
})
if (!exists) {
return []
return {
source: {
...root,
providers: [...root.providers],
exists,
skippedReason: exists ? undefined : 'missing'
} satisfies SkillDiscoverySource,
skills: exists ? await scanRoot(root) : []
}
return scanRoot(root)
})
)
sources.push(...rootResults.map((result) => result.source))
const skillGroups = rootResults.map((result) => result.skills)
const seen = new Map<string, DiscoveredSkill>()
for (const skill of skillGroups.flat()) {
// Why: overlapping repo/cwd roots and symlinked provider homes can reach
+5 -1
View File
@@ -235,7 +235,11 @@ export async function extractSkillBundleArchive(input: {
throwIfCancelled(input.signal)
return { pluginManifest, manifest, skillsDirectory, ...archiveIdentity }
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(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) {
@@ -11,8 +11,10 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati
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> }
@@ -26,6 +28,12 @@ export type SkillBundleInstallRequestDependencies = {
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
@@ -72,14 +80,19 @@ async function executeParsedSkillBundleInstallRequest(
dependencies.authority
)
ingress = await resolveIngress(request, dependencies)
const detectedProviders = destination.wslDistro
? await detectSkillProvidersInWsl(destination.wslDistro)
: await dependencies.detectProviders()
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
workspaceDirectory: destination.workspaceDirectory,
providerRootOverrides
})
: undefined
return await installSkillBundle({
@@ -97,6 +110,7 @@ async function executeParsedSkillBundleInstallRequest(
workspaceDirectory: destination.workspaceDirectory,
orcaStateDirectory: dependencies.stateDirectory,
detectedProviders,
providerRootOverrides,
destinationIdentity: destination.destinationIdentity,
hostIdentity: dependencies.authority.environmentId,
expectedArchiveSha256: request.package.archiveSha256,
@@ -1,3 +1,4 @@
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'
@@ -66,6 +67,77 @@ describe('skill bundle installation', () => {
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')
@@ -142,4 +214,49 @@ describe('skill bundle installation', () => {
})
])
})
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' })
])
}
]
})
})
})
@@ -132,7 +132,13 @@ function bundleStatus(
if (skills.every((skill) => skill.status === 'failed')) {
return 'failed'
}
return skills.some((skill) => ['kept-local', 'failed', 'cancelled'].includes(skill.status))
return skills.some(
(skill) =>
['kept-local', 'failed', 'cancelled'].includes(skill.status) ||
skill.placements.some(
(placement) => placement.status === 'failed' || placement.status === 'skipped'
)
)
? 'partial'
: 'complete'
}
@@ -7,6 +7,7 @@ import {
} 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'
@@ -41,6 +42,10 @@ export async function installSkillBundleOnSshHost(input: {
}): 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,
@@ -54,7 +59,7 @@ export async function installSkillBundleOnSshHost(input: {
read: async () => {
const value = await client(
SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD,
{ operationId: input.request.operationId },
{ operationId: request.operationId },
{ timeoutMs: 2_000, signal: input.signal }
)
if (value === null) {
@@ -75,14 +80,14 @@ export async function installSkillBundleOnSshHost(input: {
call: () =>
client(
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
{ request: input.request, workspace: input.workspace },
{ request: request, workspace: input.workspace },
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
)
})
)
} catch (error) {
if (
input.request.ingress.kind !== 'download-grant' ||
request.ingress.kind !== 'download-grant' ||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
) {
throw error
@@ -106,7 +111,7 @@ export async function installSkillBundleOnSshHost(input: {
SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD,
{
request: {
...input.request,
...request,
ingress: { kind: 'staged-upload', uploadId }
},
workspace: input.workspace
@@ -0,0 +1,21 @@
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))
})
})
+31
View File
@@ -0,0 +1,31 @@
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)
}
}
}
@@ -7,10 +7,79 @@ 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)
@@ -67,5 +136,41 @@ describe('uploadSkillPackageToSignedPolicy', () => {
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)
})
})
+42 -15
View File
@@ -2,10 +2,28 @@ 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 {
@@ -30,6 +48,7 @@ export async function uploadSkillPackageToSignedPolicy(input: {
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) {
@@ -53,13 +72,18 @@ export async function uploadSkillPackageToSignedPolicy(input: {
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: input.signal })) {
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) {
@@ -73,19 +97,22 @@ export async function uploadSkillPackageToSignedPolicy(input: {
}
yield ending
}
const request: RequestInit & { duplex: 'half' } = {
method: 'POST',
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(contentLength)
},
body: Readable.from(body()) as unknown as BodyInit,
duplex: 'half',
redirect: 'error',
signal: input.signal
}
const response = await (input.fetcher ?? fetch)(policyUrl, request)
if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error('skill-cloud-upload-failed')
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()
}
}
@@ -27,6 +27,7 @@ export type SkillCloudGrantInstallInput = {
operationId: string
environmentId?: string
destination: SkillInstallDestination
providers?: string[]
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
}
@@ -35,6 +36,7 @@ export type SkillBundleCloudGrantInstallInput = {
environmentId?: string
selectedSkillIds: string[]
destination: SkillInstallDestination
providers?: string[]
conflictDecisions?: SkillBundleInstallRequest['conflictDecisions']
}
@@ -90,6 +92,7 @@ export async function installSkillBundleCloudGrant(
selectedSkillIds: input.selectedSkillIds,
ingress: { kind: 'download-grant', ...grant.grant },
destination: input.destination,
...(input.providers ? { providers: input.providers } : {}),
conflictDecisions: input.conflictDecisions ?? []
}
try {
@@ -127,6 +130,9 @@ export async function installSkillBundleCloudGrant(
}
} 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
}
@@ -155,6 +161,7 @@ export async function installSkillCloudGrant(
expiresAt: grant.grant.expiresAt
},
destination: input.destination,
...(input.providers ? { providers: input.providers } : {}),
conflictResolution: input.conflictResolution
}
try {
@@ -188,6 +195,9 @@ export async function installSkillCloudGrant(
}
} 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
}
+6 -37
View File
@@ -1,6 +1,5 @@
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
const DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS = 60_000
import { createSkillCloudDeadline } from './skill-cloud-deadline'
export class SkillCloudRequestError extends Error {
constructor(
@@ -12,37 +11,6 @@ export class SkillCloudRequestError extends Error {
}
}
function requestSignal(
input: AbortSignal | undefined,
timeoutMs: number
): {
signal: AbortSignal
cleanup(): void
} {
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
throw new Error('skill-cloud-request-timeout-invalid')
}
const controller = new AbortController()
const forwardAbort = (): void => controller.abort(input?.reason)
if (input?.aborted) {
forwardAbort()
} else {
input?.addEventListener('abort', forwardAbort, { once: true })
}
const timeout = setTimeout(
() => controller.abort(new Error('skill-cloud-request-timeout')),
timeoutMs
)
timeout.unref()
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeout)
input?.removeEventListener('abort', forwardAbort)
}
}
}
export async function skillCloudRequest<T>(input: {
apiUrl?: string
authToken?: string
@@ -59,10 +27,11 @@ export async function skillCloudRequest<T>(input: {
if (url.origin !== apiUrl || !url.pathname.startsWith('/v1/')) {
throw new Error('skill-cloud-request-path-invalid')
}
const deadline = requestSignal(
input.signal,
input.timeoutMs ?? DEFAULT_SKILL_CLOUD_REQUEST_TIMEOUT_MS
)
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',
+160 -1
View File
@@ -1,7 +1,8 @@
import { mkdtempSync, rmSync } from 'node:fs'
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 } }))
@@ -30,6 +31,41 @@ function userDataPath(): string {
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[] = []
@@ -86,3 +122,126 @@ describe('SkillCloudService bearer links', () => {
).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)
})
})
+40 -14
View File
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import type {
SkillCloudDownloadGrant,
SkillCloudInstallTarget,
@@ -19,6 +19,13 @@ 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')
@@ -46,23 +53,42 @@ export class SkillCloudService {
request: SkillCloudPublishRequest
): Promise<SkillCloudOperation<SkillCloudVersion>> {
return this.withAuth(request, async (token, apiUrl) => {
const upload = await skillCloudRequest<{
let upload: {
upload: {
id: string
policy: { url: string; fields: Record<string, string>; expiresAt: string }
}
}>({
apiUrl,
authToken: token,
path: '/v1/skill-packages/uploads',
method: 'POST',
body: {
expectedArchiveSha256: request.archiveSha256,
expectedCompressedBytes: request.compressedBytes
},
idempotencyKey: randomUUID(),
signal: request.signal
})
}
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
+79 -2
View File
@@ -10,6 +10,8 @@ import type {
import type { AgentType } from '../../shared/agent-status-types'
import type { Repo } from '../../shared/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'>
@@ -67,11 +69,14 @@ 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',
@@ -92,7 +97,7 @@ export function buildSkillDiscoverySources(
source(
'home-claude',
'Claude home',
pathApi.join(home, '.claude', 'skills'),
providerRootOverrides.claude ?? pathApi.join(home, '.claude', 'skills'),
'home',
['claude'],
'claude'
@@ -110,7 +115,7 @@ export function buildSkillDiscoverySources(
source(
'home-grok',
'Grok home',
pathApi.join(home, '.grok', 'skills'),
providerRootOverrides.grok ?? pathApi.join(home, '.grok', 'skills'),
'home',
['agent-skills'],
'grok'
@@ -170,6 +175,38 @@ 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'
)
]
@@ -204,6 +241,46 @@ 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'
)
)
}
+7 -4
View File
@@ -3,6 +3,7 @@ import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/sk
import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl'
import { discoverSkills } from './discovery'
import { discoverSkillsInWsl } from './skill-discovery-wsl'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
export type ResolvedSkillDiscoveryTarget =
| { kind: 'native-host'; cwd: string | undefined }
@@ -55,16 +56,18 @@ export function resolveSkillDiscoveryTarget(
export async function discoverSkillsOnTarget(
target: ResolvedSkillDiscoveryTarget,
repos: readonly Repo[]
repos: readonly Repo[],
providerRootOverrides?: SkillProviderRootOverrides
): Promise<SkillDiscoveryResult> {
if (target.kind === 'wsl') {
return discoverSkillsInWsl({
distro: target.distro,
homeDir: target.homeDir,
cwd: target.cwd
cwd: target.cwd,
providerRootOverrides
})
}
return target.cwd
? discoverSkills({ repos: [], cwd: target.cwd })
: discoverSkills({ repos: [...repos] })
? discoverSkills({ repos: [], cwd: target.cwd, providerRootOverrides })
: discoverSkills({ repos: [...repos], providerRootOverrides })
}
+4 -1
View File
@@ -16,6 +16,7 @@ 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 MAX_PACKAGE_FILES = 200
@@ -180,6 +181,7 @@ 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
@@ -201,7 +203,8 @@ export async function discoverSkillsInWsl(args: {
homeDir: args.homeDir,
cwd: args.cwd,
repos: [],
pathApi: pathPosix
pathApi: pathPosix,
providerRootOverrides: args.providerRootOverrides
}),
...pluginRoots
]
@@ -98,6 +98,31 @@ describe('WSL install discovery verification', () => {
})
})
it('scans a managed WSL Claude config root used by the target session', async () => {
const managedRoot = '/home/alice/.local/share/orca/claude-accounts/account-1/auth/skills'
discoverSkillsInWslMock.mockResolvedValue(
discovery(['/home/alice/.agents/skills', managedRoot])
)
const result = await verifySkillInstallDiscovery({
result: installResult([
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.agents\\skills\\review',
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills\\review'
]),
scope: 'global',
homeDirectory: '\\\\wsl.localhost\\Ubuntu\\home\\alice',
wslDistro: 'Ubuntu',
providerRootOverrides: {
claude:
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\claude-accounts\\account-1\\auth\\skills'
}
})
expect(result.status).toBe('installed')
expect(discoverSkillsInWslMock).toHaveBeenCalledWith(
expect.objectContaining({ providerRootOverrides: { claude: managedRoot } })
)
})
it('fails verification without scanning a mismatched UNC distro', async () => {
const result = await verifySkillInstallDiscovery({
result: installResult(['\\\\wsl.localhost\\Debian\\home\\alice\\.agents\\skills\\review']),
@@ -4,6 +4,7 @@ import type { SkillDiscoveryResult } from '../../shared/skills'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { discoverSkills } from './discovery'
import { discoverSkillsInWsl } from './skill-discovery-wsl'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
function toWslDiscoveryPath(path: string, distro: string): string {
if (path.includes('\0')) {
@@ -65,12 +66,23 @@ async function discoverInstalledSkill(input: {
homeDirectory: string
workspaceDirectory?: string
wslDistro?: string
providerRootOverrides?: SkillProviderRootOverrides
}): Promise<SkillDiscoveryResult> {
if (input.wslDistro) {
const wslDistro = input.wslDistro
const providerRootOverrides = input.providerRootOverrides
? Object.fromEntries(
Object.entries(input.providerRootOverrides).map(([provider, root]) => [
provider,
toWslDiscoveryPath(root, wslDistro)
])
)
: undefined
return discoverSkillsInWsl({
distro: input.wslDistro,
homeDir: toWslDiscoveryPath(input.homeDirectory, input.wslDistro),
cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, input.wslDistro)
distro: wslDistro,
homeDir: toWslDiscoveryPath(input.homeDirectory, wslDistro),
cwd: toWslDiscoveryPath(input.workspaceDirectory ?? input.homeDirectory, wslDistro),
...(providerRootOverrides ? { providerRootOverrides } : {})
})
}
return discoverSkills({
@@ -78,7 +90,8 @@ async function discoverInstalledSkill(input: {
repos: [],
...(input.scope === 'workspace' && input.workspaceDirectory
? { cwd: input.workspaceDirectory }
: { includeCwd: false })
: { includeCwd: false }),
...(input.providerRootOverrides ? { providerRootOverrides: input.providerRootOverrides } : {})
})
}
@@ -88,6 +101,7 @@ export async function verifySkillInstallDiscovery(input: {
homeDirectory: string
workspaceDirectory?: string
wslDistro?: string
providerRootOverrides?: SkillProviderRootOverrides
discover?: () => Promise<SkillDiscoveryResult>
}): Promise<SkillInstallResult> {
const discovery = await (input.discover?.() ?? discoverInstalledSkill(input)).catch(() => null)
@@ -10,6 +10,7 @@ import {
export type SkillInstalledFileMode = { path: string; executable: boolean }
export type SkillInstallFilesystem = {
authorizeRoots?(paths: readonly string[]): void
prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise<void>
observeSkill(
path: string,
@@ -0,0 +1,16 @@
import { dirname } from 'node:path'
import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
export function historicalSuccessfulProviderRoots(receipt: SkillInstallReceiptV1 | null): string[] {
return (
receipt?.placements.flatMap((placement) =>
isSkillInstallProviderId(placement.provider) &&
placement.topology !== 'canonical-copy' &&
placement.status !== 'failed' &&
placement.status !== 'skipped'
? [dirname(placement.path)]
: []
) ?? []
)
}
@@ -1,5 +1,6 @@
import { writeSkillStateFile } from './skill-install-provenance'
import type { SkillInstallJournalV1 } from './skill-install-recovery'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
export type SkillInstallJournalBoundary = 'before' | 'after'
@@ -8,6 +9,11 @@ export type SkillInstallTransactionDependencies = {
phase: SkillInstallJournalV1['phase'],
boundary: SkillInstallJournalBoundary
) => Promise<void>
placementTransaction?: {
prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise<void>
commit(receipt: SkillInstallReceiptV1): Promise<SkillInstallReceiptV1>
finish(receipt: SkillInstallReceiptV1): Promise<void>
}
}
export async function persistSkillInstallJournalTransition(
@@ -15,15 +15,22 @@ import type { SkillInstallDestinationAuthority } from './skill-install-destinati
import { resolveSkillInstallDestination } from './skill-install-destinations'
import { inspectSkillCanonicalState } from './skill-install-planner'
import { readSkillInstallReceipt } from './skill-install-provenance'
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
import {
resolveSkillProviderDestinations,
type SkillProviderRootOverrides
} from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
type ManagementDependencies = {
authority: SkillInstallDestinationAuthority
stateDirectory: string
detectProviders(): Promise<readonly string[]>
resolveProviderRootOverrides?: (
destination: PreviewContext['destination']
) => Promise<SkillProviderRootOverrides> | SkillProviderRootOverrides
}
type PreviewContext = {
@@ -31,6 +38,7 @@ type PreviewContext = {
filesystem: ReturnType<typeof createWslSkillInstallFilesystem> | undefined
installStateDirectory: string
detectedProviders: readonly string[]
providerRootOverrides?: SkillProviderRootOverrides
}
const BUNDLE_PREVIEW_CONCURRENCY = 8
@@ -55,19 +63,22 @@ async function preparePreviewContext(
destinationRequest,
dependencies.authority
)
const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
return {
destination,
filesystem: destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory
workspaceDirectory: destination.workspaceDirectory,
providerRootOverrides
})
: undefined,
installStateDirectory: join(dependencies.stateDirectory, 'skill-installs'),
detectedProviders: destination.wslDistro
? await detectSkillProvidersInWsl(destination.wslDistro)
: await dependencies.detectProviders()
: await dependencies.detectProviders(),
providerRootOverrides
}
}
@@ -96,7 +107,8 @@ async function previewWithContext(
scope: context.destination.scope,
homeDirectory: context.destination.homeDirectory,
workspaceDirectory: context.destination.workspaceDirectory,
detectedProviders: context.detectedProviders
detectedProviders: context.detectedProviders,
providerRootOverrides: context.providerRootOverrides
})
return {
name: request.name,
@@ -179,21 +191,21 @@ export async function removeSharedSkillInstall(
request.destination,
dependencies.authority
)
const detectedProviders = destination.wslDistro
? await detectSkillProvidersInWsl(destination.wslDistro)
: await dependencies.detectProviders()
const providerRootOverrides = await dependencies.resolveProviderRootOverrides?.(destination)
const filesystem = destination.wslDistro
? createWslSkillInstallFilesystem({
distro: destination.wslDistro,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory
workspaceDirectory: destination.workspaceDirectory,
providerRootOverrides
})
: undefined
const providerRoots = resolveSkillProviderDestinations({
scope: destination.scope,
homeDirectory: destination.homeDirectory,
workspaceDirectory: destination.workspaceDirectory,
detectedProviders
detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id),
providerRootOverrides
}).map((provider) => provider.rootPath)
return removeLocalSharedSkill({
operationId: request.operationId,
@@ -26,6 +26,7 @@ describe('managed skill install receipts', () => {
destinationIdentity: 'global:runtime-1',
canonicalPath: join(root, 'missing', 'example'),
placements: [],
providers: ['codex', 'claude'],
installedAt: '2026-08-11T00:00:00.000Z',
hostIdentity: 'runtime-1'
})
@@ -35,6 +36,7 @@ describe('managed skill install receipts', () => {
name: 'example',
packageId: 'package-1',
versionId: 'version-1',
providers: ['codex', 'claude'],
state: 'missing'
})
])
@@ -17,6 +17,7 @@ export type SkillInstallReceiptV1 = {
destinationIdentity: string
canonicalPath: string
placements: SkillPlacementResult[]
providers?: string[]
previousVersionId?: string
installedAt: string
hostIdentity: string
@@ -67,6 +68,9 @@ function isReceipt(value: unknown): value is SkillInstallReceiptV1 {
typeof receipt.destinationIdentity === 'string' &&
typeof receipt.canonicalPath === 'string' &&
Array.isArray(receipt.placements) &&
(receipt.providers === undefined ||
(Array.isArray(receipt.providers) &&
receipt.providers.every((provider) => typeof provider === 'string'))) &&
typeof receipt.installedAt === 'string' &&
typeof receipt.hostIdentity === 'string' &&
(receipt.fileModes === undefined ||
@@ -110,6 +114,7 @@ export async function listManagedSkillInstalls(
scope: parsed.scope,
destinationIdentity: parsed.destinationIdentity,
installedAt: parsed.installedAt,
...(parsed.providers ? { providers: parsed.providers } : {}),
state: observed
? observed.observedDigest === parsed.packageDigest
? 'unchanged'
@@ -11,9 +11,11 @@ import {
import { installSharedSkill } from './skill-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 { skillInstallFailureFromError } from './skill-install-operation-error'
import { startSkillInstallOperation } from './skill-operation-observability'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
type StagedSkillPackage = {
archivePath: string
@@ -29,6 +31,12 @@ export type SkillInstallRequestDependencies = {
signal?: AbortSignal
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: SkillInstallRequest['package']
@@ -91,14 +99,19 @@ async function executeParsedSkillInstallRequest(
let ingress: StagedSkillPackage | null = null
try {
ingress = await resolveIngress(request, dependencies)
const detectedProviders = destination.wslDistro
? await detectSkillProvidersInWsl(destination.wslDistro)
: await dependencies.detectProviders()
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
workspaceDirectory: destination.workspaceDirectory,
providerRootOverrides
})
: undefined
return await installSharedSkill({
@@ -109,6 +122,7 @@ async function executeParsedSkillInstallRequest(
workspaceDirectory: destination.workspaceDirectory,
orcaStateDirectory: dependencies.stateDirectory,
detectedProviders,
providerRootOverrides,
destinationIdentity: destination.destinationIdentity,
hostIdentity: dependencies.authority.environmentId,
expectedArchiveSha256: request.package.archiveSha256,
+89 -6
View File
@@ -1,7 +1,7 @@
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import {
installSharedSkill,
@@ -56,7 +56,7 @@ afterEach(async () => {
})
describe('skill install service', () => {
it('installs one canonical copy and aliases Claude to it', async () => {
it('installs one canonical Codex copy and aliases the other agent home to it', async () => {
const { root, input } = await fixture()
const result = await installSharedSkill(input)
const canonical = join(root, 'home', '.agents', 'skills', 'test-skill')
@@ -67,6 +67,27 @@ describe('skill install service', () => {
expect(await realpath(claude)).toBe(await realpath(canonical))
})
it('authorizes a historical provider root before relocating it', async () => {
const { root, input } = await fixture()
const oldRoot = join(root, 'old-claude', 'skills')
await installSharedSkill({
...input,
detectedProviders: ['claude'],
providerRootOverrides: { claude: oldRoot },
filesystem: { ...nativeSkillInstallFilesystem }
})
const authorizeRoots = vi.fn()
await installSharedSkill({
...input,
detectedProviders: ['claude'],
providerRootOverrides: { claude: join(root, 'new-claude', 'skills') },
filesystem: { ...nativeSkillInstallFilesystem, authorizeRoots }
})
expect(authorizeRoots).toHaveBeenCalledWith([oldRoot])
})
it('leaves an unowned provider copy untouched and reports partial coverage', async () => {
const { root, input } = await fixture()
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
@@ -84,6 +105,51 @@ describe('skill install service', () => {
expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toBe('unowned')
})
it('never removes a byte-identical provider copy that was recorded as unowned', async () => {
const { root, input } = await fixture()
const sourceSkill = join(root, 'source')
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
await mkdir(join(root, 'home', '.claude', 'skills'), { recursive: true })
await cp(sourceSkill, claude, { recursive: true })
const installed = await installSharedSkill(input)
expect(installed.placements).toContainEqual(
expect.objectContaining({ provider: 'claude', status: 'skipped' })
)
const removed = await removeSharedSkill({
operationId: 'remove_identical_unowned',
skillName: 'test-skill',
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
detectedProviders: input.detectedProviders
})
expect(removed.status).toBe('partial')
expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toContain('# Test')
})
it('keeps an unchanged canonical install partial when a provider placement is unowned', async () => {
const { root, input } = await fixture()
await installSharedSkill({ ...input, detectedProviders: ['codex'] })
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
await mkdir(claude, { recursive: true })
await writeFile(join(claude, 'SKILL.md'), 'unowned')
const result = await installSharedSkill(input)
expect(result).toMatchObject({
status: 'partial',
placements: expect.arrayContaining([
expect.objectContaining({
provider: 'claude',
status: 'skipped',
errorCategory: 'skill-placement-unowned'
})
])
})
})
it('does not report success when normal skill discovery cannot observe the canonical copy', async () => {
const { root, input } = await fixture()
@@ -102,7 +168,7 @@ describe('skill install service', () => {
).toContain('# Test')
})
it('removes the canonical skill and its owned provider alias', async () => {
it('removes owned placements even after the provider is no longer detected', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
@@ -112,7 +178,7 @@ describe('skill install service', () => {
scope: 'global',
homeDirectory: input.homeDirectory,
orcaStateDirectory: input.orcaStateDirectory,
detectedProviders: input.detectedProviders
detectedProviders: []
})
expect(result.status).toBe('removed')
@@ -128,6 +194,24 @@ describe('skill install service', () => {
})
})
it('removes an owned placement when its provider is deselected', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
const claude = join(root, 'home', '.claude', 'skills', 'test-skill')
const updated = await installSharedSkill({
...input,
operationId: 'operation_without_claude',
detectedProviders: ['codex']
})
expect(updated.status).toBe('unchanged')
await expect(lstat(claude)).rejects.toMatchObject({ code: 'ENOENT' })
expect(updated.placements).toEqual([
expect.objectContaining({ provider: 'agent-skills', topology: 'canonical-copy' })
])
})
it('preserves modified canonical and provider content during removal', async () => {
const { root, input } = await fixture()
await installSharedSkill(input)
@@ -234,7 +318,6 @@ describe('skill install service', () => {
})
])
)
expect(interrupted.placements.some((placement) => placement.provider === 'codex')).toBe(false)
expect(await readFile(join(canonical, 'SKILL.md'), 'utf8')).toContain('# Test')
const retried = await installSharedSkill({ ...input, operationId: 'operation_retry' })
+41 -93
View File
@@ -1,6 +1,5 @@
import { join } from 'node:path'
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
import { readSkillInstallReceipt, writeSkillInstallReceipt } from './skill-install-provenance'
import type { SkillInstallResult } from '../../shared/skill-install-contract'
import {
installLocalExtractedSkillPackage,
installLocalSkillPackage,
@@ -8,13 +7,16 @@ import {
type LocalSkillInstallInput,
previewLocalSkillPackage
} from './skill-install-transaction'
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
import {
resolveSkillProviderDestinations,
type SkillProviderRootOverrides
} from './skill-provider-destinations'
import { removeLocalSharedSkill } from './skill-remove-transaction'
import type { SkillInstallFilesystem } from './skill-install-filesystem'
import { verifySkillInstallDiscovery } from './skill-install-discovery-verification'
import type { SkillDiscoveryResult } from '../../shared/skills'
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
import { createSkillPlacementTransaction } from './skill-placement-transaction-controller'
export type SkillInstallServiceInput = Omit<
LocalSkillInstallInput,
@@ -25,6 +27,7 @@ export type SkillInstallServiceInput = Omit<
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
providerRootOverrides?: SkillProviderRootOverrides
filesystem?: SkillInstallFilesystem
wslDistro?: string
discover?: () => Promise<SkillDiscoveryResult>
@@ -39,6 +42,7 @@ export type SkillRemoveServiceInput = {
workspaceDirectory?: string
orcaStateDirectory: string
detectedProviders: readonly string[]
providerRootOverrides?: SkillProviderRootOverrides
conflictResolution?: 'replace-and-discard-local' | 'cancel'
filesystem?: SkillInstallFilesystem
}
@@ -76,32 +80,29 @@ export function skillInstallLocalInput(input: SkillInstallServiceInput): LocalSk
}
}
function canonicalPlacement(result: SkillInstallResult): SkillPlacementResult {
return {
provider: 'agent-skills',
path: result.canonicalPath!,
topology: 'canonical-copy',
status: result.status === 'unchanged' ? 'unchanged' : 'installed'
}
function placementTransaction(input: SkillInstallServiceInput, request: LocalSkillInstallInput) {
return createSkillPlacementTransaction({
stateDirectory: request.stateDirectory,
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
detectedProviders: input.detectedProviders,
providerRootOverrides: input.providerRootOverrides,
filesystem: input.filesystem,
wslDistro: input.wslDistro,
signal: input.signal
})
}
export async function installSharedSkill(
input: SkillInstallServiceInput
): Promise<SkillInstallResult> {
const request = skillInstallLocalInput(input)
const preview = await previewLocalSkillPackage(request)
const previousReceipt = await readSkillInstallReceipt(
request.stateDirectory,
preview.canonicalPath
)
const result = await installLocalSkillPackage(request)
return completeSharedSkillInstall({
input,
request,
manifest: preview.manifest,
previousReceipt,
result
await previewLocalSkillPackage(request)
const result = await installLocalSkillPackage(request, {
placementTransaction: placementTransaction(input, request)
})
return completeSharedSkillInstall(input, result)
}
export async function installSharedExtractedSkill(
@@ -109,26 +110,16 @@ export async function installSharedExtractedSkill(
extracted: LocalExtractedSkillPackage
): Promise<SkillInstallResult> {
const request = skillInstallLocalInput(input)
const canonicalPath = join(request.destinationRoot, extracted.manifest.name)
const previousReceipt = await readSkillInstallReceipt(request.stateDirectory, canonicalPath)
const result = await installLocalExtractedSkillPackage(request, extracted)
return completeSharedSkillInstall({
input,
request,
manifest: extracted.manifest,
previousReceipt,
result
const result = await installLocalExtractedSkillPackage(request, extracted, {
placementTransaction: placementTransaction(input, request)
})
return completeSharedSkillInstall(input, result)
}
async function completeSharedSkillInstall(input: {
input: SkillInstallServiceInput
request: LocalSkillInstallInput
manifest: SkillPackageManifestV1
previousReceipt: Awaited<ReturnType<typeof readSkillInstallReceipt>>
async function completeSharedSkillInstall(
input: SkillInstallServiceInput,
result: SkillInstallResult
}): Promise<SkillInstallResult> {
const { result } = input
): Promise<SkillInstallResult> {
if (
!result.canonicalPath ||
result.status === 'conflict' ||
@@ -137,61 +128,17 @@ async function completeSharedSkillInstall(input: {
) {
return result
}
const placements: SkillPlacementResult[] = [canonicalPlacement(result)]
const destinations = resolveSkillProviderDestinations({
scope: input.input.scope,
homeDirectory: input.input.homeDirectory,
workspaceDirectory: input.input.workspaceDirectory,
detectedProviders: input.input.detectedProviders
})
for (const destination of destinations) {
if (destination.readsCanonicalRoot) {
continue
}
if (input.input.signal?.aborted) {
placements.push({
provider: destination.provider,
path: join(destination.rootPath, result.name),
topology: 'independent-copy',
status: 'skipped',
errorCategory: 'skill-placement-cancelled',
failure: {
category: 'cancelled',
code: 'skill-placement-cancelled',
retryable: true
}
})
continue
}
const placement = await reconcileSkillProviderPlacement({
canonicalPath: result.canonicalPath,
skillName: result.name,
destination,
previousReceipt: input.previousReceipt,
packageDigest: result.packageDigest,
fileModes: input.manifest.files,
filesystem: input.input.filesystem,
...(input.input.wslDistro ? { targetPlatform: 'linux' as const } : {})
})
if (placement) {
placements.push(placement)
}
}
const receipt = await readSkillInstallReceipt(input.request.stateDirectory, result.canonicalPath)
if (!receipt) {
throw new Error('skill-install-receipt-missing')
}
await writeSkillInstallReceipt(input.request.stateDirectory, { ...receipt, placements })
const incomplete = placements.some(
const incomplete = result.placements.some(
(placement) => placement.status === 'failed' || placement.status === 'skipped'
)
return verifySkillInstallDiscovery({
result: { ...result, status: incomplete ? 'partial' : result.status, placements },
scope: input.input.scope,
homeDirectory: input.input.homeDirectory,
workspaceDirectory: input.input.workspaceDirectory,
wslDistro: input.input.wslDistro,
discover: input.input.discover
result: { ...result, status: incomplete ? 'partial' : result.status },
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
wslDistro: input.wslDistro,
providerRootOverrides: input.providerRootOverrides,
discover: input.discover
})
}
@@ -205,7 +152,8 @@ export async function removeSharedSkill(
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
detectedProviders: input.detectedProviders
detectedProviders: SKILL_INSTALL_PROVIDERS.map((provider) => provider.id),
providerRootOverrides: input.providerRootOverrides
})
return removeLocalSharedSkill({
operationId: input.operationId,
@@ -68,6 +68,24 @@ export function skillInstallReplacementAllowed(
)
}
export function skillInstallUnchangedResult(
input: LocalSkillInstallInput,
manifest: SkillPackageManifestV1,
canonicalPath: string,
receipt: SkillInstallReceiptV1
): SkillInstallResult {
return {
operationId: input.operationId,
status: 'unchanged',
name: manifest.name,
packageDigest: manifest.packageDigest,
canonicalPath,
placements: receipt.placements.map((placement) =>
placement.status === 'installed' ? { ...placement, status: 'unchanged' } : placement
)
}
}
export function createSkillInstallReceipt(input: {
request: LocalSkillInstallInput
manifest: SkillPackageManifestV1
+17 -19
View File
@@ -34,15 +34,12 @@ import {
createSkillInstallReceipt,
skillInstallConflictResult,
skillInstallFailureResult,
skillInstallReplacementAllowed
skillInstallReplacementAllowed,
skillInstallUnchangedResult
} from './skill-install-transaction-result'
import { settleObservedSkillTransactionRecovery } from './skill-transaction-recovery-observability'
export { recoverSkillInstallTransaction } from './skill-install-recovery'
export type {
SkillInstallJournalBoundary,
SkillInstallTransactionDependencies
} from './skill-install-journal-transition'
import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots'
export type LocalSkillInstallInput = {
operationId: string
@@ -168,8 +165,11 @@ export async function installLocalExtractedSkillPackage(
path: skillInstallLockPath(input.stateDirectory, canonicalPath),
timeoutMs: input.lockTimeoutMs
})
const historicalReceipt = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt))
await recoverSkillRemovalTransaction(input.stateDirectory, canonicalPath, filesystem)
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem)
await recoverSkillPlacementTransaction(input.stateDirectory, canonicalPath, filesystem)
throwIfCancelled(input.signal)
const previous = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
const state = await inspectSkillCanonicalState({
@@ -178,7 +178,7 @@ export async function installLocalExtractedSkillPackage(
receipt: previous,
filesystem
})
const receipt = createSkillInstallReceipt({
let receipt = createSkillInstallReceipt({
request: input,
manifest: extracted.manifest,
archiveSha256: extracted.archiveSha256,
@@ -186,23 +186,17 @@ export async function installLocalExtractedSkillPackage(
previous
})
if (state.kind === 'unchanged') {
await dependencies.placementTransaction?.prepare(previous, receipt)
throwIfCancelled(input.signal)
receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt
await writeSkillInstallReceipt(input.stateDirectory, receipt)
return {
operationId: input.operationId,
status: 'unchanged',
name: extracted.manifest.name,
packageDigest: extracted.manifest.packageDigest,
canonicalPath,
placements: receipt.placements.map((placement) => ({
...placement,
status: 'unchanged'
}))
}
await dependencies.placementTransaction?.finish(receipt)
return skillInstallUnchangedResult(input, extracted.manifest, canonicalPath, receipt)
}
if (!skillInstallReplacementAllowed(state, input)) {
return skillInstallConflictResult(input.operationId, extracted.manifest, state)
}
await dependencies.placementTransaction?.prepare(previous, receipt)
throwIfCancelled(input.signal)
const transactionId = randomUUID()
const stagingPath = join(
@@ -271,8 +265,12 @@ export async function installLocalExtractedSkillPackage(
throw new Error('skill-install-committed-digest-mismatch')
}
await writeSkillInstallReceipt(input.stateDirectory, receipt)
receipt = (await dependencies.placementTransaction?.commit(receipt)) ?? receipt
await writeSkillInstallReceipt(input.stateDirectory, receipt)
journal.receipt = receipt
journal.phase = 'receipt-published'
await persistSkillInstallJournalTransition(statePath, journal, dependencies)
await dependencies.placementTransaction?.finish(receipt)
await cleanSkillInstallJournalFiles(journal, filesystem)
journal.phase = 'complete'
await persistSkillInstallJournalTransition(statePath, journal, dependencies)
@@ -4,6 +4,7 @@ import type {
SkillInstallResult,
SkillPlacementResult
} from '../../shared/skill-install-contract'
import type { SkillInstallProviderId } from '../../shared/skill-install-providers'
import type {
SkillBundleInstallRequest,
SkillBundleInstallResult
@@ -33,7 +34,7 @@ type SkillPhaseStart = {
destination?: 'startup' | 'transaction' | 'provider-placement' | 'remote-runtime' | 'global-ssh'
compressedBytes?: number
skillCount?: number
provider?: 'codex' | 'claude'
provider?: SkillInstallProviderId
}
type SkillPhaseOutcome = {
@@ -0,0 +1,53 @@
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
import { SkillInstallOperationError } from './skill-install-operation-error'
const MAX_TIMEOUT_DELAY_MS = 2_147_483_647
export function throwIfSkillDownloadUnavailable(input: {
signal?: AbortSignal
now: () => number
expiresAt: number
}): void {
if (input.signal?.aborted) {
throw new SkillInstallOperationError({
...SKILL_INSTALL_CANCELLED_FAILURE,
code: 'skill-download-cancelled'
})
}
if (input.now() >= input.expiresAt) {
throw new Error('skill-download-grant-expired')
}
}
export function isSkillDownloadGrantExpiredAbort(signal: AbortSignal | undefined): boolean {
return signal?.reason instanceof Error && signal.reason.message === 'skill-download-grant-expired'
}
export function createSkillDownloadAvailabilitySignal(input: {
signal?: AbortSignal
now: () => number
expiresAt: number
}): {
signal: AbortSignal
cleanup(): void
} {
const controller = new AbortController()
const abortFromCaller = (): void => controller.abort(input.signal?.reason)
if (input.signal?.aborted) {
abortFromCaller()
} else {
input.signal?.addEventListener('abort', abortFromCaller, { once: true })
}
const timeout = setTimeout(
() => controller.abort(new Error('skill-download-grant-expired')),
Math.min(MAX_TIMEOUT_DELAY_MS, Math.max(0, input.expiresAt - input.now()))
)
timeout.unref()
return {
signal: controller.signal,
cleanup: () => {
clearTimeout(timeout)
input.signal?.removeEventListener('abort', abortFromCaller)
}
}
}
@@ -183,6 +183,54 @@ describe('downloadSkillPackageGrant', () => {
expect(expired.fetcher).not.toHaveBeenCalled()
})
it('physically aborts a fetch stalled past grant expiry', async () => {
vi.useFakeTimers()
const stalled = await input({
expiresAt: new Date(Date.now() + 100).toISOString(),
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 download = downloadSkillPackageGrant(stalled)
const expectation = expect(download).rejects.toThrow('skill-download-grant-expired')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(100)
await expectation
vi.useRealTimers()
})
it('does not expire a far-future stalled fetch immediately when the timer delay overflows Node limits', async () => {
vi.useFakeTimers()
const caller = new AbortController()
const stalled = await input({
signal: caller.signal,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000 * 50).toISOString(),
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 download = downloadSkillPackageGrant(stalled)
await Promise.resolve()
await vi.advanceTimersByTimeAsync(10)
await expect(Promise.race([download, Promise.resolve('pending')])).resolves.toBe('pending')
caller.abort()
await expect(download).rejects.toThrow('skill-download-cancelled')
vi.useRealTimers()
})
it('deletes partial bytes when a streaming download is cancelled', async () => {
const controller = new AbortController()
let sent = false
@@ -207,4 +255,35 @@ describe('downloadSkillPackageGrant', () => {
await expect(downloadSkillPackageGrant(cancelled)).rejects.toThrow('skill-download-cancelled')
expect(await remainingDownloadFiles(cancelled.temporaryRoot)).toEqual([])
})
it('physically aborts a body read stalled past grant expiry and deletes partial bytes', async () => {
vi.useFakeTimers()
const stalled = await input({
expiresAt: new Date(Date.now() + 100).toISOString(),
fetcher: vi.fn(async (_input: URL | RequestInfo, init?: RequestInit) => {
let controller!: ReadableStreamDefaultController<Uint8Array>
init?.signal?.addEventListener(
'abort',
() => controller.error(init.signal?.reason ?? new Error('aborted')),
{ once: true }
)
return response(
new ReadableStream({
start(streamController) {
controller = streamController
controller.enqueue(bytes.subarray(0, 4))
}
})
)
}) as typeof fetch
})
const download = downloadSkillPackageGrant(stalled)
const expectation = expect(download).rejects.toThrow('skill-download-grant-expired')
await Promise.resolve()
await vi.advanceTimersByTimeAsync(100)
await expectation
expect(await remainingDownloadFiles(stalled.temporaryRoot)).toEqual([])
vi.useRealTimers()
})
})
+111 -89
View File
@@ -5,8 +5,11 @@ import {
SKILL_PACKAGE_CONTENT_TYPE,
SKILL_PACKAGE_MAX_COMPRESSED_BYTES
} from '../../shared/skill-package-manifest'
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
import { SkillInstallOperationError } from './skill-install-operation-error'
import {
createSkillDownloadAvailabilitySignal,
isSkillDownloadGrantExpiredAbort,
throwIfSkillDownloadUnavailable
} from './skill-package-download-availability'
import { startSkillPhaseOperation } from './skill-operation-observability'
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
@@ -63,40 +66,37 @@ function validateUrl(
return url
}
function throwIfUnavailable(input: SkillPackageDownloadInput, expiresAt: number): void {
if (input.signal?.aborted) {
throw new SkillInstallOperationError({
...SKILL_INSTALL_CANCELLED_FAILURE,
code: 'skill-download-cancelled'
})
}
if (input.now!() >= expiresAt) {
throw new Error('skill-download-grant-expired')
}
}
async function fetchWithoutCredentialRedirect(
input: SkillPackageDownloadInput,
allowedOrigins: ReadonlySet<string>,
expiresAt: number
expiresAt: number,
signal: AbortSignal
): Promise<Response> {
let url = validateUrl(input.url, allowedOrigins, input.requireHttps)
for (let redirectCount = 0; ; redirectCount += 1) {
throwIfUnavailable(input, expiresAt)
throwIfSkillDownloadUnavailable({
signal: input.signal,
now: input.now!,
expiresAt
})
let response: Response
try {
response = await input.fetcher!(url, {
method: 'GET',
redirect: 'manual',
signal: input.signal
signal
})
} catch {
if (input.signal?.aborted) {
throw new SkillInstallOperationError({
...SKILL_INSTALL_CANCELLED_FAILURE,
code: 'skill-download-cancelled'
throwIfSkillDownloadUnavailable({
signal: input.signal,
now: input.now!,
expiresAt
})
}
if (isSkillDownloadGrantExpiredAbort(signal)) {
throw new Error('skill-download-grant-expired')
}
throw new Error('skill-download-transport-failed')
}
if (!REDIRECT_STATUSES.has(response.status)) {
@@ -181,80 +181,102 @@ async function downloadSkillPackageGrantUnobserved(
(origin) => validateUrl(origin, new Set([new URL(origin).origin]), false).origin
)
)
throwIfUnavailable(input, expiresAt)
const response = await fetchWithoutCredentialRedirect(input, allowedOrigins, expiresAt)
if (!response.ok || !response.body) {
throw new Error('skill-download-transport-failed')
}
if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) {
throw new Error('skill-download-content-type-invalid')
}
const contentLength = response.headers.get('content-length')
if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
const processRoot = await prepareTemporaryRoot(input.temporaryRoot)
const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-'))
const archivePath = join(temporaryDirectory, 'package.tar.gz')
throwIfSkillDownloadUnavailable({ signal: input.signal, now: input.now!, expiresAt })
const availability = createSkillDownloadAvailabilitySignal({
signal: input.signal,
now: input.now!,
expiresAt
})
try {
const handle = await open(archivePath, 'wx', 0o600)
const reader = response.body.getReader()
const hash = createHash('sha256')
let compressedBytes = 0
try {
for (;;) {
throwIfUnavailable(input, expiresAt)
const chunk = await reader.read()
if (chunk.done) {
break
}
compressedBytes += chunk.value.byteLength
if (
compressedBytes > input.expectedCompressedBytes ||
compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
) {
throw new Error('skill-download-size-limit')
}
hash.update(chunk.value)
let offset = 0
while (offset < chunk.value.byteLength) {
const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset)
if (written.bytesWritten === 0) {
throw new Error('skill-download-write-failed')
}
offset += written.bytesWritten
}
}
await handle.sync()
} catch (error) {
await reader.cancel().catch(() => undefined)
if (input.signal?.aborted) {
throw new SkillInstallOperationError({
...SKILL_INSTALL_CANCELLED_FAILURE,
code: 'skill-download-cancelled'
})
}
throw error
} finally {
await handle.close()
const response = await fetchWithoutCredentialRedirect(
input,
allowedOrigins,
expiresAt,
availability.signal
)
if (!response.ok || !response.body) {
throw new Error('skill-download-transport-failed')
}
if (compressedBytes !== input.expectedCompressedBytes) {
if (response.headers.get('content-type')?.split(';', 1)[0] !== SKILL_PACKAGE_CONTENT_TYPE) {
throw new Error('skill-download-content-type-invalid')
}
const contentLength = response.headers.get('content-length')
if (contentLength !== null && Number(contentLength) !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
const archiveSha256 = hash.digest('hex')
if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) {
throw new Error('skill-download-archive-digest-mismatch')
const processRoot = await prepareTemporaryRoot(input.temporaryRoot)
const temporaryDirectory = await mkdtemp(join(processRoot, '.orca-skill-download-'))
const archivePath = join(temporaryDirectory, 'package.tar.gz')
try {
const handle = await open(archivePath, 'wx', 0o600)
const reader = response.body.getReader()
const hash = createHash('sha256')
let compressedBytes = 0
try {
for (;;) {
throwIfSkillDownloadUnavailable({
signal: input.signal,
now: input.now!,
expiresAt
})
const chunk = await reader.read()
if (chunk.done) {
break
}
compressedBytes += chunk.value.byteLength
if (
compressedBytes > input.expectedCompressedBytes ||
compressedBytes > SKILL_PACKAGE_MAX_COMPRESSED_BYTES
) {
throw new Error('skill-download-size-limit')
}
hash.update(chunk.value)
let offset = 0
while (offset < chunk.value.byteLength) {
const written = await handle.write(chunk.value, offset, chunk.value.byteLength - offset)
if (written.bytesWritten === 0) {
throw new Error('skill-download-write-failed')
}
offset += written.bytesWritten
}
}
await handle.sync()
} catch (error) {
await reader.cancel().catch(() => undefined)
if (input.signal?.aborted) {
throwIfSkillDownloadUnavailable({
signal: input.signal,
now: input.now!,
expiresAt
})
}
if (isSkillDownloadGrantExpiredAbort(availability.signal)) {
throw new Error('skill-download-grant-expired')
}
throw error
} finally {
await handle.close()
}
if (compressedBytes !== input.expectedCompressedBytes) {
throw new Error('skill-download-size-mismatch')
}
const archiveSha256 = hash.digest('hex')
if (!hashesEqual(archiveSha256, input.expectedArchiveSha256)) {
throw new Error('skill-download-archive-digest-mismatch')
}
return {
archivePath,
archiveSha256,
compressedBytes,
cleanup: () => rm(temporaryDirectory, { recursive: true, force: true })
}
} catch (error) {
await rm(temporaryDirectory, { recursive: true, force: true })
throw error
}
return {
archivePath,
archiveSha256,
compressedBytes,
cleanup: () => rm(temporaryDirectory, { recursive: true, force: true })
}
} catch (error) {
await rm(temporaryDirectory, { recursive: true, force: true })
throw error
} finally {
availability.cleanup()
}
}
@@ -0,0 +1,129 @@
import { lstat, readlink } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import type { SkillInstalledFileMode, SkillInstallFilesystem } from './skill-install-filesystem'
import type {
SkillPlacementJournalActionV1,
SkillPlacementJournalV1
} from './skill-placement-recovery-journal'
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export async function placementPathExists(path: string): Promise<boolean> {
return Boolean(await lstat(path).catch(() => null))
}
export async function placementIsAlias(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem
): Promise<boolean> {
if (filesystem.aliasTargets) {
return filesystem.aliasTargets(canonicalPath, destinationPath).catch(() => false)
}
return Boolean((await lstat(destinationPath).catch(() => null))?.isSymbolicLink())
}
export async function placementMatchesDigest(
path: string,
digest: string,
journal: SkillPlacementJournalV1,
filesystem: SkillInstallFilesystem,
fileModes: readonly SkillInstalledFileMode[] = journal.fileModes
): Promise<boolean> {
return (
(await filesystem.observeSkill(path, fileModes).catch(() => null))?.observedDigest === digest
)
}
export async function placementBackupMatchesPrevious(
journal: SkillPlacementJournalV1,
action: SkillPlacementJournalActionV1,
filesystem: SkillInstallFilesystem
): Promise<boolean> {
const previous = journal.previousReceipt?.placements.find(
(placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath)
)
if (!previous || !journal.previousReceipt) {
return false
}
if (previous.topology !== 'provider-alias') {
return placementMatchesDigest(
action.backupPath,
journal.previousReceipt.packageDigest,
journal,
filesystem,
journal.previousReceipt.fileModes ?? []
)
}
if (filesystem.aliasTargets) {
return filesystem.aliasTargets(journal.canonicalPath, action.backupPath).catch(() => false)
}
const target = await readlink(action.backupPath).catch(() => null)
return Boolean(
target &&
normalizedPath(resolve(dirname(action.backupPath), target)) ===
normalizedPath(journal.canonicalPath)
)
}
export async function settleDesiredPlacementArtifacts(
journal: SkillPlacementJournalV1,
action: SkillPlacementJournalActionV1,
filesystem: SkillInstallFilesystem
): Promise<boolean> {
const [destinationExists, stagingExists, backupExists] = await Promise.all([
placementPathExists(action.destinationPath),
placementPathExists(action.stagingPath),
placementPathExists(action.backupPath)
])
if (backupExists) {
if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
throw new Error('skill-placement-recovery-conflict')
}
if (destinationExists) {
if (
!(await placementMatchesDigest(
action.destinationPath,
journal.packageDigest,
journal,
filesystem
))
) {
throw new Error('skill-placement-recovery-conflict')
}
await filesystem.remove(action.backupPath)
} else if (
stagingExists &&
(await placementMatchesDigest(action.stagingPath, journal.packageDigest, journal, filesystem))
) {
await filesystem.rename(action.stagingPath, action.destinationPath)
await filesystem.remove(action.backupPath)
return true
} else {
if (stagingExists) {
await filesystem.remove(action.stagingPath)
}
await filesystem.rename(action.backupPath, action.destinationPath)
}
}
if (await placementPathExists(action.stagingPath)) {
if (
!(await placementMatchesDigest(
action.stagingPath,
journal.packageDigest,
journal,
filesystem
))
) {
await filesystem.remove(action.stagingPath)
return true
}
await ((await placementPathExists(action.destinationPath))
? filesystem.remove(action.stagingPath)
: filesystem.rename(action.stagingPath, action.destinationPath))
}
return stagingExists || backupExists
}
+31 -10
View File
@@ -29,12 +29,20 @@ export async function replaceOwnedSkillPlacementCopy(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
fileModes?: readonly SkillInstalledFileMode[]
fileModes?: readonly SkillInstalledFileMode[],
transaction?: { replacementPath: string; backupPath: string; retainBackup: boolean }
): Promise<void> {
const replacement = `${destinationPath}.orca-copy-${randomUUID()}`
const backup = `${destinationPath}.orca-backup-${randomUUID()}`
const replacement = transaction?.replacementPath ?? `${destinationPath}.orca-copy-${randomUUID()}`
const backup = transaction?.backupPath ?? `${destinationPath}.orca-backup-${randomUUID()}`
try {
await createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes)
await (transaction
? createSkillPlacementCopyAtMissingDestination(
canonicalPath,
replacement,
filesystem,
fileModes
)
: createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes))
await filesystem.rename(destinationPath, backup)
try {
await filesystem.rename(replacement, destinationPath)
@@ -42,7 +50,9 @@ export async function replaceOwnedSkillPlacementCopy(
await filesystem.rename(backup, destinationPath)
throw error
}
await filesystem.remove(backup)
if (!transaction?.retainBackup) {
await filesystem.remove(backup)
}
} finally {
await filesystem.remove(replacement)
}
@@ -52,14 +62,17 @@ export async function createSkillPlacementCopyAtMissingDestination(
canonicalPath: string,
destinationPath: string,
filesystem: SkillInstallFilesystem,
fileModes?: readonly SkillInstalledFileMode[]
fileModes?: readonly SkillInstalledFileMode[],
stagingPath?: string
): Promise<void> {
await mkdir(dirname(destinationPath), { recursive: true })
await mkdir(destinationPath, { mode: 0o700 })
const copyPath = stagingPath ?? destinationPath
await mkdir(copyPath, { mode: 0o700 })
let complete = false
let placed = false
try {
for (const entry of await readdir(canonicalPath)) {
await cp(join(canonicalPath, entry), join(destinationPath, entry), {
await cp(join(canonicalPath, entry), join(copyPath, entry), {
recursive: true,
verbatimSymlinks: true,
force: false,
@@ -68,15 +81,23 @@ export async function createSkillPlacementCopyAtMissingDestination(
}
const [source, copied] = await Promise.all([
filesystem.observeSkill(canonicalPath, fileModes),
filesystem.observeSkill(destinationPath, fileModes)
filesystem.observeSkill(copyPath, fileModes)
])
if (source.observedDigest !== copied.observedDigest) {
throw new Error('skill-placement-copy-digest-mismatch')
}
if (stagingPath) {
await filesystem.rename(stagingPath, destinationPath)
placed = true
await filesystem.observeSkill(destinationPath, fileModes)
}
complete = true
} finally {
if (!complete) {
await filesystem.remove(destinationPath)
await filesystem.remove(copyPath)
if (placed) {
await filesystem.remove(destinationPath)
}
}
}
}
@@ -12,10 +12,29 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
}))
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
const temporaryDirectories: string[] = []
function receipt(canonicalPath: string, packageDigest: string): SkillInstallReceiptV1 {
return {
schemaVersion: 1,
packageId: 'package_1',
versionId: 'version_1',
packageDigest,
archiveSha256: 'a'.repeat(64),
scope: 'global',
destinationIdentity: 'global:test',
canonicalPath,
placements: [],
installedAt: new Date().toISOString(),
hostIdentity: 'test',
fileModes: [],
providers: []
}
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
@@ -143,4 +162,77 @@ describe('skill provider placement reconciliation', () => {
expect(result).toMatchObject({ topology: 'provider-alias', status: 'unchanged' })
expect(createAlias).not.toHaveBeenCalled()
})
it('updates an owned copy even when host alias inspection returns false', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-'))
temporaryDirectories.push(root)
const canonicalPath = join(root, 'canonical', 'private-skill')
const providerRoot = join(root, 'provider')
const destinationPath = join(providerRoot, 'private-skill')
await mkdir(canonicalPath, { recursive: true })
await mkdir(destinationPath, { recursive: true })
await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill')
await writeFile(join(destinationPath, 'SKILL.md'), 'old skill')
const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath))
.observedDigest
const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath))
.observedDigest
const previous = receipt(canonicalPath, previousDigest)
previous.placements = [
{
provider: 'claude',
path: destinationPath,
topology: 'independent-copy',
status: 'installed'
}
]
const result = await reconcileSkillProviderPlacement({
canonicalPath,
skillName: 'private-skill',
destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
previousReceipt: previous,
packageDigest: nextDigest,
filesystem: { ...nativeSkillInstallFilesystem, aliasTargets: async () => false }
})
expect(result).toMatchObject({ topology: 'independent-copy', status: 'installed' })
expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('new skill')
})
it('does not update a byte-identical copy previously recorded as skipped', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-placement-test-'))
temporaryDirectories.push(root)
const canonicalPath = join(root, 'canonical', 'private-skill')
const providerRoot = join(root, 'provider')
const destinationPath = join(providerRoot, 'private-skill')
await mkdir(canonicalPath, { recursive: true })
await mkdir(destinationPath, { recursive: true })
await writeFile(join(canonicalPath, 'SKILL.md'), 'new skill')
await writeFile(join(destinationPath, 'SKILL.md'), 'old skill')
const previousDigest = (await nativeSkillInstallFilesystem.observeSkill(destinationPath))
.observedDigest
const nextDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath))
.observedDigest
const previous = receipt(canonicalPath, previousDigest)
previous.placements = [
{
provider: 'claude',
path: destinationPath,
topology: 'independent-copy',
status: 'skipped'
}
]
const result = await reconcileSkillProviderPlacement({
canonicalPath,
skillName: 'private-skill',
destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
previousReceipt: previous,
packageDigest: nextDigest
})
expect(result).toMatchObject({ status: 'skipped', errorCategory: 'skill-placement-unowned' })
expect(await readFile(join(destinationPath, 'SKILL.md'), 'utf8')).toBe('old skill')
})
})
@@ -40,7 +40,10 @@ function previousPlacement(
return (
receipt?.placements.find(
(placement) =>
placement.provider === provider && normalizedPath(placement.path) === normalizedPath(path)
placement.provider === provider &&
placement.status !== 'failed' &&
placement.status !== 'skipped' &&
normalizedPath(placement.path) === normalizedPath(path)
) ?? null
)
}
@@ -74,12 +77,54 @@ async function reconcileExistingPlacement(input: {
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
observation: PlacementObservation
transaction?: { stagingPath: string; backupPath: string }
}): Promise<SkillPlacementResult> {
const previous = previousPlacement(
input.previousReceipt,
input.destination.provider,
input.destinationPath
)
if (previous?.topology === 'independent-copy') {
const observed = await input.filesystem
.observeSkill(input.destinationPath, input.previousReceipt?.fileModes)
.catch(() => null)
if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'skipped',
...placementFailure('skill-placement-modified-copy')
}
}
if (observed.observedDigest === input.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'unchanged'
}
}
await replaceOwnedSkillPlacementCopy(
input.canonicalPath,
input.destinationPath,
input.filesystem,
input.fileModes,
input.transaction
? {
replacementPath: input.transaction.stagingPath,
backupPath: input.transaction.backupPath,
retainBackup: true
}
: undefined
)
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'installed'
}
}
if (input.filesystem.aliasTargets) {
return (await input.filesystem.aliasTargets(input.canonicalPath, input.destinationPath))
? {
@@ -130,40 +175,6 @@ async function reconcileExistingPlacement(input: {
...placementFailure('skill-placement-unowned-link')
}
}
if (stat.isDirectory() && previous?.topology === 'independent-copy') {
const observed = await input.filesystem
.observeSkill(input.destinationPath, input.previousReceipt?.fileModes)
.catch(() => null)
if (!observed || observed.observedDigest !== input.previousReceipt?.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'skipped',
...placementFailure('skill-placement-modified-copy')
}
}
if (observed.observedDigest === input.packageDigest) {
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'unchanged'
}
}
await replaceOwnedSkillPlacementCopy(
input.canonicalPath,
input.destinationPath,
input.filesystem,
input.fileModes
)
return {
provider: input.destination.provider,
path: input.destinationPath,
topology: 'independent-copy',
status: 'installed'
}
}
return {
provider: input.destination.provider,
path: input.destinationPath,
@@ -180,6 +191,7 @@ async function createMissingPlacement(input: {
filesystem: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
observation: PlacementObservation
transaction?: { stagingPath: string; backupPath: string }
}): Promise<SkillPlacementResult> {
try {
await createProviderAlias(input.canonicalPath, input.destinationPath, input.filesystem)
@@ -196,7 +208,8 @@ async function createMissingPlacement(input: {
input.canonicalPath,
input.destinationPath,
input.filesystem,
input.fileModes
input.fileModes,
input.transaction?.stagingPath
)
return {
provider: input.destination.provider,
@@ -225,6 +238,7 @@ export async function reconcileSkillProviderPlacement(input: {
filesystem?: SkillInstallFilesystem
fileModes?: readonly SkillInstalledFileMode[]
targetPlatform?: 'darwin' | 'linux' | 'win32' | 'other'
transaction?: { stagingPath: string; backupPath: string }
}): Promise<SkillPlacementResult | null> {
if (input.destination.readsCanonicalRoot) {
return null
@@ -0,0 +1,219 @@
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
import type { SkillInstalledFileMode } from './skill-install-filesystem'
import { skillInstallStateKey, type SkillInstallReceiptV1 } from './skill-install-provenance'
import {
resolveSkillProviderDestinations,
type SkillProviderRootOverrides
} from './skill-provider-destinations'
const JOURNAL_MAX_BYTES = 4 * 1024 * 1024
export type SkillPlacementJournalActionV1 = {
provider: string
destinationPath: string
rootPath: string
desired: boolean
stagingPath: string
backupPath: string
}
export type SkillPlacementJournalV1 = {
schemaVersion: 1
operation: 'place'
canonicalPath: string
packageDigest: string
fileModes: SkillInstalledFileMode[]
receipt: SkillInstallReceiptV1
previousReceipt: SkillInstallReceiptV1 | null
providers: string[]
providerRootOverrides?: SkillProviderRootOverrides
actions: SkillPlacementJournalActionV1[]
wslDistro?: string
}
export function skillPlacementJournalPath(stateDirectory: string, canonicalPath: string): string {
return join(stateDirectory, 'placement-journals', `${skillInstallStateKey(canonicalPath)}.json`)
}
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
function scopeRoot(canonicalPath: string): string | null {
const skills = dirname(canonicalPath)
const agents = dirname(skills)
return basename(skills) === 'skills' && basename(agents) === '.agents' ? dirname(agents) : null
}
function validFileModes(value: unknown): value is SkillInstalledFileMode[] {
if (!Array.isArray(value)) {
return false
}
const paths = value.map((entry) =>
entry && typeof entry === 'object' && 'path' in entry ? (entry as { path: unknown }).path : null
)
return (
paths.every(
(path) =>
typeof path === 'string' &&
path.length > 0 &&
path.length <= 4096 &&
!path.includes('\0') &&
!path.startsWith('/') &&
!path.split(/[\\/]/u).includes('..')
) &&
new Set(paths).size === paths.length &&
value.every(
(entry) =>
entry &&
typeof entry === 'object' &&
typeof (entry as { executable?: unknown }).executable === 'boolean'
)
)
}
function validProviderRootOverrides(value: unknown): value is SkillProviderRootOverrides {
if (value === undefined) {
return true
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
return Object.entries(value).every(
([provider, root]) =>
(provider === 'claude' || provider === 'grok') &&
typeof root === 'string' &&
root.length > 0 &&
root.length <= 32_768 &&
!root.includes('\0') &&
isAbsolute(root)
)
}
function isJournal(value: unknown, canonicalPath: string): value is SkillPlacementJournalV1 {
if (!value || typeof value !== 'object') {
return false
}
const journal = value as Partial<SkillPlacementJournalV1>
if (
journal.schemaVersion !== 1 ||
journal.operation !== 'place' ||
journal.canonicalPath !== canonicalPath ||
!journal.receipt ||
journal.receipt.canonicalPath !== canonicalPath ||
journal.packageDigest !== journal.receipt.packageDigest ||
!/^[a-f0-9]{64}$/u.test(journal.packageDigest ?? '') ||
typeof journal.receipt.packageId !== 'string' ||
typeof journal.receipt.versionId !== 'string' ||
(journal.receipt.scope !== 'global' && journal.receipt.scope !== 'workspace') ||
!Array.isArray(journal.receipt.placements) ||
!validFileModes(journal.fileModes) ||
!Array.isArray(journal.providers) ||
!journal.providers.every(isSkillInstallProviderId) ||
new Set(journal.providers).size !== journal.providers.length ||
!validProviderRootOverrides(journal.providerRootOverrides) ||
(journal.providerRootOverrides !== undefined && journal.receipt.scope !== 'global') ||
!Array.isArray(journal.actions) ||
!journal.actions.every(
(action) =>
action &&
typeof action.provider === 'string' &&
typeof action.destinationPath === 'string' &&
typeof action.rootPath === 'string' &&
typeof action.desired === 'boolean' &&
typeof action.stagingPath === 'string' &&
typeof action.backupPath === 'string'
) ||
(journal.previousReceipt !== null &&
journal.previousReceipt?.canonicalPath !== canonicalPath) ||
(journal.wslDistro !== undefined && typeof journal.wslDistro !== 'string')
) {
return false
}
const root = scopeRoot(canonicalPath)
if (!root) {
return false
}
const destinations = resolveSkillProviderDestinations({
scope: journal.receipt.scope,
homeDirectory: root,
...(journal.receipt.scope === 'workspace' ? { workspaceDirectory: root } : {}),
detectedProviders: journal.providers,
providerRootOverrides: journal.providerRootOverrides
}).filter((destination) => !destination.readsCanonicalRoot)
const desiredByProvider = new Map(
destinations.map((destination) => [destination.provider, destination])
)
const normalizedActionPaths = journal.actions.flatMap((action) => [
normalizedPath(action.destinationPath),
normalizedPath(action.stagingPath),
normalizedPath(action.backupPath)
])
return (
new Set(normalizedActionPaths).size === normalizedActionPaths.length &&
journal.actions.every((action) => {
const desiredDestination = desiredByProvider.get(action.provider as never)
const previousPlacement = journal.previousReceipt?.placements.find(
(placement) =>
placement.provider === action.provider &&
normalizedPath(placement.path) === normalizedPath(action.destinationPath)
)
const destination = action.desired
? desiredDestination
: previousPlacement
? {
provider: action.provider,
rootPath: dirname(previousPlacement.path),
readsCanonicalRoot: false
}
: undefined
const name = basename(canonicalPath)
return Boolean(
destination &&
action.rootPath === destination.rootPath &&
(action.desired
? Boolean(desiredDestination)
: Boolean(
previousPlacement &&
(!desiredDestination ||
normalizedPath(desiredDestination.rootPath) !== normalizedPath(action.rootPath))
)) &&
normalizedPath(action.destinationPath) ===
normalizedPath(join(destination.rootPath, name)) &&
dirname(action.stagingPath) === dirname(action.destinationPath) &&
dirname(action.backupPath) === dirname(action.destinationPath) &&
basename(action.stagingPath).startsWith(`.${name}.orca-placement-staging-`) &&
basename(action.backupPath).startsWith(`.${name}.orca-placement-backup-`) &&
action.stagingPath !== action.backupPath
)
})
)
}
export async function readSkillPlacementRecoveryJournal(
stateDirectory: string,
canonicalPath: string
): Promise<SkillPlacementJournalV1 | null> {
try {
const value: unknown = JSON.parse(
(
await readNodeFileWithinLimit(
skillPlacementJournalPath(stateDirectory, canonicalPath),
JOURNAL_MAX_BYTES
)
).buffer.toString('utf8')
)
if (!isJournal(value, canonicalPath)) {
throw new Error('skill-placement-journal-invalid')
}
return value
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
@@ -0,0 +1,150 @@
import { randomUUID } from 'node:crypto'
import { basename, dirname, join, resolve } from 'node:path'
import { isSkillInstallProviderId } from '../../shared/skill-install-providers'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem
} from './skill-install-filesystem'
import { writeSkillStateFile, type SkillInstallReceiptV1 } from './skill-install-provenance'
import {
skillPlacementJournalPath,
type SkillPlacementJournalV1
} from './skill-placement-recovery-journal'
import {
finishSkillPlacementTransaction,
recoverSkillPlacementTransaction
} from './skill-placement-transaction'
import {
resolveSkillProviderDestinations,
type SkillProviderDestination,
type SkillProviderRootOverrides
} from './skill-provider-destinations'
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export function createSkillPlacementTransaction(input: {
stateDirectory: string
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
detectedProviders: readonly string[]
providerRootOverrides?: SkillProviderRootOverrides
filesystem?: SkillInstallFilesystem
wslDistro?: string
signal?: AbortSignal
}): {
prepare(previous: SkillInstallReceiptV1 | null, receipt: SkillInstallReceiptV1): Promise<void>
commit(receipt: SkillInstallReceiptV1): Promise<SkillInstallReceiptV1>
finish(receipt: SkillInstallReceiptV1): Promise<void>
} {
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
const providerRootOverrides =
input.scope === 'global' &&
input.providerRootOverrides &&
Object.keys(input.providerRootOverrides).length > 0
? input.providerRootOverrides
: undefined
let canonicalPath: string | null = null
return {
async prepare(previous, receipt) {
canonicalPath = receipt.canonicalPath
const selected = new Set(input.detectedProviders.filter(isSkillInstallProviderId))
const destinations = resolveSkillProviderDestinations({
scope: input.scope,
homeDirectory: input.homeDirectory,
workspaceDirectory: input.workspaceDirectory,
detectedProviders: [...selected],
providerRootOverrides
}).filter((destination) => !destination.readsCanonicalRoot)
const desiredPathByProvider = new Map(
destinations.map((destination) => [
destination.provider,
normalizedPath(destination.rootPath)
])
)
for (const placement of previous?.placements ?? []) {
if (
!isSkillInstallProviderId(placement.provider) ||
placement.topology === 'canonical-copy' ||
placement.status === 'failed' ||
placement.status === 'skipped'
) {
continue
}
const claimedBy = destinations.find(
(destination) =>
normalizedPath(destination.rootPath) === normalizedPath(dirname(placement.path))
)
if (claimedBy && claimedBy.provider !== placement.provider) {
throw new Error('skill-install-provider-root-ownership-conflict')
}
}
const previousDestinationKeys = new Set<string>()
const previousDestinations: SkillProviderDestination[] =
previous?.placements.flatMap((placement) => {
if (!isSkillInstallProviderId(placement.provider)) {
return []
}
if (placement.status === 'failed' || placement.status === 'skipped') {
return []
}
const rootPath = dirname(placement.path)
const key = `${placement.provider}:${normalizedPath(rootPath)}`
if (
placement.topology === 'canonical-copy' ||
desiredPathByProvider.get(placement.provider) === normalizedPath(rootPath) ||
previousDestinationKeys.has(key)
) {
return []
}
previousDestinationKeys.add(key)
return [{ provider: placement.provider, readsCanonicalRoot: false, rootPath }]
}) ?? []
const id = randomUUID()
const name = basename(receipt.canonicalPath)
const journal: SkillPlacementJournalV1 = {
schemaVersion: 1,
operation: 'place',
canonicalPath: receipt.canonicalPath,
packageDigest: receipt.packageDigest,
fileModes: receipt.fileModes ?? [],
receipt: { ...receipt, providers: [...selected] },
previousReceipt: previous,
providers: [...selected],
actions: [...destinations, ...previousDestinations].map((destination) => ({
provider: destination.provider,
destinationPath: join(destination.rootPath, name),
rootPath: destination.rootPath,
desired: destinations.includes(destination),
stagingPath: join(destination.rootPath, `.${name}.orca-placement-staging-${id}`),
backupPath: join(destination.rootPath, `.${name}.orca-placement-backup-${id}`)
})),
...(providerRootOverrides ? { providerRootOverrides } : {}),
...(input.wslDistro ? { wslDistro: input.wslDistro } : {})
}
await writeSkillStateFile(
skillPlacementJournalPath(input.stateDirectory, receipt.canonicalPath),
journal
)
},
async commit(receipt) {
if (canonicalPath !== receipt.canonicalPath) {
throw new Error('skill-placement-transaction-not-prepared')
}
return (
(await recoverSkillPlacementTransaction(
input.stateDirectory,
receipt.canonicalPath,
filesystem,
{ finalize: false, signal: input.signal }
)) ?? receipt
)
},
async finish(receipt) {
await finishSkillPlacementTransaction(input.stateDirectory, receipt.canonicalPath, filesystem)
}
}
}
@@ -0,0 +1,328 @@
import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
import { readSkillInstallReceipt } from './skill-install-provenance'
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
import { createSkillPlacementTransaction } from './skill-placement-transaction-controller'
import {
readSkillPlacementRecoveryJournal,
skillPlacementJournalPath
} from './skill-placement-recovery-journal'
import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
const roots: string[] = []
async function skill(root: string, body: string): Promise<{ path: string; digest: string }> {
await mkdir(root, { recursive: true })
await writeFile(join(root, 'SKILL.md'), body)
return {
path: root,
digest: (await nativeSkillInstallFilesystem.observeSkill(root)).observedDigest
}
}
function receipt(
canonicalPath: string,
packageDigest: string,
versionId: string,
placements: SkillInstallReceiptV1['placements']
): SkillInstallReceiptV1 {
return {
schemaVersion: 1,
packageId: 'package_1',
versionId,
packageDigest,
archiveSha256: 'a'.repeat(64),
scope: 'global',
destinationIdentity: 'global:test',
canonicalPath,
placements,
installedAt: new Date().toISOString(),
hostIdentity: 'test',
fileModes: []
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill placement transaction recovery', () => {
it('does not persist empty global root overrides for a workspace transaction', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-workspace-root-'))
roots.push(root)
const workspace = join(root, 'workspace')
const canonical = await skill(join(workspace, '.agents', 'skills', 'alpha'), '# Skill')
const stateDirectory = join(root, 'state')
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'workspace',
homeDirectory: join(root, 'home'),
workspaceDirectory: workspace,
detectedProviders: ['claude'],
providerRootOverrides: {}
})
await transaction.prepare(null, receipt(canonical.path, canonical.digest, 'version_1', []))
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
expect(journal).not.toBeNull()
expect(journal).not.toHaveProperty('providerRootOverrides')
})
it('rebuilds a partial staged copy and publishes its placement receipt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-partial-'))
roots.push(root)
const canonical = await skill(join(root, 'home', '.agents', 'skills', 'alpha'), '# New')
const stateDirectory = join(root, 'state')
const next = receipt(canonical.path, canonical.digest, 'version_1', [])
const filesystem = {
...nativeSkillInstallFilesystem,
createAlias: async () => {
throw new Error('aliases-disabled')
}
}
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: join(root, 'home'),
detectedProviders: ['claude'],
filesystem
})
await transaction.prepare(null, next)
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
if (!journal?.actions[0]) {
throw new Error('missing placement action')
}
await mkdir(journal.actions[0].stagingPath, { recursive: true })
await writeFile(join(journal.actions[0].stagingPath, 'partial'), 'partial')
const recovered = await recoverSkillPlacementTransaction(
stateDirectory,
canonical.path,
filesystem
)
expect(recovered?.placements).toEqual([
expect.objectContaining({ provider: 'agent-skills' }),
expect.objectContaining({ provider: 'claude', topology: 'independent-copy' })
])
expect(
await readFile(join(root, 'home', '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8')
).toBe('# New')
await expect(
lstat(skillPlacementJournalPath(stateDirectory, canonical.path))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rolls a copy replacement forward after the old placement moved to backup', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-replace-'))
roots.push(root)
const home = join(root, 'home')
const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old')
const providerPath = join(home, '.claude', 'skills', 'alpha')
await cp(canonical.path, providerPath, { recursive: true })
const previous = receipt(canonical.path, canonical.digest, 'version_1', [
{
provider: 'agent-skills',
path: canonical.path,
topology: 'canonical-copy',
status: 'installed'
},
{
provider: 'claude',
path: providerPath,
topology: 'independent-copy',
status: 'installed'
}
])
await writeFile(join(canonical.path, 'SKILL.md'), '# New')
const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest
const next = receipt(canonical.path, digest, 'version_2', [])
const stateDirectory = join(root, 'state')
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: home,
detectedProviders: ['claude']
})
await transaction.prepare(previous, next)
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
if (!journal?.actions[0]) {
throw new Error('missing placement action')
}
await cp(canonical.path, journal.actions[0].stagingPath, { recursive: true })
await rename(providerPath, journal.actions[0].backupPath)
const recovered = await recoverSkillPlacementTransaction(stateDirectory, canonical.path)
expect(recovered?.placements).toContainEqual(
expect.objectContaining({ provider: 'claude', topology: 'independent-copy' })
)
expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# New')
expect((await readSkillInstallReceipt(stateDirectory, canonical.path))?.versionId).toBe(
'version_2'
)
await expect(lstat(journal.actions[0].backupPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('restores the previous copy when placement and rollback renames both fail', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-rollback-'))
roots.push(root)
const home = join(root, 'home')
const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Old')
const providerPath = join(home, '.claude', 'skills', 'alpha')
await cp(canonical.path, providerPath, { recursive: true })
const previous = receipt(canonical.path, canonical.digest, 'version_1', [
{
provider: 'claude',
path: providerPath,
topology: 'independent-copy',
status: 'installed'
}
])
await writeFile(join(canonical.path, 'SKILL.md'), '# New')
const digest = (await nativeSkillInstallFilesystem.observeSkill(canonical.path)).observedDigest
const stateDirectory = join(root, 'state')
let failRenames = true
const filesystem = {
...nativeSkillInstallFilesystem,
createAlias: async () => {
throw new Error('aliases-disabled')
},
rename: async (source: string, target: string): Promise<void> => {
if (failRenames && source.includes('.orca-placement-staging-')) {
throw new Error('injected-placement-rename-failure')
}
if (failRenames && source.includes('.orca-placement-backup-')) {
throw new Error('injected-rollback-rename-failure')
}
await nativeSkillInstallFilesystem.rename(source, target)
}
}
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: home,
detectedProviders: ['claude'],
filesystem
})
await transaction.prepare(previous, receipt(canonical.path, digest, 'version_2', []))
const failed = await transaction.commit(receipt(canonical.path, digest, 'version_2', []))
expect(failed.placements).toContainEqual(
expect.objectContaining({ provider: 'claude', status: 'failed' })
)
failRenames = false
await transaction.finish(failed)
expect(await readFile(join(providerPath, 'SKILL.md'), 'utf8')).toBe('# Old')
})
it('moves an owned placement when Claude changes to a custom config root', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-custom-root-'))
roots.push(root)
const home = join(root, 'home')
const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
const previousPath = join(home, '.claude', 'skills', 'alpha')
await cp(canonical.path, previousPath, { recursive: true })
const previous = receipt(canonical.path, canonical.digest, 'version_1', [
{
provider: 'claude',
path: previousPath,
topology: 'independent-copy',
status: 'installed'
}
])
const next = receipt(canonical.path, canonical.digest, 'version_2', [])
const stateDirectory = join(root, 'state')
const customRoot = join(root, 'managed-claude', 'skills')
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: home,
detectedProviders: ['claude'],
providerRootOverrides: { claude: customRoot }
})
await transaction.prepare(previous, next)
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
expect(journal?.actions).toEqual([
expect.objectContaining({ rootPath: customRoot, desired: true }),
expect.objectContaining({ rootPath: join(home, '.claude', 'skills'), desired: false })
])
const committed = await transaction.commit(next)
await transaction.finish(committed)
await expect(lstat(previousPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(await readFile(join(customRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Skill')
})
it('rejects a historical root claimed by another selected provider before journaling', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-cross-provider-root-'))
roots.push(root)
const home = join(root, 'home')
const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
const sharedRoot = join(root, 'shared', 'skills')
const previousPath = join(sharedRoot, 'alpha')
await cp(canonical.path, previousPath, { recursive: true })
const previous = receipt(canonical.path, canonical.digest, 'version_1', [
{
provider: 'claude',
path: previousPath,
topology: 'independent-copy',
status: 'installed'
}
])
const next = receipt(canonical.path, canonical.digest, 'version_2', [])
const stateDirectory = join(root, 'state')
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: home,
detectedProviders: ['claude', 'grok'],
providerRootOverrides: {
claude: join(root, 'new-claude', 'skills'),
grok: sharedRoot
}
})
await expect(transaction.prepare(previous, next)).rejects.toThrow(
'skill-install-provider-root-ownership-conflict'
)
await expect(
lstat(skillPlacementJournalPath(stateDirectory, canonical.path))
).rejects.toMatchObject({ code: 'ENOENT' })
})
it('does not treat a skipped historical placement as ownership', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-placement-skipped-owner-'))
roots.push(root)
const home = join(root, 'home')
const canonical = await skill(join(home, '.agents', 'skills', 'alpha'), '# Skill')
const sharedRoot = join(root, 'shared', 'skills')
const previous = receipt(canonical.path, canonical.digest, 'version_1', [
{
provider: 'claude',
path: join(sharedRoot, 'alpha'),
topology: 'independent-copy',
status: 'skipped'
}
])
const next = receipt(canonical.path, canonical.digest, 'version_2', [])
const stateDirectory = join(root, 'state')
const transaction = createSkillPlacementTransaction({
stateDirectory,
scope: 'global',
homeDirectory: home,
detectedProviders: ['grok'],
providerRootOverrides: { grok: sharedRoot }
})
await expect(transaction.prepare(previous, next)).resolves.toBeUndefined()
await expect(
readSkillPlacementRecoveryJournal(stateDirectory, canonical.path)
).resolves.not.toBeNull()
})
})
@@ -0,0 +1,235 @@
import { rm } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
import {
nativeSkillInstallFilesystem,
type SkillInstallFilesystem
} from './skill-install-filesystem'
import {
writeSkillInstallReceipt,
writeSkillStateFile,
type SkillInstallReceiptV1
} from './skill-install-provenance'
import { isRemovableSkillPlacement } from './skill-removable-placement'
import { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
import {
readSkillPlacementRecoveryJournal,
skillPlacementJournalPath,
type SkillPlacementJournalActionV1,
type SkillPlacementJournalV1
} from './skill-placement-recovery-journal'
import {
placementBackupMatchesPrevious,
placementIsAlias,
placementMatchesDigest,
placementPathExists,
settleDesiredPlacementArtifacts
} from './skill-placement-artifact-recovery'
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
async function settleDeselectedAction(
journal: SkillPlacementJournalV1,
action: SkillPlacementJournalActionV1,
filesystem: SkillInstallFilesystem,
allowedProviderRoots: readonly string[]
): Promise<SkillPlacementResult | null> {
if (await placementPathExists(action.backupPath)) {
if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
throw new Error('skill-placement-recovery-conflict')
}
await filesystem.remove(action.backupPath)
}
const previous = journal.previousReceipt?.placements.find(
(placement) => normalizedPath(placement.path) === normalizedPath(action.destinationPath)
)
if (!previous || !(await placementPathExists(action.destinationPath))) {
return null
}
const removable = await isRemovableSkillPlacement({
placement: previous,
receipt: journal.previousReceipt!,
allowedProviderRoots,
filesystem
})
if (removable) {
await filesystem.rename(action.destinationPath, action.backupPath)
return null
}
return {
...previous,
status: 'skipped',
errorCategory: 'skill-removal-placement-modified-or-unowned',
failure: {
category: 'conflict',
code: 'skill-removal-placement-modified-or-unowned',
retryable: false
}
}
}
export async function recoverSkillPlacementTransaction(
stateDirectory: string,
canonicalPath: string,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem,
options: { finalize?: boolean; signal?: AbortSignal } = {}
): Promise<SkillInstallReceiptV1 | null> {
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath)
if (!journal) {
return null
}
if (!(await placementMatchesDigest(canonicalPath, journal.packageDigest, journal, filesystem))) {
if (
journal.previousReceipt &&
(await placementMatchesDigest(
canonicalPath,
journal.previousReceipt.packageDigest,
journal,
filesystem,
journal.previousReceipt.fileModes ?? []
))
) {
await Promise.all(
journal.actions.flatMap((action) => [
filesystem.remove(action.stagingPath),
filesystem.remove(action.backupPath)
])
)
await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
return journal.previousReceipt
}
if (
!journal.previousReceipt &&
!(await placementPathExists(canonicalPath)) &&
!(
await Promise.all(
journal.actions.flatMap((action) => [
placementPathExists(action.stagingPath),
placementPathExists(action.backupPath)
])
)
).some(Boolean)
) {
await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
return null
}
throw new Error('skill-placement-canonical-mismatch')
}
const allowedProviderRoots = journal.actions.map((action) => action.rootPath)
const placements: SkillPlacementResult[] = [
{
provider: 'agent-skills',
path: canonicalPath,
topology: 'canonical-copy',
status: 'installed'
}
]
for (const action of journal.actions) {
if (!action.desired) {
const skipped = await settleDeselectedAction(
journal,
action,
filesystem,
allowedProviderRoots
)
if (skipped) {
placements.push(skipped)
}
continue
}
if (options.signal?.aborted) {
placements.push({
provider: action.provider,
path: action.destinationPath,
topology: 'independent-copy',
status: 'skipped',
errorCategory: 'skill-placement-cancelled',
failure: {
category: 'cancelled',
code: 'skill-placement-cancelled',
retryable: true
}
})
continue
}
const recoveredArtifacts = await settleDesiredPlacementArtifacts(journal, action, filesystem)
if (
recoveredArtifacts &&
(await placementPathExists(action.destinationPath)) &&
(await placementMatchesDigest(
action.destinationPath,
journal.packageDigest,
journal,
filesystem
))
) {
placements.push({
provider: action.provider,
path: action.destinationPath,
topology: (await placementIsAlias(canonicalPath, action.destinationPath, filesystem))
? 'provider-alias'
: 'independent-copy',
status: 'unchanged'
})
continue
}
const destination = {
provider: action.provider as never,
rootPath: action.rootPath,
readsCanonicalRoot: false
}
const placement = await reconcileSkillProviderPlacement({
canonicalPath,
skillName: basename(canonicalPath),
destination,
previousReceipt: journal.previousReceipt,
packageDigest: journal.packageDigest,
fileModes: journal.fileModes,
filesystem,
transaction: { stagingPath: action.stagingPath, backupPath: action.backupPath },
...(journal.wslDistro ? { targetPlatform: 'linux' as const } : {})
})
if (placement) {
placements.push(placement)
}
}
const receipt = { ...journal.receipt, placements, providers: journal.providers }
await writeSkillInstallReceipt(stateDirectory, receipt)
await writeSkillStateFile(skillPlacementJournalPath(stateDirectory, canonicalPath), {
...journal,
receipt
})
if (options.finalize !== false) {
await finishSkillPlacementTransaction(stateDirectory, canonicalPath, filesystem)
}
return receipt
}
export async function finishSkillPlacementTransaction(
stateDirectory: string,
canonicalPath: string,
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
): Promise<void> {
const journal = await readSkillPlacementRecoveryJournal(stateDirectory, canonicalPath)
if (!journal) {
return
}
await Promise.all(
journal.actions.map(async (action) => {
if (action.desired) {
await settleDesiredPlacementArtifacts(journal, action, filesystem)
}
await filesystem.remove(action.stagingPath)
if (await placementPathExists(action.backupPath)) {
if (!(await placementBackupMatchesPrevious(journal, action, filesystem))) {
throw new Error('skill-placement-recovery-conflict')
}
await filesystem.remove(action.backupPath)
}
})
)
await rm(skillPlacementJournalPath(stateDirectory, canonicalPath), { force: true })
}
@@ -0,0 +1,167 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { selectedOrDetectedSkillProviders } from '../../shared/skill-install-providers'
import { resolveSkillProviderDestinations } from './skill-provider-destinations'
const HOME = join('/home', 'dev')
const WORKSPACE = join('/repos', 'orca')
describe('resolveSkillProviderDestinations', () => {
it('gives an agent that reads the canonical root no placement of its own', () => {
const destinations = resolveSkillProviderDestinations({
scope: 'workspace',
homeDirectory: HOME,
workspaceDirectory: WORKSPACE,
detectedProviders: ['codex']
})
expect(destinations).toEqual([
{
provider: 'codex',
readsCanonicalRoot: true,
rootPath: join(WORKSPACE, '.agents', 'skills')
}
])
})
// Why: Codex and Cursor read a project's .agents/skills but keep their own
// home directory, so the same agent needs a placement at one scope only.
it('resolves scope-specific roots for an agent that is canonical in a workspace only', () => {
const workspace = resolveSkillProviderDestinations({
scope: 'workspace',
homeDirectory: HOME,
workspaceDirectory: WORKSPACE,
detectedProviders: ['cursor']
})
const global = resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['cursor']
})
expect(workspace[0]).toMatchObject({ readsCanonicalRoot: true })
expect(global[0]).toMatchObject({
readsCanonicalRoot: false,
rootPath: join(HOME, '.cursor', 'skills')
})
})
it('places every detected agent that owns a directory', () => {
const destinations = resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['codex', 'claude', 'droid', 'grok', 'aug', 'continue', 'trae']
})
expect(destinations.map((destination) => destination.rootPath)).toEqual([
join(HOME, '.agents', 'skills'),
join(HOME, '.claude', 'skills'),
join(HOME, '.factory', 'skills'),
join(HOME, '.continue', 'skills'),
join(HOME, '.trae-cn', 'skills'),
join(HOME, '.grok', 'skills'),
join(HOME, '.augment', 'skills')
])
})
it('uses the canonical root for Codex at global scope', () => {
expect(
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['codex']
})
).toEqual([
{ provider: 'codex', readsCanonicalRoot: true, rootPath: join(HOME, '.agents', 'skills') }
])
})
it('honors custom global config roots without changing workspace roots', () => {
const customClaudeRoot = join('/srv', 'claude', 'skills')
expect(
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['claude'],
providerRootOverrides: { claude: customClaudeRoot }
})
).toEqual([{ provider: 'claude', readsCanonicalRoot: false, rootPath: customClaudeRoot }])
expect(
resolveSkillProviderDestinations({
scope: 'workspace',
homeDirectory: HOME,
workspaceDirectory: WORKSPACE,
detectedProviders: ['claude'],
providerRootOverrides: { claude: customClaudeRoot }
})[0]?.rootPath
).toBe(join(WORKSPACE, '.claude', 'skills'))
})
it('treats a custom root equal to canonical as canonical', () => {
expect(
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['claude'],
providerRootOverrides: { claude: join(HOME, '.agents', 'skills') }
})
).toEqual([
{
provider: 'claude',
readsCanonicalRoot: true,
rootPath: join(HOME, '.agents', 'skills')
}
])
})
it('rejects two providers claiming one noncanonical root', () => {
const sharedRoot = join(HOME, '.custom-agent', 'skills')
expect(() =>
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['claude', 'grok'],
providerRootOverrides: { claude: sharedRoot, grok: sharedRoot }
})
).toThrow('skill-install-provider-root-collision')
})
it('ignores agents Orca cannot place skills for', () => {
expect(
resolveSkillProviderDestinations({
scope: 'global',
homeDirectory: HOME,
detectedProviders: ['opencode', 'goose', 'not-an-agent']
})
).toEqual([])
})
it('requires a workspace directory for workspace scope', () => {
expect(() =>
resolveSkillProviderDestinations({
scope: 'workspace',
homeDirectory: HOME,
detectedProviders: ['claude']
})
).toThrow('skill-install-workspace-required')
})
})
describe('selectedOrDetectedSkillProviders', () => {
// Why: removal passes no selection so it can still clean roots an earlier
// install wrote, even if the user has since narrowed their agents.
it('keeps every detected agent when nothing was selected', () => {
expect(selectedOrDetectedSkillProviders(['claude', 'codex'], undefined)).toEqual([
'claude',
'codex'
])
})
it('honors selected agents even when they are not currently detected', () => {
expect(selectedOrDetectedSkillProviders(['claude', 'codex'], ['claude', 'cursor'])).toEqual([
'claude',
'cursor'
])
})
it('places nothing extra when the choice is empty', () => {
expect(selectedOrDetectedSkillProviders(['claude', 'codex'], [])).toEqual([])
})
})
+38 -21
View File
@@ -1,6 +1,11 @@
import { join } from 'node:path'
import { join, resolve } from 'node:path'
import {
isSkillInstallProviderId,
SKILL_INSTALL_PROVIDERS,
type SkillInstallProviderId
} from '../../shared/skill-install-providers'
export type OrcaSkillProviderId = 'codex' | 'claude'
export type OrcaSkillProviderId = SkillInstallProviderId
export type SkillProviderDestination = {
provider: OrcaSkillProviderId
@@ -8,37 +13,49 @@ export type SkillProviderDestination = {
rootPath: string
}
const SUPPORTED_PROVIDER_IDS = new Set<OrcaSkillProviderId>(['codex', 'claude'])
export type SkillProviderRootOverrides = Partial<Record<OrcaSkillProviderId, string>>
function normalizedPath(path: string): string {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export function resolveSkillProviderDestinations(input: {
scope: 'global' | 'workspace'
homeDirectory: string
workspaceDirectory?: string
detectedProviders: readonly string[]
providerRootOverrides?: SkillProviderRootOverrides
}): SkillProviderDestination[] {
const detected = new Set(
input.detectedProviders.filter((provider): provider is OrcaSkillProviderId =>
SUPPORTED_PROVIDER_IDS.has(provider as OrcaSkillProviderId)
)
)
const detected = new Set(input.detectedProviders.filter(isSkillInstallProviderId))
const scopeRoot = input.scope === 'global' ? input.homeDirectory : input.workspaceDirectory
if (!scopeRoot) {
throw new Error('skill-install-workspace-required')
}
const destinations: SkillProviderDestination[] = []
if (detected.has('codex')) {
destinations.push({
provider: 'codex',
readsCanonicalRoot: true,
rootPath: join(scopeRoot, '.agents', 'skills')
})
}
if (detected.has('claude')) {
destinations.push({
provider: 'claude',
readsCanonicalRoot: false,
rootPath: join(scopeRoot, '.claude', 'skills')
})
const canonicalRoot = join(scopeRoot, '.agents', 'skills')
const claimedPlacementRoots = new Set<string>()
for (const provider of SKILL_INSTALL_PROVIDERS) {
if (!detected.has(provider.id)) {
continue
}
const segments = input.scope === 'global' ? provider.globalSegments : provider.workspaceSegments
const overriddenRoot =
input.scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined
const rootPath = overriddenRoot
? overriddenRoot
: segments
? join(scopeRoot, ...segments)
: canonicalRoot
const readsCanonicalRoot = normalizedPath(rootPath) === normalizedPath(canonicalRoot)
if (!readsCanonicalRoot) {
const normalizedRoot = normalizedPath(rootPath)
if (claimedPlacementRoots.has(normalizedRoot)) {
throw new Error('skill-install-provider-root-collision')
}
claimedPlacementRoots.add(normalizedRoot)
}
destinations.push({ provider: provider.id, readsCanonicalRoot, rootPath })
}
return destinations
}
@@ -0,0 +1,52 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
resolveEnvironmentSkillProviderRoots,
resolveWslGrokSkillProviderRoot,
withClaudeSkillProviderRoot
} from './skill-provider-runtime-roots'
describe('skill provider runtime roots', () => {
it('maps Claude and Grok config homes to their global skill roots', () => {
expect(
resolveEnvironmentSkillProviderRoots({
CLAUDE_CONFIG_DIR: join('/srv', 'claude'),
GROK_HOME: join('/srv', 'grok')
})
).toEqual({
claude: join('/srv', 'claude', 'skills'),
grok: join('/srv', 'grok', 'skills')
})
})
it('rejects relative config roots and lets a target-specific Claude root win', () => {
const roots = resolveEnvironmentSkillProviderRoots({
CLAUDE_CONFIG_DIR: '../claude',
GROK_HOME: '../grok'
})
expect(roots).toEqual({})
expect(withClaudeSkillProviderRoot(roots, join('/managed', 'claude'))).toEqual({
claude: join('/managed', 'claude', 'skills')
})
})
it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => {
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n')
).resolves.toBe('\\\\wsl.localhost\\Ubuntu-24.04\\srv\\grok\\skills')
})
it('ignores unsafe or missing WSL GROK_HOME values', async () => {
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu', async () => '../grok')
).resolves.toBeNull()
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu', async () => '/srv/grok\0other')
).resolves.toBeNull()
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu', async () => {
throw new Error('probe failed')
})
).resolves.toBeNull()
})
})
@@ -0,0 +1,90 @@
import { execFile } from 'node:child_process'
import { isAbsolute, join, resolve } from 'node:path'
import { toWindowsWslPath } from '../../shared/wsl-paths'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
const PROVIDER_ROOT_MAX_LENGTH = 32_768
const WSL_ENV_PROBE_TIMEOUT_MS = 8_000
const WSL_ENV_PROBE_MAX_BYTES = 4_097
const WSL_GROK_HOME_SCRIPT = [
'entry=$(getent passwd "$(id -u)" 2>/dev/null || true)',
'login_shell=${entry##*:}',
'case "$login_shell" in /*) ;; *) login_shell=/bin/sh ;; esac',
`exec "$login_shell" -lc 'printf %s "\${GROK_HOME:-}" | head -c ${WSL_ENV_PROBE_MAX_BYTES}'`
].join('\n')
function normalizedRoot(value: string | undefined): string | null {
const candidate = value?.trim()
if (
!candidate ||
candidate.length > PROVIDER_ROOT_MAX_LENGTH ||
candidate.includes('\0') ||
!isAbsolute(candidate)
) {
return null
}
return resolve(candidate)
}
export function resolveEnvironmentSkillProviderRoots(
env: NodeJS.ProcessEnv = process.env
): SkillProviderRootOverrides {
const claudeConfig = normalizedRoot(env.CLAUDE_CONFIG_DIR)
const grokHome = normalizedRoot(env.GROK_HOME)
return {
...(claudeConfig ? { claude: join(claudeConfig, 'skills') } : {}),
...(grokHome ? { grok: join(grokHome, 'skills') } : {})
}
}
export function withClaudeSkillProviderRoot(
roots: SkillProviderRootOverrides,
claudeConfigDirectory: string | null | undefined
): SkillProviderRootOverrides {
const configDirectory = normalizedRoot(claudeConfigDirectory ?? undefined)
return configDirectory ? { ...roots, claude: join(configDirectory, 'skills') } : roots
}
type WslEnvironmentProbe = (distro: string) => Promise<string>
function probeWslGrokHome(distro: string): Promise<string> {
return new Promise((resolveProbe, reject) => {
execFile(
'wsl.exe',
['-d', distro, '--exec', 'sh', '-c', WSL_GROK_HOME_SCRIPT],
{
encoding: 'utf8',
timeout: WSL_ENV_PROBE_TIMEOUT_MS,
maxBuffer: WSL_ENV_PROBE_MAX_BYTES,
windowsHide: true
},
(error, stdout) => (error ? reject(error) : resolveProbe(stdout))
)
})
}
export async function resolveWslGrokSkillProviderRoot(
distro: string,
probe: WslEnvironmentProbe = probeWslGrokHome
): Promise<string | null> {
try {
const value = await probe(distro)
const candidate = value.trim()
if (
!candidate ||
candidate.length >= WSL_ENV_PROBE_MAX_BYTES ||
!candidate.startsWith('/') ||
candidate.includes('\\') ||
Array.from(candidate).some((character) => {
const code = character.charCodeAt(0)
return code <= 0x1f || code === 0x7f
})
) {
return null
}
const grokHome = candidate.replace(/\/+$/u, '') || '/'
return toWindowsWslPath(`${grokHome === '/' ? '' : grokHome}/skills`, distro)
} catch {
return null
}
}
@@ -81,6 +81,19 @@ describe('installSkillOnRemoteRuntime', () => {
mocks.transferSkillPackageToRuntime.mockReset()
})
it('requires a host update before sending an explicit provider choice', async () => {
await expect(
installSkillOnRemoteRuntime({
userDataPath: '/state',
environmentId: 'environment-1',
request: { ...request, providers: ['claude'] },
capabilities: ['skills.install.v1'],
requireHttps: true
})
).rejects.toThrow('skill-install-remote-update-required')
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
it('uses direct remote download when the runtime can reach storage', async () => {
mocks.callRuntimeEnvironment.mockResolvedValue(success(result))
+28 -18
View File
@@ -15,6 +15,7 @@ import {
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
@@ -120,14 +121,21 @@ export async function installSkillOnRemoteRuntime(input: {
requireHttps: boolean
signal?: AbortSignal
}): Promise<SkillInstallResult> {
if (input.request.ingress.kind !== 'download-grant') {
const request = input.request
if (
request.providers !== undefined &&
!input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)
) {
throw new Error('skill-install-remote-update-required')
}
if (request.ingress.kind !== 'download-grant') {
throw new Error('skill-install-remote-ingress-invalid')
}
const grant = input.request.ingress
const grant = request.ingress
const direct = await retrySkillTransferRpc({
signal: input.signal,
retryable: retryableRemoteInstallTransportError,
call: () => install(input.userDataPath, input.environmentId, input.request, input.signal)
call: () => install(input.userDataPath, input.environmentId, request, input.signal)
})
if (!isDirectDownloadUnavailable(direct, input.requireHttps)) {
if (direct.ok !== true) {
@@ -150,8 +158,8 @@ export async function installSkillOnRemoteRuntime(input: {
const transfer = await transferSkillPackageToRuntime({
userDataPath: input.userDataPath,
environmentId: input.environmentId,
transferId: input.request.operationId,
package: input.request.package,
transferId: request.operationId,
package: request.package,
grant,
requireHttps: input.requireHttps,
signal: input.signal
@@ -161,7 +169,7 @@ export async function installSkillOnRemoteRuntime(input: {
input.userDataPath,
input.environmentId,
{
...input.request,
...request,
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
},
input.signal
@@ -186,7 +194,14 @@ export async function installSkillBundleOnRemoteRuntime(input: {
signal?: AbortSignal
onProgress?: (progress: SkillBundleInstallProgress) => void
}): Promise<SkillBundleInstallResult> {
if (input.request.ingress.kind !== 'download-grant') {
const request = input.request
if (
request.providers !== undefined &&
!input.capabilities.includes(SKILL_INSTALL_PROVIDERS_CAPABILITY)
) {
throw new Error('skill-bundle-remote-update-required')
}
if (request.ingress.kind !== 'download-grant') {
throw new Error('skill-bundle-remote-ingress-invalid')
}
if (!input.capabilities.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) {
@@ -200,21 +215,16 @@ export async function installSkillBundleOnRemoteRuntime(input: {
input.onProgress && input.capabilities.includes(SKILL_INSTALL_PROGRESS_CAPABILITY)
? startSkillInstallProgressPolling({
read: () =>
readBundleInstallProgress(
input.userDataPath,
input.environmentId,
input.request.operationId
),
readBundleInstallProgress(input.userDataPath, input.environmentId, request.operationId),
onProgress: input.onProgress
})
: null
try {
const grant = input.request.ingress
const grant = request.ingress
const direct = await retrySkillTransferRpc({
signal: input.signal,
retryable: retryableRemoteInstallTransportError,
call: () =>
installBundle(input.userDataPath, input.environmentId, input.request, input.signal)
call: () => installBundle(input.userDataPath, input.environmentId, request, input.signal)
})
if (!isDirectDownloadUnavailable(direct, input.requireHttps)) {
if (direct.ok !== true) {
@@ -236,8 +246,8 @@ export async function installSkillBundleOnRemoteRuntime(input: {
const transfer = await transferSkillPackageToRuntime({
userDataPath: input.userDataPath,
environmentId: input.environmentId,
transferId: input.request.operationId,
package: input.request.package,
transferId: request.operationId,
package: request.package,
grant,
requireHttps: input.requireHttps,
signal: input.signal
@@ -247,7 +257,7 @@ export async function installSkillBundleOnRemoteRuntime(input: {
input.userDataPath,
input.environmentId,
{
...input.request,
...request,
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
},
input.signal
+5 -1
View File
@@ -32,7 +32,11 @@ export async function isRemovableSkillPlacement(input: {
allowedProviderRoots: readonly string[]
filesystem: SkillInstallFilesystem
}): Promise<boolean> {
if (input.placement.topology === 'canonical-copy') {
if (
input.placement.topology === 'canonical-copy' ||
input.placement.status === 'failed' ||
input.placement.status === 'skipped'
) {
return false
}
if (
@@ -1,10 +1,11 @@
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createSkillPackageArchive } from './skill-package-creation'
import {
readSkillInstallReceipt,
writeSkillInstallReceipt,
writeSkillStateFile,
type SkillInstallReceiptV1
} from './skill-install-provenance'
@@ -154,6 +155,35 @@ describe('skill removal recovery', () => {
expect(await readFile(join(backupPath, 'local.md'), 'utf8')).toBe('changed')
})
it('removes an owned placement after its provider config root changes', async () => {
const fixture = await installedFixture()
const oldProviderRoot = join(fixture.root, 'old-claude-config', 'skills')
const oldPlacementPath = join(oldProviderRoot, basename(fixture.canonicalPath))
await cp(fixture.canonicalPath, oldPlacementPath, { recursive: true })
await writeSkillInstallReceipt(fixture.stateDirectory, {
...fixture.receipt,
placements: [
...fixture.receipt.placements,
{
provider: 'claude',
path: oldPlacementPath,
topology: 'independent-copy',
status: 'installed'
}
]
})
await expect(
removeLocalSharedSkill({
operationId: 'remove-custom-root',
canonicalPath: fixture.canonicalPath,
stateDirectory: fixture.stateDirectory,
allowedProviderRoots: [join(fixture.root, 'new-claude-config', 'skills')]
})
).resolves.toMatchObject({ status: 'removed' })
await expect(lstat(oldPlacementPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each(
['prepared', 'moving', 'receipt-removed'].flatMap((phase) =>
['before', 'after'].map((boundary) => [phase, boundary] as const)
+17 -2
View File
@@ -21,6 +21,9 @@ import {
type RemovalMove,
type SkillRemovalJournalV1
} from './skill-remove-recovery'
import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
import { recoverSkillInstallTransaction } from './skill-install-recovery'
import { historicalSuccessfulProviderRoots } from './skill-install-historical-provider-roots'
export {
recoverSkillRemovalTransaction,
@@ -105,7 +108,14 @@ export async function removeLocalSharedSkill(
path: skillInstallLockPath(input.stateDirectory, input.canonicalPath)
})
try {
const historicalReceipt = await readSkillInstallReceipt(
input.stateDirectory,
input.canonicalPath
)
filesystem.authorizeRoots?.(historicalSuccessfulProviderRoots(historicalReceipt))
await recoverSkillRemovalTransaction(input.stateDirectory, input.canonicalPath, filesystem)
await recoverSkillInstallTransaction(input.stateDirectory, input.canonicalPath, filesystem)
await recoverSkillPlacementTransaction(input.stateDirectory, input.canonicalPath, filesystem)
const receipt = await readSkillInstallReceipt(input.stateDirectory, input.canonicalPath)
const state = await inspectCanonicalRemoval(input, receipt)
if (!receipt) {
@@ -118,6 +128,11 @@ export async function removeLocalSharedSkill(
return conflictResult(input, receipt, 'modified')
}
const allowedProviderRoots = [
...input.allowedProviderRoots,
...historicalSuccessfulProviderRoots(receipt)
]
const removedPlacements: SkillPlacementResult[] = []
const skippedPlacements: SkillPlacementResult[] = []
const moves: RemovalMove[] = []
@@ -129,7 +144,7 @@ export async function removeLocalSharedSkill(
await isRemovableSkillPlacement({
placement,
receipt,
allowedProviderRoots: input.allowedProviderRoots,
allowedProviderRoots,
filesystem
})
) {
@@ -188,7 +203,7 @@ export async function removeLocalSharedSkill(
movedCount: 0,
moves,
receipt,
allowedProviderRoots: [...input.allowedProviderRoots]
allowedProviderRoots: [...new Set(allowedProviderRoots)]
}
const statePath = skillRemovalJournalPath(input.stateDirectory, input.canonicalPath)
await persistRemovalJournal(statePath, journal, dependencies)
@@ -133,6 +133,7 @@ export class SkillSharePreparationService {
archiveSha256: preparation.created.archiveSha256,
compressedBytes: preparation.created.compressedBytes,
packageId: preparation.created.manifest.packageId,
idempotencyKey: input.preparationId,
releaseNotes: input.releaseNotes,
signal: controller.signal,
onProgress: (progress) =>
@@ -49,6 +49,19 @@ function request(bytes: Buffer) {
}
describe('installSkillOnSshHost', () => {
it('requires a relay update before sending an explicit provider choice', async () => {
const requestHostRpc = vi.fn(async () => ({ capabilities: ['skills.install.v1'] }))
await expect(
installSkillOnSshHost({
provider: { requestHostRpc } as unknown as IPtyProvider,
userDataPath: await userDataPath(),
request: { ...request(Buffer.from('archive')), providers: ['claude'] },
requireHttps: true
})
).rejects.toThrow('skill-install-ssh-update-required')
expect(requestHostRpc).toHaveBeenCalledOnce()
})
it('uses client-mediated upload only after direct host download fails', async () => {
const bytes = Buffer.from('private skill archive')
let received = 0
+8 -3
View File
@@ -1,5 +1,6 @@
import {
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
} from '../../shared/skill-install-capability'
@@ -51,6 +52,10 @@ export async function installSkillOnSshHost(input: {
}): Promise<SkillInstallResult> {
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-install-ssh-update-required')
}
if (!supported.includes(SKILL_INSTALL_CAPABILITY)) {
recordSkillCapabilityAbsence({
capability: SKILL_INSTALL_CAPABILITY,
@@ -66,14 +71,14 @@ export async function installSkillOnSshHost(input: {
call: () =>
client(
SKILL_SSH_RELAY_INSTALL_METHOD,
{ request: input.request, workspace: input.workspace },
{ request: request, workspace: input.workspace },
{ timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal }
)
})
)
} catch (error) {
if (
input.request.ingress.kind !== 'download-grant' ||
request.ingress.kind !== 'download-grant' ||
!shouldUseSkillSshClientTransfer(error, input.requireHttps)
) {
throw error
@@ -97,7 +102,7 @@ export async function installSkillOnSshHost(input: {
SKILL_SSH_RELAY_INSTALL_METHOD,
{
request: {
...input.request,
...request,
ingress: { kind: 'staged-upload', uploadId }
},
workspace: input.workspace
@@ -18,6 +18,8 @@ import {
} from './skill-remove-recovery'
import { startSkillPhaseOperation } from './skill-operation-observability'
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
import { readSkillPlacementRecoveryJournal } from './skill-placement-recovery-journal'
import { recoverSkillPlacementTransaction } from './skill-placement-transaction'
const MAX_PENDING_TRANSACTION_JOURNALS = 64
const MAX_TRANSACTION_JOURNAL_BYTES = 4 * 1024 * 1024
@@ -27,6 +29,7 @@ type PendingTransaction = {
journalKey: string
install: boolean
removal: boolean
placement: boolean
}
export type SkillTransactionStartupRecoveryReport = {
@@ -46,7 +49,7 @@ function failureCode(error: unknown): string {
async function scanJournalDirectory(
stateDirectory: string,
directoryName: 'journals' | 'removal-journals'
directoryName: 'journals' | 'removal-journals' | 'placement-journals'
): Promise<{
candidates: { canonicalPath: string; journalKey: string }[]
failures: { journalKey: string; code: string }[]
@@ -106,13 +109,14 @@ function pendingTransactions(
const pending = new Map<string, PendingTransaction>()
const add = (
candidate: { canonicalPath: string; journalKey: string },
kind: 'install' | 'removal'
kind: 'install' | 'removal' | 'placement'
): void => {
const current = pending.get(candidate.canonicalPath) ?? {
canonicalPath: candidate.canonicalPath,
journalKey: candidate.journalKey,
install: false,
removal: false
removal: false,
placement: false
}
current[kind] = true
pending.set(candidate.canonicalPath, current)
@@ -125,36 +129,65 @@ function pendingTransactions(
async function recoverPendingSkillTransactionsUnobserved(
stateDirectory: string
): Promise<SkillTransactionStartupRecoveryReport> {
const [installs, removals, extractions, locks] = await Promise.all([
const [installs, removals, placements, extractions, locks] = await Promise.all([
scanJournalDirectory(stateDirectory, 'journals'),
scanJournalDirectory(stateDirectory, 'removal-journals'),
scanJournalDirectory(stateDirectory, 'placement-journals'),
recoverPendingSkillExtractions(stateDirectory),
reclaimDeadSkillInstallLocks(stateDirectory)
])
const report: SkillTransactionStartupRecoveryReport = {
scanned: installs.candidates.length + removals.candidates.length + extractions.scanned,
scanned:
installs.candidates.length +
removals.candidates.length +
placements.candidates.length +
extractions.scanned,
recovered: extractions.recovered,
orphanedExtractionsRecovered: extractions.recovered,
orphanedLocksReclaimed: locks.reclaimed,
failures: [...installs.failures, ...removals.failures, ...extractions.failures],
truncated: installs.truncated || removals.truncated || extractions.truncated || locks.truncated
failures: [
...installs.failures,
...removals.failures,
...placements.failures,
...extractions.failures
],
truncated:
installs.truncated ||
removals.truncated ||
placements.truncated ||
extractions.truncated ||
locks.truncated
}
for (const pending of pendingTransactions(installs.candidates, removals.candidates)) {
const pending = pendingTransactions(installs.candidates, removals.candidates)
for (const placement of placements.candidates) {
const current = pending.find((candidate) => candidate.canonicalPath === placement.canonicalPath)
if (current) {
current.placement = true
} else {
pending.push({ ...placement, install: false, removal: false, placement: true })
}
}
for (const pendingTransaction of pending) {
let releaseLock: (() => Promise<void>) | null = null
try {
releaseLock = await acquireSkillInstallLock({
path: skillInstallLockPath(stateDirectory, pending.canonicalPath)
path: skillInstallLockPath(stateDirectory, pendingTransaction.canonicalPath)
})
const installJournal = pending.install
? await readSkillInstallRecoveryJournal(stateDirectory, pending.canonicalPath)
const installJournal = pendingTransaction.install
? await readSkillInstallRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
: null
const removalJournal = pending.removal
? await readSkillRemovalRecoveryJournal(stateDirectory, pending.canonicalPath)
const removalJournal = pendingTransaction.removal
? await readSkillRemovalRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
: null
const placementJournal = pendingTransaction.placement
? await readSkillPlacementRecoveryJournal(stateDirectory, pendingTransaction.canonicalPath)
: null
const distros = new Set(
[installJournal?.receipt.wslDistro, removalJournal?.receipt.wslDistro].filter(
(distro): distro is string => Boolean(distro)
)
[
installJournal?.receipt.wslDistro,
removalJournal?.receipt.wslDistro,
placementJournal?.wslDistro
].filter((distro): distro is string => Boolean(distro))
)
if (distros.size > 1 || (distros.size && process.platform !== 'win32')) {
throw new Error('skill-transaction-wsl-recovery-unavailable')
@@ -162,23 +195,46 @@ async function recoverPendingSkillTransactionsUnobserved(
const distro = [...distros][0]
const filesystem = distro
? new WslSkillInstallFilesystem(distro, [
dirname(pending.canonicalPath),
...(removalJournal?.allowedProviderRoots ?? [])
dirname(pendingTransaction.canonicalPath),
...(removalJournal?.allowedProviderRoots ?? []),
...(placementJournal?.actions.map((action) => action.rootPath) ?? [])
])
: undefined
if (removalJournal) {
await recoverSkillRemovalTransaction(stateDirectory, pending.canonicalPath, filesystem)
await recoverSkillRemovalTransaction(
stateDirectory,
pendingTransaction.canonicalPath,
filesystem
)
report.recovered += 1
}
if (installJournal) {
await recoverSkillInstallTransaction(stateDirectory, pending.canonicalPath, filesystem)
await recoverSkillInstallTransaction(
stateDirectory,
pendingTransaction.canonicalPath,
filesystem
)
report.recovered += 1
}
if (placementJournal) {
await recoverSkillPlacementTransaction(
stateDirectory,
pendingTransaction.canonicalPath,
filesystem
)
report.recovered += 1
}
} catch (error) {
report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) })
report.failures.push({
journalKey: pendingTransaction.journalKey,
code: failureCode(error)
})
} finally {
await releaseLock?.().catch((error) => {
report.failures.push({ journalKey: pending.journalKey, code: failureCode(error) })
report.failures.push({
journalKey: pendingTransaction.journalKey,
code: failureCode(error)
})
})
}
}
@@ -9,7 +9,10 @@ vi.mock('node:child_process', () => ({
})
}))
import { WslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
import {
createWslSkillInstallFilesystem,
WslSkillInstallFilesystem
} from './skill-wsl-install-filesystem'
const WSL_ROOT = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.agents\\skills'
@@ -18,6 +21,32 @@ beforeEach(() => {
})
describe('WslSkillInstallFilesystem', () => {
it('allows every global provider destination inside the selected distro', async () => {
const home = '\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin'
const filesystem = createWslSkillInstallFilesystem({
distro: 'Ubuntu-24.04',
homeDirectory: home
})
for (const directory of [
'.claude',
'.cursor',
'.gemini',
'.factory',
'.continue',
'.trae-cn',
'.grok',
'.augment'
]) {
await filesystem.createAlias(
`${home}\\.agents\\skills\\private-skill`,
`${home}\\${directory}\\skills\\private-skill`
)
}
expect(execFileAsyncMock).toHaveBeenCalledTimes(8)
})
it('applies and verifies manifest modes through bounded guest argv batches', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
const manifest = {
@@ -94,4 +123,20 @@ describe('WslSkillInstallFilesystem', () => {
).rejects.toThrow('skill-install-wsl-path-invalid')
expect(execFileAsyncMock).not.toHaveBeenCalled()
})
it('authorizes a historical provider root before update or removal', async () => {
const filesystem = new WslSkillInstallFilesystem('Ubuntu-24.04', [WSL_ROOT])
const historicalRoot =
'\\\\wsl.localhost\\Ubuntu-24.04\\home\\jin\\.local\\share\\orca\\claude-accounts\\old\\auth\\skills'
filesystem.authorizeRoots([historicalRoot])
await filesystem.remove(`${historicalRoot}\\private-skill`)
expect(execFileAsyncMock).toHaveBeenCalledOnce()
expect(execFileAsyncMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining([
'/home/jin/.local/share/orca/claude-accounts/old/auth/skills/private-skill'
])
)
})
})
@@ -9,6 +9,8 @@ import {
type ObservedSkillPackage
} from './skill-package-identity'
import type { SkillInstallFilesystem, SkillInstalledFileMode } from './skill-install-filesystem'
import { SKILL_INSTALL_PROVIDERS } from '../../shared/skill-install-providers'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
const execFileAsync = promisify(execFile)
const BATCH_FILE_COUNT = 8
@@ -74,6 +76,15 @@ export class WslSkillInstallFilesystem implements SkillInstallFilesystem {
this.guestAllowedRoots = allowedRoots.map((path) => this.toGuestPath(path))
}
authorizeRoots(paths: readonly string[]): void {
for (const path of paths) {
const guestRoot = this.toGuestPath(path)
if (!this.guestAllowedRoots.includes(guestRoot)) {
this.guestAllowedRoots.push(guestRoot)
}
}
}
async prepareExtractedSkill(path: string, manifest: SkillPackageManifestV1): Promise<void> {
const guestRoot = this.requireAllowed(path)
if (isWindowsBackedGuestPath(guestRoot)) {
@@ -223,10 +234,18 @@ export function createWslSkillInstallFilesystem(input: {
distro: string
homeDirectory: string
workspaceDirectory?: string
providerRootOverrides?: SkillProviderRootOverrides
}): WslSkillInstallFilesystem {
const scopeRoot = input.workspaceDirectory ?? input.homeDirectory
const scope = input.workspaceDirectory ? 'workspace' : 'global'
const providerRoots = SKILL_INSTALL_PROVIDERS.flatMap((provider) => {
const segments = scope === 'global' ? provider.globalSegments : provider.workspaceSegments
const overriddenRoot =
scope === 'global' ? input.providerRootOverrides?.[provider.id] : undefined
return overriddenRoot ? [overriddenRoot] : segments ? [join(scopeRoot, ...segments)] : []
})
return new WslSkillInstallFilesystem(input.distro, [
join(scopeRoot, '.agents', 'skills'),
join(scopeRoot, '.claude', 'skills')
...providerRoots
])
}
+1
View File
@@ -113,6 +113,7 @@ describe('SkillInstallHandler', () => {
it('advertises separately gateable install, upload, and management capabilities', () => {
expect(SKILL_RELAY_CAPABILITIES).toEqual([
'skills.install.v1',
'skills.install-providers.v1',
'skills.install.bundle.v1',
'skills.install-progress.v1',
'skills.upload.v1',
+9 -2
View File
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import {
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_MANAGEMENT_CAPABILITY,
SKILL_UPLOAD_CAPABILITY
@@ -54,11 +55,13 @@ import {
skillInstallFailureFromError
} from '../main/skills/skill-install-operation-error'
import { recoverPendingSkillTransactions } from '../main/skills/skill-transaction-startup-recovery'
import { resolveEnvironmentSkillProviderRoots } from '../main/skills/skill-provider-runtime-roots'
const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host'
export const SKILL_RELAY_CAPABILITIES = [
SKILL_INSTALL_CAPABILITY,
SKILL_INSTALL_PROVIDERS_CAPABILITY,
SKILL_BUNDLE_INSTALL_CAPABILITY,
SKILL_INSTALL_PROGRESS_CAPABILITY,
SKILL_UPLOAD_CAPABILITY,
@@ -102,6 +105,7 @@ export class SkillInstallHandler {
requireHttps: true,
resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity),
detectProviders: this.detectProviders,
resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(),
signal: context.signal
})
)
@@ -117,6 +121,7 @@ export class SkillInstallHandler {
requireHttps: true,
resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity),
detectProviders: this.detectProviders,
resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots(),
signal: context.signal,
onProgress: (progress) => this.installProgress.set(input.request.operationId, progress)
})
@@ -135,7 +140,8 @@ export class SkillInstallHandler {
previewSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
detectProviders: this.detectProviders
detectProviders: this.detectProviders,
resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots()
})
)
})
@@ -145,7 +151,8 @@ export class SkillInstallHandler {
removeSharedSkillInstall(input.request, {
authority: this.authority(input.workspace),
stateDirectory: this.stateDirectory,
detectProviders: this.detectProviders
detectProviders: this.detectProviders,
resolveProviderRootOverrides: () => resolveEnvironmentSkillProviderRoots()
})
)
})
+2 -4
View File
@@ -1531,11 +1531,9 @@ function App(): React.JSX.Element {
const hasTabBar = tabCount >= 2
const showTitlebarExpandButton = workspaceChromeActive && !hasTabBar && effectiveActiveTabExpanded
// Activity/Space are full-page navigation surfaces (like Settings), so the worktree sidebar is hidden there.
// Skills is reached from the sidebar like Artifacts, so it keeps the sidebar visible to navigate away from.
const showSidebar =
activeView !== 'settings' &&
activeView !== 'activity' &&
activeView !== 'space' &&
activeView !== 'skills'
activeView !== 'settings' && activeView !== 'activity' && activeView !== 'space'
// Tasks/Landing show the full titlebar only when the sidebar is collapsed; open, they mirror workspace view (creation suppresses it).
const stackedSidebarOpen =
!workspaceChromeActive && !creationLayoutActive && showSidebar && sidebarOpen
+7
View File
@@ -1679,6 +1679,13 @@ html.native-shell .app-layout {
animation: collapsible-up 180ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.collapsible-height-content[data-state='open'],
.collapsible-height-content[data-state='closed'] {
animation: none;
}
}
@keyframes collapsible-down {
from {
height: 0;
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import { Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
import { BookOpen, Check, CircleUserRound, Files, Smartphone } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { OrcaProfileSignOutConfirmDialog } from '../orca-profiles/OrcaProfileSignOutConfirmDialog'
@@ -34,6 +35,28 @@ function accountStatusCopy(
return translate('auto.components.settings.orcaAccount.checking', 'Checking account status…')
}
function AccountBenefit({
icon: Icon,
title,
description,
className
}: {
icon: typeof Files
title: string
description: string
className?: string
}): React.JSX.Element {
return (
<div className={cn('flex items-start gap-3', className)}>
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">{title}</p>
<p className="text-xs leading-5 text-muted-foreground">{description}</p>
</div>
</div>
)
}
export function OrcaAccountSettingsPane(): React.JSX.Element {
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
@@ -120,38 +143,41 @@ export function OrcaAccountSettingsPane(): React.JSX.Element {
'Included with your account'
)}
</p>
<div className="grid gap-5 md:grid-cols-2 md:gap-0 md:divide-x md:divide-border/60">
<div className="flex items-start gap-3 md:pr-6">
<Files className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">
{translate(
'auto.components.settings.orcaAccount.artifactsTitle',
'Artifact sharing'
)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.settings.orcaAccount.artifactsDescription',
'Publish HTML and Markdown files, then manage every shared link from Orca.'
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 md:pl-6">
<Smartphone className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium">
{translate('auto.components.settings.orcaAccount.relayTitle', 'Orca Relay')}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.settings.orcaAccount.relayDescription',
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
)}
</p>
</div>
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2 md:gap-0 md:divide-x md:divide-border/60">
<AccountBenefit
icon={Files}
className="md:pr-6"
title={translate(
'auto.components.settings.orcaAccount.artifactsTitle',
'Artifact sharing'
)}
description={translate(
'auto.components.settings.orcaAccount.artifactsDescription',
'Publish HTML and Markdown files, then manage every shared link from Orca.'
)}
/>
<AccountBenefit
icon={Smartphone}
className="md:pl-6"
title={translate('auto.components.settings.orcaAccount.relayTitle', 'Orca Relay')}
description={translate(
'auto.components.settings.orcaAccount.relayDescription',
'Connect Orca Mobile to this desktop across cellular or any Wi-Fi.'
)}
/>
</div>
{/* Why: a third column would squeeze all three; a full-width row
below keeps the pair's divider and reads as one list. */}
<AccountBenefit
icon={BookOpen}
className="border-t border-border/60 pt-5"
title={translate('auto.components.settings.orcaAccount.skillsTitle', 'Skill sharing')}
description={translate(
'auto.components.settings.orcaAccount.skillsDescription',
'Share one skill or a whole set behind an unlisted link, and install them on any machine you use.'
)}
/>
</div>
</div>
</div>
@@ -1,63 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
afterEach(() => {
cleanup()
Reflect.deleteProperty(window, 'api')
})
describe('OwnedSkillShareLinks', () => {
it('copies and revokes owner-only shared links', async () => {
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const revokeShare = vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: { writeClipboardText },
skills: {
listOwnedShares: vi.fn().mockResolvedValue({
status: 'ok',
value: [
{
id: 'share_1',
url: 'https://share.onorca.dev/skills/share/share_1',
packageId: 'package_1',
name: 'team-skills',
description: 'Team bundle',
createdAt: '2026-08-12T00:00:00.000Z'
}
]
}),
revokeShare
}
}
})
const user = userEvent.setup()
render(<OwnedSkillShareLinks />)
await screen.findByText('team-skills')
await user.click(screen.getByRole('button', { name: 'Copy link' }))
expect(writeClipboardText).toHaveBeenCalledWith('https://share.onorca.dev/skills/share/share_1')
const unshare = screen.getByRole('button', { name: 'Unshare' })
unshare.focus()
await user.click(unshare)
expect(revokeShare).not.toHaveBeenCalled()
const confirm = screen.getByRole('button', { name: 'Confirm unshare' })
expect(confirm).toHaveFocus()
await user.click(confirm)
await waitFor(() => expect(revokeShare).toHaveBeenCalledWith('share_1'))
expect(screen.queryByText('team-skills')).not.toBeInTheDocument()
})
})
@@ -1,178 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Clipboard, Link2Off, Loader2, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { SkillCloudOwnedShare } from '../../../../shared/skill-cloud-contract'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
function inventoryError(status: string): string {
return status === 'reconnect-required'
? translate(
'auto.components.settings.shareSkills.linksReconnect',
'Sign in again to manage shared links.'
)
: translate(
'auto.components.settings.shareSkills.linksUnavailable',
'Shared links are unavailable right now.'
)
}
export function OwnedSkillShareLinks(): React.JSX.Element {
const [shares, setShares] = useState<SkillCloudOwnedShare[]>([])
const [loading, setLoading] = useState(true)
const [busyShareId, setBusyShareId] = useState<string | null>(null)
const [confirmShareId, setConfirmShareId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const generation = useRef(0)
const load = useCallback(async (): Promise<void> => {
const current = ++generation.current
setLoading(true)
setError(null)
try {
const operation = await window.api.skills.listOwnedShares()
if (generation.current !== current) {
return
}
if (operation.status !== 'ok') {
setError(inventoryError(operation.status))
return
}
setShares(operation.value)
} catch {
if (generation.current === current) {
setError(
translate(
'auto.components.settings.shareSkills.linksUnavailable',
'Shared links are unavailable right now.'
)
)
}
} finally {
if (generation.current === current) {
setLoading(false)
}
}
}, [])
useEffect(() => {
void load()
return () => {
generation.current += 1
}
}, [load])
const copy = async (share: SkillCloudOwnedShare): Promise<void> => {
await window.api.ui.writeClipboardText(share.url)
toast.success(translate('auto.components.settings.shareSkills.linkCopied', 'Share link copied'))
}
const unshare = async (share: SkillCloudOwnedShare): Promise<void> => {
if (confirmShareId !== share.id) {
setConfirmShareId(share.id)
return
}
setBusyShareId(share.id)
setError(null)
try {
const operation = await window.api.skills.revokeShare(share.id)
if (operation.status !== 'ok') {
setError(inventoryError(operation.status))
return
}
setShares((current) => current.filter((candidate) => candidate.id !== share.id))
setConfirmShareId(null)
toast.success(translate('auto.components.settings.shareSkills.linkRevoked', 'Link revoked'))
} catch {
setError(
translate(
'auto.components.settings.shareSkills.revokeFailed',
'Orca could not revoke this link.'
)
)
} finally {
setBusyShareId(null)
}
}
return (
<section className="space-y-3 py-5">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.activeLinksDescription',
'Only people with a link can open it. Unshare a link to block future inspection and installs.'
)}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={loading}
onClick={() => void load()}
>
{loading ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{translate('auto.components.settings.shareSkills.refreshLinks', 'Refresh')}
</Button>
</div>
{!loading && shares.length === 0 && !error ? (
<p className="rounded-md border border-border p-3 text-xs text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.noActiveLinks',
'No active links. Publish a skill bundle from Skills to create one.'
)}
</p>
) : null}
{shares.length > 0 ? (
<ul className="space-y-2">
{shares.map((share) => (
<li key={share.id} className="space-y-2 rounded-md border border-border p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{share.name}</p>
<p className="truncate font-mono text-xs text-muted-foreground">{share.url}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => void copy(share)}>
<Clipboard className="size-4" />
{translate('auto.components.settings.shareSkills.copyLink', 'Copy link')}
</Button>
<Button
type="button"
size="sm"
variant={confirmShareId === share.id ? 'destructive' : 'outline'}
disabled={busyShareId !== null}
onClick={() => void unshare(share)}
>
{busyShareId === share.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Link2Off className="size-4" />
)}
{confirmShareId === share.id
? translate(
'auto.components.settings.shareSkills.confirmUnshare',
'Confirm unshare'
)
: translate('auto.components.settings.shareSkills.unshare', 'Unshare')}
</Button>
</div>
</li>
))}
</ul>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
</section>
)
}
@@ -1463,7 +1463,7 @@ function Settings(): React.JSX.Element {
badge="Beta"
description={translate(
'auto.components.settings.shareSkills.description',
'Share one or many skills through unlisted, revocable links.'
'Share your skills with an unlisted link. Anyone who has it can install them.'
)}
searchEntries={getSectionSearchEntries('share-skills')}
>
@@ -3,6 +3,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TooltipProvider } from '@/components/ui/tooltip'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -65,7 +66,11 @@ describe('ShareSkillsSettingsPane', () => {
it('explains unlisted multi-skill links and opens Skills', async () => {
const user = userEvent.setup()
render(<ShareSkillsSettingsPane />)
render(
<TooltipProvider>
<ShareSkillsSettingsPane />
</TooltipProvider>
)
expect(screen.getByText('Unlisted skill links')).toBeInTheDocument()
expect(screen.getByText('Select one or more skills')).toBeInTheDocument()
@@ -82,7 +87,11 @@ describe('ShareSkillsSettingsPane', () => {
it('offers owner sign-in while explaining recipients stay signed out', async () => {
const user = userEvent.setup()
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render(<ShareSkillsSettingsPane />)
render(
<TooltipProvider>
<ShareSkillsSettingsPane />
</TooltipProvider>
)
expect(screen.getByText('Sign in to share skills')).toBeInTheDocument()
expect(screen.getByText(/Recipients do not need an account/)).toBeInTheDocument()
@@ -93,7 +102,11 @@ describe('ShareSkillsSettingsPane', () => {
it('does not offer desktop publishing from the web client', () => {
mocks.state.isWebClient = true
mocks.state.orcaProfileAuthStatus = { configured: true, state: 'local' }
render(<ShareSkillsSettingsPane />)
render(
<TooltipProvider>
<ShareSkillsSettingsPane />
</TooltipProvider>
)
expect(screen.getByText(/available in the Orca desktop app/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Open Skills/ })).not.toBeInTheDocument()
@@ -1,15 +1,18 @@
import { useEffect } from 'react'
import { ArrowRight, Share2 } from 'lucide-react'
import { ArrowRight, BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { isWebClientLocation } from '@/lib/web-client-location'
import { useAppStore } from '@/store'
import { OwnedSkillShareLinks } from './OwnedSkillShareLinks'
import { SettingsSwitchRow } from './SettingsFormControls'
type HowToStep = { key: string; title: string; description: string }
export function ShareSkillsSettingsPane(): React.JSX.Element {
const openSkillsPage = useAppStore((state) => state.openSkillsPage)
const openSkillsSharedLinks = useAppStore((state) => state.openSkillsSharedLinks)
const settings = useAppStore((state) => state.settings)
const updateSettings = useAppStore((state) => state.updateSettings)
const authStatus = useAppStore((state) => state.orcaProfileAuthStatus)
const connecting = useAppStore((state) => state.orcaProfileConnecting)
const connect = useAppStore((state) => state.connectCurrentOrcaProfile)
@@ -78,6 +81,18 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
</p>
</section>
{!isWebClient ? (
<SettingsSwitchRow
label={translate('auto.components.settings.shareSkills.showButton', 'Show Skills Button')}
description={translate(
'auto.components.settings.shareSkills.showButtonDescription',
'Show the Skills shortcut in the sidebar.'
)}
checked={settings?.showSkillsButton === true}
onChange={() => void updateSettings({ showSkillsButton: !settings?.showSkillsButton })}
/>
) : null}
{!signedIn ? (
<section className="flex flex-wrap items-center gap-4 py-5">
<div className="min-w-0 flex-1 space-y-1">
@@ -116,7 +131,27 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
</section>
) : null}
{signedIn && !isWebClient ? <OwnedSkillShareLinks /> : null}
{/* Why: the link inventory grows without bound and belongs with the page
that publishes it; settings keeps the preference and the way over. */}
{signedIn && !isWebClient ? (
<section className="flex flex-wrap items-center gap-4 py-5">
<div className="min-w-0 flex-1 space-y-1">
<h3 className="text-sm font-medium">
{translate('auto.components.settings.shareSkills.activeLinks', 'Active shared links')}
</h3>
<p className="text-xs leading-relaxed text-muted-foreground">
{translate(
'auto.components.settings.shareSkills.activeLinksDescription',
'Only people with a link can open it. Unshare a link to block future inspection and installs.'
)}
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={openSkillsSharedLinks}>
{translate('auto.components.settings.shareSkills.manageInSkills', 'Manage in Skills')}
<ArrowRight className="size-3.5" />
</Button>
</section>
) : null}
<section className="space-y-4 py-5">
<div className="space-y-1">
@@ -153,7 +188,7 @@ export function ShareSkillsSettingsPane(): React.JSX.Element {
onClick={openSkillsPage}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground">
<Share2 className="size-4" />
<BookOpen className="size-4" />
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block text-sm font-medium text-foreground">
@@ -7,7 +7,7 @@ export const getShareSkillsSettingsSearchEntries = createLocalizedCatalog(() =>
title: translate('auto.components.settings.shareSkills.linkTitle', 'Unlisted skill links'),
description: translate(
'auto.components.settings.shareSkills.searchDescription',
'Share one or more skills behind an unlisted, revocable link.'
'Share your skills with an unlisted link. Anyone who has it can install them.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.shareSkills.keywordSkills', 'skills'),
@@ -1,5 +1,5 @@
import React from 'react'
import { Bell, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react'
import { Bell, BookOpen, CalendarClock, EyeOff, Files, Search, Smartphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
@@ -49,6 +49,12 @@ export function shouldShowArtifactsButton(
return settings?.showArtifactsButton === true
}
export function shouldShowSkillsButton(
settings: Pick<GlobalSettings, 'showSkillsButton'> | null | undefined
): boolean {
return settings?.showSkillsButton === true
}
const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry'))
const SidebarNav = React.memo(function SidebarNav() {
@@ -60,6 +66,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const openActivityPage = useAppStore((s) => s.openActivityPage)
const openMobilePage = useAppStore((s) => s.openMobilePage)
const openArtifactsPage = useAppStore((s) => s.openArtifactsPage)
const openSkillsPage = useAppStore((s) => s.openSkillsPage)
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const activeView = useAppStore((s) => s.activeView)
@@ -73,10 +80,12 @@ const SidebarNav = React.memo(function SidebarNav() {
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
const showArtifactsButton = useAppStore((s) => shouldShowArtifactsButton(s.settings))
const showSkillsButton = useAppStore((s) => shouldShowSkillsButton(s.settings))
const automationsActive = activeView === 'automations'
const activityActive = activeView === 'activity'
const mobileActive = activeView === 'mobile'
const artifactsActive = activeView === 'artifacts'
const skillsActive = activeView === 'skills'
const activityUnreadCount = useActivityUnreadCount(showAgentsButton, 'sidebar-badge')
const mobileOnboardingBadge = useMobileSidebarOnboardingBadge(showMobileButton)
const hideAutomationsButton = React.useCallback(() => {
@@ -88,6 +97,9 @@ const SidebarNav = React.memo(function SidebarNav() {
const hideArtifactsButton = React.useCallback(() => {
void updateSettings({ showArtifactsButton: false })
}, [updateSettings])
const hideSkillsButton = React.useCallback(() => {
void updateSettings({ showSkillsButton: false })
}, [updateSettings])
return (
<div
@@ -125,6 +137,35 @@ const SidebarNav = React.memo(function SidebarNav() {
<HideSidebarMenu onHide={hideArtifactsButton} />
</ContextMenu>
) : null}
{showSkillsButton ? (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
onClick={openSkillsPage}
aria-current={skillsActive ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
skillsActive
? 'bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground'
: 'text-worktree-sidebar-foreground/60 hover:bg-worktree-sidebar-foreground/8'
)}
>
<BookOpen
className={cn(
'size-4 shrink-0',
!skillsActive && 'text-worktree-sidebar-foreground/30'
)}
strokeWidth={skillsActive ? 2.25 : 1.75}
/>
<span className="flex-1">
{translate('auto.components.sidebar.SidebarNav.skills', 'Skills')}
</span>
</button>
</ContextMenuTrigger>
<HideSidebarMenu onHide={hideSkillsButton} />
</ContextMenu>
) : null}
{showAutomationsButton ? (
<ContextMenu>
<ContextMenuTrigger asChild>
@@ -14,6 +14,10 @@ import type { SkillInstallDestination } from '../../../../shared/skill-install-c
import { SkillBundleInstallOutcome } from './SkillBundleInstallOutcome'
import { SkillBundleInstallReview } from './SkillBundleInstallReview'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
import { installSkillsActionLabel, retrySkillsActionLabel } from './skill-display-labels'
import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents'
import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
import { useSkillInstallProgress } from './skill-install-progress-state'
import { translate } from '@/i18n/i18n'
@@ -56,6 +60,9 @@ export function SkillBundleInstallFlow(props: {
const [result, setResult] = useState<SkillBundleInstallResult | null>(null)
const [environmentId, setEnvironmentId] = useState('local')
const [scope, setScope] = useState<'global' | 'workspace'>('global')
// Why: null means "follow detection"; storing the derived default instead
// would freeze the picker on whichever machine was selected first.
const [providerChoice, setProviderChoice] = useState<Set<SkillInstallProviderId> | null>(null)
const [workspace, setWorkspace] = useState('')
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
null
@@ -63,6 +70,11 @@ export function SkillBundleInstallFlow(props: {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const installProgress = useSkillInstallProgress()
const detectedAgents = useSkillInstallDetectedAgents({
environmentId,
wslDistro: executionTarget?.distro ?? null
})
const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents)
const workspaceChoices = useMemo(
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
@@ -155,6 +167,7 @@ export function SkillBundleInstallFlow(props: {
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
selectedSkillIds: [...requestedIds],
destination: target,
providers: [...providers],
conflictDecisions: checked.skills
.filter((skill) => CONFLICT_STATES.has(skill.currentState))
.map((skill) => ({
@@ -271,6 +284,10 @@ export function SkillBundleInstallFlow(props: {
setExecutionTarget(value)
resetPreview()
}}
providers={providers}
detectedAgents={detectedAgents}
onProvidersChange={setProviderChoice}
busy={busy}
runtimeEnvironments={runtimeEnvironments}
runtimeStatus={runtimeStatus}
sshConnections={sshConnections}
@@ -314,16 +331,8 @@ export function SkillBundleInstallFlow(props: {
{busy
? translate('auto.components.skills.SkillBundleInstallFlow.01c5a13e03', 'Installing…')
: result
? translate(
'auto.components.skills.SkillBundleInstallFlow.01c5a13e04',
'Retry {{value0}} skills',
{ value0: retryIds.size }
)
: translate(
'auto.components.skills.SkillBundleInstallFlow.01c5a13e05',
'Install {{value0}} skills',
{ value0: selectedSkillIds.size }
)}
? retrySkillsActionLabel(retryIds.size)
: installSkillsActionLabel(selectedSkillIds.size)}
</Button>
) : null}
</DialogFooter>
@@ -1,9 +1,10 @@
import type { ReactNode } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import type { SkillCloudVersion } from '../../../../shared/skill-cloud-contract'
import type { SkillBundleInstallPreview } from '../../../../shared/skill-bundle-install-contract'
import { SkillPackageChecklist } from './SkillPackageChecklist'
import { checklistItemsFromVersion } from './skill-package-checklist-items'
import { translate } from '@/i18n/i18n'
type BundleVersion = SkillCloudVersion & {
@@ -12,14 +13,26 @@ type BundleVersion = SkillCloudVersion & {
const CONFLICT_STATES = new Set(['modified', 'unowned', 'external-link', 'name-collision'])
function skillCounts(skill: BundleVersion['manifest']['skills'][number]): {
scripts: number
executables: number
} {
return {
scripts: skill.files.filter((file) => file.path.startsWith('scripts/')).length,
executables: skill.files.filter((file) => file.executable).length
// Why: the row already names the skill, so the note only has to say what
// installing it would do to the copy already on the machine.
function destinationNotes(
preview: SkillBundleInstallPreview | null
): ReadonlyMap<string, string> | undefined {
if (!preview) {
return undefined
}
return new Map(
preview.skills.map((skill) => [
skill.id,
CONFLICT_STATES.has(skill.currentState)
? translate('auto.components.skills.install.rowNeedsDecision', 'Needs a decision')
: skill.currentState === 'unchanged'
? translate('auto.components.skills.install.rowInstalled', 'Already installed')
: skill.currentState === 'clean-update'
? translate('auto.components.skills.install.rowUpdate', 'Update')
: translate('auto.components.skills.install.rowNew', 'New')
])
)
}
export function SkillBundleInstallReview(props: {
@@ -34,104 +47,42 @@ export function SkillBundleInstallReview(props: {
children: ReactNode
}): React.JSX.Element {
const { manifest } = props.version
const selectedSkills = manifest.skills.filter((skill) => props.selectedSkillIds.has(skill.id))
const selectedFiles = selectedSkills.flatMap((skill) => skill.files)
const conflicts =
props.destinationPreview?.skills.filter((skill) => CONFLICT_STATES.has(skill.currentState)) ??
[]
const previewSkills = props.destinationPreview?.skills ?? []
const allSelected = props.selectedSkillIds.size === manifest.skills.length
return (
<div className="space-y-5">
<div className="space-y-4">
<section className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-words text-sm font-semibold">{manifest.bundleName}</h3>
<p className="break-words text-xs leading-5 text-muted-foreground">
{manifest.description}
{manifest.skills.length > 1 ? (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.install.chooseSkills',
'Choose what to install from this link.'
)}
</p>
</div>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e01',
'Immutable version'
)}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e02',
'{{value0}} skills',
{ value0: selectedSkills.length }
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e03',
'{{value0}} files',
{ value0: selectedFiles.length }
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e04',
'{{value0}} scripts',
{
value0: selectedFiles.filter((file) => file.path.startsWith('scripts/')).length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e05',
'{{value0}} executable',
{ value0: selectedFiles.filter((file) => file.executable).length }
)}
</Badge>
</div>
{props.destinationPreview ? (
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground" role="status">
<Badge variant="outline">
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={allSelected ? true : props.selectedSkillIds.size ? 'indeterminate' : false}
disabled={props.busy}
onCheckedChange={(checked) => props.onToggleAll(checked === true)}
/>
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e14',
'{{value0}} new',
{ value0: previewSkills.filter((skill) => skill.currentState === 'missing').length }
'auto.components.skills.SkillBundleInstallReview.01c5a11e0d',
'Select all'
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e15',
'{{value0}} unchanged',
{
value0: previewSkills.filter((skill) => skill.currentState === 'unchanged').length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e16',
'{{value0}} updates',
{
value0: previewSkills.filter((skill) => skill.currentState === 'clean-update')
.length
}
)}
</Badge>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e17',
'{{value0}} conflicts',
{ value0: conflicts.length }
)}
</Badge>
</label>
</div>
) : null}
<p className="truncate font-mono text-[11px] text-muted-foreground">
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e06', 'SHA-256')}{' '}
{manifest.bundleDigest}
</p>
<SkillPackageChecklist
items={checklistItemsFromVersion(props.version)}
selectedIds={props.selectedSkillIds}
notes={destinationNotes(props.destinationPreview)}
busy={props.busy}
onSelectedChange={props.onToggleSkill}
/>
{props.version.releaseNotes.trim() ? (
<p className="break-words whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{translate(
@@ -143,84 +94,12 @@ export function SkillBundleInstallReview(props: {
) : null}
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0a',
'Skills contain instructions and may include scripts. Treat them as code from their author.'
'auto.components.skills.install.trustNote',
'Skills are instructions and code from their author. Install what you trust.'
)}
</p>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0b',
'Skills to install'
)}
</p>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0c',
'Choose any subset from this bundle.'
)}
</p>
</div>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={allSelected ? true : props.selectedSkillIds.size ? 'indeterminate' : false}
disabled={props.busy}
onCheckedChange={(checked) => props.onToggleAll(checked === true)}
/>
{translate('auto.components.skills.SkillBundleInstallReview.01c5a11e0d', 'Select all')}
</label>
</div>
<div className="max-h-52 space-y-1 overflow-y-auto rounded-md border border-border p-1 scrollbar-sleek">
{manifest.skills.map((skill) => {
const counts = skillCounts(skill)
const state = props.destinationPreview?.skills.find((item) => item.id === skill.id)
return (
<label
key={skill.id}
className="flex cursor-pointer items-start gap-3 rounded-md px-2 py-2 hover:bg-accent"
>
<Checkbox
checked={props.selectedSkillIds.has(skill.id)}
disabled={props.busy}
onCheckedChange={(checked) => props.onToggleSkill(skill.id, checked === true)}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{skill.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{skill.description ||
translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0e',
'No description'
)}
</span>
</span>
<span className="shrink-0 text-right text-[11px] text-muted-foreground">
<span className="block">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e0f',
'{{value0}} files · {{value1}} scripts',
{ value0: skill.files.length, value1: counts.scripts }
)}
</span>
<span className="block">
{translate(
'auto.components.skills.SkillBundleInstallReview.01c5a11e10',
'{{value0}} executable',
{ value0: counts.executables }
)}
{state ? ` · ${state.currentState}` : ''}
</span>
</span>
</label>
)
})}
</div>
</section>
{props.children}
{conflicts.length ? (
@@ -1,165 +0,0 @@
import { useId } from 'react'
import { BookOpen, Clock, FolderOpen, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Checkbox } from '@/components/ui/checkbox'
import { translate } from '@/i18n/i18n'
import type { DiscoveredSkill, SkillProvider } from '../../../../shared/skills'
import { pluralize, sourceLabels } from './skill-display-labels'
const providerLabels: Record<SkillProvider, string> = {
codex: 'Codex',
claude: 'Claude',
'agent-skills': 'Agent Skills'
}
const dateFormatter = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
function formatUpdatedAt(value: number | null): string {
return value ? dateFormatter.format(new Date(value)) : 'Unknown'
}
export function SkillCard({
skill,
onShare,
selected = false,
selectionMode = false,
selectionDisabled = false,
selectionDisabledReason,
onSelectionChange
}: {
skill: DiscoveredSkill
onShare?: () => void
selected?: boolean
selectionMode?: boolean
selectionDisabled?: boolean
selectionDisabledReason?: string | null
onSelectionChange?: (selected: boolean) => void
}): React.JSX.Element {
const selectionReasonId = useId()
const revealSkill = async (): Promise<void> => {
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
if (!result.ok) {
toast.error(
translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
)
}
}
return (
<Card className="rounded-lg">
<CardContent className="space-y-3 p-4">
<div className="flex min-w-0 items-start gap-3">
{selectionMode ? (
<Checkbox
className="mt-2"
checked={selected}
disabled={selectionDisabled}
aria-describedby={selectionDisabledReason ? selectionReasonId : undefined}
aria-label={translate(
'auto.components.skills.SkillCard.01c5a16e01',
'Select {{value0}}',
{ value0: skill.name }
)}
onCheckedChange={(value) => onSelectionChange?.(value === true)}
/>
) : null}
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate text-sm font-semibold">{skill.name}</h3>
<Badge
variant={skill.installed ? 'secondary' : 'outline'}
className="h-5 text-[10px]"
>
{skill.installed
? translate('auto.components.skills.SkillsPage.0c74e7ff34', 'Installed')
: translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
</Badge>
<Badge variant="outline" className="h-5 text-[10px]">
{sourceLabels[skill.sourceKind]}
</Badge>
</div>
{skill.description ? (
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{skill.description}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{onShare ? (
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" onClick={onShare}>
<Share2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill')}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
void revealSkill()
}}
>
<FolderOpen className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
</TooltipContent>
</Tooltip>
</div>
</div>
{selectionMode && selectionDisabledReason ? (
<p id={selectionReasonId} className="text-xs text-muted-foreground">
{selectionDisabledReason}
</p>
) : null}
<div className="grid gap-2 text-[11px] text-muted-foreground md:grid-cols-[1fr_auto_auto] md:items-center">
<div className="min-w-0 truncate font-mono" title={skill.skillFilePath}>
{skill.skillFilePath}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{skill.providers.map((provider) => (
<Badge key={provider} variant="outline" className="h-5 text-[10px]">
{providerLabels[provider]}
</Badge>
))}
</div>
<div className="flex items-center gap-3 whitespace-nowrap">
<span>{skill.sourceLabel}</span>
<span>{pluralize(skill.fileCount, 'file')}</span>
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{formatUpdatedAt(skill.updatedAt)}
</span>
</div>
</div>
</CardContent>
</Card>
)
}
@@ -1,108 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
import { SkillCloudManagementActions } from './SkillCloudManagementActions'
const details: SkillCloudPackageDetails = {
id: 'pkg_1',
name: 'private-skill',
description: 'Private skill',
createdAt: '2026-08-11T00:00:00.000Z',
canManage: true,
versions: [],
management: {
shares: [
{
id: 'share_1',
url: 'https://share.test/skills/share/share_1',
pinnedVersionId: 'ver_1',
createdAt: '2026-08-11T00:00:00.000Z'
}
]
}
}
function setup() {
const skills = {
revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
}
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
const onChanged = vi.fn().mockResolvedValue(undefined)
const onPackageDeleted = vi.fn()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
skills,
ui: { writeClipboardText }
}
})
render(
<SkillCloudManagementActions
details={details}
selectedVersionId="ver_1"
onChanged={onChanged}
onPackageDeleted={onPackageDeleted}
/>
)
return { skills, onChanged, onPackageDeleted, writeClipboardText }
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'api')
})
describe('SkillCloudManagementActions', () => {
it('requires confirmation before revoking an active link', async () => {
const { skills } = setup()
expect(screen.getByText(/leaves installed copies unchanged/)).toBeTruthy()
expect(screen.queryByText('Access')).toBeNull()
const unshare = screen.getByRole('button', { name: 'Unshare' })
unshare.focus()
fireEvent.click(unshare)
expect(skills.revokeShare).not.toHaveBeenCalled()
const confirm = screen.getByRole('button', { name: 'Confirm unshare' })
expect(document.activeElement).toBe(confirm)
fireEvent.click(confirm)
await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('share_1'))
})
it('copies an active unlisted link', async () => {
const { writeClipboardText } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Copy link' }))
await waitFor(() =>
expect(writeClipboardText).toHaveBeenCalledWith('https://share.test/skills/share/share_1')
)
})
it('requires confirmation before deleting the selected immutable version', async () => {
const { skills } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Delete selected Cloud version' }))
expect(skills.deletePackageVersion).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Confirm version deletion' }))
await waitFor(() =>
expect(skills.deletePackageVersion).toHaveBeenCalledWith({
packageId: 'pkg_1',
versionId: 'ver_1'
})
)
})
it('requires confirmation before deleting the Cloud package', async () => {
const { skills, onChanged, onPackageDeleted } = setup()
fireEvent.click(screen.getByRole('button', { name: 'Delete Cloud package' }))
expect(skills.deletePackage).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Confirm package deletion' }))
await waitFor(() => expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1'))
expect(onPackageDeleted).toHaveBeenCalledOnce()
expect(onChanged).not.toHaveBeenCalled()
})
})
@@ -1,232 +0,0 @@
import { useState } from 'react'
import { Clipboard, Link2Off, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type {
SkillCloudOperation,
SkillCloudPackageDetails
} from '../../../../shared/skill-cloud-contract'
import { translate } from '@/i18n/i18n'
type SkillCloudManagement = NonNullable<SkillCloudPackageDetails['management']>
type SkillCloudManagementActionsProps = {
details: SkillCloudPackageDetails
selectedVersionId: string
onChanged: () => Promise<void>
onPackageDeleted: () => void
}
function operationMessage(operation: SkillCloudOperation<void>): string | null {
if (operation.status === 'ok') {
return null
}
return operation.status === 'reconnect-required'
? 'Reconnect your Orca account and try again.'
: operation.message
}
export function SkillCloudManagementActions(
props: SkillCloudManagementActionsProps
): React.JSX.Element | null {
const management = props.details.management
if (!props.details.canManage || !management) {
return null
}
const stateKey = JSON.stringify([props.details.id, management.shares])
return <SkillCloudManagementForm key={stateKey} {...props} management={management} />
}
function SkillCloudManagementForm({
details,
selectedVersionId,
onChanged,
onPackageDeleted,
management
}: SkillCloudManagementActionsProps & {
management: SkillCloudManagement
}): React.JSX.Element {
const [busyAction, setBusyAction] = useState<string | null>(null)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const copyShareLink = async (url?: string): Promise<void> => {
if (!url) {
return
}
await window.api.ui.writeClipboardText(url)
toast.success(
translate(
'auto.components.skills.SkillCloudManagementActions.linkCopied',
'Share link copied'
)
)
}
const run = async (
action: string,
operation: () => Promise<SkillCloudOperation<void>>,
after: () => Promise<void> | void = onChanged
): Promise<void> => {
setBusyAction(action)
setMessage(null)
try {
const result = await operation()
const failure = operationMessage(result)
if (failure) {
setMessage(failure)
return
}
setConfirmation(null)
try {
await after()
} catch {
setMessage('Cloud change completed, but Orca could not refresh package details.')
}
} catch {
setMessage('Orca could not complete this Cloud change.')
} finally {
setBusyAction(null)
}
}
return (
<section
className="space-y-4 border-t border-border pt-3"
aria-label={translate(
'auto.components.skills.SkillCloudManagementActions.505cd6105c',
'Cloud sharing controls'
)}
>
<div className="space-y-2">
<h4 className="text-xs font-semibold">
{translate(
'auto.components.skills.SkillCloudManagementActions.8cac3b9362',
'Active links'
)}
</h4>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillCloudManagementActions.activeLinkBearerDescription',
'Anyone with an active link can inspect and install the skills. Revoking blocks future access but leaves installed copies unchanged.'
)}
</p>
{management.shares.length === 0 ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillCloudManagementActions.9e6bd31487',
'No active share links.'
)}
</p>
) : (
management.shares.map((share) => (
<div
key={share.id}
className="flex items-center gap-2 rounded-md border border-border p-2"
>
<Badge variant="outline" className="min-w-0 truncate font-mono">
{share.url ?? share.id}
</Badge>
{share.url ? (
<Button
size="sm"
variant="outline"
disabled={busyAction !== null}
onClick={() => void copyShareLink(share.url)}
>
<Clipboard className="size-4" />
{translate(
'auto.components.skills.SkillCloudManagementActions.copyLink',
'Copy link'
)}
</Button>
) : null}
<Button
size="sm"
variant={confirmation === `share:${share.id}` ? 'destructive' : 'outline'}
disabled={busyAction !== null}
onClick={() => {
if (confirmation !== `share:${share.id}`) {
setConfirmation(`share:${share.id}`)
return
}
void run(`share:${share.id}`, () => window.api.skills.revokeShare(share.id))
}}
>
<Link2Off className="size-4" />
{confirmation === `share:${share.id}`
? translate(
'auto.components.skills.SkillCloudManagementActions.0ac5c175fd',
'Confirm unshare'
)
: translate(
'auto.components.skills.SkillCloudManagementActions.7a80285dad',
'Unshare'
)}
</Button>
</div>
))
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={confirmation === `version:${selectedVersionId}` ? 'destructive' : 'outline'}
disabled={!selectedVersionId || busyAction !== null}
onClick={() => {
if (confirmation !== `version:${selectedVersionId}`) {
setConfirmation(`version:${selectedVersionId}`)
return
}
void run(`version:${selectedVersionId}`, () =>
window.api.skills.deletePackageVersion({
packageId: details.id,
versionId: selectedVersionId
})
)
}}
>
<Trash2 className="size-4" />
{confirmation === `version:${selectedVersionId}`
? translate(
'auto.components.skills.SkillCloudManagementActions.bbec37d8f8',
'Confirm version deletion'
)
: translate(
'auto.components.skills.SkillCloudManagementActions.6b6adf68c1',
'Delete selected Cloud version'
)}
</Button>
<Button
size="sm"
variant={confirmation === 'package' ? 'destructive' : 'outline'}
disabled={busyAction !== null}
onClick={() => {
if (confirmation !== 'package') {
setConfirmation('package')
return
}
void run('package', () => window.api.skills.deletePackage(details.id), onPackageDeleted)
}}
>
<Trash2 className="size-4" />
{confirmation === 'package'
? translate(
'auto.components.skills.SkillCloudManagementActions.640bc6b92e',
'Confirm package deletion'
)
: translate(
'auto.components.skills.SkillCloudManagementActions.ed753624c0',
'Delete Cloud package'
)}
</Button>
</div>
{message ? (
<p className="text-xs text-destructive" role="alert">
{message}
</p>
) : null}
</section>
)
}
@@ -0,0 +1,48 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { isLongSkillDescription } from './skill-description-length'
/**
* A skill description doubles as its trigger documentation, so it can run for
* paragraphs. Show the opening lines and let the reader ask for the rest.
*/
export function SkillDescriptionDisclosure({
description,
className
}: {
description: string | null | undefined
className?: string
}): React.JSX.Element | null {
const [expanded, setExpanded] = useState(false)
if (!description) {
return null
}
const long = isLongSkillDescription(description)
return (
<div className={cn('space-y-0.5', className)}>
<p
className={cn(
'break-words text-xs leading-5 text-muted-foreground',
long && !expanded && 'line-clamp-2'
)}
>
{description}
</p>
{long ? (
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs font-normal text-muted-foreground no-underline hover:text-foreground hover:no-underline"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{expanded
? translate('auto.components.skills.description.showLess', 'Show less')
: translate('auto.components.skills.description.showMore', 'Show more')}
</Button>
) : null}
</div>
)
}
@@ -0,0 +1,164 @@
import { ClipboardCopy, FolderOpen, Share2 } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
import type { DiscoveredSkill } from '../../../../shared/skills'
import { fileCountLabel, sourceKindLabel } from './skill-display-labels'
import { skillAgentLabel } from './skill-agent-filter'
const updatedFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
function DetailRow({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0">{children}</dd>
</>
)
}
/** Agent names come from the roots that reached the file, so a skill shared by
* several agents lists all of them rather than the coarse provider tag. */
function agentNames(
skill: DiscoveredSkill,
agentByRootPath: ReadonlyMap<string, string>
): string[] {
const roots = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath]
const labels = roots
.map((root) => agentByRootPath.get(root))
.filter((agent): agent is string => Boolean(agent))
.map(skillAgentLabel)
return [...new Set(labels)].sort((left, right) => left.localeCompare(right))
}
export function SkillDetailDialog({
skill,
agentByRootPath,
shareable,
onOpenChange,
onShare
}: {
skill: DiscoveredSkill | null
agentByRootPath: ReadonlyMap<string, string>
shareable: boolean
onOpenChange: (open: boolean) => void
onShare: () => void
}): React.JSX.Element | null {
if (!skill) {
return null
}
const copyPath = async (): Promise<void> => {
await window.api.ui.writeClipboardText(skill.skillFilePath)
toast.success(translate('auto.components.skills.SkillRow.pathCopied', 'Path copied'))
}
const revealSkill = async (): Promise<void> => {
const result = await window.api.shell.openInFileManager(skill.skillFilePath)
if (!result.ok) {
toast.error(
translate('auto.components.skills.SkillsPage.995fde8337', 'Could not reveal skill file')
)
}
}
const agents = agentNames(skill, agentByRootPath)
return (
<Dialog open onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl gap-0 overflow-hidden p-0">
{/* Why: pr-10 keeps the title clear of the dialog's own close button. */}
<DialogHeader className="space-y-1 border-b border-border py-4 pr-10 pl-5">
<div className="flex min-w-0 items-center gap-2">
<DialogTitle className="min-w-0 break-words text-base">{skill.name}</DialogTitle>
{!skill.installed ? (
<Badge variant="outline" className="h-4 shrink-0 px-1 text-[10px]">
{translate('auto.components.skills.SkillsPage.35b9a724a0', 'Available')}
</Badge>
) : null}
</div>
<DialogDescription className="text-xs">
{sourceKindLabel(skill.sourceKind)}
{' · '}
{skill.sourceLabel}
</DialogDescription>
</DialogHeader>
<div className="scrollbar-sleek max-h-[60vh] space-y-4 overflow-y-auto px-5 py-4">
<p className="whitespace-pre-wrap text-sm leading-6">
{skill.description ??
translate('auto.components.skills.SkillsPage.9963dff6d3', 'No description found.')}
</p>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 border-t border-border pt-4 text-xs">
{agents.length > 0 ? (
<DetailRow
label={translate('auto.components.skills.SkillDetailDialog.agents', 'Agents')}
>
{agents.join(', ')}
</DetailRow>
) : null}
<DetailRow
label={translate('auto.components.skills.SkillRow.detailContents', 'Contents')}
>
{fileCountLabel(skill.fileCount)}
</DetailRow>
<DetailRow
label={translate('auto.components.skills.SkillDetailDialog.updated', 'Updated')}
>
{skill.updatedAt
? updatedFormatter.format(new Date(skill.updatedAt))
: translate('auto.components.skills.SkillRow.updatedUnknown', 'No date')}
</DetailRow>
<DetailRow label={translate('auto.components.skills.SkillRow.detailPath', 'Path')}>
{/* Why: a labelled button beats an icon + tooltip here — the
tooltip would cover the very path it names. */}
<span className="flex min-w-0 items-start gap-2">
<span className="min-w-0 flex-1 break-all font-mono">{skill.skillFilePath}</span>
<Button
type="button"
variant="ghost"
size="xs"
className="-my-1 shrink-0 text-muted-foreground"
onClick={() => void copyPath()}
>
<ClipboardCopy />
{translate('auto.components.skills.SkillDetailDialog.copy', 'Copy')}
</Button>
</span>
</DetailRow>
</dl>
</div>
<DialogFooter className="border-t border-border px-5 py-3">
<Button type="button" variant="ghost" size="sm" onClick={() => void revealSkill()}>
<FolderOpen className="size-3.5" />
{translate('auto.components.skills.SkillsPage.dc4c3328ee', 'Reveal file')}
</Button>
<Button type="button" size="sm" disabled={!shareable} onClick={onShare}>
<Share2 className="size-3.5" />
{translate('auto.components.skills.SkillCard.d25a1b8ae6', 'Share skill')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,23 @@
import { ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CollapsibleTrigger } from '@/components/ui/collapsible'
/** Radix stamps `data-state` on the trigger, so the chevron follows the section
* without a second copy of its open state in React. */
export function SkillDisclosureTrigger({ label }: { label: string }): React.JSX.Element {
return (
<CollapsibleTrigger asChild>
<Button
type="button"
variant="link"
// Why: the button size variant adds `has-[>svg]:px-3`, which a plain
// `p-0` cannot override — the chevron would sit indented from the label
// it belongs to.
className="group h-auto gap-1 p-0 text-xs font-normal text-muted-foreground no-underline hover:text-foreground hover:no-underline has-[>svg]:px-0"
>
<ChevronRight className="size-3 transition-transform group-data-[state=open]:rotate-90" />
{label}
</Button>
</CollapsibleTrigger>
)
}
@@ -0,0 +1,139 @@
import { ChevronRight } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Label } from '@/components/ui/label'
import { translate } from '@/i18n/i18n'
import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
import {
groupSkillInstallProviders,
toggledSkillProviderSelection
} from './skill-install-provider-groups'
function AgentRow({
name,
directory,
checked,
disabled,
note,
onCheckedChange
}: {
name: string
directory: string
checked: boolean
disabled: boolean
note: string | null
onCheckedChange?: (checked: boolean) => void
}): React.JSX.Element {
return (
<li>
<label className="flex cursor-pointer items-center gap-3 px-3 py-2 text-xs">
<Checkbox
checked={checked}
disabled={disabled}
aria-label={name}
onCheckedChange={(value) => onCheckedChange?.(value === true)}
/>
{/* Why: a fixed name column lines the paths up, so the eye reads one
list of destinations instead of a ragged edge. */}
<span className="w-28 shrink-0 truncate">{name}</span>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
{directory}
</span>
{note ? <span className="shrink-0 text-[11px] text-muted-foreground">{note}</span> : null}
</label>
</li>
)
}
/**
* Mirrors the `skills` CLI: one list, where agents that read the canonical
* `.agents/skills` root at this scope are shown already on and locked, because
* the canonical copy is written whatever the user picks.
*/
export function SkillInstallAgentPicker({
scope,
selected,
detectedAgents,
busy,
onChange
}: {
scope: 'global' | 'workspace'
selected: ReadonlySet<SkillInstallProviderId>
detectedAgents: readonly string[] | null
busy: boolean
onChange: (next: Set<SkillInstallProviderId>) => void
}): React.JSX.Element {
const { canonical, selectable } = groupSkillInstallProviders(scope, detectedAgents)
const installed = detectedAgents ? new Set(detectedAgents) : null
const canonicalRoot = scope === 'global' ? '~/.agents/skills' : '.agents/skills'
const chosen = [
...canonical.map((provider) => provider.displayName),
...selectable
.filter((choice) => selected.has(choice.provider.id))
.map((choice) => choice.provider.displayName)
]
return (
<Collapsible className="space-y-2">
<div className="space-y-1">
<Label>{translate('auto.components.skills.install.agentsLabel', 'Agents')}</Label>
{/* Why: a full-width row with the chevron trailing, so this disclosure
and the skill rows above it open the same way. */}
<CollapsibleTrigger className="group flex w-full items-center gap-3 rounded-md text-left text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring">
<span className="min-w-0 flex-1 truncate">
{chosen.length === 0
? translate('auto.components.skills.install.agentsNone', 'No agents selected')
: translate(
'auto.components.skills.install.agentsSelected',
'Installing for: {{value0}}',
{ value0: chosen.join(', ') }
)}
</span>
<ChevronRight className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90" />
</CollapsibleTrigger>
</div>
<CollapsibleContent className="collapsible-height-content space-y-2">
<ul className="divide-y divide-border rounded-md border border-border">
{canonical.map((provider) => (
<AgentRow
key={provider.id}
name={provider.displayName}
directory={canonicalRoot}
checked
disabled
note={translate('auto.components.skills.install.agentAlways', 'Always')}
/>
))}
{selectable.map(({ provider, directory }) => (
<AgentRow
key={provider.id}
name={provider.displayName}
directory={directory}
checked={selected.has(provider.id)}
disabled={busy}
note={
installed !== null && !installed.has(provider.id)
? translate('auto.components.skills.install.agentNotInstalled', 'Not installed')
: null
}
onCheckedChange={(checked) =>
onChange(toggledSkillProviderSelection(selected, provider.id, checked))
}
/>
))}
</ul>
{canonical.length > 0 ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.install.agentsCanonicalNote',
'{{value0}} read {{value1}}, which every install writes.',
{
value0: canonical.map((provider) => provider.displayName).join(', '),
value1: canonicalRoot
}
)}
</p>
) : null}
</CollapsibleContent>
</Collapsible>
)
}
@@ -80,6 +80,13 @@ function bundleVersion(): SkillCloudVersion {
}
}
function detectionApi(agents: string[]) {
return {
detectAgents: vi.fn().mockResolvedValue(agents),
detectRemoteAgents: vi.fn().mockResolvedValue(agents)
}
}
function installApi(previewInstall: ReturnType<typeof vi.fn>) {
let progressListener: ((progress: SkillInstallProgress) => void) | null = null
return {
@@ -154,7 +161,7 @@ describe('SkillInstallDialog', () => {
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill(sharedVersion.description)
expect(screen.getByRole('heading', { name: longName })).toBeTruthy()
expect(screen.getByRole('button', { name: new RegExp(longName) })).toBeTruthy()
expect(screen.queryByText(/Published by Orca user/)).toBeNull()
expect(
screen.getByText(
@@ -164,6 +171,131 @@ describe('SkillInstallDialog', () => {
).toBeTruthy()
})
// Why: the picker is the only place a recipient can say "not Claude"; the
// request has to carry that choice or the host installs everywhere it can.
it('installs only for the agents left checked', async () => {
const skills = installApi(
vi.fn().mockResolvedValue({
status: 'ok',
value: {
name: 'private-skill',
packageDigest: DIGEST,
destinationIdentity: 'global:local',
currentState: 'missing',
providers: []
}
})
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['claude', 'codex', 'droid']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
fireEvent.click(await screen.findByRole('button', { name: /Installing for:/ }))
fireEvent.click(screen.getByRole('checkbox', { name: /Claude Code/ }))
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
await waitFor(() => expect(skills.installShare).toHaveBeenCalled())
const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }]
expect(request.providers).not.toContain('claude')
expect(request.providers).toContain('codex')
expect(request.providers).toContain('droid')
})
// Why: checking agents the machine does not have would write placements no
// agent reads, and make the user opt out of tools they never installed.
it('starts from the agents the target machine has', async () => {
const skills = installApi(
vi.fn().mockResolvedValue({
status: 'ok',
value: {
name: 'private-skill',
packageDigest: DIGEST,
destinationIdentity: 'global:local',
currentState: 'missing',
providers: []
}
})
)
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
await screen.findByRole('button', { name: 'Installing for: Codex' })
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
await waitFor(() => expect(skills.installShare).toHaveBeenCalled())
const [request] = skills.installShare.mock.calls[0] as [{ providers: string[] }]
expect(request.providers).toEqual(['codex'])
})
// Why: a skill description doubles as its trigger documentation and can run
// for paragraphs; the row shows one line until the reader opens it.
it('unclamps a long description when the row is opened', async () => {
const sharedVersion = version()
sharedVersion.description = `Use the public CLI. ${'trigger words.'.repeat(20)}`
const skills = installApi(vi.fn())
skills.resolveShare.mockResolvedValue({
status: 'ok',
value: { id: 'share_1', version: sharedVersion }
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill(sharedVersion.description)
const description = screen.getByText(sharedVersion.description)
expect(description.className).toContain('line-clamp-1')
expect(description.className).toContain('group-data-[state=open]/row:line-clamp-none')
})
// Why: "view the full skill contents" is the point of the row — the file list
// is the only place a recipient sees what lands on their machine.
it('lists the files a skill would write when its row is expanded', async () => {
const skills = installApi(vi.fn())
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill()
expect(screen.queryByText('SKILL.md')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /private-skill/ }))
expect(screen.getByText('SKILL.md')).toBeTruthy()
})
// Why: a recipient cannot compare a digest against anything, and the badge
// language ("Immutable version") explained nothing. Both left the review.
it.each([
['skill', () => version(), 'a'],
['bundle', () => bundleVersion(), 'c']
])('keeps package plumbing out of the %s review', async (_kind, build, char) => {
const skills = installApi(vi.fn())
skills.resolveShare.mockResolvedValue({
status: 'ok',
value: { id: 'share_1', version: build() }
})
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill('skills' in build().manifest ? 'alpha description' : build().description)
expect(screen.queryByText(/SHA-256/)).toBeNull()
expect(screen.queryByText(char.repeat(64))).toBeNull()
expect(screen.queryByText('Immutable version')).toBeNull()
expect(screen.queryByText('0 scripts')).toBeNull()
expect(screen.getAllByText(/No scripts or executables/).length).toBeGreaterThan(0)
})
it('focuses the link first and programmatically names destination controls', async () => {
const user = userEvent.setup()
const onOpenChange = vi.fn()
@@ -179,16 +311,26 @@ describe('SkillInstallDialog', () => {
}
})
)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={onOpenChange} />)
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Orca skill link' }))
// Why: the submit sits in the footer beside Close, so Enter in the field is
// the keyboard path rather than tabbing past the back-out action.
const footerButtons = screen
.getAllByRole('button')
.filter((button) => button.getAttribute('data-slot') !== 'dialog-close')
expect(footerButtons.map((button) => button.textContent?.trim())).toEqual([
'Close',
'Inspect skill'
])
await user.type(
screen.getByRole('textbox', { name: 'Orca skill link' }),
'https://app.orca.dev/skills/share/share_1'
)
await user.tab()
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Inspect skill' }))
await user.keyboard('{Enter}')
await screen.findByText('A private skill')
expect(screen.getByRole('combobox', { name: 'Machine' })).toBeTruthy()
@@ -251,12 +393,15 @@ describe('SkillInstallDialog', () => {
}
})
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
Object.defineProperty(window, 'api', {
configurable: true,
value: { skills, preflight: detectionApi(['codex']) }
})
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill('Two private skills')
await inspectSkill('alpha description')
fireEvent.click(screen.getByRole('checkbox', { name: /alpha/ }))
fireEvent.click(screen.getByRole('button', { name: 'Install 1 skills' }))
fireEvent.click(screen.getByRole('button', { name: 'Install 1 skill' }))
await screen.findByText('Skills installed and verified.')
expect(skills.installBundleShare).toHaveBeenCalledWith(
@@ -298,7 +443,7 @@ describe('SkillInstallDialog', () => {
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
await inspectSkill('Two private skills')
await inspectSkill('alpha description')
fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
await screen.findByText('Local copies need a decision')
@@ -1,10 +1,9 @@
import { useMemo, useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Download, Loader2, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
@@ -20,6 +19,9 @@ import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
import { isSkillBundleVersion, type ResolvedSkillShare } from './skill-share-version-summary'
import { SkillBundleInstallFlow } from './SkillBundleInstallFlow'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
import { useSkillInstallDetectedAgents } from './use-skill-install-detected-agents'
import type { SkillInstallProviderId } from '../../../../shared/skill-install-providers'
import {
SkillInstallOutcome,
SkillInstallReview,
@@ -49,16 +51,25 @@ export function SkillInstallDialog({
const [preview, setPreview] = useState<ResolvedSkillShare | null>(null)
const [environmentId, setEnvironmentId] = useState<string>('local')
const [scope, setScope] = useState<'global' | 'workspace'>('global')
// Why: null means "follow detection"; storing the derived default instead
// would freeze the picker on whichever machine was selected first.
const [providerChoice, setProviderChoice] = useState<Set<SkillInstallProviderId> | null>(null)
const [workspace, setWorkspace] = useState<string>('')
const [executionTarget, setExecutionTarget] = useState<{ kind: 'wsl'; distro: string } | null>(
null
)
const [busy, setBusy] = useState(false)
const [bundleBusy, setBundleBusy] = useState(false)
const autoResolved = useRef(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<SkillInstallResult | null>(null)
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(null)
const installProgress = useSkillInstallProgress()
const detectedAgents = useSkillInstallDetectedAgents({
environmentId,
wslDistro: executionTarget?.distro ?? null
})
const providers = providerChoice ?? defaultSelectedSkillProviders(detectedAgents)
const workspaceChoices = useMemo(
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
@@ -74,8 +85,8 @@ export function SkillInstallDialog({
[sshConnectionStates, sshTargetLabels]
)
const inspect = async (): Promise<void> => {
const shareId = parseSkillShareId(link)
const resolveLink = useCallback(async (value: string): Promise<void> => {
const shareId = parseSkillShareId(value)
if (!shareId) {
setError('Enter an Orca skill share link.')
return
@@ -100,7 +111,23 @@ export function SkillInstallDialog({
} finally {
setBusy(false)
}
}
}, [])
const inspect = (): Promise<void> => resolveLink(link)
/** True from the moment a prefilled link opens the dialog until it resolves
* or fails, so the paste step never appears for a caller-supplied link. */
const resolvingInitialLink = Boolean(initialLink) && !preview && !error
// Why: arriving with a link already in hand means the paste step is busywork,
// so the dialog opens on the review screen instead of asking for a click.
useEffect(() => {
if (!open || !initialLink || autoResolved.current) {
return
}
autoResolved.current = true
void resolveLink(initialLink)
}, [initialLink, open, resolveLink])
const install = async (discardLocal = false): Promise<void> => {
if (!preview) {
@@ -163,6 +190,7 @@ export function SkillInstallDialog({
operationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination,
providers: [...providers],
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status === 'unsupported') {
@@ -219,7 +247,15 @@ export function SkillInstallDialog({
return (
<Dialog open={open} onOpenChange={(next) => !next && !busy && !bundleBusy && close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
{/* Why: DialogContent is a grid, so a child that cannot wrap (a digest, a
path, a long name) raises the grid's minimum width and the whole dialog
scrolls sideways with its content clipped. min-w-0 lets children reflow
at the dialog width; overflow-x-hidden makes sideways scroll impossible
even if some future child still refuses to shrink. */}
<DialogContent
className="max-h-[calc(100vh-3rem)] overflow-x-hidden overflow-y-auto scrollbar-sleek sm:max-w-xl [&>*]:min-w-0"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>
{bundleVersion
@@ -232,18 +268,20 @@ export function SkillInstallDialog({
'Install shared skill'
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.skills.SkillInstallDialog.4a00d133c5',
'Orca verifies access and package identity before changing the selected machine.'
)}
</DialogDescription>
{/* Why: the title plus what is on screen already says this; the old
line was package-identity jargon a recipient cannot act on. */}
</DialogHeader>
{!preview ? (
{resolvingInitialLink ? (
// Why: the paste form would flash for the length of the round-trip
// before being replaced by a review the caller already asked for.
<p className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate('auto.components.skills.SkillInstallDialog.opening', 'Opening this link…')}
</p>
) : !preview ? (
<SkillShareLinkInputForm
link={link}
busy={busy}
onLinkChange={setLink}
onSubmit={() => void inspect()}
/>
@@ -285,6 +323,10 @@ export function SkillInstallDialog({
setExecutionTarget(value)
setDestinationPreview(null)
}}
providers={providers}
detectedAgents={detectedAgents}
onProvidersChange={setProviderChoice}
busy={busy}
runtimeEnvironments={runtimeEnvironments}
runtimeStatus={runtimeStatus}
sshConnections={sshConnections}
@@ -308,6 +350,24 @@ export function SkillInstallDialog({
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
</Button>
{!preview && !resolvingInitialLink ? (
<Button type="button" disabled={busy || !link.trim()} onClick={() => void inspect()}>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ShieldCheck className="size-4" />
)}
{busy
? translate(
'auto.components.skills.SkillInstallReviewContent.69236de8d6',
'Checking…'
)
: translate(
'auto.components.skills.SkillInstallReviewContent.157de228b4',
'Inspect skill'
)}
</Button>
) : null}
{busy && installProgress.activeOperationId ? (
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
{translate(
@@ -0,0 +1,38 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
export function SkillInstallMachineSelect(props: {
value: string
onChange(value: string): void
localLabel: string
sshLabel: string
disconnectedLabel: string
environments: { id: string; name: string }[]
sshTargets: { id: string; label: string; connected: boolean }[]
}): React.JSX.Element {
return (
<Select value={props.value} onValueChange={props.onChange}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{props.localLabel}</SelectItem>
{props.environments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
{props.sshTargets.map((target) => (
<SelectItem key={target.id} value={target.id} disabled={!target.connected}>
{target.label} {target.connected ? props.sshLabel : props.disconnectedLabel}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -155,10 +155,19 @@ function bundleInstall(name: string, state: ManagedSkillInstall['state'] = 'unch
return { ...install('ver_1'), name, bundleDigest: DIGEST, state }
}
async function selectInstall(versionId: string): Promise<void> {
await screen.findByText(`global · ${versionId}`)
fireEvent.click(screen.getByRole('button', { name: new RegExp(`private-skill.*${versionId}`) }))
await screen.findByText(`Installed version ${versionId}`)
const ROW_ACTION = /Reinstall|Use this version/
function dateLabel(iso: string): string {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
}).format(new Date(iso))
}
async function openInstall(name = 'private-skill'): Promise<void> {
fireEvent.click(await screen.findByRole('button', { name: new RegExp(name) }))
await screen.findByRole('button', { name: ROW_ACTION })
}
beforeEach(() => {
@@ -205,17 +214,89 @@ describe('SkillInstallManagementDialog', () => {
let resolveLocal: ((value: unknown) => void) | undefined
skills.listManagedInstalls
.mockImplementationOnce(() => new Promise((resolve) => (resolveLocal = resolve)) as never)
.mockResolvedValueOnce({ status: 'ok', value: [install('remote_version')] })
.mockResolvedValueOnce({
status: 'ok',
value: [{ ...install('remote_version'), name: 'remote-skill' }]
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Remote' }))
await screen.findByText('global · remote_version')
await screen.findByText('remote-skill · Everywhere')
resolveLocal?.({ status: 'ok', value: [install('local_version')] })
await waitFor(() => expect(screen.queryByText('global · local_version')).toBeNull())
expect(screen.getByText('global · remote_version')).toBeTruthy()
await waitFor(() => expect(screen.queryByText('private-skill · Everywhere')).toBeNull())
expect(screen.getByText('remote-skill · Everywhere')).toBeTruthy()
})
it('ignores stale package details after switching machines', async () => {
useAppStore.setState({
runtimeEnvironments: [
{
id: 'remote_1',
name: 'Remote',
createdAt: 1,
updatedAt: 1,
lastUsedAt: null,
runtimeId: null,
endpoints: [
{
id: 'remote_ws',
kind: 'websocket',
label: 'WebSocket',
endpoint: 'ws://remote.invalid'
}
],
preferredEndpointId: 'remote_ws'
}
]
})
const remoteInstall = {
...install('remote_version'),
name: 'remote-skill',
packageId: 'pkg_remote'
}
const skills = skillsApi(install('local_version'), [
version('local_version', '2026-08-12T00:00:00.000Z')
])
let resolveLocalDetails: ((value: unknown) => void) | undefined
skills.listManagedInstalls
.mockResolvedValueOnce({ status: 'ok', value: [install('local_version')] })
.mockResolvedValueOnce({ status: 'ok', value: [remoteInstall] })
skills.getPackage
.mockImplementationOnce(
() => new Promise((resolve) => (resolveLocalDetails = resolve)) as never
)
.mockResolvedValueOnce({
status: 'ok',
value: {
...packageDetails([version('remote_version', '2026-08-13T00:00:00.000Z')]),
id: 'pkg_remote',
name: 'remote-skill'
}
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await screen.findByText('private-skill · Everywhere')
fireEvent.click(screen.getByRole('button', { name: /private-skill/ }))
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByRole('option', { name: 'Remote' }))
fireEvent.click(await screen.findByRole('button', { name: /remote-skill/ }))
const remoteLabel = `${dateLabel('2026-08-13T00:00:00.000Z')} (installed)`
await screen.findByText(remoteLabel)
resolveLocalDetails?.({
status: 'ok',
value: packageDetails([version('local_version', '2026-08-12T00:00:00.000Z')])
})
// Why: the stale local details would repaint the version picker with the
// local build's date.
await waitFor(() =>
expect(screen.queryByText(dateLabel('2026-08-12T00:00:00.000Z'))).toBeNull()
)
expect(screen.getByText(remoteLabel)).toBeTruthy()
})
it('updates an installed skill to the newest immutable version', async () => {
@@ -227,9 +308,9 @@ describe('SkillInstallManagementDialog', () => {
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_1')
await openInstall()
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(skills.installPackageVersion).toHaveBeenCalledWith(
@@ -258,9 +339,9 @@ describe('SkillInstallManagementDialog', () => {
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_1')
await openInstall()
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(changed).not.toHaveBeenCalled()
@@ -274,9 +355,9 @@ describe('SkillInstallManagementDialog', () => {
])
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_2')
await openInstall()
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
expect(skills.installPackageVersion).toHaveBeenCalledWith(
@@ -288,7 +369,7 @@ describe('SkillInstallManagementDialog', () => {
const skills = skillsApi(install('ver_2'), [version('ver_2', '2026-08-12T00:00:00.000Z')])
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_2')
await openInstall()
const remove = screen.getByRole('button', { name: 'Remove' })
remove.focus()
@@ -304,31 +385,20 @@ describe('SkillInstallManagementDialog', () => {
)
})
it('keeps the local install visible after deleting its Cloud package', async () => {
const versions = [version('ver_2', '2026-08-12T00:00:00.000Z')]
const details: SkillCloudPackageDetails = {
...packageDetails(versions),
management: {
shares: []
}
}
const skills = skillsApi(install('ver_2'), versions, details)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
skills,
orcaProfiles: { authStatus: vi.fn().mockResolvedValue({ cloud: undefined }) }
}
it('allows removal when Cloud package history is unavailable', async () => {
const skills = skillsApi(install('ver_2'), [version('ver_2', '2026-08-12T00:00:00.000Z')])
skills.getPackage.mockResolvedValue({
status: 'unsupported',
message: 'Package history is unavailable.'
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_2')
fireEvent.click(screen.getByRole('button', { name: 'Delete Cloud package' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm package deletion' }))
fireEvent.click(await screen.findByRole('button', { name: /private-skill/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm remove' }))
await screen.findByText('Cloud package deleted. The installed copy remains on this machine.')
expect(skills.deletePackage).toHaveBeenCalledWith('pkg_1')
expect(screen.getByText('global · ver_2')).toBeTruthy()
await waitFor(() => expect(skills.removeInstall).toHaveBeenCalledOnce())
})
it('retries incomplete coverage for the currently installed version', async () => {
@@ -356,11 +426,11 @@ describe('SkillInstallManagementDialog', () => {
})
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
await selectInstall('ver_1')
await openInstall()
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
await screen.findByRole('button', { name: 'Retry incomplete coverage' })
fireEvent.click(screen.getByRole('button', { name: 'Retry incomplete coverage' }))
fireEvent.click(screen.getByRole('button', { name: 'Use this version' }))
await screen.findByRole('button', { name: 'Finish installing' })
fireEvent.click(screen.getByRole('button', { name: 'Finish installing' }))
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledTimes(2))
expect(skills.installPackageVersion.mock.calls[1]?.[0]).toEqual(
@@ -375,9 +445,8 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
await screen.findByText('team-skills')
fireEvent.click(screen.getByRole('button', { name: 'Install 2 skills' }))
fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Use this version' }))
await waitFor(() => expect(skills.installBundlePackageVersion).toHaveBeenCalledOnce())
expect(skills.installBundlePackageVersion).toHaveBeenCalledWith(
@@ -410,8 +479,8 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={onOpenChange} />)
fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Install on another machine' }))
fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
fireEvent.click(await screen.findByRole('button', { name: /Install elsewhere/ }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(useAppStore.getState().pendingSkillShareId).toBe('share_bundle')
@@ -444,10 +513,9 @@ describe('SkillInstallManagementDialog', () => {
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
fireEvent.click(await screen.findByRole('button', { name: /2 skill bundle.*ver_1/ }))
await screen.findByText('team-skills')
fireEvent.click(screen.getByRole('button', { name: 'Remove 2 skills' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm remove 2 skills' }))
fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
fireEvent.click(screen.getByRole('button', { name: 'Confirm remove' }))
await screen.findByText('1 removed · 1 modified skill preserved.')
expect(skills.removeInstall).toHaveBeenCalledTimes(2)
@@ -459,4 +527,32 @@ describe('SkillInstallManagementDialog', () => {
)
expect(skills.removeInstall.mock.calls[1]?.[0]).not.toHaveProperty('conflictResolution')
})
it('discards changes and removes every skill in a mixed-state bundle', async () => {
const installed = [bundleInstall('alpha-skill'), bundleInstall('beta-skill', 'modified')]
const skills = skillsApi(installed, [bundleVersion('ver_1', ['alpha-skill', 'beta-skill'])])
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
render(<SkillInstallManagementDialog open onOpenChange={() => undefined} />)
fireEvent.click(await screen.findByRole('button', { name: /alpha-skill \+1/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Discard my edits and remove' }))
await waitFor(() => expect(skills.removeInstall).toHaveBeenCalledTimes(2))
expect(skills.removeInstall.mock.calls).toEqual(
expect.arrayContaining([
[
expect.objectContaining({
name: 'alpha-skill',
conflictResolution: 'replace-and-discard-local'
})
],
[
expect.objectContaining({
name: 'beta-skill',
conflictResolution: 'replace-and-discard-local'
})
]
])
)
})
})
@@ -9,13 +9,6 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useAppStore } from '@/store'
import type {
ManagedSkillInstall,
@@ -27,14 +20,14 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkil
import { skillInstallResultLabel } from './skill-install-result-label'
import { skillInstallManagementCopy } from './skill-install-management-copy'
import { useSkillInstallProgress } from './skill-install-progress-state'
import { SkillManagedInstallList } from './SkillManagedInstallList'
import { SkillManagedInstallDetails } from './SkillManagedInstallDetails'
import { SkillManagedInstallRow } from './SkillManagedInstallRow'
import { summarizeManagedSkillRemoval } from './skill-managed-removal-summary'
import { retainManagedSkillVersion } from './skill-managed-version-selection'
import {
groupManagedSkillInstalls,
type SkillManagedInstallGroup
} from './skill-managed-install-groups'
import { SkillInstallMachineSelect } from './SkillInstallMachineSelect'
import { SkillInstallManagementStatus } from './SkillInstallManagementStatus'
export function SkillInstallManagementDialog({
open,
@@ -60,6 +53,7 @@ export function SkillInstallManagementDialog({
const [notice, setNotice] = useState<string | null>(null)
const installProgress = useSkillInstallProgress()
const loadGeneration = useRef(0)
const detailGeneration = useRef(0)
const groups = useMemo(() => groupManagedSkillInstalls(installs), [installs])
const selected = useMemo(
@@ -70,6 +64,7 @@ export function SkillInstallManagementDialog({
const load = useCallback(async (): Promise<void> => {
const generation = ++loadGeneration.current
detailGeneration.current += 1
if (!open) {
return
}
@@ -108,7 +103,9 @@ export function SkillInstallManagementDialog({
}, [load])
const selectInstall = async (group: SkillManagedInstallGroup): Promise<void> => {
const generation = ++detailGeneration.current
setSelectedKey(group.key)
setDetails(null)
setBusy(true)
setError(null)
setNotice(null)
@@ -117,6 +114,9 @@ export function SkillInstallManagementDialog({
setConfirmRemove(false)
try {
const operation = await window.api.skills.getPackage(group.packageId)
if (generation !== detailGeneration.current) {
return
}
if (operation.status !== 'ok') {
setError(
operation.status === 'reconnect-required'
@@ -128,29 +128,25 @@ export function SkillInstallManagementDialog({
setDetails(operation.value)
setVersionId(operation.value.versions[0]?.versionId ?? group.versionId)
} catch (cause) {
if (generation !== detailGeneration.current) {
return
}
console.warn('[skills] package history failed:', cause)
setError('Version history is unavailable for this skill.')
} finally {
setBusy(false)
if (generation === detailGeneration.current) {
setBusy(false)
}
}
}
const refreshPackageDetails = async (): Promise<void> => {
if (!selected) {
return
}
const operation = await window.api.skills.getPackage(selected.packageId)
if (operation.status !== 'ok') {
throw new Error(operation.status)
}
setDetails(operation.value)
setVersionId((current) => retainManagedSkillVersion(operation.value.versions, current))
}
const packageDeleted = (): void => {
const collapse = (): void => {
detailGeneration.current += 1
setSelectedKey('')
setDetails(null)
setVersionId('')
setNotice('Cloud package deleted. The installed copy remains on this machine.')
setResult(null)
setBundleResult(null)
setConfirmRemove(false)
}
const installVersion = async (discardLocal = false): Promise<void> => {
@@ -183,6 +179,7 @@ export function SkillInstallManagementDialog({
? {}
: { environmentId }),
selectedSkillIds: selectedSkills.map((skill) => skill.id),
...(selectedInstall.providers ? { providers: selectedInstall.providers } : {}),
destination: selected.destination,
...(discardLocal
? {
@@ -216,6 +213,7 @@ export function SkillInstallManagementDialog({
operationId,
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
destination: selected.destination,
...(selectedInstall.providers ? { providers: selectedInstall.providers } : {}),
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
})
if (operation.status !== 'ok') {
@@ -267,9 +265,7 @@ export function SkillInstallManagementDialog({
setError(null)
setNotice(null)
try {
const targets = discardLocal
? selected.installs.filter((install) => install.state === 'modified')
: selected.installs
const targets = selected.installs
const operations = await Promise.all(
targets.map((install) =>
window.api.skills.removeInstall({
@@ -305,6 +301,8 @@ export function SkillInstallManagementDialog({
}
const close = (): void => {
loadGeneration.current += 1
detailGeneration.current += 1
setSelectedKey('')
setDetails(null)
setError(null)
@@ -319,34 +317,24 @@ export function SkillInstallManagementDialog({
return (
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-2xl">
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-x-hidden overflow-y-auto scrollbar-sleek sm:max-w-2xl [&>*]:min-w-0">
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<Select value={environmentId} onValueChange={setEnvironmentId}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{copy.localMachine}</SelectItem>
{runtimeEnvironments.map((environment) => (
<SelectItem key={environment.id} value={environment.id}>
{environment.name}
</SelectItem>
))}
{[...sshTargetLabels.entries()].map(([id, label]) => (
<SelectItem
key={`ssh:${id}`}
value={`ssh:${id}`}
disabled={sshConnectionStates.get(id)?.status !== 'connected'}
>
{label}{' '}
{sshConnectionStates.get(id)?.status === 'connected' ? copy.ssh : copy.disconnected}
</SelectItem>
))}
</SelectContent>
</Select>
<SkillInstallMachineSelect
value={environmentId}
onChange={setEnvironmentId}
localLabel={copy.localMachine}
sshLabel={copy.ssh}
disconnectedLabel={copy.disconnected}
environments={runtimeEnvironments}
sshTargets={[...sshTargetLabels.entries()].map(([id, label]) => ({
id: `ssh:${id}`,
label,
connected: sshConnectionStates.get(id)?.status === 'connected'
}))}
/>
{busy && installs.length === 0 ? <Loader2 className="mx-auto size-5 animate-spin" /> : null}
{!busy && installs.length === 0 ? (
@@ -354,56 +342,40 @@ export function SkillInstallManagementDialog({
{copy.noInstalls}
</p>
) : null}
<SkillManagedInstallList
groups={groups}
selectedKey={selectedKey}
onSelect={(group) => void selectInstall(group)}
{groups.length > 0 ? (
<ul className="divide-y divide-border rounded-md border border-border">
{groups.map((group) => (
<SkillManagedInstallRow
key={group.key}
group={group}
open={selectedKey === group.key}
details={selectedKey === group.key ? details : null}
versionId={selectedKey === group.key ? versionId : ''}
busy={busy}
confirmRemove={selectedKey === group.key && confirmRemove}
installActive={Boolean(installProgress.activeOperationId)}
editedWarning={selectedKey === group.key && destructiveConflict}
bundleResult={selectedKey === group.key ? bundleResult : null}
result={selectedKey === group.key ? result : null}
onOpenChange={(next) => (next ? void selectInstall(group) : collapse())}
onVersionChange={setVersionId}
onInstall={(discardLocal) => void installVersion(discardLocal)}
onCancelInstall={() => void cancelInstall()}
onSendToMachine={(shareId) => {
close()
useAppStore.getState().openSkillShare(shareId)
}}
onRemove={(discardLocal) => void remove(discardLocal)}
/>
))}
</ul>
) : null}
<SkillInstallManagementStatus
resultLabel={result ? skillInstallResultLabel(result) : null}
progressLabel={installProgress.phaseLabel}
error={error}
notice={notice}
/>
{selected && details ? (
<SkillManagedInstallDetails
selected={selected}
details={details}
versionId={versionId}
busy={busy}
confirmRemove={confirmRemove}
result={result}
bundleResult={bundleResult}
installActive={Boolean(installProgress.activeOperationId)}
destructiveConflict={destructiveConflict}
copy={copy}
onVersionChange={setVersionId}
onInstall={(discardLocal) => void installVersion(discardLocal)}
onCancelInstall={() => void cancelInstall()}
onInstallAnotherMachine={(shareId) => {
close()
useAppStore.getState().openSkillShare(shareId)
}}
onRemove={(discardLocal) => void remove(discardLocal)}
onCloudChanged={refreshPackageDetails}
onPackageDeleted={packageDeleted}
/>
) : null}
{result ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{skillInstallResultLabel(result)}
</p>
) : null}
{installProgress.phaseLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{installProgress.phaseLabel}
</p>
) : null}
{error ? (
<p className="text-xs text-destructive" role="alert">
{error}
</p>
) : null}
{notice ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{notice}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
{copy.close}
@@ -0,0 +1,31 @@
export function SkillInstallManagementStatus(props: {
resultLabel: string | null
progressLabel: string | null
error: string | null
notice: string | null
}): React.JSX.Element {
return (
<>
{props.resultLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{props.resultLabel}
</p>
) : null}
{props.progressLabel ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{props.progressLabel}
</p>
) : null}
{props.error ? (
<p className="text-xs text-destructive" role="alert">
{props.error}
</p>
) : null}
{props.notice ? (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
{props.notice}
</p>
) : null}
</>
)
}
@@ -1,6 +1,5 @@
import type { ReactNode } from 'react'
import { AlertTriangle, Check, Loader2, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -8,63 +7,54 @@ import type {
SkillInstallPreview,
SkillInstallResult
} from '../../../../shared/skill-install-contract'
import { SkillPackageChecklist } from './SkillPackageChecklist'
import { checklistItemsFromVersion } from './skill-package-checklist-items'
import { skillInstallResultLabel } from './skill-install-result-label'
import { summarizeSkillShareVersion, type ResolvedSkillShare } from './skill-share-version-summary'
import type { ResolvedSkillShare } from './skill-share-version-summary'
import { translate } from '@/i18n/i18n'
/** Why: the submit lives in the dialog footer beside Close, so this is only the
* field; Enter still submits through the form. */
export function SkillShareLinkInputForm({
link,
busy,
onLinkChange,
onSubmit
}: {
link: string
busy: boolean
onLinkChange: (link: string) => void
onSubmit: () => void
}): React.JSX.Element {
return (
<form
className="space-y-3"
className="space-y-2"
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<div className="space-y-2">
<Label htmlFor="skill-share-link">
{translate(
'auto.components.skills.SkillInstallReviewContent.93eb0fe8c7',
'Orca skill link'
)}
</Label>
<Input
id="skill-share-link"
value={link}
onChange={(event) => onLinkChange(event.target.value)}
placeholder={translate(
'auto.components.skills.SkillInstallReviewContent.66cff7a804',
'https://app.orca.dev/skills/share/…'
)}
className="font-mono text-xs"
autoFocus
/>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillInstallReviewContent.27672470d9',
'Opening the link does not install anything. Review the immutable version first.'
)}
</p>
</div>
<Button type="submit" disabled={busy || !link.trim()} className="w-32">
{busy ? <Loader2 className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
{busy
? translate('auto.components.skills.SkillInstallReviewContent.69236de8d6', 'Checking…')
: translate(
'auto.components.skills.SkillInstallReviewContent.157de228b4',
'Inspect skill'
)}
</Button>
<Label htmlFor="skill-share-link">
{translate(
'auto.components.skills.SkillInstallReviewContent.93eb0fe8c7',
'Orca skill link'
)}
</Label>
<Input
id="skill-share-link"
value={link}
onChange={(event) => onLinkChange(event.target.value)}
placeholder={translate(
'auto.components.skills.SkillInstallReviewContent.66cff7a804',
'https://app.orca.dev/skills/share/…'
)}
className="font-mono text-xs"
autoFocus
/>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.install.linkHint',
'Opening a link never installs anything — you review it first.'
)}
</p>
</form>
)
}
@@ -136,7 +126,6 @@ export function SkillInstallReview({
children: ReactNode
}): React.JSX.Element {
const version = preview.version
const versionSummary = summarizeSkillShareVersion(version)
const hasConflict =
result?.status === 'conflict' ||
(destinationPreview &&
@@ -146,38 +135,11 @@ export function SkillInstallReview({
return (
<div className="space-y-5">
<section className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="break-words text-sm font-semibold">{version.name}</h3>
<p className="break-words text-xs leading-5 text-muted-foreground">
{version.description}
</p>
</div>
<Badge variant="outline">
{translate(
'auto.components.skills.SkillInstallReviewContent.8f9833d509',
'Immutable version'
)}
</Badge>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline">
{versionSummary.fileCount}{' '}
{translate('auto.components.skills.SkillInstallReviewContent.fab8fce842', 'files')}
</Badge>
<Badge variant="outline">
{versionSummary.scriptCount}{' '}
{translate('auto.components.skills.SkillInstallReviewContent.87137bcb8d', 'scripts')}
</Badge>
<Badge variant="outline">
{versionSummary.executableCount}{' '}
{translate('auto.components.skills.SkillInstallReviewContent.3d8421ca2f', 'executable')}
</Badge>
</div>
<p className="truncate font-mono text-[11px] text-muted-foreground">
{translate('auto.components.skills.SkillInstallReviewContent.f72ee4022a', 'SHA-256')}{' '}
{version.packageDigest}
</p>
<SkillPackageChecklist
items={checklistItemsFromVersion(version)}
selectedIds={null}
busy={busy}
/>
{version.releaseNotes.trim() ? (
<p className="break-words whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
{translate(
@@ -189,8 +151,8 @@ export function SkillInstallReview({
) : null}
<p className="text-xs leading-5 text-muted-foreground">
{translate(
'auto.components.skills.SkillInstallReviewContent.98ed90e523',
'A skill contains instructions and may include scripts. Treat it as code from its author.'
'auto.components.skills.install.trustNote',
'Skills are instructions and code from their author. Install what you trust.'
)}
</p>
</section>
@@ -4,6 +4,7 @@ import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
import { defaultSelectedSkillProviders } from './skill-install-provider-groups'
const callbacks = {
onEnvironmentChange: vi.fn(),
@@ -34,7 +35,10 @@ describe('SkillInstallTargetFields', () => {
runtimeEnvironments: [],
runtimeStatus: new Map(),
sshConnections: [],
workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }]
workspaceChoices: [{ id: 'folder_1', label: longWorkspace, kind: 'folder' as const }],
providers: defaultSelectedSkillProviders(null),
detectedAgents: null,
onProvidersChange: vi.fn()
}
const view = render(<SkillInstallTargetFields {...props} />)

Some files were not shown because too many files have changed in this diff Show More