mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
feat(skills): harden sharing and install lifecycle
This commit is contained in:
@@ -212,6 +212,9 @@ Rules:
|
||||
- `skill/SKILL.md` is required and must parse successfully.
|
||||
- The installed folder receives only the contents of `skill/`.
|
||||
- Paths use `/` in the manifest and are converted with the executing host's path API.
|
||||
- LF and CRLF non-executable text share one normalized package identity, while each immutable
|
||||
archive retains and hashes its exact source bytes; executable and binary files always use exact
|
||||
bytes for identity.
|
||||
- Names are normalized once during publication. Installation never silently renames a conflict.
|
||||
- V1 rejects symlinks and special files. A later package-builder feature may safely dereference
|
||||
internal links after proving they remain within the source root.
|
||||
@@ -636,6 +639,8 @@ and test the same contract accurately.
|
||||
- Share creation, resolution, revocation, and access management.
|
||||
- Download grant creation.
|
||||
- Package catalog and version lookup.
|
||||
- Manager-only package details include current user/organization access and active, unexpired
|
||||
share records; non-managers never receive that management metadata.
|
||||
|
||||
Exact files follow the repository that owns Orca Cloud APIs; desktop contracts stay provider
|
||||
neutral.
|
||||
@@ -652,6 +657,8 @@ neutral.
|
||||
|
||||
- Share preview and access dialog.
|
||||
- Share completion dialog with copyable durable link.
|
||||
- Access editing, active-link revocation, immutable-version deletion, and Cloud-package deletion
|
||||
with explicit confirmation and copy explaining that installed copies remain local.
|
||||
- Install preview with destination, scope, coverage, and conflict state.
|
||||
- Install progress and structured outcome.
|
||||
- Installed version, update, removal, and incomplete-coverage actions on the Skills page.
|
||||
@@ -840,7 +847,8 @@ staging bytes.
|
||||
4. If bytes match the receipt, use the normal installation transaction.
|
||||
5. If bytes were modified, offer:
|
||||
- Keep local version.
|
||||
- Export local bytes as a new package version when authorized.
|
||||
- Publish local bytes as a new immutable version when exactly one non-missing Orca-managed
|
||||
install matches the skill name and scope; Cloud rechecks ownership of that stable package ID.
|
||||
- Replace and discard local changes after explicit confirmation.
|
||||
6. Reconcile all recorded aliases and fallback copies.
|
||||
7. Preserve the previous package version in Cloud; rollback is a normal install of that immutable
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE
|
||||
} from '../../shared/skill-install-capability'
|
||||
import {
|
||||
ManagedSkillInstallListSchema,
|
||||
SkillInstallDestinationSchema,
|
||||
SkillInstallPreviewSchema,
|
||||
SkillInstallResultSchema,
|
||||
SkillPackageIdentitySchema,
|
||||
type SkillInstallRequest
|
||||
} from '../../shared/skill-install-contract'
|
||||
import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills'
|
||||
@@ -20,15 +16,14 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { installSkillOnRemoteRuntime } from '../skills/skill-remote-install-service'
|
||||
import { classifySkillCloudInstallTarget } from '../skills/skill-cloud-install-target'
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import { supportsSkillRuntimeManagement } from '../skills/skill-runtime-capability'
|
||||
import { listWslDistrosAsync } from '../wsl'
|
||||
import { skillInstallFailureFromError } from '../skills/skill-install-operation-error'
|
||||
import {
|
||||
callRuntimeEnvironment,
|
||||
getRuntimeEnvironmentStatus
|
||||
} from './runtime-environment-transport-routing'
|
||||
import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers'
|
||||
|
||||
const environmentIdSchema = z.string().min(1).max(128)
|
||||
const skillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
|
||||
const sharePrepareSchema = z
|
||||
.object({
|
||||
@@ -48,6 +43,7 @@ const sharePublishSchema = z
|
||||
.strict()
|
||||
|
||||
const installDestinationFields = {
|
||||
operationId: z.string().min(1).max(128).optional(),
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z
|
||||
@@ -71,21 +67,18 @@ const packageVersionInstallSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const installPreviewSchema = z
|
||||
const replaceAccessSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
package: SkillPackageIdentitySchema,
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema
|
||||
packageId: z.string().min(1).max(128),
|
||||
userIds: z.array(z.string().min(1).max(128)).max(100),
|
||||
shareWithOrganization: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const removeSchema = z
|
||||
const packageVersionSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z.enum(['replace-and-discard-local', 'cancel']).optional()
|
||||
packageId: z.string().min(1).max(128),
|
||||
versionId: z.string().min(1).max(128)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -95,7 +88,7 @@ async function installGrant(
|
||||
input: z.infer<typeof shareInstallSchema> | z.infer<typeof packageVersionInstallSchema>
|
||||
) {
|
||||
const request: SkillInstallRequest = {
|
||||
operationId: randomUUID(),
|
||||
operationId: input.operationId ?? randomUUID(),
|
||||
package: {
|
||||
packageId: grant.version.packageId,
|
||||
versionId: grant.version.versionId,
|
||||
@@ -111,26 +104,45 @@ async function installGrant(
|
||||
destination: input.destination,
|
||||
conflictResolution: input.conflictResolution
|
||||
}
|
||||
if (!input.environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.installSharedSkillRequest(request) }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
|
||||
if (
|
||||
status.ok !== true ||
|
||||
status.result.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await installSkillOnRemoteRuntime({
|
||||
userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
request,
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
requireHttps: app.isPackaged
|
||||
})
|
||||
try {
|
||||
if (!input.environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.installSharedSkillRequest(request) }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
const status = await getRuntimeEnvironmentStatus(userDataPath, input.environmentId, 15_000)
|
||||
if (
|
||||
status.ok !== true ||
|
||||
status.result.capabilities?.includes(SKILL_INSTALL_CAPABILITY) !== true
|
||||
) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await installSkillOnRemoteRuntime({
|
||||
userDataPath,
|
||||
environmentId: input.environmentId,
|
||||
request,
|
||||
capabilities: status.result.capabilities ?? [],
|
||||
requireHttps: app.isPackaged
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (!failure) {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: {
|
||||
operationId: request.operationId,
|
||||
status: failure.category === 'cancelled' ? ('cancelled' as const) : ('failed' as const),
|
||||
name: grant.version.name,
|
||||
packageDigest: request.package.packageDigest,
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,108 +210,52 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void {
|
||||
)
|
||||
return grant.status === 'ok' ? installGrant(runtime, grant.value, input) : grant
|
||||
})
|
||||
ipcMain.handle('skills:getPackage', (_event, packageId: unknown) =>
|
||||
runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
}
|
||||
|
||||
function registerInstallManagementHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return listWslDistrosAsync()
|
||||
ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => {
|
||||
const input = z
|
||||
.object({
|
||||
operationId: z.string().min(1).max(128),
|
||||
environmentId: environmentIdSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.parse(value)
|
||||
if (!input.environmentId || input.environmentId.startsWith('ssh:')) {
|
||||
return { cancelled: runtime.cancelSharedSkillInstall(input.operationId) }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
environmentId,
|
||||
'host.wsl.listDistros',
|
||||
{},
|
||||
input.environmentId,
|
||||
'skills.cancelInstall',
|
||||
{ operationId: input.operationId },
|
||||
15_000
|
||||
)
|
||||
return response.ok === true && Array.isArray(response.result)
|
||||
? response.result.filter((distro): distro is string => typeof distro === 'string')
|
||||
: []
|
||||
return response.ok === true && response.result && typeof response.result === 'object'
|
||||
? { cancelled: (response.result as { cancelled?: unknown }).cancelled === true }
|
||||
: { cancelled: false }
|
||||
})
|
||||
ipcMain.handle('skills:previewInstall', async (_event, value: unknown) => {
|
||||
const input = installPreviewSchema.parse(value)
|
||||
const request = { package: input.package, name: input.name, destination: input.destination }
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.previewSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.previewInstall',
|
||||
request,
|
||||
30_000
|
||||
ipcMain.handle('skills:getPackage', (_event, packageId: unknown) =>
|
||||
runtime.getSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
ipcMain.handle('skills:replacePackageAccess', (_event, value: unknown) => {
|
||||
const input = replaceAccessSchema.parse(value)
|
||||
return runtime.replaceSkillPackageAccess(
|
||||
input.packageId,
|
||||
{
|
||||
userIds: input.userIds,
|
||||
shareWithOrganization: input.shareWithOrganization
|
||||
},
|
||||
{}
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-preview-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => {
|
||||
const input = removeSchema.parse(value)
|
||||
const request = {
|
||||
operationId: randomUUID(),
|
||||
name: input.name,
|
||||
destination: input.destination,
|
||||
conflictResolution: input.conflictResolution
|
||||
}
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.removeSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.removeInstall',
|
||||
request,
|
||||
5 * 60_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-remove-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallResultSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:listManagedInstalls', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.listManagedSkillInstalls() }
|
||||
}
|
||||
if (environmentId.startsWith('ssh:')) {
|
||||
const value = await runtime.listManagedSkillInstalls(environmentId.slice('ssh:'.length))
|
||||
return { status: 'ok' as const, value }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'skills.listManagedInstalls',
|
||||
{},
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-list-managed-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: ManagedSkillInstallListSchema.parse(response.result) }
|
||||
ipcMain.handle('skills:revokeShare', (_event, shareId: unknown) =>
|
||||
runtime.revokeSkillShare(z.string().min(1).max(128).parse(shareId), {})
|
||||
)
|
||||
ipcMain.handle('skills:deletePackageVersion', (_event, value: unknown) => {
|
||||
const input = packageVersionSchema.parse(value)
|
||||
return runtime.deleteSkillPackageVersion(input.packageId, input.versionId, {})
|
||||
})
|
||||
ipcMain.handle('skills:deletePackage', (_event, packageId: unknown) =>
|
||||
runtime.deleteSkillPackage(z.string().min(1).max(128).parse(packageId), {})
|
||||
)
|
||||
}
|
||||
|
||||
export function registerSkillCloudIpcHandlers(
|
||||
@@ -308,5 +264,5 @@ export function registerSkillCloudIpcHandlers(
|
||||
): void {
|
||||
registerSharingHandlers(runtime, discover)
|
||||
registerCloudInstallHandlers(runtime)
|
||||
registerInstallManagementHandlers(runtime)
|
||||
registerSkillInstallManagementIpcHandlers(runtime)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import { z } from 'zod'
|
||||
import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability'
|
||||
import {
|
||||
ManagedSkillInstallListSchema,
|
||||
SkillInstallDestinationSchema,
|
||||
SkillInstallPreviewSchema,
|
||||
SkillInstallResultSchema,
|
||||
SkillPackageIdentitySchema
|
||||
} from '../../shared/skill-install-contract'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import { supportsSkillRuntimeManagement } from '../skills/skill-runtime-capability'
|
||||
import { listWslDistrosAsync } from '../wsl'
|
||||
import { callRuntimeEnvironment } from './runtime-environment-transport-routing'
|
||||
|
||||
const environmentIdSchema = z.string().min(1).max(128)
|
||||
const skillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
const installPreviewSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
package: SkillPackageIdentitySchema,
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema
|
||||
})
|
||||
.strict()
|
||||
const removeSchema = z
|
||||
.object({
|
||||
environmentId: environmentIdSchema.optional(),
|
||||
name: skillNameSchema,
|
||||
destination: SkillInstallDestinationSchema,
|
||||
conflictResolution: z.enum(['replace-and-discard-local', 'cancel']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export function registerSkillInstallManagementIpcHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.handle('skills:listWslDistros', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return listWslDistrosAsync()
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
app.getPath('userData'),
|
||||
environmentId,
|
||||
'host.wsl.listDistros',
|
||||
{},
|
||||
15_000
|
||||
)
|
||||
return response.ok === true && Array.isArray(response.result)
|
||||
? response.result.filter((distro): distro is string => typeof distro === 'string')
|
||||
: []
|
||||
})
|
||||
ipcMain.handle('skills:previewInstall', async (_event, value: unknown) => {
|
||||
const input = installPreviewSchema.parse(value)
|
||||
const request = { package: input.package, name: input.name, destination: input.destination }
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.previewSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.previewInstall',
|
||||
request,
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-preview-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallPreviewSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:removeInstall', async (_event, value: unknown) => {
|
||||
const input = removeSchema.parse(value)
|
||||
const request = {
|
||||
operationId: randomUUID(),
|
||||
name: input.name,
|
||||
destination: input.destination,
|
||||
conflictResolution: input.conflictResolution
|
||||
}
|
||||
if (!input.environmentId) {
|
||||
return {
|
||||
status: 'ok' as const,
|
||||
value: await runtime.removeSharedSkillInstallRequest(request)
|
||||
}
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, input.environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
input.environmentId,
|
||||
'skills.removeInstall',
|
||||
request,
|
||||
5 * 60_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-remove-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: SkillInstallResultSchema.parse(response.result) }
|
||||
})
|
||||
ipcMain.handle('skills:listManagedInstalls', async (_event, environmentIdValue: unknown) => {
|
||||
const environmentId = environmentIdSchema.optional().parse(environmentIdValue)
|
||||
if (!environmentId) {
|
||||
return { status: 'ok' as const, value: await runtime.listManagedSkillInstalls() }
|
||||
}
|
||||
if (environmentId.startsWith('ssh:')) {
|
||||
const value = await runtime.listManagedSkillInstalls(environmentId.slice('ssh:'.length))
|
||||
return { status: 'ok' as const, value }
|
||||
}
|
||||
const userDataPath = app.getPath('userData')
|
||||
if (!(await supportsSkillRuntimeManagement(userDataPath, environmentId))) {
|
||||
return { status: 'unsupported' as const, message: SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE }
|
||||
}
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'skills.listManagedInstalls',
|
||||
{},
|
||||
30_000
|
||||
)
|
||||
if (response.ok !== true) {
|
||||
throw new Error(`skill-list-managed-remote-${response.error.code}`)
|
||||
}
|
||||
return { status: 'ok' as const, value: ManagedSkillInstallListSchema.parse(response.result) }
|
||||
})
|
||||
}
|
||||
@@ -3291,6 +3291,7 @@ export class OrcaRuntimeService {
|
||||
private artifactService: ArtifactCloudService | null = null
|
||||
private skillCloudService: SkillCloudService | null = null
|
||||
private skillUploadSessions: SkillUploadSessionService | null = null
|
||||
private readonly skillInstallOperations = new Map<string, AbortController>()
|
||||
private readonly claudeAgentTeams = new ClaudeAgentTeamsService()
|
||||
private mobileDictation: {
|
||||
id: string
|
||||
@@ -4649,6 +4650,37 @@ export class OrcaRuntimeService {
|
||||
async installSharedSkillRequest(
|
||||
request: SkillInstallRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<SkillInstallResult> {
|
||||
if (this.skillInstallOperations.has(request.operationId)) {
|
||||
throw new Error('skill-install-operation-in-progress')
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => controller.abort()
|
||||
if (signal?.aborted) {
|
||||
abort()
|
||||
} else {
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
}
|
||||
this.skillInstallOperations.set(request.operationId, controller)
|
||||
try {
|
||||
return await this.executeSharedSkillInstall(request, controller.signal)
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort)
|
||||
if (this.skillInstallOperations.get(request.operationId) === controller) {
|
||||
this.skillInstallOperations.delete(request.operationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelSharedSkillInstall(operationId: string): boolean {
|
||||
const operation = this.skillInstallOperations.get(operationId)
|
||||
operation?.abort()
|
||||
return Boolean(operation)
|
||||
}
|
||||
|
||||
private async executeSharedSkillInstall(
|
||||
request: SkillInstallRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<SkillInstallResult> {
|
||||
const runtimeId = this.getStatus().runtimeId
|
||||
const sshTarget = await this.resolveSkillSshTarget(request.destination)
|
||||
|
||||
@@ -14,6 +14,27 @@ class LineageError extends Error {
|
||||
}
|
||||
|
||||
describe('mapRuntimeError', () => {
|
||||
it('preserves the stable skill failure category and retryability across RPC', () => {
|
||||
expect(
|
||||
mapRuntimeError(
|
||||
'req_1',
|
||||
{ runtimeId: 'runtime-1' },
|
||||
new Error('skill-download-transport-failed')
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'skill_install_failure',
|
||||
message: 'skill-download-transport-failed',
|
||||
data: {
|
||||
category: 'transport',
|
||||
code: 'skill-download-transport-failed',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['terminal_tab_close_timeout', 'terminal_tab_not_found', 'terminal_tab_pinned'])(
|
||||
'preserves the durable terminal tab close failure %s',
|
||||
(code) => {
|
||||
|
||||
@@ -8,6 +8,10 @@ import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
|
||||
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
|
||||
import { AGENT_SESSION_RPC_ERROR_CODES } from '../../../shared/agent-session-host-authority'
|
||||
import { ARTIFACT_SHARING_DISABLED_CODE } from '../../../shared/artifact-sharing-gate'
|
||||
import {
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
classifySkillInstallFailureCode
|
||||
} from '../../../shared/skill-install-failure'
|
||||
|
||||
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
|
||||
return {
|
||||
@@ -98,7 +102,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'stale_delivery',
|
||||
'waiter_exists',
|
||||
'invalid_argument',
|
||||
ARTIFACT_SHARING_DISABLED_CODE
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
SKILL_INSTALL_RPC_ERROR_CODE
|
||||
])
|
||||
|
||||
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
|
||||
@@ -157,6 +162,16 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow
|
||||
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
|
||||
return errorResponse(id, meta, message, message)
|
||||
}
|
||||
const skillInstallFailure = classifySkillInstallFailureCode(message)
|
||||
if (skillInstallFailure) {
|
||||
return errorResponse(
|
||||
id,
|
||||
meta,
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
skillInstallFailure.code,
|
||||
skillInstallFailure
|
||||
)
|
||||
}
|
||||
if (message === 'invalid_terminal_send') {
|
||||
return errorResponse(id, meta, 'invalid_argument', 'Missing terminal send payload')
|
||||
}
|
||||
|
||||
@@ -111,6 +111,57 @@ describe('skills.install RPC', () => {
|
||||
await installMethod().handler(request, { runtime } as unknown as RpcContext)
|
||||
expect(installSharedSkillRequest).toHaveBeenCalledWith(request, undefined)
|
||||
})
|
||||
|
||||
it('downgrades cancelled results for clients without the additive result capability', async () => {
|
||||
const cancelled = {
|
||||
operationId: 'operation_1',
|
||||
status: 'cancelled' as const,
|
||||
name: 'example',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
placements: [],
|
||||
failure: {
|
||||
category: 'cancelled' as const,
|
||||
code: 'skill-install-cancelled',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
const runtime = { installSharedSkillRequest: vi.fn(async () => cancelled) }
|
||||
const request = {
|
||||
operationId: 'operation_1',
|
||||
package: {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
compressedBytes: 100
|
||||
},
|
||||
ingress: {
|
||||
kind: 'download-grant' as const,
|
||||
url: 'https://storage.googleapis.com/package',
|
||||
expiresAt: '2026-08-11T12:00:00.000Z'
|
||||
},
|
||||
destination: { scope: 'global' as const }
|
||||
}
|
||||
|
||||
await expect(
|
||||
installMethod().handler(request, { runtime, clientCapabilities: [] } as unknown as RpcContext)
|
||||
).resolves.toMatchObject({ status: 'failed', errorCategory: 'skill-install-cancelled' })
|
||||
await expect(
|
||||
installMethod().handler(request, {
|
||||
runtime,
|
||||
clientCapabilities: ['skills.install-result.v2']
|
||||
} as unknown as RpcContext)
|
||||
).resolves.toMatchObject({ status: 'cancelled', failure: { category: 'cancelled' } })
|
||||
})
|
||||
|
||||
it('routes cancellation to the destination runtime by operation ID', async () => {
|
||||
const cancelSharedSkillInstall = vi.fn(() => true)
|
||||
const result = await method('skills.cancelInstall').handler({ operationId: 'operation_1' }, {
|
||||
runtime: { cancelSharedSkillInstall }
|
||||
} as unknown as RpcContext)
|
||||
expect(result).toEqual({ cancelled: true })
|
||||
expect(cancelSharedSkillInstall).toHaveBeenCalledWith('operation_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('skill management RPC', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { z } from 'zod'
|
||||
import { SkillDiscoveryTargetSchema } from '../../../../shared/skills'
|
||||
import {
|
||||
SkillInstallPreviewRequestSchema,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
discoverSkillsOnTarget,
|
||||
resolveSkillDiscoveryTarget
|
||||
} from '../../../skills/skill-discovery-target'
|
||||
import { SKILL_INSTALL_RESULT_V2_CAPABILITY } from '../../../../shared/skill-install-capability'
|
||||
|
||||
export const SKILL_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -35,7 +37,28 @@ export const SKILL_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'skills.install',
|
||||
params: SkillInstallRequestSchema,
|
||||
handler: (params, { runtime, signal }) => runtime.installSharedSkillRequest(params, signal)
|
||||
handler: async (params, { runtime, signal, clientCapabilities }) => {
|
||||
const result = await runtime.installSharedSkillRequest(params, signal)
|
||||
if (
|
||||
result.status === 'cancelled' &&
|
||||
!clientCapabilities?.includes(SKILL_INSTALL_RESULT_V2_CAPABILITY)
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
status: 'failed' as const,
|
||||
errorCategory: result.failure?.code ?? 'skill-install-cancelled',
|
||||
failure: undefined
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.cancelInstall',
|
||||
params: z.object({ operationId: z.string().min(1).max(128) }).strict(),
|
||||
handler: (params, { runtime }) => ({
|
||||
cancelled: runtime.cancelSharedSkillInstall(params.operationId)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'skills.previewInstall',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { dirname, posix, resolve } from 'node:path'
|
||||
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
import { discoverSkills } from './discovery'
|
||||
import { discoverSkillsInWsl } from './skill-discovery-wsl'
|
||||
|
||||
function normalizedPath(path: string, wslDistro?: string): string {
|
||||
const normalized = wslDistro ? path.replace(/\/+$/, '') : resolve(path)
|
||||
return !wslDistro && process.platform === 'win32'
|
||||
? normalized.toLocaleLowerCase('en-US')
|
||||
: normalized
|
||||
}
|
||||
|
||||
function placementIsDiscovered(
|
||||
discovery: SkillDiscoveryResult,
|
||||
placement: SkillPlacementResult,
|
||||
skillName: string,
|
||||
wslDistro?: string
|
||||
): boolean {
|
||||
const placementPath = normalizedPath(placement.path, wslDistro)
|
||||
const placementRoot = normalizedPath(
|
||||
wslDistro ? posix.dirname(placement.path) : dirname(placement.path),
|
||||
wslDistro
|
||||
)
|
||||
return discovery.skills.some((skill) => {
|
||||
if (skill.name !== skillName) {
|
||||
return false
|
||||
}
|
||||
if (normalizedPath(skill.directoryPath, wslDistro) === placementPath) {
|
||||
return true
|
||||
}
|
||||
return (skill.rootPaths ?? [skill.rootPath]).some(
|
||||
(root) => normalizedPath(root, wslDistro) === placementRoot
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function discoverInstalledSkill(input: {
|
||||
scope: 'global' | 'workspace'
|
||||
homeDirectory: string
|
||||
workspaceDirectory?: string
|
||||
wslDistro?: string
|
||||
}): Promise<SkillDiscoveryResult> {
|
||||
if (input.wslDistro) {
|
||||
return discoverSkillsInWsl({
|
||||
distro: input.wslDistro,
|
||||
homeDir: input.homeDirectory,
|
||||
cwd: input.workspaceDirectory ?? input.homeDirectory
|
||||
})
|
||||
}
|
||||
return discoverSkills({
|
||||
homeDir: input.homeDirectory,
|
||||
repos: [],
|
||||
...(input.scope === 'workspace' && input.workspaceDirectory
|
||||
? { cwd: input.workspaceDirectory }
|
||||
: { includeCwd: false })
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifySkillInstallDiscovery(input: {
|
||||
result: SkillInstallResult
|
||||
scope: 'global' | 'workspace'
|
||||
homeDirectory: string
|
||||
workspaceDirectory?: string
|
||||
wslDistro?: string
|
||||
discover?: () => Promise<SkillDiscoveryResult>
|
||||
}): Promise<SkillInstallResult> {
|
||||
const discovery = await (input.discover?.() ?? discoverInstalledSkill(input)).catch(() => null)
|
||||
if (!discovery) {
|
||||
return {
|
||||
...input.result,
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-discovery-verification-failed',
|
||||
failure: {
|
||||
category: 'provider-placement',
|
||||
code: 'skill-discovery-verification-failed',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placements = input.result.placements.map((placement) => {
|
||||
if (
|
||||
(placement.status !== 'installed' && placement.status !== 'unchanged') ||
|
||||
placementIsDiscovered(discovery, placement, input.result.name, input.wslDistro)
|
||||
) {
|
||||
return placement
|
||||
}
|
||||
return {
|
||||
...placement,
|
||||
status: 'failed' as const,
|
||||
errorCategory: 'skill-discovery-placement-missing',
|
||||
failure: {
|
||||
category: 'provider-placement' as const,
|
||||
code: 'skill-discovery-placement-missing',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
})
|
||||
const canonical = placements.find((placement) => placement.topology === 'canonical-copy')
|
||||
if (!canonical || canonical.status === 'failed') {
|
||||
return {
|
||||
...input.result,
|
||||
status: 'failed',
|
||||
placements,
|
||||
errorCategory: 'skill-discovery-canonical-missing',
|
||||
failure: {
|
||||
category: 'provider-placement',
|
||||
code: 'skill-discovery-canonical-missing',
|
||||
retryable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...input.result,
|
||||
status: placements.some(
|
||||
(placement) => placement.status === 'failed' || placement.status === 'skipped'
|
||||
)
|
||||
? 'partial'
|
||||
: input.result.status,
|
||||
placements
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { SKILL_INSTALL_BUSY_FAILURE } from '../../shared/skill-install-failure'
|
||||
import { SkillInstallOperationError } from './skill-install-operation-error'
|
||||
|
||||
const LOCK_RETRY_MS = 50
|
||||
const LOCK_STALE_MS = 30 * 60 * 1000
|
||||
@@ -74,7 +76,7 @@ export async function acquireSkillInstallLock(input: {
|
||||
}
|
||||
await removeStaleLock(input.path)
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('skill-install-busy')
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_BUSY_FAILURE)
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, LOCK_RETRY_MS))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
SkillInstallFailureSchema,
|
||||
classifySkillInstallFailureCode,
|
||||
type SkillInstallFailure
|
||||
} from '../../shared/skill-install-failure'
|
||||
|
||||
export class SkillInstallOperationError extends Error {
|
||||
readonly code = SKILL_INSTALL_RPC_ERROR_CODE
|
||||
readonly data: SkillInstallFailure
|
||||
|
||||
constructor(failure: SkillInstallFailure, options?: ErrorOptions) {
|
||||
super(failure.code, options)
|
||||
this.name = 'SkillInstallOperationError'
|
||||
this.data = SkillInstallFailureSchema.parse(failure)
|
||||
}
|
||||
}
|
||||
|
||||
export function skillInstallFailureFromError(error: unknown): SkillInstallFailure | null {
|
||||
if (error instanceof SkillInstallOperationError) {
|
||||
return error.data
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const classified = classifySkillInstallFailureCode(error.message)
|
||||
if (classified) {
|
||||
return classified
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code) {
|
||||
return {
|
||||
category: 'filesystem',
|
||||
code: 'skill-install-filesystem-failed',
|
||||
retryable: code === 'EBUSY' || code === 'EACCES' || code === 'EPERM'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!error || typeof error !== 'object' || !('data' in error)) {
|
||||
return null
|
||||
}
|
||||
const parsed = SkillInstallFailureSchema.safeParse((error as { data: unknown }).data)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { 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 { createSkillPackageArchive } from './skill-package-creation'
|
||||
import { extractSkillPackageArchive } from './skill-package-extraction'
|
||||
import {
|
||||
readSkillInstallReceipt,
|
||||
writeSkillInstallReceipt,
|
||||
writeSkillStateFile,
|
||||
type SkillInstallReceiptV1
|
||||
} from './skill-install-provenance'
|
||||
import {
|
||||
recoverSkillInstallTransaction,
|
||||
skillInstallJournalPath,
|
||||
type SkillInstallJournalV1
|
||||
} from './skill-install-recovery'
|
||||
import { installLocalSkillPackage } from './skill-install-transaction'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function packageVersion(root: string, versionId: string, body: string) {
|
||||
const source = join(root, `source-${versionId}`)
|
||||
await mkdir(source)
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
`---\nname: recovery-skill\ndescription: Recovery\n---\n\n${body}\n`
|
||||
)
|
||||
return createSkillPackageArchive({
|
||||
sourceDirectory: source,
|
||||
archivePath: join(root, `${versionId}.tar.gz`),
|
||||
packageId: 'package_1',
|
||||
versionId
|
||||
})
|
||||
}
|
||||
|
||||
async function interruptedUpdate(phase: SkillInstallJournalV1['phase']): Promise<{
|
||||
root: string
|
||||
canonicalPath: string
|
||||
stateDirectory: string
|
||||
journal: SkillInstallJournalV1
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-recovery-test-'))
|
||||
roots.push(root)
|
||||
const first = await packageVersion(root, 'version_1', '# First')
|
||||
const second = await packageVersion(root, 'version_2', '# Second')
|
||||
const destinationRoot = join(root, 'skills')
|
||||
const stateDirectory = join(root, 'state')
|
||||
const input = {
|
||||
operationId: 'install-first',
|
||||
archivePath: first.archivePath,
|
||||
destinationRoot,
|
||||
stateDirectory,
|
||||
scope: 'global' as const,
|
||||
destinationIdentity: 'global:test',
|
||||
hostIdentity: 'test',
|
||||
expectedPackageDigest: first.manifest.packageDigest
|
||||
}
|
||||
await installLocalSkillPackage(input)
|
||||
const canonicalPath = join(destinationRoot, 'recovery-skill')
|
||||
const previous = await readSkillInstallReceipt(stateDirectory, canonicalPath)
|
||||
if (!previous) {
|
||||
throw new Error('fixture receipt missing')
|
||||
}
|
||||
const extractionPath = join(destinationRoot, '.orca-skill-extract-recovery')
|
||||
const stagingPath = join(destinationRoot, '.recovery-skill.orca-staging-recovery')
|
||||
const backupPath = join(destinationRoot, '.recovery-skill.orca-backup-recovery')
|
||||
await extractSkillPackageArchive({
|
||||
archivePath: second.archivePath,
|
||||
destinationDirectory: extractionPath,
|
||||
expectedPackageDigest: second.manifest.packageDigest
|
||||
})
|
||||
await rename(join(extractionPath, 'skill'), stagingPath)
|
||||
await rename(canonicalPath, backupPath)
|
||||
await rename(stagingPath, canonicalPath)
|
||||
const receipt: SkillInstallReceiptV1 = {
|
||||
...previous,
|
||||
versionId: second.manifest.versionId,
|
||||
packageDigest: second.manifest.packageDigest,
|
||||
archiveSha256: second.archiveSha256,
|
||||
previousVersionId: previous.versionId,
|
||||
installedAt: new Date().toISOString(),
|
||||
fileModes: second.manifest.files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable
|
||||
}))
|
||||
}
|
||||
const journal: SkillInstallJournalV1 = {
|
||||
schemaVersion: 1,
|
||||
operation: 'install',
|
||||
phase,
|
||||
canonicalPath,
|
||||
extractionPath,
|
||||
stagingPath,
|
||||
backupPath,
|
||||
backupDigest: first.manifest.packageDigest,
|
||||
stagingFileModes: second.manifest.files,
|
||||
backupFileModes: first.manifest.files,
|
||||
receipt
|
||||
}
|
||||
await writeSkillStateFile(skillInstallJournalPath(stateDirectory, canonicalPath), journal)
|
||||
return { root, canonicalPath, stateDirectory, journal }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill install recovery', () => {
|
||||
it('publishes provenance for a placed destination that has no new receipt', async () => {
|
||||
const fixture = await interruptedUpdate('canonical-placed')
|
||||
|
||||
await recoverSkillInstallTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
|
||||
expect(
|
||||
(await readSkillInstallReceipt(fixture.stateDirectory, fixture.canonicalPath))?.versionId
|
||||
).toBe('version_2')
|
||||
expect(await readFile(join(fixture.canonicalPath, 'SKILL.md'), 'utf8')).toContain('# Second')
|
||||
await expect(readFile(fixture.journal.backupPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await expect(
|
||||
readFile(skillInstallJournalPath(fixture.stateDirectory, fixture.canonicalPath))
|
||||
).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('finalizes a published receipt with an incomplete journal', async () => {
|
||||
const fixture = await interruptedUpdate('receipt-published')
|
||||
await writeSkillInstallReceipt(fixture.stateDirectory, fixture.journal.receipt)
|
||||
|
||||
await recoverSkillInstallTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
|
||||
expect(
|
||||
(await readSkillInstallReceipt(fixture.stateDirectory, fixture.canonicalPath))?.versionId
|
||||
).toBe('version_2')
|
||||
await expect(
|
||||
readFile(skillInstallJournalPath(fixture.stateDirectory, fixture.canonicalPath))
|
||||
).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects journal paths outside the canonical parent without removing them', async () => {
|
||||
const fixture = await interruptedUpdate('canonical-placed')
|
||||
const outside = join(fixture.root, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'keep.txt'), 'keep')
|
||||
await writeSkillStateFile(
|
||||
skillInstallJournalPath(fixture.stateDirectory, fixture.canonicalPath),
|
||||
{
|
||||
...fixture.journal,
|
||||
backupPath: outside
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
recoverSkillInstallTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
).rejects.toThrow('skill-install-journal-invalid')
|
||||
expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
})
|
||||
|
||||
it('preserves a journal path whose bytes no longer match the recorded identity', async () => {
|
||||
const fixture = await interruptedUpdate('canonical-placed')
|
||||
await writeFile(join(fixture.journal.backupPath, 'local.md'), 'changed after crash')
|
||||
|
||||
await expect(
|
||||
recoverSkillInstallTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
).rejects.toThrow('skill-install-recovery-conflict')
|
||||
expect(await readFile(join(fixture.journal.backupPath, 'local.md'), 'utf8')).toBe(
|
||||
'changed after crash'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lstat, readFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import {
|
||||
skillInstallStateKey,
|
||||
writeSkillInstallReceipt,
|
||||
@@ -26,6 +26,9 @@ export type SkillInstallJournalV1 = {
|
||||
extractionPath: string
|
||||
stagingPath: string
|
||||
backupPath: string
|
||||
backupDigest: string | null
|
||||
stagingFileModes: SkillInstalledFileMode[]
|
||||
backupFileModes: SkillInstalledFileMode[]
|
||||
receipt: SkillInstallReceiptV1
|
||||
}
|
||||
|
||||
@@ -42,15 +45,33 @@ function isInstallJournal(value: unknown, canonicalPath: string): value is Skill
|
||||
return false
|
||||
}
|
||||
const journal = value as Partial<SkillInstallJournalV1>
|
||||
const parent = dirname(resolve(canonicalPath))
|
||||
const name = basename(canonicalPath)
|
||||
const ownedPath = (path: unknown, prefix: string): path is string =>
|
||||
typeof path === 'string' &&
|
||||
dirname(resolve(path)) === parent &&
|
||||
basename(path).startsWith(prefix)
|
||||
return (
|
||||
journal.schemaVersion === 1 &&
|
||||
journal.operation === 'install' &&
|
||||
typeof journal.phase === 'string' &&
|
||||
journal.canonicalPath === canonicalPath &&
|
||||
typeof journal.extractionPath === 'string' &&
|
||||
typeof journal.stagingPath === 'string' &&
|
||||
typeof journal.backupPath === 'string' &&
|
||||
Boolean(journal.receipt)
|
||||
ownedPath(journal.extractionPath, '.orca-skill-extract-') &&
|
||||
ownedPath(journal.stagingPath, `.${name}.orca-staging-`) &&
|
||||
ownedPath(journal.backupPath, `.${name}.orca-backup-`) &&
|
||||
journal.extractionPath !== journal.stagingPath &&
|
||||
journal.extractionPath !== journal.backupPath &&
|
||||
journal.stagingPath !== journal.backupPath &&
|
||||
(journal.backupDigest === null ||
|
||||
(typeof journal.backupDigest === 'string' && /^[a-f0-9]{64}$/.test(journal.backupDigest))) &&
|
||||
Array.isArray(journal.stagingFileModes) &&
|
||||
Array.isArray(journal.backupFileModes) &&
|
||||
[...journal.stagingFileModes, ...journal.backupFileModes].every(
|
||||
(file) => typeof file.path === 'string' && typeof file.executable === 'boolean'
|
||||
) &&
|
||||
Boolean(journal.receipt) &&
|
||||
journal.receipt?.canonicalPath === canonicalPath &&
|
||||
typeof journal.receipt?.packageDigest === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,8 +113,36 @@ export async function cleanSkillInstallJournalFiles(
|
||||
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
|
||||
): Promise<void> {
|
||||
await filesystem.remove(journal.extractionPath)
|
||||
await filesystem.remove(journal.stagingPath)
|
||||
await filesystem.remove(journal.backupPath)
|
||||
await removeJournalOwnedSkill(
|
||||
journal.stagingPath,
|
||||
journal.receipt.packageDigest,
|
||||
journal.stagingFileModes,
|
||||
filesystem
|
||||
)
|
||||
await removeJournalOwnedSkill(
|
||||
journal.backupPath,
|
||||
journal.backupDigest,
|
||||
journal.backupFileModes,
|
||||
filesystem
|
||||
)
|
||||
}
|
||||
|
||||
async function removeJournalOwnedSkill(
|
||||
path: string,
|
||||
expectedDigest: string | null,
|
||||
files: readonly SkillInstalledFileMode[],
|
||||
filesystem: SkillInstallFilesystem
|
||||
): Promise<void> {
|
||||
if (!(await skillInstallPathExists(path))) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!expectedDigest ||
|
||||
!(await skillInstallDestinationMatches(path, expectedDigest, filesystem, files))
|
||||
) {
|
||||
throw new Error('skill-install-recovery-conflict')
|
||||
}
|
||||
await filesystem.remove(path)
|
||||
}
|
||||
|
||||
export async function recoverSkillInstallTransaction(
|
||||
@@ -123,6 +172,17 @@ export async function recoverSkillInstallTransaction(
|
||||
return
|
||||
}
|
||||
if (!destinationExists && backupExists) {
|
||||
if (
|
||||
!journal.backupDigest ||
|
||||
!(await skillInstallDestinationMatches(
|
||||
journal.backupPath,
|
||||
journal.backupDigest,
|
||||
filesystem,
|
||||
journal.backupFileModes
|
||||
))
|
||||
) {
|
||||
throw new Error('skill-install-recovery-conflict')
|
||||
}
|
||||
await filesystem.rename(journal.backupPath, canonicalPath)
|
||||
await filesystem.remove(journal.extractionPath)
|
||||
await filesystem.remove(journal.stagingPath)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -90,4 +90,23 @@ describe('executeSkillInstallRequest', () => {
|
||||
)
|
||||
).rejects.toThrow('skill-install-local-ingress-rejected')
|
||||
})
|
||||
|
||||
it('returns a structured cancelled result without leaving partial ingress bytes', async () => {
|
||||
const { request, dependencies } = await fixture()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const result = await executeSkillInstallRequest(request, {
|
||||
...dependencies,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'cancelled',
|
||||
errorCategory: 'skill-download-cancelled',
|
||||
failure: { category: 'cancelled', retryable: true }
|
||||
})
|
||||
const downloads = join(dependencies.stateDirectory, 'skill-installs', 'downloads')
|
||||
expect(await readdir(downloads).catch(() => [])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { installSharedSkill } from './skill-install-service'
|
||||
import { downloadSkillPackageGrant } from './skill-package-download'
|
||||
import { detectSkillProvidersInWsl } from './skill-wsl-provider-detection'
|
||||
import { createWslSkillInstallFilesystem } from './skill-wsl-install-filesystem'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
|
||||
type StagedSkillPackage = {
|
||||
archivePath: string
|
||||
@@ -71,8 +72,9 @@ export async function executeSkillInstallRequest(
|
||||
request.destination,
|
||||
dependencies.authority
|
||||
)
|
||||
const ingress = await resolveIngress(request, dependencies)
|
||||
let ingress: StagedSkillPackage | null = null
|
||||
try {
|
||||
ingress = await resolveIngress(request, dependencies)
|
||||
const detectedProviders = destination.wslDistro
|
||||
? await detectSkillProvidersInWsl(destination.wslDistro)
|
||||
: await dependencies.detectProviders()
|
||||
@@ -99,9 +101,24 @@ export async function executeSkillInstallRequest(
|
||||
expectedVersionId: request.package.versionId,
|
||||
conflictResolution: request.conflictResolution,
|
||||
filesystem,
|
||||
wslDistro: destination.wslDistro
|
||||
wslDistro: destination.wslDistro,
|
||||
signal: dependencies.signal
|
||||
})
|
||||
} catch (error) {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (failure?.category !== 'cancelled') {
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
operationId: request.operationId,
|
||||
status: 'cancelled',
|
||||
name: request.package.packageId,
|
||||
packageDigest: request.package.packageDigest,
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}
|
||||
} finally {
|
||||
await ingress.cleanup()
|
||||
await ingress?.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,24 @@ describe('skill install service', () => {
|
||||
expect(await readFile(join(claude, 'SKILL.md'), 'utf8')).toBe('unowned')
|
||||
})
|
||||
|
||||
it('does not report success when normal skill discovery cannot observe the canonical copy', async () => {
|
||||
const { root, input } = await fixture()
|
||||
|
||||
const result = await installSharedSkill({
|
||||
...input,
|
||||
discover: async () => ({ skills: [], sources: [], scannedAt: Date.now() })
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-discovery-canonical-missing',
|
||||
failure: { category: 'provider-placement', retryable: true }
|
||||
})
|
||||
expect(
|
||||
await readFile(join(root, 'home', '.agents', 'skills', 'test-skill', 'SKILL.md'), 'utf8')
|
||||
).toContain('# Test')
|
||||
})
|
||||
|
||||
it('removes the canonical skill and its owned provider alias', async () => {
|
||||
const { root, input } = await fixture()
|
||||
await installSharedSkill(input)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { reconcileSkillProviderPlacement } from './skill-placement-reconciliatio
|
||||
import { resolveSkillProviderDestinations } 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'
|
||||
|
||||
export type SkillInstallServiceInput = Omit<
|
||||
LocalSkillInstallInput,
|
||||
@@ -22,6 +24,8 @@ export type SkillInstallServiceInput = Omit<
|
||||
detectedProviders: readonly string[]
|
||||
filesystem?: SkillInstallFilesystem
|
||||
wslDistro?: string
|
||||
discover?: () => Promise<SkillDiscoveryResult>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type SkillRemoveServiceInput = {
|
||||
@@ -63,7 +67,8 @@ function localInput(input: SkillInstallServiceInput): LocalSkillInstallInput {
|
||||
expectedVersionId: input.expectedVersionId,
|
||||
conflictResolution: input.conflictResolution,
|
||||
filesystem: input.filesystem,
|
||||
wslDistro: input.wslDistro
|
||||
wslDistro: input.wslDistro,
|
||||
signal: input.signal
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +91,12 @@ export async function installSharedSkill(
|
||||
preview.canonicalPath
|
||||
)
|
||||
const result = await installLocalSkillPackage(request)
|
||||
if (!result.canonicalPath || result.status === 'conflict' || result.status === 'failed') {
|
||||
if (
|
||||
!result.canonicalPath ||
|
||||
result.status === 'conflict' ||
|
||||
result.status === 'failed' ||
|
||||
result.status === 'cancelled'
|
||||
) {
|
||||
return result
|
||||
}
|
||||
const placements: SkillPlacementResult[] = [canonicalPlacement(result)]
|
||||
@@ -97,6 +107,21 @@ export async function installSharedSkill(
|
||||
detectedProviders: input.detectedProviders
|
||||
})
|
||||
for (const destination of destinations) {
|
||||
if (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,
|
||||
@@ -118,7 +143,14 @@ export async function installSharedSkill(
|
||||
const incomplete = placements.some(
|
||||
(placement) => placement.status === 'failed' || placement.status === 'skipped'
|
||||
)
|
||||
return { ...result, status: incomplete ? 'partial' : result.status, placements }
|
||||
return verifySkillInstallDiscovery({
|
||||
result: { ...result, status: incomplete ? 'partial' : result.status, placements },
|
||||
scope: input.scope,
|
||||
homeDirectory: input.homeDirectory,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
wslDistro: input.wslDistro,
|
||||
discover: input.discover
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeSharedSkill(
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SkillInstallResult } from '../../shared/skill-install-contract'
|
||||
import type { SkillPackageManifestV1 } from '../../shared/skill-package-manifest'
|
||||
import { skillInstallFailureFromError } from './skill-install-operation-error'
|
||||
import type { SkillCanonicalState } from './skill-install-planner'
|
||||
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
|
||||
import type { LocalSkillInstallInput } from './skill-install-transaction'
|
||||
|
||||
export function skillInstallConflictResult(
|
||||
operationId: string,
|
||||
manifest: SkillPackageManifestV1,
|
||||
state: SkillCanonicalState,
|
||||
code = `skill-install-conflict-${state.kind}`
|
||||
): SkillInstallResult {
|
||||
const kind =
|
||||
state.kind === 'modified' ||
|
||||
state.kind === 'unowned' ||
|
||||
state.kind === 'external-link' ||
|
||||
state.kind === 'name-collision'
|
||||
? state.kind
|
||||
: 'modified'
|
||||
return {
|
||||
operationId,
|
||||
status: 'conflict',
|
||||
name: manifest.name,
|
||||
packageDigest: manifest.packageDigest,
|
||||
placements: [],
|
||||
conflict: {
|
||||
kind,
|
||||
...('digest' in state && state.digest ? { existingDigest: state.digest } : {})
|
||||
},
|
||||
errorCategory: code,
|
||||
failure: { category: 'conflict', code, retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function skillInstallFailureResult(
|
||||
input: LocalSkillInstallInput,
|
||||
manifest: SkillPackageManifestV1,
|
||||
canonicalPath: string,
|
||||
error: unknown
|
||||
): SkillInstallResult | null {
|
||||
const failure = skillInstallFailureFromError(error)
|
||||
if (!failure || (failure.code !== 'skill-install-busy' && failure.category !== 'cancelled')) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
operationId: input.operationId,
|
||||
status: failure.category === 'cancelled' ? 'cancelled' : 'failed',
|
||||
name: manifest.name,
|
||||
packageDigest: manifest.packageDigest,
|
||||
canonicalPath,
|
||||
placements: [],
|
||||
errorCategory: failure.code,
|
||||
failure
|
||||
}
|
||||
}
|
||||
|
||||
export function skillInstallReplacementAllowed(
|
||||
state: SkillCanonicalState,
|
||||
input: LocalSkillInstallInput
|
||||
): boolean {
|
||||
if (state.kind === 'missing' || state.kind === 'clean-update') {
|
||||
return input.conflictResolution !== 'cancel'
|
||||
}
|
||||
return (
|
||||
(state.kind === 'modified' || state.kind === 'unowned') &&
|
||||
input.conflictResolution === 'replace-and-discard-local'
|
||||
)
|
||||
}
|
||||
|
||||
export function createSkillInstallReceipt(input: {
|
||||
request: LocalSkillInstallInput
|
||||
manifest: SkillPackageManifestV1
|
||||
archiveSha256: string
|
||||
canonicalPath: string
|
||||
previous: SkillInstallReceiptV1 | null
|
||||
}): SkillInstallReceiptV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
packageId: input.manifest.packageId,
|
||||
versionId: input.manifest.versionId,
|
||||
packageDigest: input.manifest.packageDigest,
|
||||
archiveSha256: input.archiveSha256,
|
||||
scope: input.request.scope,
|
||||
destinationIdentity: input.request.destinationIdentity,
|
||||
canonicalPath: input.canonicalPath,
|
||||
placements: [
|
||||
{
|
||||
provider: 'agent-skills',
|
||||
path: input.canonicalPath,
|
||||
topology: 'canonical-copy',
|
||||
status: 'installed'
|
||||
}
|
||||
],
|
||||
...(input.previous ? { previousVersionId: input.previous.versionId } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
hostIdentity: input.request.hostIdentity,
|
||||
fileModes: input.manifest.files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable
|
||||
})),
|
||||
...(input.request.wslDistro ? { wslDistro: input.request.wslDistro } : {})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type LocalSkillInstallInput
|
||||
} from './skill-install-transaction'
|
||||
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
|
||||
import { acquireSkillInstallLock } from './skill-install-lock'
|
||||
import { skillInstallStateKey } from './skill-install-provenance'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
@@ -147,4 +149,89 @@ describe('skill install transaction', () => {
|
||||
'# Second'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a retryable failure when another process owns the destination lock', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const archive = await packageVersion(root, 'version_1', '# First')
|
||||
const canonicalPath = join(root, 'skills', 'test-skill')
|
||||
const lockPath = join(root, 'state', 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
|
||||
const release = await acquireSkillInstallLock({ path: lockPath })
|
||||
try {
|
||||
const result = await installLocalSkillPackage(
|
||||
installInput(root, archive, { lockTimeoutMs: 1 })
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-install-busy',
|
||||
failure: { category: 'filesystem', retryable: true }
|
||||
})
|
||||
expect(
|
||||
(await readdir(join(root, 'skills'))).filter((name) => name.includes('extract'))
|
||||
).toEqual([])
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates a plan when destination state changes immediately before commit', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const archive = await packageVersion(root, 'version_1', '# Cloud')
|
||||
const canonicalPath = join(root, 'skills', 'test-skill')
|
||||
let injected = false
|
||||
const changingFilesystem = {
|
||||
...nativeSkillInstallFilesystem,
|
||||
rename: async (source: string, target: string): Promise<void> => {
|
||||
await nativeSkillInstallFilesystem.rename(source, target)
|
||||
if (!injected && target.includes('.orca-staging-')) {
|
||||
injected = true
|
||||
await mkdir(canonicalPath)
|
||||
await writeFile(join(canonicalPath, 'SKILL.md'), 'local content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await installLocalSkillPackage(
|
||||
installInput(root, archive, { filesystem: changingFilesystem })
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'conflict',
|
||||
errorCategory: 'skill-install-conflict-stale-preview'
|
||||
})
|
||||
expect(await readFile(join(canonicalPath, 'SKILL.md'), 'utf8')).toBe('local content')
|
||||
expect((await readdir(join(root, 'skills'))).filter((name) => name.includes('.orca-'))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the old version when cancellation arrives before canonical placement', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const first = await packageVersion(root, 'version_1', '# First')
|
||||
const second = await packageVersion(root, 'version_2', '# Second')
|
||||
await installLocalSkillPackage(installInput(root, first))
|
||||
const controller = new AbortController()
|
||||
const cancellingFilesystem = {
|
||||
...nativeSkillInstallFilesystem,
|
||||
rename: async (source: string, target: string): Promise<void> => {
|
||||
await nativeSkillInstallFilesystem.rename(source, target)
|
||||
if (target.includes('.orca-backup-')) {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await installLocalSkillPackage(
|
||||
installInput(root, second, { filesystem: cancellingFilesystem, signal: controller.signal })
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'cancelled',
|
||||
failure: { category: 'cancelled', retryable: true }
|
||||
})
|
||||
expect(await readFile(join(root, 'skills', 'test-skill', 'SKILL.md'), 'utf8')).toContain(
|
||||
'# First'
|
||||
)
|
||||
await expect(lstat(join(root, 'state', 'journals'))).resolves.toBeDefined()
|
||||
expect(await readdir(join(root, 'state', 'journals'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
readSkillInstallReceipt,
|
||||
skillInstallStateKey,
|
||||
writeSkillInstallReceipt,
|
||||
writeSkillStateFile,
|
||||
type SkillInstallReceiptV1
|
||||
writeSkillStateFile
|
||||
} from './skill-install-provenance'
|
||||
import { extractSkillPackageArchive } from './skill-package-extraction'
|
||||
import { recoverSkillRemovalTransaction } from './skill-remove-transaction'
|
||||
@@ -26,6 +25,14 @@ import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from './skill-install-filesystem'
|
||||
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
|
||||
import { SkillInstallOperationError } from './skill-install-operation-error'
|
||||
import {
|
||||
createSkillInstallReceipt,
|
||||
skillInstallConflictResult,
|
||||
skillInstallFailureResult,
|
||||
skillInstallReplacementAllowed
|
||||
} from './skill-install-transaction-result'
|
||||
|
||||
export { recoverSkillInstallTransaction } from './skill-install-recovery'
|
||||
|
||||
@@ -44,6 +51,8 @@ export type LocalSkillInstallInput = {
|
||||
conflictResolution?: 'replace-unmodified' | 'replace-and-discard-local' | 'cancel'
|
||||
filesystem?: SkillInstallFilesystem
|
||||
wslDistro?: string
|
||||
signal?: AbortSignal
|
||||
lockTimeoutMs?: number
|
||||
}
|
||||
|
||||
export type LocalSkillInstallPreview = {
|
||||
@@ -56,31 +65,6 @@ function lockPath(stateDirectory: string, canonicalPath: string): string {
|
||||
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
|
||||
}
|
||||
|
||||
function conflictResult(
|
||||
operationId: string,
|
||||
manifest: SkillPackageManifestV1,
|
||||
state: SkillCanonicalState
|
||||
): SkillInstallResult {
|
||||
const kind =
|
||||
state.kind === 'modified' ||
|
||||
state.kind === 'unowned' ||
|
||||
state.kind === 'external-link' ||
|
||||
state.kind === 'name-collision'
|
||||
? state.kind
|
||||
: 'modified'
|
||||
return {
|
||||
operationId,
|
||||
status: 'conflict',
|
||||
name: manifest.name,
|
||||
packageDigest: manifest.packageDigest,
|
||||
placements: [],
|
||||
conflict: {
|
||||
kind,
|
||||
...('digest' in state && state.digest ? { existingDigest: state.digest } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectExtractedPackage(input: LocalSkillInstallInput): Promise<{
|
||||
extractionPath: string
|
||||
manifest: SkillPackageManifestV1
|
||||
@@ -95,11 +79,18 @@ async function inspectExtractedPackage(input: LocalSkillInstallInput): Promise<{
|
||||
expectedPackageDigest: input.expectedPackageDigest,
|
||||
expectedPackageId: input.expectedPackageId,
|
||||
expectedVersionId: input.expectedVersionId,
|
||||
filesystem: input.filesystem
|
||||
filesystem: input.filesystem,
|
||||
signal: input.signal
|
||||
})
|
||||
return { extractionPath, manifest: extracted.manifest, archiveSha256: extracted.archiveSha256 }
|
||||
}
|
||||
|
||||
function throwIfCancelled(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewLocalSkillPackage(
|
||||
input: LocalSkillInstallInput
|
||||
): Promise<LocalSkillInstallPreview> {
|
||||
@@ -121,64 +112,23 @@ export async function previewLocalSkillPackage(
|
||||
}
|
||||
}
|
||||
|
||||
function replacementAllowed(state: SkillCanonicalState, input: LocalSkillInstallInput): boolean {
|
||||
if (state.kind === 'missing' || state.kind === 'clean-update') {
|
||||
return input.conflictResolution !== 'cancel'
|
||||
}
|
||||
return (
|
||||
(state.kind === 'modified' || state.kind === 'unowned') &&
|
||||
input.conflictResolution === 'replace-and-discard-local'
|
||||
)
|
||||
}
|
||||
|
||||
function createReceipt(input: {
|
||||
request: LocalSkillInstallInput
|
||||
manifest: SkillPackageManifestV1
|
||||
archiveSha256: string
|
||||
canonicalPath: string
|
||||
previous: SkillInstallReceiptV1 | null
|
||||
}): SkillInstallReceiptV1 {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
packageId: input.manifest.packageId,
|
||||
versionId: input.manifest.versionId,
|
||||
packageDigest: input.manifest.packageDigest,
|
||||
archiveSha256: input.archiveSha256,
|
||||
scope: input.request.scope,
|
||||
destinationIdentity: input.request.destinationIdentity,
|
||||
canonicalPath: input.canonicalPath,
|
||||
placements: [
|
||||
{
|
||||
provider: 'agent-skills',
|
||||
path: input.canonicalPath,
|
||||
topology: 'canonical-copy',
|
||||
status: 'installed'
|
||||
}
|
||||
],
|
||||
...(input.previous ? { previousVersionId: input.previous.versionId } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
hostIdentity: input.request.hostIdentity,
|
||||
fileModes: input.manifest.files.map((file) => ({
|
||||
path: file.path,
|
||||
executable: file.executable
|
||||
})),
|
||||
...(input.request.wslDistro ? { wslDistro: input.request.wslDistro } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function installLocalSkillPackage(
|
||||
input: LocalSkillInstallInput
|
||||
): Promise<SkillInstallResult> {
|
||||
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
|
||||
const extracted = await inspectExtractedPackage(input)
|
||||
const canonicalPath = join(input.destinationRoot, extracted.manifest.name)
|
||||
const releaseLock = await acquireSkillInstallLock({
|
||||
path: lockPath(input.stateDirectory, canonicalPath)
|
||||
})
|
||||
let releaseLock: (() => Promise<void>) | null = null
|
||||
let journal: SkillInstallJournalV1 | null = null
|
||||
try {
|
||||
throwIfCancelled(input.signal)
|
||||
releaseLock = await acquireSkillInstallLock({
|
||||
path: lockPath(input.stateDirectory, canonicalPath),
|
||||
timeoutMs: input.lockTimeoutMs
|
||||
})
|
||||
await recoverSkillRemovalTransaction(input.stateDirectory, canonicalPath, filesystem)
|
||||
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem)
|
||||
throwIfCancelled(input.signal)
|
||||
const previous = await readSkillInstallReceipt(input.stateDirectory, canonicalPath)
|
||||
const state = await inspectSkillCanonicalState({
|
||||
canonicalPath,
|
||||
@@ -186,7 +136,7 @@ export async function installLocalSkillPackage(
|
||||
receipt: previous,
|
||||
filesystem
|
||||
})
|
||||
const receipt = createReceipt({
|
||||
const receipt = createSkillInstallReceipt({
|
||||
request: input,
|
||||
manifest: extracted.manifest,
|
||||
archiveSha256: extracted.archiveSha256,
|
||||
@@ -194,6 +144,7 @@ export async function installLocalSkillPackage(
|
||||
previous
|
||||
})
|
||||
if (state.kind === 'unchanged') {
|
||||
throwIfCancelled(input.signal)
|
||||
await writeSkillInstallReceipt(input.stateDirectory, receipt)
|
||||
return {
|
||||
operationId: input.operationId,
|
||||
@@ -207,9 +158,10 @@ export async function installLocalSkillPackage(
|
||||
}))
|
||||
}
|
||||
}
|
||||
if (!replacementAllowed(state, input)) {
|
||||
return conflictResult(input.operationId, extracted.manifest, state)
|
||||
if (!skillInstallReplacementAllowed(state, input)) {
|
||||
return skillInstallConflictResult(input.operationId, extracted.manifest, state)
|
||||
}
|
||||
throwIfCancelled(input.signal)
|
||||
const transactionId = randomUUID()
|
||||
const stagingPath = join(
|
||||
input.destinationRoot,
|
||||
@@ -219,6 +171,11 @@ export async function installLocalSkillPackage(
|
||||
input.destinationRoot,
|
||||
`.${extracted.manifest.name}.orca-backup-${transactionId}`
|
||||
)
|
||||
const destinationExists = await skillInstallPathExists(canonicalPath)
|
||||
const backupDigest = 'digest' in state ? (state.digest ?? null) : null
|
||||
if (destinationExists && !backupDigest) {
|
||||
return skillInstallConflictResult(input.operationId, extracted.manifest, state)
|
||||
}
|
||||
await filesystem.rename(join(extracted.extractionPath, 'skill'), stagingPath)
|
||||
journal = {
|
||||
schemaVersion: 1,
|
||||
@@ -228,14 +185,35 @@ export async function installLocalSkillPackage(
|
||||
extractionPath: extracted.extractionPath,
|
||||
stagingPath,
|
||||
backupPath,
|
||||
backupDigest,
|
||||
stagingFileModes: extracted.manifest.files,
|
||||
backupFileModes: previous?.fileModes ?? extracted.manifest.files,
|
||||
receipt
|
||||
}
|
||||
const statePath = skillInstallJournalPath(input.stateDirectory, canonicalPath)
|
||||
await writeSkillStateFile(statePath, journal)
|
||||
if (await skillInstallPathExists(canonicalPath)) {
|
||||
throwIfCancelled(input.signal)
|
||||
const commitState = await inspectSkillCanonicalState({
|
||||
canonicalPath,
|
||||
manifest: extracted.manifest,
|
||||
receipt: previous,
|
||||
filesystem
|
||||
})
|
||||
if (JSON.stringify(commitState) !== JSON.stringify(state)) {
|
||||
await recoverSkillInstallTransaction(input.stateDirectory, canonicalPath, filesystem)
|
||||
journal = null
|
||||
return skillInstallConflictResult(
|
||||
input.operationId,
|
||||
extracted.manifest,
|
||||
commitState,
|
||||
'skill-install-conflict-stale-preview'
|
||||
)
|
||||
}
|
||||
if (destinationExists) {
|
||||
await filesystem.rename(canonicalPath, backupPath)
|
||||
journal.phase = 'backup-created'
|
||||
await writeSkillStateFile(statePath, journal)
|
||||
throwIfCancelled(input.signal)
|
||||
}
|
||||
await filesystem.rename(stagingPath, canonicalPath)
|
||||
journal.phase = 'canonical-placed'
|
||||
@@ -271,9 +249,13 @@ export async function installLocalSkillPackage(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
const result = skillInstallFailureResult(input, extracted.manifest, canonicalPath, error)
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await filesystem.remove(extracted.extractionPath)
|
||||
await releaseLock()
|
||||
await releaseLock?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { gunzipSync, gzipSync } from 'node:zlib'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSkillPackageArchive } from './skill-package-creation'
|
||||
import { extractSkillPackageArchive } from './skill-package-extraction'
|
||||
@@ -25,6 +26,16 @@ async function createSkill(root: string): Promise<string> {
|
||||
return skill
|
||||
}
|
||||
|
||||
async function mutateArchive(
|
||||
source: string,
|
||||
target: string,
|
||||
mutate: (tar: Buffer) => Buffer | void
|
||||
): Promise<void> {
|
||||
const tar = gunzipSync(await readFile(source))
|
||||
const mutated = mutate(tar) ?? tar
|
||||
await writeFile(target, gzipSync(mutated, { level: 9 }))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
@@ -108,4 +119,149 @@ describe('skill package creation and extraction', () => {
|
||||
})
|
||||
).rejects.toThrow('skill-package-archive-digest-mismatch')
|
||||
})
|
||||
|
||||
it('rejects source changes after the package snapshot and removes temporary output', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const sourceDirectory = await createSkill(root)
|
||||
const archivePath = join(root, 'drifted.tar.gz')
|
||||
|
||||
await expect(
|
||||
createSkillPackageArchive(
|
||||
{
|
||||
sourceDirectory,
|
||||
archivePath,
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1'
|
||||
},
|
||||
{
|
||||
afterSourceObserved: async () => {
|
||||
await writeFile(join(sourceDirectory, 'SKILL.md'), 'changed after observation')
|
||||
}
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('skill-package-source-changed-during-staging')
|
||||
await expect(readFile(archivePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('normalizes LF and CRLF text identity while preserving exact archive bytes', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const lf = join(root, 'lf')
|
||||
const crlf = join(root, 'crlf')
|
||||
await Promise.all([mkdir(lf), mkdir(crlf)])
|
||||
const markdown = '---\nname: line-skill\ndescription: Lines\n---\n\n# Lines\n'
|
||||
await writeFile(join(lf, 'SKILL.md'), markdown)
|
||||
await writeFile(join(crlf, 'SKILL.md'), markdown.replaceAll('\n', '\r\n'))
|
||||
const publication = {
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1',
|
||||
createdAt: '2026-08-11T12:00:00.000Z'
|
||||
}
|
||||
|
||||
const lfPackage = await createSkillPackageArchive({
|
||||
...publication,
|
||||
sourceDirectory: lf,
|
||||
archivePath: join(root, 'lf.tar.gz')
|
||||
})
|
||||
const crlfPackage = await createSkillPackageArchive({
|
||||
...publication,
|
||||
sourceDirectory: crlf,
|
||||
archivePath: join(root, 'crlf.tar.gz')
|
||||
})
|
||||
|
||||
expect(lfPackage.manifest.packageDigest).toBe(crlfPackage.manifest.packageDigest)
|
||||
expect(lfPackage.manifest.files[0]?.identitySha256).toBe(
|
||||
crlfPackage.manifest.files[0]?.identitySha256
|
||||
)
|
||||
expect(lfPackage.manifest.files[0]?.sha256).not.toBe(crlfPackage.manifest.files[0]?.sha256)
|
||||
expect(lfPackage.archiveSha256).not.toBe(crlfPackage.archiveSha256)
|
||||
})
|
||||
|
||||
it('rejects missing and malformed SKILL.md sources with stable archive errors', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const missing = join(root, 'missing')
|
||||
const malformed = join(root, 'malformed')
|
||||
await Promise.all([mkdir(missing), mkdir(malformed)])
|
||||
await writeFile(join(missing, 'README.md'), 'no skill')
|
||||
await writeFile(join(malformed, 'SKILL.md'), '# No frontmatter identity')
|
||||
const input = (sourceDirectory: string, name: string) => ({
|
||||
sourceDirectory,
|
||||
archivePath: join(root, `${name}.tar.gz`),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1'
|
||||
})
|
||||
|
||||
await expect(createSkillPackageArchive(input(missing, 'missing'))).rejects.toThrow(
|
||||
'skill-package-skill-markdown-required'
|
||||
)
|
||||
await expect(createSkillPackageArchive(input(malformed, 'malformed'))).rejects.toThrow(
|
||||
'skill-package-skill-name-invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects truncated tar data and invalid tar checksums before publishing extraction', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const created = await createSkillPackageArchive({
|
||||
sourceDirectory: await createSkill(root),
|
||||
archivePath: join(root, 'package.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1'
|
||||
})
|
||||
const invalidChecksum = join(root, 'invalid-checksum.tar.gz')
|
||||
const truncated = join(root, 'truncated.tar.gz')
|
||||
await mutateArchive(created.archivePath, invalidChecksum, (tar) => {
|
||||
tar[0] ^= 1
|
||||
})
|
||||
await mutateArchive(created.archivePath, truncated, (tar) => tar.subarray(0, -600))
|
||||
|
||||
await expect(
|
||||
extractSkillPackageArchive({
|
||||
archivePath: invalidChecksum,
|
||||
destinationDirectory: join(root, 'invalid-checksum')
|
||||
})
|
||||
).rejects.toThrow('skill-package-tar-checksum-invalid')
|
||||
await expect(
|
||||
extractSkillPackageArchive({
|
||||
archivePath: truncated,
|
||||
destinationDirectory: join(root, 'truncated')
|
||||
})
|
||||
).rejects.toThrow('skill-package-tar-truncated')
|
||||
})
|
||||
|
||||
it('rejects content checksum and SKILL identity mismatches', async () => {
|
||||
const root = await temporaryDirectory()
|
||||
const created = await createSkillPackageArchive({
|
||||
sourceDirectory: await createSkill(root),
|
||||
archivePath: join(root, 'package.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1'
|
||||
})
|
||||
const invalidContent = join(root, 'invalid-content.tar.gz')
|
||||
const invalidIdentity = join(root, 'invalid-identity.tar.gz')
|
||||
await mutateArchive(created.archivePath, invalidContent, (tar) => {
|
||||
const marker = Buffer.from('# Test\n')
|
||||
const offset = tar.indexOf(marker)
|
||||
expect(offset).toBeGreaterThan(0)
|
||||
tar[offset + 2] ^= 1
|
||||
})
|
||||
await mutateArchive(created.archivePath, invalidIdentity, (tar) => {
|
||||
const from = Buffer.from('"name":"test-skill"')
|
||||
const to = Buffer.from('"name":"best-skill"')
|
||||
const offset = tar.indexOf(from)
|
||||
expect(offset).toBeGreaterThan(0)
|
||||
to.copy(tar, offset)
|
||||
})
|
||||
|
||||
await expect(
|
||||
extractSkillPackageArchive({
|
||||
archivePath: invalidContent,
|
||||
destinationDirectory: join(root, 'invalid-content')
|
||||
})
|
||||
).rejects.toThrow('skill-package-file-digest-mismatch')
|
||||
await expect(
|
||||
extractSkillPackageArchive({
|
||||
archivePath: invalidIdentity,
|
||||
destinationDirectory: join(root, 'invalid-identity')
|
||||
})
|
||||
).rejects.toThrow('skill-package-skill-name-mismatch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'
|
||||
import {
|
||||
computeSkillPackageDigest,
|
||||
parseSkillPackageManifest,
|
||||
validateSkillPackageName,
|
||||
type SkillPackageManifestV1
|
||||
} from '../../shared/skill-package-manifest'
|
||||
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
|
||||
@@ -20,6 +21,10 @@ export type CreatedSkillPackage = {
|
||||
compressedBytes: number
|
||||
}
|
||||
|
||||
export type SkillPackageCreationDependencies = {
|
||||
afterSourceObserved?: () => Promise<void>
|
||||
}
|
||||
|
||||
function observationsMatch(left: ObservedSkillPackage, right: ObservedSkillPackage): boolean {
|
||||
return (
|
||||
left.files.length === right.files.length &&
|
||||
@@ -65,14 +70,21 @@ function packageManifest(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export async function createSkillPackageArchive(input: {
|
||||
sourceDirectory: string
|
||||
archivePath: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
createdAt?: string
|
||||
}): Promise<CreatedSkillPackage> {
|
||||
export async function createSkillPackageArchive(
|
||||
input: {
|
||||
sourceDirectory: string
|
||||
archivePath: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
createdAt?: string
|
||||
},
|
||||
dependencies: SkillPackageCreationDependencies = {}
|
||||
): Promise<CreatedSkillPackage> {
|
||||
const sourceObservation = await observeSkillPackage(input.sourceDirectory)
|
||||
if (!sourceObservation.files.some((file) => file.path === 'SKILL.md')) {
|
||||
throw new Error('skill-package-skill-markdown-required')
|
||||
}
|
||||
await dependencies.afterSourceObserved?.()
|
||||
const workDirectory = await mkdtemp(join(tmpdir(), 'orca-skill-package-'))
|
||||
const stagedSkill = join(workDirectory, 'skill')
|
||||
const verificationDirectory = join(workDirectory, 'verification')
|
||||
@@ -92,6 +104,7 @@ export async function createSkillPackageArchive(input: {
|
||||
if (!summary.name) {
|
||||
throw new Error('skill-package-skill-name-required')
|
||||
}
|
||||
validateSkillPackageName(summary.name)
|
||||
const manifest = packageManifest({
|
||||
packageId: input.packageId,
|
||||
versionId: input.versionId,
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
|
||||
const MAX_REDIRECTS = 3
|
||||
@@ -59,7 +61,10 @@ function validateUrl(
|
||||
|
||||
function throwIfUnavailable(input: SkillPackageDownloadInput, expiresAt: number): void {
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('skill-download-cancelled')
|
||||
throw new SkillInstallOperationError({
|
||||
...SKILL_INSTALL_CANCELLED_FAILURE,
|
||||
code: 'skill-download-cancelled'
|
||||
})
|
||||
}
|
||||
if (input.now!() >= expiresAt) {
|
||||
throw new Error('skill-download-grant-expired')
|
||||
@@ -83,7 +88,10 @@ async function fetchWithoutCredentialRedirect(
|
||||
})
|
||||
} catch {
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('skill-download-cancelled')
|
||||
throw new SkillInstallOperationError({
|
||||
...SKILL_INSTALL_CANCELLED_FAILURE,
|
||||
code: 'skill-download-cancelled'
|
||||
})
|
||||
}
|
||||
throw new Error('skill-download-transport-failed')
|
||||
}
|
||||
@@ -173,7 +181,10 @@ export async function downloadSkillPackageGrant(
|
||||
} catch (error) {
|
||||
await reader.cancel().catch(() => undefined)
|
||||
if (input.signal?.aborted) {
|
||||
throw new Error('skill-download-cancelled')
|
||||
throw new SkillInstallOperationError({
|
||||
...SKILL_INSTALL_CANCELLED_FAILURE,
|
||||
code: 'skill-download-cancelled'
|
||||
})
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
nativeSkillInstallFilesystem,
|
||||
type SkillInstallFilesystem
|
||||
} from './skill-install-filesystem'
|
||||
import { SKILL_INSTALL_CANCELLED_FAILURE } from '../../shared/skill-install-failure'
|
||||
import { SkillInstallOperationError } from './skill-install-operation-error'
|
||||
|
||||
export type SkillPackageExtractionResult = {
|
||||
manifest: SkillPackageManifestV1
|
||||
@@ -40,7 +42,8 @@ async function consumeZeroPadding(reader: TarByteReader, size: number): Promise<
|
||||
async function extractFile(
|
||||
reader: TarByteReader,
|
||||
destination: string,
|
||||
expected: SkillPackageFile
|
||||
expected: SkillPackageFile,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(destination), { recursive: true, mode: 0o700 })
|
||||
const handle = await open(destination, 'wx', expected.executable ? 0o700 : 0o600)
|
||||
@@ -48,6 +51,9 @@ async function extractFile(
|
||||
let offset = 0
|
||||
try {
|
||||
while (offset < expected.size) {
|
||||
if (signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
const bytes = await reader.readExact(Math.min(64 * 1024, expected.size - offset))
|
||||
hash.update(bytes)
|
||||
let chunkOffset = 0
|
||||
@@ -143,11 +149,15 @@ export async function extractSkillPackageArchive(input: {
|
||||
expectedPackageId?: string
|
||||
expectedVersionId?: string
|
||||
filesystem?: SkillInstallFilesystem
|
||||
signal?: AbortSignal
|
||||
}): Promise<SkillPackageExtractionResult> {
|
||||
const filesystem = input.filesystem ?? nativeSkillInstallFilesystem
|
||||
const archive = await openSkillTarGzip(input.archivePath)
|
||||
let destinationCreated = false
|
||||
try {
|
||||
if (input.signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
const manifest = await readManifest(archive.reader)
|
||||
if (
|
||||
(input.expectedPackageDigest && manifest.packageDigest !== input.expectedPackageDigest) ||
|
||||
@@ -161,6 +171,9 @@ export async function extractSkillPackageArchive(input: {
|
||||
const skillDirectory = join(input.destinationDirectory, 'skill')
|
||||
await mkdir(skillDirectory, { mode: 0o700 })
|
||||
for (const expected of manifest.files) {
|
||||
if (input.signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
const header = parseSkillTarHeader(await archive.reader.readExact(SKILL_TAR_BLOCK_BYTES))
|
||||
if (
|
||||
!header ||
|
||||
@@ -170,9 +183,17 @@ export async function extractSkillPackageArchive(input: {
|
||||
) {
|
||||
throw new Error('skill-package-file-envelope-mismatch')
|
||||
}
|
||||
await extractFile(archive.reader, join(skillDirectory, ...expected.path.split('/')), expected)
|
||||
await extractFile(
|
||||
archive.reader,
|
||||
join(skillDirectory, ...expected.path.split('/')),
|
||||
expected,
|
||||
input.signal
|
||||
)
|
||||
}
|
||||
await requireArchiveEnd(archive.reader)
|
||||
if (input.signal?.aborted) {
|
||||
throw new SkillInstallOperationError(SKILL_INSTALL_CANCELLED_FAILURE)
|
||||
}
|
||||
const archiveIdentity = await archive.archiveIdentity
|
||||
if (
|
||||
input.expectedArchiveSha256 &&
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, 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 { reconcileSkillProviderPlacement } from './skill-placement-reconciliation'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function fixture(): Promise<{
|
||||
root: string
|
||||
canonicalRoot: string
|
||||
canonicalPath: string
|
||||
packageDigest: string
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-alias-repair-test-'))
|
||||
roots.push(root)
|
||||
const canonicalRoot = join(root, 'canonical')
|
||||
const canonicalPath = join(canonicalRoot, 'private-skill')
|
||||
await mkdir(canonicalPath, { recursive: true })
|
||||
await writeFile(join(canonicalPath, 'SKILL.md'), 'private skill')
|
||||
const { nativeSkillInstallFilesystem } = await import('./skill-install-filesystem')
|
||||
const packageDigest = (await nativeSkillInstallFilesystem.observeSkill(canonicalPath))
|
||||
.observedDigest
|
||||
return { root, canonicalRoot, canonicalPath, packageDigest }
|
||||
}
|
||||
|
||||
function receipt(
|
||||
canonicalPath: string,
|
||||
placementPath: 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: [
|
||||
{
|
||||
provider: 'claude',
|
||||
path: placementPath,
|
||||
topology: 'provider-alias',
|
||||
status: 'installed'
|
||||
}
|
||||
],
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
hostIdentity: 'test'
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill provider alias reconciliation', () => {
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'detects a provider parent already linked to canonical storage',
|
||||
async () => {
|
||||
const value = await fixture()
|
||||
const providerRoot = join(value.root, 'provider')
|
||||
await symlink(value.canonicalRoot, providerRoot, 'dir')
|
||||
expect(await realpath(join(providerRoot, 'private-skill'))).toBe(
|
||||
await realpath(value.canonicalPath)
|
||||
)
|
||||
|
||||
const result = await reconcileSkillProviderPlacement({
|
||||
canonicalPath: value.canonicalPath,
|
||||
skillName: 'private-skill',
|
||||
destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
|
||||
previousReceipt: null,
|
||||
packageDigest: value.packageDigest
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ topology: 'provider-alias', status: 'unchanged' })
|
||||
}
|
||||
)
|
||||
|
||||
it.runIf(process.platform !== 'win32')('repairs a broken Orca-owned alias', async () => {
|
||||
const value = await fixture()
|
||||
const providerRoot = join(value.root, 'provider')
|
||||
const placementPath = join(providerRoot, 'private-skill')
|
||||
await mkdir(providerRoot)
|
||||
await symlink('../missing-skill', placementPath, 'dir')
|
||||
|
||||
const result = await reconcileSkillProviderPlacement({
|
||||
canonicalPath: value.canonicalPath,
|
||||
skillName: 'private-skill',
|
||||
destination: { provider: 'claude', rootPath: providerRoot, readsCanonicalRoot: false },
|
||||
previousReceipt: receipt(value.canonicalPath, placementPath, value.packageDigest),
|
||||
packageDigest: value.packageDigest
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ topology: 'provider-alias', status: 'installed' })
|
||||
expect(await realpath(placementPath)).toBe(await realpath(value.canonicalPath))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cp, mkdir, readdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { SkillInstalledFileMode, SkillInstallFilesystem } from './skill-install-filesystem'
|
||||
|
||||
export async function createVerifiedSkillPlacementCopy(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
const temporary = `${destinationPath}.orca-copy-${randomUUID()}`
|
||||
try {
|
||||
await cp(canonicalPath, temporary, { recursive: true, verbatimSymlinks: true })
|
||||
const [source, copied] = await Promise.all([
|
||||
filesystem.observeSkill(canonicalPath, fileModes),
|
||||
filesystem.observeSkill(temporary, fileModes)
|
||||
])
|
||||
if (source.observedDigest !== copied.observedDigest) {
|
||||
throw new Error('skill-placement-copy-digest-mismatch')
|
||||
}
|
||||
await filesystem.rename(temporary, destinationPath)
|
||||
} finally {
|
||||
await filesystem.remove(temporary)
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceOwnedSkillPlacementCopy(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
const replacement = `${destinationPath}.orca-copy-${randomUUID()}`
|
||||
const backup = `${destinationPath}.orca-backup-${randomUUID()}`
|
||||
try {
|
||||
await createVerifiedSkillPlacementCopy(canonicalPath, replacement, filesystem, fileModes)
|
||||
await filesystem.rename(destinationPath, backup)
|
||||
try {
|
||||
await filesystem.rename(replacement, destinationPath)
|
||||
} catch (error) {
|
||||
await filesystem.rename(backup, destinationPath)
|
||||
throw error
|
||||
}
|
||||
await filesystem.remove(backup)
|
||||
} finally {
|
||||
await filesystem.remove(replacement)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSkillPlacementCopyAtMissingDestination(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
await mkdir(destinationPath, { mode: 0o700 })
|
||||
let complete = false
|
||||
try {
|
||||
for (const entry of await readdir(canonicalPath)) {
|
||||
await cp(join(canonicalPath, entry), join(destinationPath, entry), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true
|
||||
})
|
||||
}
|
||||
const [source, copied] = await Promise.all([
|
||||
filesystem.observeSkill(canonicalPath, fileModes),
|
||||
filesystem.observeSkill(destinationPath, fileModes)
|
||||
])
|
||||
if (source.observedDigest !== copied.observedDigest) {
|
||||
throw new Error('skill-placement-copy-digest-mismatch')
|
||||
}
|
||||
complete = true
|
||||
} finally {
|
||||
if (!complete) {
|
||||
await filesystem.remove(destinationPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { cp, lstat, mkdir, readdir, realpath, symlink } from 'node:fs/promises'
|
||||
import { lstat, mkdir, realpath, symlink } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
|
||||
import type { SkillInstallReceiptV1 } from './skill-install-provenance'
|
||||
@@ -9,12 +8,23 @@ import {
|
||||
type SkillInstalledFileMode,
|
||||
type SkillInstallFilesystem
|
||||
} from './skill-install-filesystem'
|
||||
import {
|
||||
createSkillPlacementCopyAtMissingDestination,
|
||||
replaceOwnedSkillPlacementCopy
|
||||
} from './skill-placement-copy'
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
const normalized = resolve(path)
|
||||
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
|
||||
}
|
||||
|
||||
function placementFailure(code: string, retryable = false) {
|
||||
return {
|
||||
errorCategory: code,
|
||||
failure: { category: 'provider-placement' as const, code, retryable }
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
return Boolean(await lstat(path).catch(() => null))
|
||||
}
|
||||
@@ -52,51 +62,6 @@ async function createProviderAlias(
|
||||
await symlink(relative(realParent, realCanonical), destinationPath, 'dir')
|
||||
}
|
||||
|
||||
async function createVerifiedCopy(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
const temporary = `${destinationPath}.orca-copy-${randomUUID()}`
|
||||
try {
|
||||
await cp(canonicalPath, temporary, { recursive: true, verbatimSymlinks: true })
|
||||
const [source, copied] = await Promise.all([
|
||||
filesystem.observeSkill(canonicalPath, fileModes),
|
||||
filesystem.observeSkill(temporary, fileModes)
|
||||
])
|
||||
if (source.observedDigest !== copied.observedDigest) {
|
||||
throw new Error('skill-placement-copy-digest-mismatch')
|
||||
}
|
||||
await filesystem.rename(temporary, destinationPath)
|
||||
} finally {
|
||||
await filesystem.remove(temporary)
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceOwnedCopy(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
const replacement = `${destinationPath}.orca-copy-${randomUUID()}`
|
||||
const backup = `${destinationPath}.orca-backup-${randomUUID()}`
|
||||
try {
|
||||
await createVerifiedCopy(canonicalPath, replacement, filesystem, fileModes)
|
||||
await filesystem.rename(destinationPath, backup)
|
||||
try {
|
||||
await filesystem.rename(replacement, destinationPath)
|
||||
} catch (error) {
|
||||
await filesystem.rename(backup, destinationPath)
|
||||
throw error
|
||||
}
|
||||
await filesystem.remove(backup)
|
||||
} finally {
|
||||
await filesystem.remove(replacement)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileExistingPlacement(input: {
|
||||
canonicalPath: string
|
||||
destinationPath: string
|
||||
@@ -106,6 +71,11 @@ async function reconcileExistingPlacement(input: {
|
||||
filesystem: SkillInstallFilesystem
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
}): Promise<SkillPlacementResult> {
|
||||
const previous = previousPlacement(
|
||||
input.previousReceipt,
|
||||
input.destination.provider,
|
||||
input.destinationPath
|
||||
)
|
||||
if (input.filesystem.aliasTargets) {
|
||||
return (await input.filesystem.aliasTargets(input.canonicalPath, input.destinationPath))
|
||||
? {
|
||||
@@ -119,31 +89,40 @@ async function reconcileExistingPlacement(input: {
|
||||
path: input.destinationPath,
|
||||
topology: 'provider-alias',
|
||||
status: 'skipped',
|
||||
errorCategory: 'skill-placement-unowned-link'
|
||||
...placementFailure('skill-placement-unowned-link')
|
||||
}
|
||||
}
|
||||
const resolvedDestination = await realpath(input.destinationPath).catch(() => null)
|
||||
const resolvedCanonical = await realpath(input.canonicalPath).catch(() => input.canonicalPath)
|
||||
if (
|
||||
resolvedDestination &&
|
||||
normalizedPath(resolvedDestination) === normalizedPath(resolvedCanonical)
|
||||
) {
|
||||
return {
|
||||
provider: input.destination.provider,
|
||||
path: input.destinationPath,
|
||||
topology: 'provider-alias',
|
||||
status: 'unchanged'
|
||||
}
|
||||
}
|
||||
const stat = await lstat(input.destinationPath)
|
||||
const previous = previousPlacement(
|
||||
input.previousReceipt,
|
||||
input.destination.provider,
|
||||
input.destinationPath
|
||||
)
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = await realpath(input.destinationPath).catch(() => null)
|
||||
if (target && normalizedPath(target) === normalizedPath(input.canonicalPath)) {
|
||||
return {
|
||||
provider: input.destination.provider,
|
||||
path: input.destinationPath,
|
||||
topology: 'provider-alias',
|
||||
status: 'unchanged'
|
||||
}
|
||||
if (!resolvedDestination && previous?.topology === 'provider-alias') {
|
||||
await input.filesystem.remove(input.destinationPath)
|
||||
return createMissingPlacement({
|
||||
canonicalPath: input.canonicalPath,
|
||||
destinationPath: input.destinationPath,
|
||||
destination: input.destination,
|
||||
filesystem: input.filesystem,
|
||||
fileModes: input.fileModes
|
||||
})
|
||||
}
|
||||
return {
|
||||
provider: input.destination.provider,
|
||||
path: input.destinationPath,
|
||||
topology: 'provider-alias',
|
||||
status: 'skipped',
|
||||
errorCategory: 'skill-placement-unowned-link'
|
||||
...placementFailure('skill-placement-unowned-link')
|
||||
}
|
||||
}
|
||||
if (stat.isDirectory() && previous?.topology === 'independent-copy') {
|
||||
@@ -156,7 +135,7 @@ async function reconcileExistingPlacement(input: {
|
||||
path: input.destinationPath,
|
||||
topology: 'independent-copy',
|
||||
status: 'skipped',
|
||||
errorCategory: 'skill-placement-modified-copy'
|
||||
...placementFailure('skill-placement-modified-copy')
|
||||
}
|
||||
}
|
||||
if (observed.observedDigest === input.packageDigest) {
|
||||
@@ -167,7 +146,7 @@ async function reconcileExistingPlacement(input: {
|
||||
status: 'unchanged'
|
||||
}
|
||||
}
|
||||
await replaceOwnedCopy(
|
||||
await replaceOwnedSkillPlacementCopy(
|
||||
input.canonicalPath,
|
||||
input.destinationPath,
|
||||
input.filesystem,
|
||||
@@ -185,7 +164,7 @@ async function reconcileExistingPlacement(input: {
|
||||
path: input.destinationPath,
|
||||
topology: 'independent-copy',
|
||||
status: 'skipped',
|
||||
errorCategory: 'skill-placement-unowned'
|
||||
...placementFailure('skill-placement-unowned')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +185,7 @@ async function createMissingPlacement(input: {
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
await createVerifiedCopyAtMissingDestination(
|
||||
await createSkillPlacementCopyAtMissingDestination(
|
||||
input.canonicalPath,
|
||||
input.destinationPath,
|
||||
input.filesystem,
|
||||
@@ -224,45 +203,12 @@ async function createMissingPlacement(input: {
|
||||
path: input.destinationPath,
|
||||
topology: 'independent-copy',
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-placement-create-failed'
|
||||
...placementFailure('skill-placement-create-failed', true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createVerifiedCopyAtMissingDestination(
|
||||
canonicalPath: string,
|
||||
destinationPath: string,
|
||||
filesystem: SkillInstallFilesystem,
|
||||
fileModes?: readonly SkillInstalledFileMode[]
|
||||
): Promise<void> {
|
||||
await mkdir(dirname(destinationPath), { recursive: true })
|
||||
await mkdir(destinationPath, { mode: 0o700 })
|
||||
let complete = false
|
||||
try {
|
||||
for (const entry of await readdir(canonicalPath)) {
|
||||
await cp(join(canonicalPath, entry), join(destinationPath, entry), {
|
||||
recursive: true,
|
||||
verbatimSymlinks: true,
|
||||
force: false,
|
||||
errorOnExist: true
|
||||
})
|
||||
}
|
||||
const [source, copied] = await Promise.all([
|
||||
filesystem.observeSkill(canonicalPath, fileModes),
|
||||
filesystem.observeSkill(destinationPath, fileModes)
|
||||
])
|
||||
if (source.observedDigest !== copied.observedDigest) {
|
||||
throw new Error('skill-placement-copy-digest-mismatch')
|
||||
}
|
||||
complete = true
|
||||
} finally {
|
||||
if (!complete) {
|
||||
await filesystem.remove(destinationPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reconcileSkillProviderPlacement(input: {
|
||||
canonicalPath: string
|
||||
skillName: string
|
||||
@@ -287,7 +233,7 @@ export async function reconcileSkillProviderPlacement(input: {
|
||||
path: destinationPath,
|
||||
topology: 'independent-copy',
|
||||
status: 'failed',
|
||||
errorCategory: 'skill-placement-reconciliation-failed'
|
||||
...placementFailure('skill-placement-reconciliation-failed', true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer'
|
||||
import { SkillInstallFailureSchema } from '../../shared/skill-install-failure'
|
||||
import { SkillInstallOperationError } from './skill-install-operation-error'
|
||||
|
||||
const DIRECT_DOWNLOAD_FAILURE = 'skill-download-transport-failed'
|
||||
|
||||
@@ -26,13 +28,26 @@ async function install(
|
||||
}
|
||||
|
||||
function isDownloadTransportFailure(response: RuntimeRpcResponse<unknown>): boolean {
|
||||
const structured =
|
||||
response.ok === false ? SkillInstallFailureSchema.safeParse(response.error.data) : null
|
||||
return (
|
||||
response.ok === false &&
|
||||
(response.error.code === DIRECT_DOWNLOAD_FAILURE ||
|
||||
response.error.message === DIRECT_DOWNLOAD_FAILURE)
|
||||
response.error.message === DIRECT_DOWNLOAD_FAILURE ||
|
||||
(structured?.success === true && structured.data.code === DIRECT_DOWNLOAD_FAILURE))
|
||||
)
|
||||
}
|
||||
|
||||
function remoteFailure(response: RuntimeRpcResponse<unknown>): Error {
|
||||
if (response.ok === true) {
|
||||
return new Error('skill-install-remote-response-invalid')
|
||||
}
|
||||
const failure = SkillInstallFailureSchema.safeParse(response.error.data)
|
||||
return failure.success
|
||||
? new SkillInstallOperationError(failure.data)
|
||||
: new Error(`skill-install-remote-${response.error.code}`)
|
||||
}
|
||||
|
||||
export async function installSkillOnRemoteRuntime(input: {
|
||||
userDataPath: string
|
||||
environmentId: string
|
||||
@@ -46,7 +61,7 @@ export async function installSkillOnRemoteRuntime(input: {
|
||||
const direct = await install(input.userDataPath, input.environmentId, input.request)
|
||||
if (!isDownloadTransportFailure(direct)) {
|
||||
if (direct.ok !== true) {
|
||||
throw new Error(`skill-install-remote-${direct.error.code}`)
|
||||
throw remoteFailure(direct)
|
||||
}
|
||||
return SkillInstallResultSchema.parse(direct.result)
|
||||
}
|
||||
@@ -67,7 +82,7 @@ export async function installSkillOnRemoteRuntime(input: {
|
||||
ingress: { kind: 'staged-upload', uploadId: transfer.uploadId }
|
||||
})
|
||||
if (staged.ok !== true) {
|
||||
throw new Error(`skill-install-remote-${staged.error.code}`)
|
||||
throw remoteFailure(staged)
|
||||
}
|
||||
return SkillInstallResultSchema.parse(staged.result)
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { lstat, readFile, readlink, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import type { SkillPlacementResult } from '../../shared/skill-install-contract'
|
||||
import type { SkillInstallFilesystem } from './skill-install-filesystem'
|
||||
import { nativeSkillInstallFilesystem } from './skill-install-filesystem'
|
||||
import { skillInstallStateKey, type SkillInstallReceiptV1 } from './skill-install-provenance'
|
||||
|
||||
export type RemovalMove = {
|
||||
sourcePath: string
|
||||
backupPath: string
|
||||
placement: SkillPlacementResult
|
||||
expectedDigest?: string
|
||||
expectedAliasTarget?: string
|
||||
}
|
||||
|
||||
export type SkillRemovalJournalV1 = {
|
||||
schemaVersion: 1
|
||||
operation: 'remove'
|
||||
phase: 'prepared' | 'moving' | 'receipt-removed'
|
||||
canonicalPath: string
|
||||
movedCount: number
|
||||
moves: RemovalMove[]
|
||||
receipt: SkillInstallReceiptV1
|
||||
allowedProviderRoots: string[]
|
||||
}
|
||||
|
||||
export function skillRemovalJournalPath(stateDirectory: string, canonicalPath: string): string {
|
||||
return join(stateDirectory, 'removal-journals', `${skillInstallStateKey(canonicalPath)}.json`)
|
||||
}
|
||||
|
||||
function validBackup(move: RemovalMove): boolean {
|
||||
return (
|
||||
typeof move?.sourcePath === 'string' &&
|
||||
typeof move.backupPath === 'string' &&
|
||||
Boolean(move.placement) &&
|
||||
typeof move.placement.path === 'string' &&
|
||||
typeof move.placement.topology === 'string' &&
|
||||
dirname(move.sourcePath) === dirname(move.backupPath) &&
|
||||
basename(move.backupPath).startsWith(`.${basename(move.sourcePath)}.orca-remove-backup-`)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedPath(path: string): string {
|
||||
const normalized = resolve(path)
|
||||
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
|
||||
}
|
||||
|
||||
function pathInside(root: string, path: string): boolean {
|
||||
const child = relative(resolve(root), resolve(path))
|
||||
return (
|
||||
child !== '' &&
|
||||
child !== '..' &&
|
||||
!child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) &&
|
||||
!isAbsolute(child)
|
||||
)
|
||||
}
|
||||
|
||||
function moveOwnedByJournal(journal: Partial<SkillRemovalJournalV1>, move: RemovalMove): boolean {
|
||||
if (!journal.receipt || !Array.isArray(journal.allowedProviderRoots)) {
|
||||
return false
|
||||
}
|
||||
const canonical = normalizedPath(journal.canonicalPath!)
|
||||
const source = normalizedPath(move.sourcePath)
|
||||
if (source === canonical) {
|
||||
return (
|
||||
move.placement.topology === 'canonical-copy' &&
|
||||
typeof move.expectedDigest === 'string' &&
|
||||
/^[a-f0-9]{64}$/.test(move.expectedDigest)
|
||||
)
|
||||
}
|
||||
const receiptPlacement = journal.receipt.placements.find(
|
||||
(placement) => normalizedPath(placement.path) === source
|
||||
)
|
||||
return Boolean(
|
||||
receiptPlacement &&
|
||||
receiptPlacement.topology === move.placement.topology &&
|
||||
journal.allowedProviderRoots.some(
|
||||
(root) =>
|
||||
pathInside(root, move.sourcePath) &&
|
||||
normalizedPath(move.sourcePath) ===
|
||||
normalizedPath(join(root, basename(journal.canonicalPath!)))
|
||||
) &&
|
||||
(move.placement.topology === 'provider-alias'
|
||||
? move.expectedAliasTarget === journal.canonicalPath
|
||||
: move.expectedDigest === journal.receipt.packageDigest)
|
||||
)
|
||||
}
|
||||
|
||||
function isRemovalJournal(value: unknown, canonicalPath: string): value is SkillRemovalJournalV1 {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const journal = value as Partial<SkillRemovalJournalV1>
|
||||
return (
|
||||
journal.schemaVersion === 1 &&
|
||||
journal.operation === 'remove' &&
|
||||
journal.canonicalPath === canonicalPath &&
|
||||
(journal.phase === 'prepared' ||
|
||||
journal.phase === 'moving' ||
|
||||
journal.phase === 'receipt-removed') &&
|
||||
Number.isInteger(journal.movedCount) &&
|
||||
Array.isArray(journal.moves) &&
|
||||
Boolean(journal.receipt) &&
|
||||
journal.receipt?.canonicalPath === canonicalPath &&
|
||||
Array.isArray(journal.allowedProviderRoots) &&
|
||||
journal.moves.every((move) => validBackup(move) && moveOwnedByJournal(journal, move)) &&
|
||||
new Set(journal.moves.map((move) => normalizedPath(move.sourcePath))).size ===
|
||||
journal.moves.length &&
|
||||
journal.movedCount! >= 0 &&
|
||||
journal.movedCount! <= journal.moves.length
|
||||
)
|
||||
}
|
||||
|
||||
async function movedBackupMatches(
|
||||
move: RemovalMove,
|
||||
receipt: SkillInstallReceiptV1,
|
||||
filesystem: SkillInstallFilesystem
|
||||
): Promise<boolean> {
|
||||
if (move.expectedAliasTarget) {
|
||||
if (filesystem.aliasTargets) {
|
||||
return filesystem.aliasTargets(move.expectedAliasTarget, move.backupPath)
|
||||
}
|
||||
const target = await readlink(move.backupPath).catch(() => null)
|
||||
return Boolean(
|
||||
target &&
|
||||
normalizedPath(resolve(dirname(move.backupPath), target)) ===
|
||||
normalizedPath(move.expectedAliasTarget)
|
||||
)
|
||||
}
|
||||
if (!move.expectedDigest) {
|
||||
return false
|
||||
}
|
||||
const observed = await filesystem
|
||||
.observeSkill(move.backupPath, receipt.fileModes)
|
||||
.catch(() => null)
|
||||
return observed?.observedDigest === move.expectedDigest
|
||||
}
|
||||
|
||||
async function readJournal(
|
||||
stateDirectory: string,
|
||||
canonicalPath: string
|
||||
): Promise<SkillRemovalJournalV1 | null> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
await readFile(skillRemovalJournalPath(stateDirectory, canonicalPath), 'utf8')
|
||||
)
|
||||
if (!isRemovalJournal(value, canonicalPath)) {
|
||||
throw new Error('skill-removal-journal-invalid')
|
||||
}
|
||||
return value
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function skillRemovalPathExists(path: string): Promise<boolean> {
|
||||
return Boolean(await lstat(path).catch(() => null))
|
||||
}
|
||||
|
||||
export async function recoverSkillRemovalTransaction(
|
||||
stateDirectory: string,
|
||||
canonicalPath: string,
|
||||
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
|
||||
): Promise<void> {
|
||||
const journal = await readJournal(stateDirectory, canonicalPath)
|
||||
if (!journal) {
|
||||
return
|
||||
}
|
||||
if (journal.phase === 'receipt-removed') {
|
||||
for (const move of journal.moves.slice(0, journal.movedCount)) {
|
||||
if (!(await movedBackupMatches(move, journal.receipt, filesystem))) {
|
||||
throw new Error('skill-removal-recovery-conflict')
|
||||
}
|
||||
await filesystem.remove(move.backupPath)
|
||||
}
|
||||
await rm(skillRemovalJournalPath(stateDirectory, canonicalPath), { force: true })
|
||||
return
|
||||
}
|
||||
for (const move of journal.moves.slice(0, journal.movedCount).toReversed()) {
|
||||
if (
|
||||
(await skillRemovalPathExists(move.backupPath)) &&
|
||||
!(await skillRemovalPathExists(move.sourcePath))
|
||||
) {
|
||||
if (!(await movedBackupMatches(move, journal.receipt, filesystem))) {
|
||||
throw new Error('skill-removal-recovery-conflict')
|
||||
}
|
||||
await filesystem.rename(move.backupPath, move.sourcePath)
|
||||
}
|
||||
}
|
||||
await rm(skillRemovalJournalPath(stateDirectory, canonicalPath), { force: true })
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { 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,
|
||||
writeSkillStateFile,
|
||||
type SkillInstallReceiptV1
|
||||
} from './skill-install-provenance'
|
||||
import { installLocalSkillPackage } from './skill-install-transaction'
|
||||
import {
|
||||
recoverSkillRemovalTransaction,
|
||||
skillRemovalJournalPath,
|
||||
type SkillRemovalJournalV1
|
||||
} from './skill-remove-transaction'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function installedFixture(): Promise<{
|
||||
root: string
|
||||
canonicalPath: string
|
||||
stateDirectory: string
|
||||
receipt: SkillInstallReceiptV1
|
||||
}> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-remove-recovery-test-'))
|
||||
roots.push(root)
|
||||
const source = join(root, 'source')
|
||||
await mkdir(source)
|
||||
await writeFile(
|
||||
join(source, 'SKILL.md'),
|
||||
'---\nname: remove-skill\ndescription: Remove\n---\n\n# Remove\n'
|
||||
)
|
||||
const archive = await createSkillPackageArchive({
|
||||
sourceDirectory: source,
|
||||
archivePath: join(root, 'package.tar.gz'),
|
||||
packageId: 'package_1',
|
||||
versionId: 'version_1'
|
||||
})
|
||||
const destinationRoot = join(root, 'skills')
|
||||
const stateDirectory = join(root, 'state')
|
||||
await installLocalSkillPackage({
|
||||
operationId: 'install',
|
||||
archivePath: archive.archivePath,
|
||||
destinationRoot,
|
||||
stateDirectory,
|
||||
scope: 'global',
|
||||
destinationIdentity: 'global:test',
|
||||
hostIdentity: 'test'
|
||||
})
|
||||
const canonicalPath = join(destinationRoot, 'remove-skill')
|
||||
const receipt = await readSkillInstallReceipt(stateDirectory, canonicalPath)
|
||||
if (!receipt) {
|
||||
throw new Error('fixture receipt missing')
|
||||
}
|
||||
return { root, canonicalPath, stateDirectory, receipt }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('skill removal recovery', () => {
|
||||
it('rejects a journal-owned move outside its recorded provider roots', async () => {
|
||||
const fixture = await installedFixture()
|
||||
const outside = join(fixture.root, 'outside')
|
||||
await mkdir(outside)
|
||||
await writeFile(join(outside, 'keep.txt'), 'keep')
|
||||
const sourcePath = outside
|
||||
const backupPath = join(dirname(sourcePath), `.${basename(sourcePath)}.orca-remove-backup-x`)
|
||||
const journal: SkillRemovalJournalV1 = {
|
||||
schemaVersion: 1,
|
||||
operation: 'remove',
|
||||
phase: 'prepared',
|
||||
canonicalPath: fixture.canonicalPath,
|
||||
movedCount: 0,
|
||||
moves: [
|
||||
{
|
||||
sourcePath,
|
||||
backupPath,
|
||||
placement: {
|
||||
provider: 'claude',
|
||||
path: sourcePath,
|
||||
topology: 'independent-copy',
|
||||
status: 'installed'
|
||||
},
|
||||
expectedDigest: fixture.receipt.packageDigest
|
||||
}
|
||||
],
|
||||
receipt: {
|
||||
...fixture.receipt,
|
||||
placements: [
|
||||
...fixture.receipt.placements,
|
||||
{
|
||||
provider: 'claude',
|
||||
path: sourcePath,
|
||||
topology: 'independent-copy',
|
||||
status: 'installed'
|
||||
}
|
||||
]
|
||||
},
|
||||
allowedProviderRoots: []
|
||||
}
|
||||
await writeSkillStateFile(
|
||||
skillRemovalJournalPath(fixture.stateDirectory, fixture.canonicalPath),
|
||||
journal
|
||||
)
|
||||
|
||||
await expect(
|
||||
recoverSkillRemovalTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
).rejects.toThrow('skill-removal-journal-invalid')
|
||||
expect(await readFile(join(outside, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
})
|
||||
|
||||
it('preserves a moved backup whose bytes changed before recovery', async () => {
|
||||
const fixture = await installedFixture()
|
||||
const backupPath = join(
|
||||
dirname(fixture.canonicalPath),
|
||||
`.${basename(fixture.canonicalPath)}.orca-remove-backup-x`
|
||||
)
|
||||
await rename(fixture.canonicalPath, backupPath)
|
||||
await writeFile(join(backupPath, 'local.md'), 'changed')
|
||||
const journal: SkillRemovalJournalV1 = {
|
||||
schemaVersion: 1,
|
||||
operation: 'remove',
|
||||
phase: 'moving',
|
||||
canonicalPath: fixture.canonicalPath,
|
||||
movedCount: 1,
|
||||
moves: [
|
||||
{
|
||||
sourcePath: fixture.canonicalPath,
|
||||
backupPath,
|
||||
placement: {
|
||||
provider: 'agent-skills',
|
||||
path: fixture.canonicalPath,
|
||||
topology: 'canonical-copy',
|
||||
status: 'installed'
|
||||
},
|
||||
expectedDigest: fixture.receipt.packageDigest
|
||||
}
|
||||
],
|
||||
receipt: fixture.receipt,
|
||||
allowedProviderRoots: []
|
||||
}
|
||||
await writeSkillStateFile(
|
||||
skillRemovalJournalPath(fixture.stateDirectory, fixture.canonicalPath),
|
||||
journal
|
||||
)
|
||||
|
||||
await expect(
|
||||
recoverSkillRemovalTransaction(fixture.stateDirectory, fixture.canonicalPath)
|
||||
).rejects.toThrow('skill-removal-recovery-conflict')
|
||||
expect(await readFile(join(backupPath, 'local.md'), 'utf8')).toBe('changed')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { lstat, readFile, rm } from 'node:fs/promises'
|
||||
import { lstat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { SkillInstallResult, SkillPlacementResult } from '../../shared/skill-install-contract'
|
||||
import { acquireSkillInstallLock } from './skill-install-lock'
|
||||
@@ -15,21 +15,19 @@ import {
|
||||
type SkillInstallFilesystem
|
||||
} from './skill-install-filesystem'
|
||||
import { isRemovableSkillPlacement } from './skill-removable-placement'
|
||||
import {
|
||||
recoverSkillRemovalTransaction,
|
||||
skillRemovalJournalPath,
|
||||
skillRemovalPathExists,
|
||||
type RemovalMove,
|
||||
type SkillRemovalJournalV1
|
||||
} from './skill-remove-recovery'
|
||||
|
||||
type RemovalMove = {
|
||||
sourcePath: string
|
||||
backupPath: string
|
||||
placement: SkillPlacementResult
|
||||
}
|
||||
|
||||
type SkillRemovalJournalV1 = {
|
||||
schemaVersion: 1
|
||||
operation: 'remove'
|
||||
phase: 'prepared' | 'moving' | 'receipt-removed'
|
||||
canonicalPath: string
|
||||
movedCount: number
|
||||
moves: RemovalMove[]
|
||||
}
|
||||
export {
|
||||
recoverSkillRemovalTransaction,
|
||||
skillRemovalJournalPath,
|
||||
type SkillRemovalJournalV1
|
||||
} from './skill-remove-recovery'
|
||||
|
||||
export type LocalSkillRemovalInput = {
|
||||
operationId: string
|
||||
@@ -40,94 +38,16 @@ export type LocalSkillRemovalInput = {
|
||||
filesystem?: SkillInstallFilesystem
|
||||
}
|
||||
|
||||
function journalPath(stateDirectory: string, canonicalPath: string): string {
|
||||
return join(stateDirectory, 'removal-journals', `${skillInstallStateKey(canonicalPath)}.json`)
|
||||
}
|
||||
|
||||
function lockPath(stateDirectory: string, canonicalPath: string): string {
|
||||
return join(stateDirectory, 'locks', `${skillInstallStateKey(canonicalPath)}.lock`)
|
||||
}
|
||||
|
||||
function validBackup(move: RemovalMove): boolean {
|
||||
return (
|
||||
dirname(move.sourcePath) === dirname(move.backupPath) &&
|
||||
basename(move.backupPath).startsWith(`.${basename(move.sourcePath)}.orca-remove-backup-`)
|
||||
)
|
||||
}
|
||||
|
||||
function isRemovalJournal(value: unknown, canonicalPath: string): value is SkillRemovalJournalV1 {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const journal = value as Partial<SkillRemovalJournalV1>
|
||||
return (
|
||||
journal.schemaVersion === 1 &&
|
||||
journal.operation === 'remove' &&
|
||||
journal.canonicalPath === canonicalPath &&
|
||||
(journal.phase === 'prepared' ||
|
||||
journal.phase === 'moving' ||
|
||||
journal.phase === 'receipt-removed') &&
|
||||
Number.isInteger(journal.movedCount) &&
|
||||
Array.isArray(journal.moves) &&
|
||||
journal.moves.every(validBackup) &&
|
||||
journal.movedCount! >= 0 &&
|
||||
journal.movedCount! <= journal.moves.length
|
||||
)
|
||||
}
|
||||
|
||||
async function readJournal(
|
||||
stateDirectory: string,
|
||||
canonicalPath: string
|
||||
): Promise<SkillRemovalJournalV1 | null> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(
|
||||
await readFile(journalPath(stateDirectory, canonicalPath), 'utf8')
|
||||
)
|
||||
if (!isRemovalJournal(value, canonicalPath)) {
|
||||
throw new Error('skill-removal-journal-invalid')
|
||||
}
|
||||
return value
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
return Boolean(await lstat(path).catch(() => null))
|
||||
}
|
||||
|
||||
export async function recoverSkillRemovalTransaction(
|
||||
stateDirectory: string,
|
||||
canonicalPath: string,
|
||||
filesystem: SkillInstallFilesystem = nativeSkillInstallFilesystem
|
||||
): Promise<void> {
|
||||
const journal = await readJournal(stateDirectory, canonicalPath)
|
||||
if (!journal) {
|
||||
return
|
||||
}
|
||||
if (journal.phase === 'receipt-removed') {
|
||||
for (const move of journal.moves.slice(0, journal.movedCount)) {
|
||||
await filesystem.remove(move.backupPath)
|
||||
}
|
||||
await rm(journalPath(stateDirectory, canonicalPath), { force: true })
|
||||
return
|
||||
}
|
||||
for (const move of journal.moves.slice(0, journal.movedCount).toReversed()) {
|
||||
if ((await pathExists(move.backupPath)) && !(await pathExists(move.sourcePath))) {
|
||||
await filesystem.rename(move.backupPath, move.sourcePath)
|
||||
}
|
||||
}
|
||||
await rm(journalPath(stateDirectory, canonicalPath), { force: true })
|
||||
}
|
||||
|
||||
function conflictResult(
|
||||
input: LocalSkillRemovalInput,
|
||||
receipt: SkillInstallReceiptV1 | null,
|
||||
kind: 'modified' | 'unowned' | 'external-link' | 'name-collision'
|
||||
): SkillInstallResult {
|
||||
const code = `skill-removal-conflict-${kind}`
|
||||
return {
|
||||
operationId: input.operationId,
|
||||
status: 'conflict',
|
||||
@@ -135,7 +55,9 @@ function conflictResult(
|
||||
packageDigest: receipt?.packageDigest ?? '',
|
||||
canonicalPath: input.canonicalPath,
|
||||
placements: [],
|
||||
conflict: { kind }
|
||||
conflict: { kind },
|
||||
errorCategory: code,
|
||||
failure: { category: 'conflict', code, retryable: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,17 +126,32 @@ export async function removeLocalSharedSkill(
|
||||
dirname(placement.path),
|
||||
`.${basename(placement.path)}.orca-remove-backup-${randomUUID()}`
|
||||
),
|
||||
placement
|
||||
placement,
|
||||
...(placement.topology === 'provider-alias'
|
||||
? { expectedAliasTarget: receipt.canonicalPath }
|
||||
: { expectedDigest: receipt.packageDigest })
|
||||
})
|
||||
} else if (await pathExists(placement.path)) {
|
||||
} else if (await skillRemovalPathExists(placement.path)) {
|
||||
skippedPlacements.push({
|
||||
...placement,
|
||||
status: 'skipped',
|
||||
errorCategory: 'skill-removal-placement-modified-or-unowned'
|
||||
errorCategory: 'skill-removal-placement-modified-or-unowned',
|
||||
failure: {
|
||||
category: 'conflict',
|
||||
code: 'skill-removal-placement-modified-or-unowned',
|
||||
retryable: false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (state !== 'missing') {
|
||||
const canonicalDigest = await filesystem
|
||||
.observeSkill(input.canonicalPath, receipt.fileModes)
|
||||
.then((observed) => observed.observedDigest)
|
||||
.catch(() => null)
|
||||
if (!canonicalDigest) {
|
||||
return conflictResult(input, receipt, 'modified')
|
||||
}
|
||||
moves.push({
|
||||
sourcePath: input.canonicalPath,
|
||||
backupPath: join(
|
||||
@@ -226,7 +163,8 @@ export async function removeLocalSharedSkill(
|
||||
path: input.canonicalPath,
|
||||
topology: 'canonical-copy',
|
||||
status: 'installed'
|
||||
}
|
||||
},
|
||||
expectedDigest: canonicalDigest
|
||||
})
|
||||
}
|
||||
const journal: SkillRemovalJournalV1 = {
|
||||
@@ -235,9 +173,11 @@ export async function removeLocalSharedSkill(
|
||||
phase: 'prepared',
|
||||
canonicalPath: input.canonicalPath,
|
||||
movedCount: 0,
|
||||
moves
|
||||
moves,
|
||||
receipt,
|
||||
allowedProviderRoots: [...input.allowedProviderRoots]
|
||||
}
|
||||
const statePath = journalPath(input.stateDirectory, input.canonicalPath)
|
||||
const statePath = skillRemovalJournalPath(input.stateDirectory, input.canonicalPath)
|
||||
await writeSkillStateFile(statePath, journal)
|
||||
for (const move of moves) {
|
||||
journal.movedCount += 1
|
||||
|
||||
@@ -415,6 +415,7 @@ import type {
|
||||
SkillRemoveOperation,
|
||||
SkillShareInstallInput,
|
||||
SkillShareInstallOperation,
|
||||
SkillInstallCancelInput,
|
||||
SkillSharePreview,
|
||||
SkillShareProgress,
|
||||
SkillSharePublishInput,
|
||||
@@ -2580,10 +2581,22 @@ export type PreloadApi = {
|
||||
installPackageVersion: (
|
||||
input: SkillPackageVersionInstallInput
|
||||
) => Promise<SkillShareInstallOperation>
|
||||
cancelInstall: (input: SkillInstallCancelInput) => Promise<{ cancelled: boolean }>
|
||||
previewInstall: (input: SkillInstallPreviewInput) => Promise<SkillInstallPreviewOperation>
|
||||
removeInstall: (input: SkillRemoveInput) => Promise<SkillRemoveOperation>
|
||||
listManagedInstalls: (environmentId?: string) => Promise<ManagedSkillInstallListOperation>
|
||||
getPackage: (packageId: string) => Promise<SkillCloudOperation<SkillCloudPackageDetails>>
|
||||
replacePackageAccess: (input: {
|
||||
packageId: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
}) => Promise<SkillCloudOperation<void>>
|
||||
revokeShare: (shareId: string) => Promise<SkillCloudOperation<void>>
|
||||
deletePackageVersion: (input: {
|
||||
packageId: string
|
||||
versionId: string
|
||||
}) => Promise<SkillCloudOperation<void>>
|
||||
deletePackage: (packageId: string) => Promise<SkillCloudOperation<void>>
|
||||
listWslDistros: (environmentId?: string) => Promise<string[]>
|
||||
onShareProgress: (callback: (progress: SkillShareProgress) => void) => () => void
|
||||
onUpdateRun: (callback: (run: SkillUpdateRun) => void) => () => void
|
||||
|
||||
@@ -143,6 +143,7 @@ import type {
|
||||
SkillRemoveOperation,
|
||||
SkillShareInstallInput,
|
||||
SkillShareInstallOperation,
|
||||
SkillInstallCancelInput,
|
||||
SkillSharePreview,
|
||||
SkillShareProgress,
|
||||
SkillSharePublishInput,
|
||||
@@ -2537,6 +2538,8 @@ const api = {
|
||||
input: SkillPackageVersionInstallInput
|
||||
): Promise<SkillShareInstallOperation> =>
|
||||
ipcRenderer.invoke('skills:installPackageVersion', input),
|
||||
cancelInstall: (input: SkillInstallCancelInput): Promise<{ cancelled: boolean }> =>
|
||||
ipcRenderer.invoke('skills:cancelInstall', input),
|
||||
previewInstall: (input: SkillInstallPreviewInput): Promise<SkillInstallPreviewOperation> =>
|
||||
ipcRenderer.invoke('skills:previewInstall', input),
|
||||
removeInstall: (input: SkillRemoveInput): Promise<SkillRemoveOperation> =>
|
||||
@@ -2545,6 +2548,21 @@ const api = {
|
||||
ipcRenderer.invoke('skills:listManagedInstalls', environmentId),
|
||||
getPackage: (packageId: string): Promise<SkillCloudOperation<SkillCloudPackageDetails>> =>
|
||||
ipcRenderer.invoke('skills:getPackage', packageId),
|
||||
replacePackageAccess: (input: {
|
||||
packageId: string
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
}): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:replacePackageAccess', input),
|
||||
revokeShare: (shareId: string): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:revokeShare', shareId),
|
||||
deletePackageVersion: (input: {
|
||||
packageId: string
|
||||
versionId: string
|
||||
}): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:deletePackageVersion', input),
|
||||
deletePackage: (packageId: string): Promise<SkillCloudOperation<void>> =>
|
||||
ipcRenderer.invoke('skills:deletePackage', packageId),
|
||||
listWslDistros: (environmentId?: string): Promise<string[]> =>
|
||||
ipcRenderer.invoke('skills:listWslDistros', environmentId),
|
||||
onShareProgress: (callback: (progress: SkillShareProgress) => void): (() => void) => {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @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: {
|
||||
userIds: ['legacy_user'],
|
||||
shareWithOrganization: false,
|
||||
shares: [
|
||||
{
|
||||
id: 'share_1',
|
||||
pinnedVersionId: 'ver_1',
|
||||
createdAt: '2026-08-11T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const skills = {
|
||||
replacePackageAccess: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
|
||||
}
|
||||
const onChanged = vi.fn().mockResolvedValue(undefined)
|
||||
const onPackageDeleted = vi.fn()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
skills,
|
||||
orcaProfiles: {
|
||||
authStatus: vi.fn().mockResolvedValue({
|
||||
cloud: { userId: 'viewer', activeOrgId: 'org_1' }
|
||||
}),
|
||||
orgMembersList: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
roster: {
|
||||
members: [
|
||||
{
|
||||
userId: 'user_2',
|
||||
email: 'teammate@example.com',
|
||||
displayName: 'Teammate',
|
||||
role: 'member'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
render(
|
||||
<SkillCloudManagementActions
|
||||
details={details}
|
||||
selectedVersionId="ver_1"
|
||||
onChanged={onChanged}
|
||||
onPackageDeleted={onPackageDeleted}
|
||||
/>
|
||||
)
|
||||
return { skills, onChanged, onPackageDeleted }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
describe('SkillCloudManagementActions', () => {
|
||||
it('saves organization and selected-user access while preserving unknown recipients', async () => {
|
||||
const { skills, onChanged } = setup()
|
||||
const organization = await screen.findByRole('checkbox', { name: 'Current organization' })
|
||||
await waitFor(() => expect((organization as HTMLButtonElement).disabled).toBe(false))
|
||||
fireEvent.click(organization)
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Teammate' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save access' }))
|
||||
|
||||
await waitFor(() => expect(skills.replacePackageAccess).toHaveBeenCalledOnce())
|
||||
expect(skills.replacePackageAccess).toHaveBeenCalledWith({
|
||||
packageId: 'pkg_1',
|
||||
userIds: ['legacy_user', 'user_2'],
|
||||
shareWithOrganization: true
|
||||
})
|
||||
expect(onChanged).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('requires confirmation before revoking an active link', async () => {
|
||||
const { skills } = setup()
|
||||
expect(screen.getByText(/Copies already installed on any machine remain there\./)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unshare' }))
|
||||
expect(skills.revokeShare).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm unshare' }))
|
||||
|
||||
await waitFor(() => expect(skills.revokeShare).toHaveBeenCalledWith('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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Link2Off, Save, Trash2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
|
||||
import type {
|
||||
SkillCloudOperation,
|
||||
SkillCloudPackageDetails
|
||||
} from '../../../../shared/skill-cloud-contract'
|
||||
|
||||
type IdentifiedMember = OrcaOrgMember & { userId: string }
|
||||
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.userIds,
|
||||
management.shareWithOrganization,
|
||||
management.shares
|
||||
])
|
||||
return <SkillCloudManagementForm key={stateKey} {...props} management={management} />
|
||||
}
|
||||
|
||||
function SkillCloudManagementForm({
|
||||
details,
|
||||
selectedVersionId,
|
||||
onChanged,
|
||||
onPackageDeleted,
|
||||
management
|
||||
}: SkillCloudManagementActionsProps & {
|
||||
management: SkillCloudManagement
|
||||
}): React.JSX.Element {
|
||||
const [members, setMembers] = useState<IdentifiedMember[]>([])
|
||||
const [organizationAvailable, setOrganizationAvailable] = useState(false)
|
||||
const [userIds, setUserIds] = useState<string[]>(management?.userIds ?? [])
|
||||
const [shareWithOrganization, setShareWithOrganization] = useState(
|
||||
management?.shareWithOrganization ?? false
|
||||
)
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setOrganizationAvailable(false)
|
||||
setMembers([])
|
||||
void window.api.orcaProfiles
|
||||
.authStatus()
|
||||
.then(async (auth) => {
|
||||
const orgId = auth.cloud?.activeOrgId
|
||||
if (!active || !orgId) {
|
||||
return
|
||||
}
|
||||
setOrganizationAvailable(true)
|
||||
const result = await window.api.orcaProfiles.orgMembersList({ orgId })
|
||||
if (active && result.status === 'ok') {
|
||||
setMembers(
|
||||
result.roster.members.filter(
|
||||
(member): member is IdentifiedMember =>
|
||||
Boolean(member.userId) && member.userId !== auth.cloud?.userId
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const unknownUserCount = useMemo(
|
||||
() => userIds.filter((id) => !members.some((member) => member.userId === id)).length,
|
||||
[members, userIds]
|
||||
)
|
||||
|
||||
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="Cloud sharing controls">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">Access</h4>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={shareWithOrganization}
|
||||
disabled={!organizationAvailable || busyAction !== null}
|
||||
onCheckedChange={(checked) => setShareWithOrganization(checked === true)}
|
||||
/>
|
||||
Current organization
|
||||
</label>
|
||||
{members.length > 0 ? (
|
||||
<div className="max-h-28 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
|
||||
{members.map((member) => (
|
||||
<label key={member.userId} className="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={userIds.includes(member.userId)}
|
||||
disabled={busyAction !== null}
|
||||
onCheckedChange={(checked) =>
|
||||
setUserIds((current) =>
|
||||
checked
|
||||
? [...new Set([...current, member.userId])]
|
||||
: current.filter((id) => id !== member.userId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{member.displayName || member.email}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{unknownUserCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unknownUserCount} existing recipient{unknownUserCount === 1 ? '' : 's'} outside the
|
||||
current roster will be preserved.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busyAction !== null}
|
||||
onClick={() =>
|
||||
void run('access', () =>
|
||||
window.api.skills.replacePackageAccess({
|
||||
packageId: details.id,
|
||||
userIds,
|
||||
shareWithOrganization
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{busyAction === 'access' ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
Save access
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold">Active links</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unsharing blocks future installs. Copies already installed on any machine remain there.
|
||||
</p>
|
||||
{management.shares.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">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.id}
|
||||
</Badge>
|
||||
<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}` ? 'Confirm unshare' : '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}`
|
||||
? 'Confirm version deletion'
|
||||
: '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' ? 'Confirm package deletion' : 'Delete Cloud package'}
|
||||
</Button>
|
||||
</div>
|
||||
{message ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ function installApi(previewInstall: ReturnType<typeof vi.fn>) {
|
||||
placements: []
|
||||
}
|
||||
}),
|
||||
cancelInstall: vi.fn().mockResolvedValue({ cancelled: true }),
|
||||
listWslDistros: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}
|
||||
@@ -134,4 +135,71 @@ describe('SkillInstallDialog', () => {
|
||||
)
|
||||
expect(skills.installShare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invalidates skill discovery after a verified install', async () => {
|
||||
const previewInstall = vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
name: 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
destinationIdentity: 'local:global',
|
||||
currentState: 'missing',
|
||||
providers: []
|
||||
}
|
||||
})
|
||||
const skills = installApi(previewInstall)
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
const changed = vi.fn()
|
||||
window.addEventListener('orca:installed-agent-skills-changed', changed)
|
||||
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
|
||||
await inspectSkill()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
|
||||
|
||||
await screen.findByText('Installed and verified.')
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
window.removeEventListener('orca:installed-agent-skills-changed', changed)
|
||||
})
|
||||
|
||||
it('cancels an active destination-owned install and renders the structured result', async () => {
|
||||
const previewInstall = vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
name: 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
destinationIdentity: 'local:global',
|
||||
currentState: 'missing',
|
||||
providers: []
|
||||
}
|
||||
})
|
||||
let settleInstall: ((value: unknown) => void) | undefined
|
||||
const skills = installApi(previewInstall)
|
||||
skills.installShare.mockImplementation(
|
||||
() => new Promise((resolve) => (settleInstall = resolve)) as never
|
||||
)
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: { skills } })
|
||||
render(<SkillInstallDialog open onOpenChange={() => undefined} />)
|
||||
await inspectSkill()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Cancel installation' }))
|
||||
|
||||
await waitFor(() => expect(skills.cancelInstall).toHaveBeenCalledOnce())
|
||||
const operationId = skills.installShare.mock.calls[0]?.[0].operationId
|
||||
expect(skills.cancelInstall).toHaveBeenCalledWith({ operationId })
|
||||
settleInstall?.({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId,
|
||||
status: 'cancelled',
|
||||
name: 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: [],
|
||||
errorCategory: 'skill-install-cancelled',
|
||||
failure: { category: 'cancelled', code: 'skill-install-cancelled', retryable: true }
|
||||
}
|
||||
})
|
||||
await screen.findByText('Installation cancelled.')
|
||||
expect(screen.getByRole('button', { name: 'Retry install' })).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Check, Download, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -10,8 +9,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
SkillInstallDestination,
|
||||
@@ -19,10 +16,15 @@ import type {
|
||||
SkillInstallResult
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import { parseSkillShareId } from './skill-share-link'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
import { skillInstallWorkspaceChoices } from './skill-install-workspace-choices'
|
||||
import { summarizeSkillShareVersion, type ResolvedSkillShare } from './skill-share-version-summary'
|
||||
import type { ResolvedSkillShare } from './skill-share-version-summary'
|
||||
import { SkillInstallTargetFields } from './SkillInstallTargetFields'
|
||||
import {
|
||||
SkillInstallOutcome,
|
||||
SkillInstallReview,
|
||||
SkillShareLinkInputForm
|
||||
} from './SkillInstallReviewContent'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
|
||||
export function SkillInstallDialog({
|
||||
open,
|
||||
@@ -52,6 +54,7 @@ export function SkillInstallDialog({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SkillInstallResult | null>(null)
|
||||
const [destinationPreview, setDestinationPreview] = useState<SkillInstallPreview | null>(null)
|
||||
const [activeOperationId, setActiveOperationId] = useState<string | null>(null)
|
||||
|
||||
const workspaceChoices = useMemo(
|
||||
() => skillInstallWorkspaceChoices({ environmentId, folderWorkspaces, repos, worktreesByRepo }),
|
||||
@@ -149,8 +152,11 @@ export function SkillInstallDialog({
|
||||
return
|
||||
}
|
||||
}
|
||||
const operationId = crypto.randomUUID()
|
||||
setActiveOperationId(operationId)
|
||||
const operation = await window.api.skills.installShare({
|
||||
shareId: preview.shareId,
|
||||
operationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
|
||||
destination,
|
||||
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
|
||||
@@ -168,14 +174,31 @@ export function SkillInstallDialog({
|
||||
return
|
||||
}
|
||||
setResult(operation.value)
|
||||
if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] install failed:', cause)
|
||||
setError('Installation failed before Orca could verify the requested version.')
|
||||
} finally {
|
||||
setActiveOperationId(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelInstall = async (): Promise<void> => {
|
||||
if (!activeOperationId) {
|
||||
return
|
||||
}
|
||||
const cancelled = await window.api.skills.cancelInstall({
|
||||
operationId: activeOperationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId })
|
||||
})
|
||||
if (!cancelled.cancelled) {
|
||||
setError('The destination had already finished this installation.')
|
||||
}
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
setPreview(null)
|
||||
setDestinationPreview(null)
|
||||
@@ -187,9 +210,6 @@ export function SkillInstallDialog({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const version = preview?.version
|
||||
const versionSummary = summarizeSkillShareVersion(version)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
|
||||
@@ -201,95 +221,22 @@ export function SkillInstallDialog({
|
||||
</DialogHeader>
|
||||
|
||||
{!preview ? (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void inspect()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-share-link">Orca skill link</Label>
|
||||
<Input
|
||||
id="skill-share-link"
|
||||
value={link}
|
||||
onChange={(event) => setLink(event.target.value)}
|
||||
placeholder="https://app.orca.dev/skills/share/…"
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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 ? 'Checking…' : 'Inspect skill'}
|
||||
</Button>
|
||||
</form>
|
||||
<SkillShareLinkInputForm
|
||||
link={link}
|
||||
busy={busy}
|
||||
onLinkChange={setLink}
|
||||
onSubmit={() => void inspect()}
|
||||
/>
|
||||
) : result && result.status !== 'conflict' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border p-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<Check className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{skillInstallResultLabel(result)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{result.placements.length} placement{result.placements.length === 1 ? '' : 's'}{' '}
|
||||
checked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.status === 'partial' ? (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{result.placements
|
||||
.filter((item) => item.status === 'failed' || item.status === 'skipped')
|
||||
.map((item) => (
|
||||
<p key={`${item.provider}:${item.path}`}>
|
||||
{item.provider}: {item.errorCategory || item.status}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SkillInstallOutcome result={result} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{version?.name}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{version?.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">Immutable version</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">{version?.manifest.files.length} files</Badge>
|
||||
<Badge variant="outline">{versionSummary.scriptCount} scripts</Badge>
|
||||
<Badge variant="outline">{versionSummary.executableCount} executable</Badge>
|
||||
</div>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
SHA-256 {version?.packageDigest}
|
||||
</p>
|
||||
{version?.publisher ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Published by Orca user {version.publisher.userId}
|
||||
{version.publisher.organizationId
|
||||
? ` in organization ${version.publisher.organizationId}`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
A skill contains instructions and may include scripts. Treat it as code from its
|
||||
author.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<SkillInstallReview
|
||||
preview={preview}
|
||||
destinationPreview={destinationPreview}
|
||||
result={result}
|
||||
busy={busy}
|
||||
onDiscard={() => void install(true)}
|
||||
>
|
||||
<SkillInstallTargetFields
|
||||
environmentId={environmentId}
|
||||
onEnvironmentChange={(value) => {
|
||||
@@ -316,34 +263,7 @@ export function SkillInstallDialog({
|
||||
sshConnections={sshConnections}
|
||||
workspaceChoices={workspaceChoices}
|
||||
/>
|
||||
|
||||
{result?.status === 'conflict' ||
|
||||
(destinationPreview &&
|
||||
['modified', 'unowned', 'external-link', 'name-collision'].includes(
|
||||
destinationPreview.currentState
|
||||
)) ? (
|
||||
<section className="space-y-2 rounded-md border border-border p-3" role="alert">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="size-4" /> Local copy needs a decision
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Orca found{' '}
|
||||
{result?.conflict?.kind || destinationPreview?.currentState || 'changed'} content
|
||||
and left it untouched. Keep it, or explicitly discard and replace it with this
|
||||
version.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void install(true)}
|
||||
>
|
||||
Discard and replace
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</SkillInstallReview>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
@@ -355,7 +275,12 @@ export function SkillInstallDialog({
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
||||
Close
|
||||
</Button>
|
||||
{preview && (!result || result.status === 'conflict') ? (
|
||||
{busy && activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
|
||||
Cancel installation
|
||||
</Button>
|
||||
) : null}
|
||||
{preview && (!result || ['conflict', 'failed', 'cancelled'].includes(result.status)) ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || (scope === 'workspace' && !workspace)}
|
||||
@@ -363,7 +288,7 @@ export function SkillInstallDialog({
|
||||
className="w-32"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
{busy ? 'Installing…' : 'Install skill'}
|
||||
{busy ? 'Installing…' : result ? 'Retry install' : 'Install skill'}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SkillCloudVersion
|
||||
} from '../../../../shared/skill-cloud-contract'
|
||||
import { useAppStore } from '@/store'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from '@/hooks/installed-agent-skills-change-event'
|
||||
import { SkillInstallManagementDialog } from './SkillInstallManagementDialog'
|
||||
|
||||
const DIGEST = 'a'.repeat(64)
|
||||
@@ -62,10 +63,14 @@ function packageDetails(versions: SkillCloudVersion[]): SkillCloudPackageDetails
|
||||
}
|
||||
}
|
||||
|
||||
function skillsApi(installed: ManagedSkillInstall, versions: SkillCloudVersion[]) {
|
||||
function skillsApi(
|
||||
installed: ManagedSkillInstall,
|
||||
versions: SkillCloudVersion[],
|
||||
details = packageDetails(versions)
|
||||
) {
|
||||
return {
|
||||
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: [installed] }),
|
||||
getPackage: vi.fn().mockResolvedValue({ status: 'ok', value: packageDetails(versions) }),
|
||||
getPackage: vi.fn().mockResolvedValue({ status: 'ok', value: details }),
|
||||
installPackageVersion: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
@@ -85,7 +90,11 @@ function skillsApi(installed: ManagedSkillInstall, versions: SkillCloudVersion[]
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
})
|
||||
}),
|
||||
replacePackageAccess: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
revokeShare: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackageVersion: vi.fn().mockResolvedValue({ status: 'ok', value: undefined }),
|
||||
deletePackage: vi.fn().mockResolvedValue({ status: 'ok', value: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +124,8 @@ describe('SkillInstallManagementDialog', () => {
|
||||
version('ver_2', '2026-08-12T00:00:00.000Z'),
|
||||
version('ver_1', '2026-08-11T00:00:00.000Z')
|
||||
])
|
||||
const changed = vi.fn()
|
||||
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')
|
||||
@@ -125,6 +136,36 @@ describe('SkillInstallManagementDialog', () => {
|
||||
expect(skills.installPackageVersion).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ packageId: 'pkg_1', versionId: 'ver_2' })
|
||||
)
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
|
||||
})
|
||||
|
||||
it('does not invalidate discovery after a failed local mutation', async () => {
|
||||
const skills = skillsApi(install('ver_1'), [
|
||||
version('ver_2', '2026-08-12T00:00:00.000Z'),
|
||||
version('ver_1', '2026-08-11T00:00:00.000Z')
|
||||
])
|
||||
skills.installPackageVersion.mockResolvedValue({
|
||||
status: 'ok',
|
||||
value: {
|
||||
operationId: 'operation_failed',
|
||||
status: 'failed',
|
||||
name: 'private-skill',
|
||||
packageDigest: DIGEST,
|
||||
placements: []
|
||||
}
|
||||
})
|
||||
const changed = vi.fn()
|
||||
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')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Install selected version' }))
|
||||
|
||||
await waitFor(() => expect(skills.installPackageVersion).toHaveBeenCalledOnce())
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, changed)
|
||||
})
|
||||
|
||||
it('rolls an installed skill back to an older immutable version', async () => {
|
||||
@@ -159,4 +200,33 @@ describe('SkillInstallManagementDialog', () => {
|
||||
expect.objectContaining({ name: 'private-skill', destination: { scope: 'global' } })
|
||||
)
|
||||
})
|
||||
|
||||
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: {
|
||||
userIds: [],
|
||||
shareWithOrganization: false,
|
||||
shares: []
|
||||
}
|
||||
}
|
||||
const skills = skillsApi(install('ver_2'), versions, details)
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
skills,
|
||||
orcaProfiles: { authStatus: vi.fn().mockResolvedValue({ cloud: undefined }) }
|
||||
}
|
||||
})
|
||||
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' }))
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
SkillInstallResult
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { SkillCloudManagementActions } from './SkillCloudManagementActions'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
|
||||
function installKey(install: ManagedSkillInstall): string {
|
||||
return `${install.destinationIdentity}:${install.name}:${install.packageId}`
|
||||
@@ -47,6 +50,8 @@ export function SkillInstallManagementDialog({
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [result, setResult] = useState<SkillInstallResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [activeOperationId, setActiveOperationId] = useState<string | null>(null)
|
||||
|
||||
const selected = useMemo(
|
||||
() => installs.find((install) => installKey(install) === selectedKey) ?? null,
|
||||
@@ -70,6 +75,7 @@ export function SkillInstallManagementDialog({
|
||||
setInstalls(operation.value)
|
||||
setSelectedKey('')
|
||||
setDetails(null)
|
||||
setNotice(null)
|
||||
} catch (cause) {
|
||||
console.warn('[skills] managed install listing failed:', cause)
|
||||
setError('Orca could not inspect managed installs on this machine.')
|
||||
@@ -86,6 +92,7 @@ export function SkillInstallManagementDialog({
|
||||
setSelectedKey(installKey(install))
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setResult(null)
|
||||
setConfirmRemove(false)
|
||||
try {
|
||||
@@ -108,16 +115,42 @@ export function SkillInstallManagementDialog({
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
operation.value.versions.some((version) => version.versionId === current)
|
||||
? current
|
||||
: (operation.value.versions[0]?.versionId ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
const packageDeleted = (): void => {
|
||||
setDetails(null)
|
||||
setVersionId('')
|
||||
setNotice('Cloud package deleted. The installed copy remains on this machine.')
|
||||
}
|
||||
|
||||
const installVersion = async (discardLocal = false): Promise<void> => {
|
||||
if (!selected || !versionId) {
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
const operationId = crypto.randomUUID()
|
||||
setActiveOperationId(operationId)
|
||||
try {
|
||||
const operation = await window.api.skills.installPackageVersion({
|
||||
packageId: selected.packageId,
|
||||
versionId,
|
||||
operationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
|
||||
destination: selected.destination,
|
||||
...(discardLocal ? { conflictResolution: 'replace-and-discard-local' } : {})
|
||||
@@ -131,17 +164,32 @@ export function SkillInstallManagementDialog({
|
||||
return
|
||||
}
|
||||
setResult(operation.value)
|
||||
if (operation.value.status !== 'conflict') {
|
||||
if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
await load()
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] version installation failed:', cause)
|
||||
setError('Orca could not verify the requested version.')
|
||||
} finally {
|
||||
setActiveOperationId(null)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelInstall = async (): Promise<void> => {
|
||||
if (!activeOperationId) {
|
||||
return
|
||||
}
|
||||
const cancelled = await window.api.skills.cancelInstall({
|
||||
operationId: activeOperationId,
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId })
|
||||
})
|
||||
if (!cancelled.cancelled) {
|
||||
setError('The destination had already finished this installation.')
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (discardLocal = false): Promise<void> => {
|
||||
if (!selected) {
|
||||
return
|
||||
@@ -152,6 +200,7 @@ export function SkillInstallManagementDialog({
|
||||
}
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
try {
|
||||
const operation = await window.api.skills.removeInstall({
|
||||
...(environmentId === 'local' || environmentId.startsWith('ssh:') ? {} : { environmentId }),
|
||||
@@ -164,7 +213,8 @@ export function SkillInstallManagementDialog({
|
||||
return
|
||||
}
|
||||
setResult(operation.value)
|
||||
if (operation.value.status !== 'conflict') {
|
||||
if (!['conflict', 'failed', 'cancelled'].includes(operation.value.status)) {
|
||||
notifyInstalledAgentSkillsChanged()
|
||||
await load()
|
||||
}
|
||||
} catch (cause) {
|
||||
@@ -179,6 +229,7 @@ export function SkillInstallManagementDialog({
|
||||
setSelectedKey('')
|
||||
setDetails(null)
|
||||
setError(null)
|
||||
setNotice(null)
|
||||
setResult(null)
|
||||
setConfirmRemove(false)
|
||||
onOpenChange(false)
|
||||
@@ -299,6 +350,11 @@ export function SkillInstallManagementDialog({
|
||||
)}
|
||||
Install selected version
|
||||
</Button>
|
||||
{activeOperationId ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => void cancelInstall()}>
|
||||
Cancel installation
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={confirmRemove ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
@@ -308,13 +364,29 @@ export function SkillInstallManagementDialog({
|
||||
<Trash2 className="size-4" /> {confirmRemove ? 'Confirm remove' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
<SkillCloudManagementActions
|
||||
details={details}
|
||||
selectedVersionId={versionId}
|
||||
onChanged={refreshPackageDetails}
|
||||
onPackageDeleted={packageDeleted}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{result ? (
|
||||
<p className="text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
{skillInstallResultLabel(result)}
|
||||
</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}>
|
||||
Close
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type {
|
||||
SkillInstallPreview,
|
||||
SkillInstallResult
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
import { summarizeSkillShareVersion, type ResolvedSkillShare } from './skill-share-version-summary'
|
||||
|
||||
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"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skill-share-link">Orca skill link</Label>
|
||||
<Input
|
||||
id="skill-share-link"
|
||||
value={link}
|
||||
onChange={(event) => onLinkChange(event.target.value)}
|
||||
placeholder="https://app.orca.dev/skills/share/…"
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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 ? 'Checking…' : 'Inspect skill'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillInstallOutcome({ result }: { result: SkillInstallResult }): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-md border border-border p-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
{result.status === 'failed' || result.status === 'cancelled' ? (
|
||||
<AlertTriangle className="size-4" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{skillInstallResultLabel(result)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{result.placements.length} placement{result.placements.length === 1 ? '' : 's'} checked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.status === 'partial' ? (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{result.placements
|
||||
.filter((item) => item.status === 'failed' || item.status === 'skipped')
|
||||
.map((item) => (
|
||||
<p key={`${item.provider}:${item.path}`}>
|
||||
{item.provider}: {item.errorCategory || item.status}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{result.failure ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{result.failure.code}
|
||||
{result.failure.retryable ? ' · You can retry safely.' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillInstallReview({
|
||||
preview,
|
||||
destinationPreview,
|
||||
result,
|
||||
busy,
|
||||
onDiscard,
|
||||
children
|
||||
}: {
|
||||
preview: ResolvedSkillShare
|
||||
destinationPreview: SkillInstallPreview | null
|
||||
result: SkillInstallResult | null
|
||||
busy: boolean
|
||||
onDiscard: () => void
|
||||
children: ReactNode
|
||||
}): React.JSX.Element {
|
||||
const version = preview.version
|
||||
const versionSummary = summarizeSkillShareVersion(version)
|
||||
const hasConflict =
|
||||
result?.status === 'conflict' ||
|
||||
(destinationPreview &&
|
||||
['modified', 'unowned', 'external-link', 'name-collision'].includes(
|
||||
destinationPreview.currentState
|
||||
))
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{version.name}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{version.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline">Immutable version</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">{version.manifest.files.length} files</Badge>
|
||||
<Badge variant="outline">{versionSummary.scriptCount} scripts</Badge>
|
||||
<Badge variant="outline">{versionSummary.executableCount} executable</Badge>
|
||||
</div>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
SHA-256 {version.packageDigest}
|
||||
</p>
|
||||
{version.publisher ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Published by Orca user {version.publisher.userId}
|
||||
{version.publisher.organizationId
|
||||
? ` in organization ${version.publisher.organizationId}`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
A skill contains instructions and may include scripts. Treat it as code from its author.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{children}
|
||||
|
||||
{hasConflict ? (
|
||||
<section className="space-y-2 rounded-md border border-border p-3" role="alert">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="size-4" /> Local copy needs a decision
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Orca found {result?.conflict?.kind || destinationPreview?.currentState || 'changed'}{' '}
|
||||
content and left it untouched. Keep it, or explicitly discard and replace it with this
|
||||
version.
|
||||
</p>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={busy} onClick={onDiscard}>
|
||||
Discard and replace
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress
|
||||
} from '../../../../shared/skill-sharing-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import { SkillShareDialog } from './SkillShareDialog'
|
||||
|
||||
const skill: DiscoveredSkill = {
|
||||
id: 'home:private-skill',
|
||||
name: 'private-skill',
|
||||
description: 'Private skill',
|
||||
providers: ['codex'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Home',
|
||||
rootPath: '/home/skills',
|
||||
directoryPath: '/home/skills/private-skill',
|
||||
skillFilePath: '/home/skills/private-skill/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: null
|
||||
}
|
||||
|
||||
const preview: SkillSharePreview = {
|
||||
preparationId: '11111111-1111-4111-8111-111111111111',
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_2',
|
||||
name: 'private-skill',
|
||||
description: 'Private skill',
|
||||
packageDigest: 'a'.repeat(64),
|
||||
archiveSha256: 'b'.repeat(64),
|
||||
fileCount: 1,
|
||||
totalBytes: 128,
|
||||
compressedBytes: 96,
|
||||
scriptPaths: [],
|
||||
executablePaths: [],
|
||||
expiresAt: '2026-08-11T01:00:00.000Z'
|
||||
}
|
||||
|
||||
function managedInstall(destinationIdentity: string): ManagedSkillInstall {
|
||||
return {
|
||||
name: 'private-skill',
|
||||
packageId: 'pkg_1',
|
||||
versionId: 'ver_1',
|
||||
packageDigest: 'c'.repeat(64),
|
||||
scope: 'global',
|
||||
destinationIdentity,
|
||||
destination: { scope: 'global' },
|
||||
installedAt: '2026-08-11T00:00:00.000Z',
|
||||
state: 'unchanged'
|
||||
}
|
||||
}
|
||||
|
||||
function setup(installs: ManagedSkillInstall[], organization = false) {
|
||||
let progressListener: ((progress: SkillShareProgress) => void) | null = null
|
||||
const skills = {
|
||||
listManagedInstalls: vi.fn().mockResolvedValue({ status: 'ok', value: installs }),
|
||||
prepareShare: vi.fn().mockResolvedValue(preview),
|
||||
publishShare: vi.fn(),
|
||||
cancelShare: vi.fn().mockResolvedValue(undefined),
|
||||
releaseShare: vi.fn().mockResolvedValue(undefined),
|
||||
onShareProgress: vi.fn((listener) => {
|
||||
progressListener = listener
|
||||
return () => undefined
|
||||
})
|
||||
}
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
skills,
|
||||
orcaProfiles: {
|
||||
authStatus: vi.fn().mockResolvedValue({
|
||||
cloud: {
|
||||
email: 'owner@example.com',
|
||||
...(organization ? { activeOrgId: 'org_1', activeOrgName: 'Orca' } : {})
|
||||
}
|
||||
}),
|
||||
orgMembersList: vi.fn().mockResolvedValue({
|
||||
status: 'ok',
|
||||
roster: { members: [] }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
render(<SkillShareDialog skill={skill} open onOpenChange={() => undefined} />)
|
||||
return {
|
||||
skills,
|
||||
emitProgress: (progress: SkillShareProgress) => progressListener?.(progress)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(window, 'api')
|
||||
})
|
||||
|
||||
describe('SkillShareDialog', () => {
|
||||
it('publishes a new immutable version for one exact managed-install match', async () => {
|
||||
const { skills } = setup([managedInstall('global:local')])
|
||||
|
||||
await screen.findByRole('heading', { name: 'Publish new skill version' })
|
||||
expect(screen.getByRole('button', { name: 'Publish new version' })).toBeTruthy()
|
||||
expect(skills.prepareShare).toHaveBeenCalledWith({
|
||||
skillId: skill.id,
|
||||
packageId: 'pkg_1'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not choose a package when managed-install matching is ambiguous', async () => {
|
||||
const { skills } = setup([
|
||||
managedInstall('global:local'),
|
||||
managedInstall('global:other-environment')
|
||||
])
|
||||
|
||||
await screen.findByRole('heading', { name: 'Share skill' })
|
||||
await waitFor(() =>
|
||||
expect(skills.prepareShare).toHaveBeenCalledWith({
|
||||
skillId: skill.id
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows bounded upload progress and supports cancellation', async () => {
|
||||
let rejectPublish: (error: Error) => void = () => undefined
|
||||
const { skills, emitProgress } = setup([], true)
|
||||
skills.publishShare.mockReturnValue(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectPublish = reject
|
||||
})
|
||||
)
|
||||
await screen.findByRole('heading', { name: 'Share skill' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Publish skill' }))
|
||||
await screen.findByRole('button', { name: 'Cancel upload' })
|
||||
emitProgress({
|
||||
preparationId: preview.preparationId,
|
||||
phase: 'uploading',
|
||||
bytesSent: 48,
|
||||
totalBytes: 96
|
||||
})
|
||||
await screen.findByText('50%')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel upload' }))
|
||||
await waitFor(() => expect(skills.cancelShare).toHaveBeenCalledWith(preview.preparationId))
|
||||
rejectPublish(new Error('aborted'))
|
||||
|
||||
await screen.findByText('Upload cancelled. The prepared copy is still available to retry.')
|
||||
})
|
||||
})
|
||||
@@ -1,27 +1,22 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Clipboard, FileCode2, Loader2, Share2 } from 'lucide-react'
|
||||
import { Loader2, Share2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress
|
||||
} from '../../../../shared/skill-sharing-contract'
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
import {
|
||||
SkillShareDialogHeader,
|
||||
SkillSharePreparationReview,
|
||||
SkillSharePublishedLink,
|
||||
type SelectableOrgMember
|
||||
} from './SkillShareReviewContent'
|
||||
import { matchingManagedSkillInstall } from './skill-share-package-selection'
|
||||
|
||||
type SkillShareDialogProps = {
|
||||
skill: DiscoveredSkill | null
|
||||
@@ -29,18 +24,6 @@ type SkillShareDialogProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
type SelectableOrgMember = OrcaOrgMember & { userId: string }
|
||||
|
||||
function byteLabel(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function operationError(status: string): string {
|
||||
return status === 'reconnect-required'
|
||||
? translate(
|
||||
@@ -70,7 +53,10 @@ export function SkillShareDialog({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [preparing, setPreparing] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const [publishingNewVersion, setPublishingNewVersion] = useState(false)
|
||||
const generation = useRef(0)
|
||||
const cancellationRequested = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !skill) {
|
||||
@@ -81,17 +67,34 @@ export function SkillShareDialog({
|
||||
setShareUrl(null)
|
||||
setProgress(null)
|
||||
setError(null)
|
||||
setPublishingNewVersion(false)
|
||||
setPreparing(true)
|
||||
void Promise.all([
|
||||
window.api.skills.prepareShare({ skillId: skill.id }),
|
||||
window.api.orcaProfiles.authStatus()
|
||||
])
|
||||
.then(async ([nextPreview, auth]) => {
|
||||
void (async () => {
|
||||
let managedInstall: ManagedSkillInstall | null = null
|
||||
try {
|
||||
const operation = await window.api.skills.listManagedInstalls()
|
||||
if (operation.status === 'ok') {
|
||||
managedInstall = matchingManagedSkillInstall(skill, operation.value)
|
||||
}
|
||||
} catch (cause) {
|
||||
console.warn('[skills] managed install lookup failed during share:', cause)
|
||||
}
|
||||
const [nextPreview, auth] = await Promise.all([
|
||||
window.api.skills.prepareShare({
|
||||
skillId: skill.id,
|
||||
...(managedInstall ? { packageId: managedInstall.packageId } : {})
|
||||
}),
|
||||
window.api.orcaProfiles.authStatus()
|
||||
])
|
||||
return { nextPreview, auth, managedInstall }
|
||||
})()
|
||||
.then(async ({ nextPreview, auth, managedInstall }) => {
|
||||
if (generation.current !== current) {
|
||||
await window.api.skills.releaseShare(nextPreview.preparationId)
|
||||
return
|
||||
}
|
||||
setPreview(nextPreview)
|
||||
setPublishingNewVersion(managedInstall !== null)
|
||||
const cloud = auth.cloud
|
||||
setAuthor(cloud?.displayName || cloud?.email || '')
|
||||
setOrganization(cloud?.activeOrgName || '')
|
||||
@@ -168,6 +171,7 @@ export function SkillShareDialog({
|
||||
return
|
||||
}
|
||||
setPublishing(true)
|
||||
cancellationRequested.current = false
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.api.skills.publishShare({
|
||||
@@ -184,16 +188,38 @@ export function SkillShareDialog({
|
||||
} catch (cause) {
|
||||
console.warn('[skills] publish failed:', cause)
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.skills.SkillShareDialog.publishFailed',
|
||||
'Could not publish this skill. The prepared copy is still available to retry.'
|
||||
)
|
||||
cancellationRequested.current
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.publishCancelled',
|
||||
'Upload cancelled. The prepared copy is still available to retry.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.publishFailed',
|
||||
'Could not publish this skill. The prepared copy is still available to retry.'
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
cancellationRequested.current = false
|
||||
setCancelling(false)
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPublish = async (): Promise<void> => {
|
||||
if (!preview || cancelling) {
|
||||
return
|
||||
}
|
||||
cancellationRequested.current = true
|
||||
setCancelling(true)
|
||||
try {
|
||||
await window.api.skills.cancelShare(preview.preparationId)
|
||||
} catch {
|
||||
cancellationRequested.current = false
|
||||
setCancelling(false)
|
||||
setError('Orca could not send the cancellation request. The upload may still finish.')
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async (): Promise<void> => {
|
||||
if (!shareUrl) {
|
||||
return
|
||||
@@ -205,24 +231,10 @@ export function SkillShareDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !publishing && void close()}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto scrollbar-sleek sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{shareUrl
|
||||
? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready')
|
||||
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{shareUrl
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.readyDescription',
|
||||
'Recipients authenticate with Orca before they can inspect or install it.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.description',
|
||||
'Review the exact files, choose who can access them, then publish an immutable version.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SkillShareDialogHeader
|
||||
published={Boolean(shareUrl)}
|
||||
publishingNewVersion={publishingNewVersion}
|
||||
/>
|
||||
|
||||
{preparing ? (
|
||||
<div className="flex min-h-40 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -230,124 +242,23 @@ export function SkillShareDialog({
|
||||
{translate('auto.components.skills.SkillShareDialog.preparing', 'Preparing preview…')}
|
||||
</div>
|
||||
) : preview && !shareUrl ? (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<FileCode2 className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold">{preview.name}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{preview.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">{preview.fileCount} files</Badge>
|
||||
<Badge variant="outline">{byteLabel(preview.totalBytes)}</Badge>
|
||||
<Badge variant="outline">{preview.scriptPaths.length} scripts</Badge>
|
||||
<Badge variant="outline">{preview.executablePaths.length} executable</Badge>
|
||||
</div>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
SHA-256 {preview.packageDigest}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{author
|
||||
? `Publishing as ${author}${organization ? ` in ${organization}` : ''}.`
|
||||
: 'A connected Orca Cloud account is required.'}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
value={audience}
|
||||
onValueChange={(value) => setAudience(value as typeof audience)}
|
||||
>
|
||||
<TabsList aria-label="Skill access">
|
||||
<TabsTrigger value="organization" disabled={!organization}>
|
||||
Organization
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="people">Selected people</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="organization" className="pt-2 text-xs text-muted-foreground">
|
||||
Everyone currently in {organization || 'the organization'} can access the link.
|
||||
</TabsContent>
|
||||
<TabsContent value="people" className="space-y-2 pt-2">
|
||||
{members.length > 0 ? (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
|
||||
{members.map((member) => {
|
||||
const checked = selectedUserIds.includes(member.userId)
|
||||
return (
|
||||
<label
|
||||
key={member.userId}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(next) =>
|
||||
setSelectedUserIds((current) =>
|
||||
next
|
||||
? [...new Set([...current, member.userId])]
|
||||
: current.filter((id) => id !== member.userId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{member.displayName || member.email}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No teammates are available.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label htmlFor="skill-release-notes">Release notes</Label>
|
||||
<textarea
|
||||
id="skill-release-notes"
|
||||
value={releaseNotes}
|
||||
onChange={(event) => setReleaseNotes(event.target.value)}
|
||||
maxLength={10_000}
|
||||
placeholder="What changed in this version?"
|
||||
className="min-h-20 w-full resize-y rounded-md border border-border bg-input px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{publishing ? (
|
||||
<section className="space-y-2" aria-live="polite">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{progress?.phase === 'finalizing' ? 'Validating and publishing…' : 'Uploading…'}
|
||||
</span>
|
||||
<span>{progress?.phase === 'finalizing' ? '100%' : `${progressPercent}%`}</span>
|
||||
</div>
|
||||
<Progress value={progress?.phase === 'finalizing' ? 100 : progressPercent} />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<SkillSharePreparationReview
|
||||
preview={preview}
|
||||
author={author}
|
||||
organization={organization}
|
||||
audience={audience}
|
||||
onAudienceChange={setAudience}
|
||||
members={members}
|
||||
selectedUserIds={selectedUserIds}
|
||||
onSelectedUserIdsChange={setSelectedUserIds}
|
||||
releaseNotes={releaseNotes}
|
||||
onReleaseNotesChange={setReleaseNotes}
|
||||
publishing={publishing}
|
||||
progress={progress}
|
||||
progressPercent={progressPercent}
|
||||
/>
|
||||
) : shareUrl ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border p-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<Check className="size-4" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs">{shareUrl}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void copyLink()}>
|
||||
<Clipboard className="size-4" /> Copy link
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Revoking this link blocks future access. It does not remove copies already installed
|
||||
on recipients’ machines.
|
||||
</p>
|
||||
</div>
|
||||
<SkillSharePublishedLink shareUrl={shareUrl} onCopy={() => void copyLink()} />
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
@@ -364,13 +275,15 @@ export function SkillShareDialog({
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => preview && window.api.skills.cancelShare(preview.preparationId)}
|
||||
disabled={cancelling}
|
||||
onClick={() => void cancelPublish()}
|
||||
>
|
||||
Cancel upload
|
||||
{cancelling ? 'Cancelling…' : 'Cancel upload'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" disabled={!preview || preparing} onClick={() => void publish()}>
|
||||
<Share2 className="size-4" /> Publish skill
|
||||
<Share2 className="size-4" />
|
||||
{publishingNewVersion ? 'Publish new version' : 'Publish skill'}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Check, Clipboard, FileCode2 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OrcaOrgMember } from '../../../../shared/orca-profiles'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress
|
||||
} from '../../../../shared/skill-sharing-contract'
|
||||
|
||||
export type SelectableOrgMember = OrcaOrgMember & { userId: string }
|
||||
|
||||
export function SkillShareDialogHeader({
|
||||
published,
|
||||
publishingNewVersion
|
||||
}: {
|
||||
published: boolean
|
||||
publishingNewVersion: boolean
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{published
|
||||
? translate('auto.components.skills.SkillShareDialog.ready', 'Skill link ready')
|
||||
: publishingNewVersion
|
||||
? 'Publish new skill version'
|
||||
: translate('auto.components.skills.SkillShareDialog.title', 'Share skill')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{published
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.readyDescription',
|
||||
'Recipients authenticate with Orca before they can inspect or install it.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillShareDialog.description',
|
||||
publishingNewVersion
|
||||
? 'Review the exact files, then publish an immutable version to the existing Cloud package.'
|
||||
: 'Review the exact files, choose who can access them, then publish an immutable version.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function byteLabel(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function SkillSharePreparationReview({
|
||||
preview,
|
||||
author,
|
||||
organization,
|
||||
audience,
|
||||
onAudienceChange,
|
||||
members,
|
||||
selectedUserIds,
|
||||
onSelectedUserIdsChange,
|
||||
releaseNotes,
|
||||
onReleaseNotesChange,
|
||||
publishing,
|
||||
progress,
|
||||
progressPercent
|
||||
}: {
|
||||
preview: SkillSharePreview
|
||||
author: string
|
||||
organization: string
|
||||
audience: 'organization' | 'people'
|
||||
onAudienceChange: (audience: 'organization' | 'people') => void
|
||||
members: SelectableOrgMember[]
|
||||
selectedUserIds: string[]
|
||||
onSelectedUserIdsChange: (userIds: string[]) => void
|
||||
releaseNotes: string
|
||||
onReleaseNotesChange: (notes: string) => void
|
||||
publishing: boolean
|
||||
progress: SkillShareProgress | null
|
||||
progressPercent: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-background">
|
||||
<FileCode2 className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold">{preview.name}</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{preview.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline">{preview.fileCount} files</Badge>
|
||||
<Badge variant="outline">{byteLabel(preview.totalBytes)}</Badge>
|
||||
<Badge variant="outline">{preview.scriptPaths.length} scripts</Badge>
|
||||
<Badge variant="outline">{preview.executablePaths.length} executable</Badge>
|
||||
</div>
|
||||
<p className="truncate font-mono text-[11px] text-muted-foreground">
|
||||
SHA-256 {preview.packageDigest}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{author
|
||||
? `Publishing as ${author}${organization ? ` in ${organization}` : ''}.`
|
||||
: 'A connected Orca Cloud account is required.'}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs
|
||||
value={audience}
|
||||
onValueChange={(value) => onAudienceChange(value as typeof audience)}
|
||||
>
|
||||
<TabsList aria-label="Skill access">
|
||||
<TabsTrigger value="organization" disabled={!organization}>
|
||||
Organization
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="people">Selected people</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="organization" className="pt-2 text-xs text-muted-foreground">
|
||||
Everyone currently in {organization || 'the organization'} can access the link.
|
||||
</TabsContent>
|
||||
<TabsContent value="people" className="space-y-2 pt-2">
|
||||
{members.length > 0 ? (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto scrollbar-sleek rounded-md border border-border p-2">
|
||||
{members.map((member) => {
|
||||
const checked = selectedUserIds.includes(member.userId)
|
||||
return (
|
||||
<label
|
||||
key={member.userId}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(next) =>
|
||||
onSelectedUserIdsChange(
|
||||
next
|
||||
? [...new Set([...selectedUserIds, member.userId])]
|
||||
: selectedUserIds.filter((id) => id !== member.userId)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs">
|
||||
{member.displayName || member.email}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No teammates are available.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label htmlFor="skill-release-notes">Release notes</Label>
|
||||
<textarea
|
||||
id="skill-release-notes"
|
||||
value={releaseNotes}
|
||||
onChange={(event) => onReleaseNotesChange(event.target.value)}
|
||||
maxLength={10_000}
|
||||
placeholder="What changed in this version?"
|
||||
className="min-h-20 w-full resize-y rounded-md border border-border bg-input px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{publishing ? (
|
||||
<section className="space-y-2" aria-live="polite">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{progress?.phase === 'finalizing' ? 'Validating and publishing…' : 'Uploading…'}
|
||||
</span>
|
||||
<span>{progress?.phase === 'finalizing' ? '100%' : `${progressPercent}%`}</span>
|
||||
</div>
|
||||
<Progress value={progress?.phase === 'finalizing' ? 100 : progressPercent} />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkillSharePublishedLink({
|
||||
shareUrl,
|
||||
onCopy
|
||||
}: {
|
||||
shareUrl: string
|
||||
onCopy: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border p-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<Check className="size-4" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-xs">{shareUrl}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onCopy}>
|
||||
<Clipboard className="size-4" /> Copy link
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Revoking this link blocks future access. It does not remove copies already installed on
|
||||
recipients’ machines.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { SkillInstallManagementDialog } from './SkillInstallManagementDialog'
|
||||
import { pluralize, sourceLabels } from './skill-display-labels'
|
||||
import { countSkillsBySource, filterSkills, type SkillsFilterState } from './skills-filter'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from '@/hooks/installed-agent-skills-change-event'
|
||||
|
||||
const EMPTY_SKILLS: DiscoveredSkill[] = []
|
||||
|
||||
@@ -129,6 +130,12 @@ export default function SkillsPage(): React.JSX.Element {
|
||||
void loadSkills()
|
||||
}, [loadSkills])
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = (): void => void loadSkills()
|
||||
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refresh)
|
||||
return () => window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, refresh)
|
||||
}, [loadSkills])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSkillShareId) {
|
||||
return
|
||||
|
||||
@@ -12,7 +12,10 @@ export function skillInstallResultLabel(result: SkillInstallResult): string {
|
||||
return 'Installed, but one or more agent placements need attention.'
|
||||
case 'removed':
|
||||
return 'Removed.'
|
||||
default:
|
||||
case 'cancelled':
|
||||
return 'Installation cancelled.'
|
||||
case 'conflict':
|
||||
case 'failed':
|
||||
return 'Installation did not complete.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
|
||||
export function matchingManagedSkillInstall(
|
||||
skill: DiscoveredSkill,
|
||||
installs: ManagedSkillInstall[]
|
||||
): ManagedSkillInstall | null {
|
||||
const scope =
|
||||
skill.sourceKind === 'home' ? 'global' : skill.sourceKind === 'repo' ? 'workspace' : null
|
||||
if (!scope) {
|
||||
return null
|
||||
}
|
||||
const matches = installs.filter(
|
||||
(install) =>
|
||||
install.name === skill.name && install.scope === scope && install.state !== 'missing'
|
||||
)
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
@@ -3085,11 +3085,20 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
installShare: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
installPackageVersion: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
cancelInstall: () => Promise.resolve({ cancelled: false }),
|
||||
previewInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
removeInstall: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
listManagedInstalls: () =>
|
||||
Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
getPackage: () => Promise.reject(new Error('Skill installation requires the desktop app.')),
|
||||
replacePackageAccess: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
revokeShare: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
deletePackageVersion: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
deletePackage: () =>
|
||||
Promise.reject(new Error('Skill package management requires the desktop app.')),
|
||||
listWslDistros: () => Promise.resolve([]),
|
||||
onShareProgress: () => () => {},
|
||||
onUpdateRun: () => () => {}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update'
|
||||
import {
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CANCEL_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY
|
||||
} from './skill-install-capability'
|
||||
@@ -125,6 +127,8 @@ export const RUNTIME_CAPABILITIES = [
|
||||
ACCOUNT_IMPORT_RUNTIME_CAPABILITY,
|
||||
CODEX_RESET_CREDIT_RUNTIME_CAPABILITY,
|
||||
SKILL_INSTALL_CAPABILITY,
|
||||
SKILL_INSTALL_CANCEL_CAPABILITY,
|
||||
SKILL_INSTALL_RESULT_V2_CAPABILITY,
|
||||
SKILL_UPLOAD_CAPABILITY,
|
||||
SKILL_MANAGEMENT_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -43,6 +43,16 @@ export type SkillCloudPackageDetails = {
|
||||
createdAt: string
|
||||
canManage: boolean
|
||||
versions: SkillCloudVersion[]
|
||||
management?: {
|
||||
userIds: string[]
|
||||
shareWithOrganization: boolean
|
||||
shares: {
|
||||
id: string
|
||||
pinnedVersionId?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export type SkillCloudPublishRequest = SkillCloudOptions & {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const SKILL_INSTALL_CAPABILITY = 'skills.install.v1' as const
|
||||
export const SKILL_INSTALL_RESULT_V2_CAPABILITY = 'skills.install-result.v2' as const
|
||||
export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as const
|
||||
export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const
|
||||
export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { SkillInstallFailureSchema, type SkillInstallFailure } from './skill-install-failure'
|
||||
|
||||
export const SkillPackageIdentitySchema = z
|
||||
.object({
|
||||
@@ -26,6 +27,8 @@ const SkillInstallIngressSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('local-file'), path: z.string().min(1) }).strict()
|
||||
])
|
||||
|
||||
const SkillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
|
||||
export const SkillInstallDestinationSchema = z.discriminatedUnion('scope', [
|
||||
z
|
||||
.object({
|
||||
@@ -62,8 +65,6 @@ export const SkillInstallRequestSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
const SkillNameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||||
|
||||
export const SkillInstallPreviewRequestSchema = z
|
||||
.object({
|
||||
package: SkillPackageIdentitySchema,
|
||||
@@ -160,11 +161,20 @@ export type SkillPlacementResult = {
|
||||
topology: 'canonical-copy' | 'provider-alias' | 'independent-copy'
|
||||
status: 'installed' | 'unchanged' | 'removed' | 'skipped' | 'failed'
|
||||
errorCategory?: string
|
||||
failure?: SkillInstallFailure
|
||||
}
|
||||
|
||||
export type SkillInstallResult = {
|
||||
operationId: string
|
||||
status: 'installed' | 'updated' | 'unchanged' | 'removed' | 'conflict' | 'partial' | 'failed'
|
||||
status:
|
||||
| 'installed'
|
||||
| 'updated'
|
||||
| 'unchanged'
|
||||
| 'removed'
|
||||
| 'conflict'
|
||||
| 'partial'
|
||||
| 'cancelled'
|
||||
| 'failed'
|
||||
name: string
|
||||
packageDigest: string
|
||||
canonicalPath?: string
|
||||
@@ -174,11 +184,21 @@ export type SkillInstallResult = {
|
||||
existingDigest?: string
|
||||
}
|
||||
errorCategory?: string
|
||||
failure?: SkillInstallFailure
|
||||
}
|
||||
|
||||
export const SkillInstallResultSchema: z.ZodType<SkillInstallResult> = z.object({
|
||||
operationId: z.string(),
|
||||
status: z.enum(['installed', 'updated', 'unchanged', 'removed', 'conflict', 'partial', 'failed']),
|
||||
status: z.enum([
|
||||
'installed',
|
||||
'updated',
|
||||
'unchanged',
|
||||
'removed',
|
||||
'conflict',
|
||||
'partial',
|
||||
'cancelled',
|
||||
'failed'
|
||||
]),
|
||||
name: z.string(),
|
||||
packageDigest: z.string(),
|
||||
canonicalPath: z.string().optional(),
|
||||
@@ -188,7 +208,8 @@ export const SkillInstallResultSchema: z.ZodType<SkillInstallResult> = z.object(
|
||||
path: z.string(),
|
||||
topology: z.enum(['canonical-copy', 'provider-alias', 'independent-copy']),
|
||||
status: z.enum(['installed', 'unchanged', 'removed', 'skipped', 'failed']),
|
||||
errorCategory: z.string().optional()
|
||||
errorCategory: z.string().optional(),
|
||||
failure: SkillInstallFailureSchema.optional()
|
||||
})
|
||||
),
|
||||
conflict: z
|
||||
@@ -197,5 +218,6 @@ export const SkillInstallResultSchema: z.ZodType<SkillInstallResult> = z.object(
|
||||
existingDigest: z.string().optional()
|
||||
})
|
||||
.optional(),
|
||||
errorCategory: z.string().optional()
|
||||
errorCategory: z.string().optional(),
|
||||
failure: SkillInstallFailureSchema.optional()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SKILL_INSTALL_BUSY_FAILURE,
|
||||
SKILL_INSTALL_CANCELLED_FAILURE,
|
||||
SkillInstallFailureCategorySchema,
|
||||
SkillInstallFailureSchema,
|
||||
classifySkillInstallFailureCode
|
||||
} from './skill-install-failure'
|
||||
|
||||
describe('skill install failure contract', () => {
|
||||
it('freezes every V1 failure category', () => {
|
||||
expect(SkillInstallFailureCategorySchema.options).toEqual([
|
||||
'admission',
|
||||
'transport',
|
||||
'archive',
|
||||
'filesystem',
|
||||
'conflict',
|
||||
'recovery',
|
||||
'provider-placement',
|
||||
'compatibility',
|
||||
'cancelled'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps retryability explicit and rejects unstable codes', () => {
|
||||
expect(SkillInstallFailureSchema.parse(SKILL_INSTALL_BUSY_FAILURE).retryable).toBe(true)
|
||||
expect(SkillInstallFailureSchema.parse(SKILL_INSTALL_CANCELLED_FAILURE).category).toBe(
|
||||
'cancelled'
|
||||
)
|
||||
expect(() =>
|
||||
SkillInstallFailureSchema.parse({
|
||||
category: 'filesystem',
|
||||
code: 'EBUSY: /private/path',
|
||||
retryable: true
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['skill-install-workspace-required', 'admission'],
|
||||
['skill-download-transport-failed', 'transport'],
|
||||
['skill-package-tar-truncated', 'archive'],
|
||||
['skill-install-busy', 'filesystem'],
|
||||
['skill-install-conflict-modified', 'conflict'],
|
||||
['skill-install-recovery-conflict', 'recovery'],
|
||||
['skill-placement-create-failed', 'provider-placement'],
|
||||
['skill-install-ssh-update-required', 'compatibility'],
|
||||
['skill-install-cancelled', 'cancelled']
|
||||
] as const)('classifies %s as %s', (code, category) => {
|
||||
expect(classifySkillInstallFailureCode(code)?.category).toBe(category)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SkillInstallFailureCategorySchema = z.enum([
|
||||
'admission',
|
||||
'transport',
|
||||
'archive',
|
||||
'filesystem',
|
||||
'conflict',
|
||||
'recovery',
|
||||
'provider-placement',
|
||||
'compatibility',
|
||||
'cancelled'
|
||||
])
|
||||
|
||||
export type SkillInstallFailureCategory = z.infer<typeof SkillInstallFailureCategorySchema>
|
||||
|
||||
export const SkillInstallFailureSchema = z
|
||||
.object({
|
||||
category: SkillInstallFailureCategorySchema,
|
||||
code: z.string().regex(/^skill-[a-z0-9-]+$/),
|
||||
retryable: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type SkillInstallFailure = z.infer<typeof SkillInstallFailureSchema>
|
||||
|
||||
export const SKILL_INSTALL_RPC_ERROR_CODE = 'skill_install_failure'
|
||||
|
||||
export const SKILL_INSTALL_BUSY_FAILURE: SkillInstallFailure = {
|
||||
category: 'filesystem',
|
||||
code: 'skill-install-busy',
|
||||
retryable: true
|
||||
}
|
||||
|
||||
export const SKILL_INSTALL_CANCELLED_FAILURE: SkillInstallFailure = {
|
||||
category: 'cancelled',
|
||||
code: 'skill-install-cancelled',
|
||||
retryable: true
|
||||
}
|
||||
|
||||
function retryableCode(code: string): boolean {
|
||||
return (
|
||||
code.includes('busy') ||
|
||||
code.includes('timeout') ||
|
||||
code.includes('transport') ||
|
||||
code.includes('expired') ||
|
||||
code.includes('unavailable') ||
|
||||
code.includes('failed')
|
||||
)
|
||||
}
|
||||
|
||||
export function classifySkillInstallFailureCode(code: string): SkillInstallFailure | null {
|
||||
if (!/^skill-[a-z0-9-]+$/.test(code)) {
|
||||
return null
|
||||
}
|
||||
if (code.includes('cancelled')) {
|
||||
return { category: 'cancelled', code, retryable: true }
|
||||
}
|
||||
if (code.includes('recovery') || code.includes('journal') || code.includes('receipt')) {
|
||||
return { category: 'recovery', code, retryable: retryableCode(code) }
|
||||
}
|
||||
if (code.startsWith('skill-placement-') || code.startsWith('skill-discovery-')) {
|
||||
return { category: 'provider-placement', code, retryable: retryableCode(code) }
|
||||
}
|
||||
if (code.includes('conflict') || code.includes('modified') || code.includes('unowned')) {
|
||||
return { category: 'conflict', code, retryable: false }
|
||||
}
|
||||
if (code.startsWith('skill-package-')) {
|
||||
return { category: 'archive', code, retryable: false }
|
||||
}
|
||||
if (
|
||||
code.startsWith('skill-download-') ||
|
||||
code.startsWith('skill-transfer-') ||
|
||||
code.startsWith('skill-cloud-')
|
||||
) {
|
||||
return { category: 'transport', code, retryable: retryableCode(code) }
|
||||
}
|
||||
if (
|
||||
code.includes('update-required') ||
|
||||
code.includes('unsupported') ||
|
||||
code.includes('remote-download-unavailable') ||
|
||||
code.includes('ssh-relay-unavailable')
|
||||
) {
|
||||
return { category: 'compatibility', code, retryable: false }
|
||||
}
|
||||
if (
|
||||
code.includes('destination') ||
|
||||
code.includes('workspace') ||
|
||||
code.includes('environment') ||
|
||||
code.includes('home-') ||
|
||||
code.includes('ingress') ||
|
||||
code.includes('name-invalid') ||
|
||||
code.includes('wsl-unavailable')
|
||||
) {
|
||||
return { category: 'admission', code, retryable: false }
|
||||
}
|
||||
if (
|
||||
code.startsWith('skill-install-') ||
|
||||
code.startsWith('skill-remove-') ||
|
||||
code.startsWith('skill-removal-')
|
||||
) {
|
||||
return { category: 'filesystem', code, retryable: retryableCode(code) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -28,6 +28,10 @@ function manifest(files: SkillPackageFile[] = [file]): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function packageFile(path: string, size = 0): SkillPackageFile {
|
||||
return { ...file, path, size }
|
||||
}
|
||||
|
||||
describe('skill package manifest', () => {
|
||||
it('parses a canonical manifest', () => {
|
||||
expect(parseSkillPackageManifest(manifest()).name).toBe('test-skill')
|
||||
@@ -54,4 +58,83 @@ describe('skill package manifest', () => {
|
||||
parseSkillPackageManifest({ ...(manifest() as object), packageDigest: 'c'.repeat(64) })
|
||||
).toThrow('skill-package-digest-mismatch')
|
||||
})
|
||||
|
||||
it('requires one identity-matched SKILL.md in canonical path order', () => {
|
||||
expect(() => parseSkillPackageManifest(manifest([packageFile('README.md')]))).toThrow(
|
||||
'skill-package-skill-markdown-required'
|
||||
)
|
||||
expect(() => parseSkillPackageManifest(manifest([file, { ...file }]))).toThrow(
|
||||
'skill-package-manifest-path-order'
|
||||
)
|
||||
expect(() =>
|
||||
parseSkillPackageManifest({ ...(manifest() as object), schemaVersion: 2 })
|
||||
).toThrow('skill-package-manifest-invalid')
|
||||
})
|
||||
|
||||
it('enforces path depth at, below, and above the V1 limit', () => {
|
||||
const path = (depth: number): string =>
|
||||
[...Array.from({ length: depth - 1 }, (_, index) => `d${index}`), 'SKILL.md'].join('/')
|
||||
expect(() => validateSkillPackagePath(path(15))).not.toThrow()
|
||||
expect(() => validateSkillPackagePath(path(16))).not.toThrow()
|
||||
expect(() => validateSkillPackagePath(path(17))).toThrow('skill-package-path-invalid')
|
||||
})
|
||||
|
||||
it('enforces file count at, below, and above the V1 limit', () => {
|
||||
const files = (count: number): SkillPackageFile[] => [
|
||||
file,
|
||||
...Array.from({ length: count - 1 }, (_, index) =>
|
||||
packageFile(`file-${index.toString().padStart(3, '0')}.md`)
|
||||
)
|
||||
]
|
||||
expect(() => parseSkillPackageManifest(manifest(files(511)))).not.toThrow()
|
||||
expect(() => parseSkillPackageManifest(manifest(files(512)))).not.toThrow()
|
||||
expect(() => parseSkillPackageManifest(manifest(files(513)))).toThrow(
|
||||
'skill-package-manifest-invalid'
|
||||
)
|
||||
})
|
||||
|
||||
it('enforces per-file and total extracted bytes around both V1 limits', () => {
|
||||
const fourMiB = 4 * 1024 * 1024
|
||||
expect(() =>
|
||||
parseSkillPackageManifest(manifest([{ ...file, size: fourMiB - 1 }]))
|
||||
).not.toThrow()
|
||||
expect(() => parseSkillPackageManifest(manifest([{ ...file, size: fourMiB }]))).not.toThrow()
|
||||
expect(() => parseSkillPackageManifest(manifest([{ ...file, size: fourMiB + 1 }]))).toThrow(
|
||||
'skill-package-manifest-invalid'
|
||||
)
|
||||
const total = (extra: number): SkillPackageFile[] => [
|
||||
{ ...file, size: 0 },
|
||||
...Array.from({ length: 8 }, (_, index) => packageFile(`payload-${index}.bin`, fourMiB)),
|
||||
...(extra ? [packageFile('z-extra.bin', extra)] : [])
|
||||
]
|
||||
expect(() => parseSkillPackageManifest(manifest(total(0)))).not.toThrow()
|
||||
expect(() => parseSkillPackageManifest(manifest(total(1)))).toThrow(
|
||||
'skill-package-total-size-limit'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects Unicode normalization and case-fold collisions', () => {
|
||||
expect(() => validateSkillPackagePath('references/e\u0301.md')).toThrow(
|
||||
'skill-package-path-invalid'
|
||||
)
|
||||
expect(() =>
|
||||
parseSkillPackageManifest(
|
||||
manifest([file, packageFile('references/É.md'), packageFile('references/é.md')])
|
||||
)
|
||||
).toThrow('skill-package-case-collision')
|
||||
})
|
||||
|
||||
it('fuzzes path normalization without accepting traversal or platform syntax', () => {
|
||||
let state = 0x5eed
|
||||
const random = (): number => {
|
||||
state = (state * 1103515245 + 12345) & 0x7fffffff
|
||||
return state
|
||||
}
|
||||
const hazards = ['..', '.', '', 'C:', '\\server', 'nul', 'x.', 'x ', 'x\u0000y']
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
const hazard = hazards[random() % hazards.length]
|
||||
const path = `safe/${hazard}/file-${random()}.md`
|
||||
expect(() => validateSkillPackagePath(path)).toThrow('skill-package-path-invalid')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,6 +43,12 @@ export const SkillPackageManifestV1Schema = z
|
||||
|
||||
export type SkillPackageManifestV1 = z.infer<typeof SkillPackageManifestV1Schema>
|
||||
|
||||
export function validateSkillPackageName(name: string): void {
|
||||
if (!SKILL_NAME_PATTERN.test(name)) {
|
||||
throw new Error('skill-package-skill-name-invalid')
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSkillPackagePath(path: string): void {
|
||||
if (path !== path.normalize('NFC') || Buffer.byteLength(path, 'utf8') > 1024) {
|
||||
throw new Error('skill-package-path-invalid')
|
||||
@@ -51,7 +57,10 @@ export function validateSkillPackagePath(path: string): void {
|
||||
throw new Error('skill-package-path-invalid')
|
||||
}
|
||||
const segments = path.split('/')
|
||||
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
||||
if (
|
||||
segments.length > 16 ||
|
||||
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
||||
) {
|
||||
throw new Error('skill-package-path-invalid')
|
||||
}
|
||||
for (const segment of segments) {
|
||||
|
||||
@@ -50,6 +50,7 @@ export type SkillShareProgress = {
|
||||
|
||||
export type SkillShareInstallInput = {
|
||||
shareId: string
|
||||
operationId?: string
|
||||
versionId?: string
|
||||
environmentId?: string
|
||||
destination: SkillInstallDestination
|
||||
@@ -89,6 +90,11 @@ export type SkillRemoveOperation =
|
||||
| { status: 'ok'; value: SkillInstallResult }
|
||||
| { status: 'unsupported'; message: string }
|
||||
|
||||
export type SkillInstallCancelInput = {
|
||||
operationId: string
|
||||
environmentId?: string
|
||||
}
|
||||
|
||||
export type ManagedSkillInstallListOperation =
|
||||
| { status: 'ok'; value: ManagedSkillInstall[] }
|
||||
| { status: 'unsupported'; message: string }
|
||||
|
||||
Reference in New Issue
Block a user