mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
feat(skills): complete private bundle sharing
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user