feat(skills): manage bundled skill updates

This commit is contained in:
Brennan Benson
2026-07-12 23:10:16 -07:00
parent 7b12d38b17
commit f4a12bcd6b
125 changed files with 12210 additions and 120 deletions
+7
View File
@@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
@@ -69,6 +71,11 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
# Why: skill revisions are append-only; checking merge-queue and main
# heads prevents independently green PRs from claiming one revision.
- name: Verify bundled skill manifest
run: pnpm run verify:skill-bundle-manifest -- --base-ref=origin/${{ github.base_ref }}
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
+4 -2
View File
@@ -472,7 +472,10 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
git add package.json
# Why: the versioned release commit is the mechanical authority that
# records exactly which generated skill revisions shipped.
node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release
git add package.json resources/skills
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -1220,7 +1223,6 @@ jobs:
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
+38
View File
@@ -0,0 +1,38 @@
name: Skill CLI round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/generate-skill-bundle-manifest.mjs'
- 'config/scripts/verify-skill-cli-roundtrip.mjs'
- '.github/workflows/skill-cli-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
skills-cli: ['1.5.16', latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify LF and CRLF installer identities
shell: bash
run: |
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=symlink
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=copy
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=symlink
node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=copy
@@ -0,0 +1,21 @@
name: Skill manifest merge gate
on:
merge_group:
push:
branches:
- main
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version-file: package.json
# Why: rerunning generation on the integrated head prevents two skill
# changes from independently claiming the same immutable revision.
- run: node config/scripts/generate-skill-bundle-manifest.mjs --base-ref=HEAD^
+11 -1
View File
@@ -18,6 +18,12 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: the updater must compare against the exact skill bundle belonging to
// the running app instead of mutable checkout or network content.
const skillBundleResources = [
{ from: 'skills', to: 'skills/packages' },
{ from: 'resources/skills', to: 'skills' }
]
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
@@ -31,7 +37,11 @@ const relayExtraResource = {
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [
relayExtraResource,
...packagedRuntimeNodeModuleResources,
...skillBundleResources
]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
+156
View File
@@ -3582,6 +3582,162 @@
"No live Electron reload-survival assertion yet — the gate proves the main-side accounting reset, not the full renderer reattach paint."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.authoritative-exit-status",
"title": "Install ownership requires an authoritative successful terminal exit",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-provider-contract",
"surfaces": [
"local terminal",
"daemon and WSL terminal",
"SSH and remote-runtime terminal",
"skill install ownership"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coverageNotes": "Deterministic provider-contract tests prove that the shared local/daemon/WSL IPC path preserves numeric process status, while remote runtime stream retirement reports unknown status and ownership accepts only numeric zero.",
"motivatingLinks": ["https://github.com/stablyai/orca"],
"invariant": "A transport may report numeric zero only when its terminal provider supplies an authoritative process exit; disconnect, stale-handle retirement, and stream end remain unknown and cannot grant skill ownership.",
"oracle": "Drive the IPC PTY transport with zero and nonzero process exits, drive remote runtime close/end/stale events without a process status, and assert the install decision accepts only code zero after an active install window.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"assertions": [
"the shared local IPC transport forwards authoritative zero and nonzero process exit codes"
]
},
{
"file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"assertions": [
"remote stream end, disconnect, and stale-handle retirement report a null exit status"
]
},
{
"file": "src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
"assertions": [
"skill ownership accepts numeric zero only after the install window begins and rejects null or nonzero"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
"result": "passed",
"durationSeconds": 2.11,
"summary": "120 deterministic provider-contract and install-ownership tests passed."
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "local renderer unit tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic contract gate; soak history has not accumulated."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The remote oracle fails if stream end invents zero, and the ownership oracle fails if null or nonzero is accepted."
},
"performanceBudget": {
"required": false,
"evidence": "The change only propagates an event payload and adds no loop, polling, or hot-path work."
},
"promotionCriteria": [
"Accumulate the required soak runs across all required CI platforms.",
"Add a live provider-contract run when SSH and WSL terminal harnesses are available in CI."
],
"knownGaps": [
"Daemon and WSL use the same IPC transport contract as local but do not yet have live provider processes in this unit gate.",
"Remote runtimes currently expose stream completion rather than an authoritative subprocess exit status."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
},
{
"id": "skill-management.inventory-bounded-work",
"title": "Mounted skill inventory has deterministic filesystem work limits",
"maturity": "experimental",
"protection": "partial",
"owner": "skill-management",
"layer": "main-process-filesystem",
"surfaces": ["managed skill adoption nudge", "focus inventory refresh"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Deterministic fixtures prove depth, entry, file-count, per-file-byte, and aggregate-byte limits and prove one inaccessible candidate cannot hide healthy siblings.",
"motivatingLinks": ["https://github.com/stablyai/orca"],
"invariant": "Always-mounted and focus-triggered skill inventory performs bounded directory and byte work per candidate, and a pathological candidate cannot reject or hide healthy sibling rows.",
"oracle": "Drive tiny injected limits across depth, entries, files, single-file bytes, and aggregate bytes; then inventory an oversized and an access-denied candidate beside a healthy official copy and assert stable fail-closed rows plus the healthy sibling.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts"
],
"testFiles": [
"src/main/skills/skill-package-observation-limits.test.ts",
"src/main/skills/skill-management-inventory.test.ts"
],
"assertionRefs": [
{
"file": "src/main/skills/skill-package-observation-limits.test.ts",
"assertions": [
"depth, entry, file-count, single-file-byte, and aggregate-byte limits fail with stable categories"
]
},
{
"file": "src/main/skills/skill-management-inventory.test.ts",
"assertions": [
"oversized and access-denied candidates fail closed without hiding healthy siblings"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts",
"result": "passed",
"durationSeconds": 0.183,
"summary": "15 deterministic bounded-observation and sibling-isolation tests passed."
}
],
"runtimeBudget": { "p95Seconds": 10, "scope": "local main-process unit tests" },
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic filesystem fixtures; soak history has not accumulated."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The limit fixtures fail if their corresponding counter or byte guard is removed; saved CI evidence remains pending."
},
"performanceBudget": {
"required": true,
"evidence": "Production observation is capped at depth 16, 2,048 entries, 512 files, 4 MiB per file, and 32 MiB aggregate per candidate with no polling or subprocesses."
},
"promotionCriteria": [
"Accumulate the required soak runs across macOS, Linux, and Windows.",
"Attach saved red/green evidence for every limit guard."
],
"knownGaps": [
"The deterministic gate runs locally on macOS; Linux and Windows CI evidence is pending."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
}
]
}
@@ -65,6 +65,14 @@ describe('electron-builder config', () => {
})
])
)
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toEqual(
expect.arrayContaining([
{ from: 'skills', to: 'skills/packages' },
{ from: 'resources/skills', to: 'skills' }
])
)
}
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
@@ -0,0 +1,463 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
function classifyFile(bytes) {
if (bytes.includes(0)) {
return 'binary'
}
try {
normalizeText(bytes)
return 'text'
} catch {
return 'binary'
}
}
function assertSafeRelativePath(relativePath) {
if (
path.isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`)
) {
throw new Error(`Unsafe skill package path: ${relativePath}`)
}
}
function describeFile(manifestPath, bytes, executable) {
const classification = classifyFile(bytes)
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
return {
path: manifestPath,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => Buffer.from(left.name).compare(Buffer.from(right.name)))
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true })
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
if (fileStat.isDirectory()) {
await visit(absolutePath)
continue
}
if (!fileStat.isFile()) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
const bytes = await readFile(absolutePath)
const executable = (fileStat.mode & 0o111) !== 0
files.push(describeFile(manifestPath, bytes, executable))
}
}
await visit(packageRoot)
return files
}
function collectGitPackageFiles(ref, name) {
const sourcePrefix = `skills/${name}/`
const output = execFileSync('git', ['ls-tree', '-r', '-z', ref, '--', `skills/${name}`])
.toString('utf8')
.split('\0')
.filter(Boolean)
const caseFoldedPaths = new Map()
return output.map((line) => {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry at ${ref}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry at ${ref}: ${line}`)
}
const manifestPath = sourcePath.slice(sourcePrefix.length)
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths at ${ref}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = execFileSync('git', ['cat-file', 'blob', objectSha])
return describeFile(manifestPath, bytes, mode === '100755')
})
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShaAtRef(ref) {
try {
const output = execFileSync('git', ['ls-tree', ref, 'skills'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
return output ? output.split(/\s+/)[2] : null
} catch {
return null
}
}
function skillNamesAtRef(ref) {
try {
return execFileSync('git', ['ls-tree', '-d', '--name-only', `${ref}:skills`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
.split('\n')
.filter(Boolean)
.sort((left, right) => left.localeCompare(right, 'en'))
} catch {
return []
}
}
function bootstrapReleaseHistory() {
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
let previousSkillsTreeSha = null
for (const tag of releaseTags()) {
const skillsTreeSha = skillsTreeShaAtRef(tag)
// Why: one mapping per shipped package set proves provenance without
// repeating hundreds of app releases that shipped identical skill bytes.
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
for (const name of skillNamesAtRef(tag)) {
const filesWithGitHashes = collectGitPackageFiles(tag, name)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
function packageDigest(files) {
const identity = files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
return sha256(Buffer.from(JSON.stringify(identity), 'utf8'))
}
async function readJsonIfPresent(filePath, fallback) {
try {
return JSON.parse(await readFile(filePath, 'utf8'))
} catch (error) {
if (error && error.code === 'ENOENT') {
return fallback
}
throw error
}
}
function readJsonAtRef(ref, relativePath) {
try {
return JSON.parse(
execFileSync('git', ['show', `${ref}:${relativePath}`], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
)
} catch {
return null
}
}
function assertBaseHistoryPreserved(baseRef, artifacts) {
if (!baseRef) {
return
}
const baseRegistry = readJsonAtRef(baseRef, 'resources/skills/snapshot-registry.json')
const baseMapping = readJsonAtRef(baseRef, 'resources/skills/release-mapping.json')
if (baseRegistry) {
for (const [name, snapshots] of Object.entries(baseRegistry.skills ?? {})) {
const current = artifacts.snapshotRegistry.skills[name] ?? []
if (JSON.stringify(current.slice(0, snapshots.length)) !== JSON.stringify(snapshots)) {
throw new Error(`Skill snapshot history for ${name} differs from immutable base ${baseRef}`)
}
}
}
if (
baseMapping &&
JSON.stringify(artifacts.releaseMapping.releases.slice(0, baseMapping.releases.length)) !==
JSON.stringify(baseMapping.releases)
) {
throw new Error(`Skill release mapping differs from immutable base ${baseRef}`)
}
}
async function buildArtifacts({ appVersion, recordRelease, bootstrapHistory = false }) {
const history = bootstrapHistory ? bootstrapReleaseHistory() : null
const existingRegistry =
history?.registry ??
(await readJsonIfPresent(SNAPSHOT_REGISTRY_PATH, {
schemaVersion: SCHEMA_VERSION,
skills: {}
}))
if (existingRegistry.schemaVersion !== SCHEMA_VERSION) {
throw new Error(`Unsupported skill snapshot registry schema: ${existingRegistry.schemaVersion}`)
}
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right, 'en'))
const currentSkills = []
const nextRegistry = structuredClone(existingRegistry)
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const existingSnapshots = nextRegistry.skills[name] ?? []
const latest = existingSnapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
existingSnapshots.push(snapshot)
nextRegistry.skills[name] = existingSnapshots
}
const currentFiles = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
currentSkills.push({
name,
sourcePath: `skills/${name}`,
appVersion,
releaseRevision: snapshot.releaseRevision,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files: currentFiles
})
}
const currentManifest = {
schemaVersion: SCHEMA_VERSION,
appVersion,
skills: currentSkills
}
const existingMapping =
history?.mapping ??
(await readJsonIfPresent(RELEASE_MAPPING_PATH, {
schemaVersion: SCHEMA_VERSION,
releases: []
}))
if (existingMapping.schemaVersion !== SCHEMA_VERSION) {
throw new Error(`Unsupported skill release mapping schema: ${existingMapping.schemaVersion}`)
}
const releaseMapping = structuredClone(existingMapping)
if (recordRelease) {
const revisions = Object.fromEntries(
currentSkills.map((skill) => [skill.name, skill.releaseRevision])
)
const prior = releaseMapping.releases.find((release) => release.appVersion === appVersion)
if (prior && JSON.stringify(prior.skills) !== JSON.stringify(revisions)) {
throw new Error(`Release mapping for ${appVersion} is immutable`)
}
if (!prior) {
releaseMapping.releases.push({ appVersion, skills: revisions })
}
}
return { currentManifest, snapshotRegistry: nextRegistry, releaseMapping }
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
])
}
async function verifyArtifacts(artifacts) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
]
const stale = []
for (const [filePath, value] of expected) {
try {
await access(filePath, constants.R_OK)
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
throw new Error(
`Generated skill artifacts are stale:\n${stale.map((filePath) => path.relative(REPO_ROOT, filePath)).join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
)
}
}
async function main() {
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
const write = process.argv.includes('--write')
const recordRelease = process.argv.includes('--record-release')
const bootstrapHistory = process.argv.includes('--bootstrap-release-history')
const baseRef = process.argv
.find((arg) => arg.startsWith('--base-ref='))
?.slice('--base-ref='.length)
const artifacts = await buildArtifacts({
appVersion: packageJson.version,
recordRelease,
bootstrapHistory
})
assertBaseHistoryPreserved(baseRef, artifacts)
await (write ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
buildArtifacts,
classifyFile,
collectPackageFiles,
gitTreeSha,
normalizeText,
packageDigest
}
@@ -0,0 +1,98 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
classifyFile,
collectPackageFiles,
gitTreeSha,
normalizeText,
packageDigest
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill bundle manifest generator', () => {
it('folds platform line endings for text identity', () => {
const lf = Buffer.from('first\nsecond\n')
const crlf = Buffer.from('first\r\nsecond\r\n')
expect(classifyFile(lf)).toBe('text')
expect(normalizeText(crlf)).toEqual(lf)
})
it('classifies null-containing and invalid UTF-8 content as binary', () => {
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
})
it('uses normalized text identity but exact executable identity', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'line one\r\nline two\r\n')
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
const files = await collectPackageFiles(packageRoot)
const skillFile = files.find((file) => file.path === 'SKILL.md')
const executable = files.find((file) => file.path === 'run.sh')
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(packageDigest(files)).toMatch(/^[a-f0-9]{64}$/)
})
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
await writeFile(path.join(packageRoot, 'README.md'), 'two')
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
})
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
encoding: 'utf8'
})
.trim()
.split(/\s+/)[2]
expect(gitTreeSha(files)).toBe(expected)
})
it('includes nested directory names in Git tree identity', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'assets'))
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
await writeFile(path.join(packageRoot, 'assets', 'prompt.txt'), 'prompt\n')
const files = await collectPackageFiles(packageRoot)
expect(gitTreeSha(files)).toMatch(/^[a-f0-9]{40}$/)
})
})
@@ -0,0 +1,134 @@
import { execFileSync } from 'node:child_process'
import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import process from 'node:process'
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
const cliVersion = process.argv.find((value) => value.startsWith('--cli='))?.slice('--cli='.length)
const autocrlf = process.argv
.find((value) => value.startsWith('--autocrlf='))
?.slice('--autocrlf='.length)
const shape = process.argv.find((value) => value.startsWith('--shape='))?.slice('--shape='.length)
if (
!cliVersion ||
(autocrlf !== 'true' && autocrlf !== 'false') ||
(shape !== 'symlink' && shape !== 'copy')
) {
throw new Error(
'Usage: verify-skill-cli-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy'
)
}
const sandbox = await mkdtemp(join(tmpdir(), 'orca-skill-cli-roundtrip-'))
const home = join(sandbox, 'home')
const source = join(sandbox, 'source')
const manifest = JSON.parse(
await readFile(resolve('resources/skills/current-manifest.json'), 'utf8')
)
const manifestByName = new Map(manifest.skills.map((skill) => [skill.name, skill]))
const fixtureName = 'orca-roundtrip-fixture'
const skillNames = [...manifest.skills.map((skill) => skill.name), fixtureName]
async function createRepresentativeFixture() {
const fixtureRoot = join(source, 'skills', fixtureName)
const executablePath = join(fixtureRoot, 'scripts', 'check.sh')
await mkdir(join(fixtureRoot, 'assets'), { recursive: true })
await mkdir(join(fixtureRoot, 'scripts'), { recursive: true })
await writeFile(
join(fixtureRoot, 'SKILL.md'),
`---\nname: ${fixtureName}\ndescription: Exercises real multi-file CLI installation.\n---\n\n# Round-trip fixture\n`
)
await writeFile(join(fixtureRoot, 'assets', 'guide.txt'), 'nested text asset\nsecond line\n')
await writeFile(join(fixtureRoot, 'assets', 'payload.bin'), Buffer.from([0, 1, 2, 13, 10, 255]))
await writeFile(executablePath, '#!/bin/sh\nprintf fixture\\n\n')
if (process.platform !== 'win32') {
await chmod(executablePath, 0o755)
}
const files = await collectPackageFiles(fixtureRoot)
manifestByName.set(fixtureName, { name: fixtureName, packageDigest: packageDigest(files) })
}
async function verifyPackage(name, packageRoot) {
const expected = manifestByName.get(name)
if (!expected) {
throw new Error(`Missing current manifest entry for ${name}`)
}
const files = await collectPackageFiles(packageRoot)
const digest = packageDigest(files)
if (digest !== expected.packageDigest) {
throw new Error(
`${name} identity mismatch with core.autocrlf=${autocrlf}: ${digest} != ${expected.packageDigest}`
)
}
}
try {
execFileSync(
'git',
[
'-c',
`core.autocrlf=${autocrlf}`,
'clone',
'--quiet',
'--no-hardlinks',
process.cwd(),
source
],
{ stdio: 'inherit' }
)
// Why: shipped packages are currently single-file, so this temporary source
// package keeps nested, binary, and POSIX executable semantics in the real CLI oracle.
await createRepresentativeFixture()
execFileSync(
process.platform === 'win32' ? 'npx.cmd' : 'npx',
[
'--yes',
`skills@${cliVersion}`,
'add',
source,
'--skill',
...skillNames,
'--agent',
'codex',
'claude-code',
'--global',
'--yes',
...(shape === 'copy' ? ['--copy'] : [])
],
{
cwd: source,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
XDG_STATE_HOME: join(home, '.state'),
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf
},
stdio: 'inherit'
}
)
for (const name of skillNames) {
const canonical = join(home, '.agents', 'skills', name)
const canonicalStat = await lstat(canonical)
if (!canonicalStat.isDirectory() || canonicalStat.isSymbolicLink()) {
throw new Error(`${name} canonical CLI placement is not a real directory`)
}
await verifyPackage(name, canonical)
const provider = join(home, '.claude', 'skills', name)
const providerStat = await lstat(provider)
if (shape === 'symlink' && !providerStat.isSymbolicLink()) {
throw new Error(`${name} provider CLI placement is not a symlink or junction`)
}
if (shape === 'copy' && (!providerStat.isDirectory() || providerStat.isSymbolicLink())) {
throw new Error(`${name} provider CLI placement is not a copy`)
}
await verifyPackage(name, await realpath(provider))
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
+5 -1
View File
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
@@ -21,6 +21,10 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
"bootstrap:skill-release-history": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release --bootstrap-release-history",
"record-release:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release",
"verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs",
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
"tc:node": "pnpm run typecheck:node",
+158
View File
@@ -0,0 +1,158 @@
{
"schemaVersion": 1,
"appVersion": "1.4.138-rc.2",
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 4,
"packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
"gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
"files": [
{
"path": "SKILL.md",
"size": 10625,
"executable": false,
"classification": "text",
"exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
"textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
"identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b"
}
]
},
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
"files": [
{
"path": "SKILL.md",
"size": 10596,
"executable": false,
"classification": "text",
"exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
}
]
},
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 31,
"packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
"gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
"files": [
{
"path": "SKILL.md",
"size": 20862,
"executable": false,
"classification": "text",
"exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
"textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
"identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14"
}
]
},
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 3,
"packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
"gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
"files": [
{
"path": "SKILL.md",
"size": 10853,
"executable": false,
"classification": "text",
"exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
"textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
"identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834"
}
]
},
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 1,
"packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
"gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
"files": [
{
"path": "SKILL.md",
"size": 8189,
"executable": false,
"classification": "text",
"exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
"textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
"identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844"
}
]
},
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 2,
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
"files": [
{
"path": "SKILL.md",
"size": 10320,
"executable": false,
"classification": "text",
"exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
}
]
},
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 2,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
"files": [
{
"path": "SKILL.md",
"size": 43769,
"executable": false,
"classification": "text",
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
}
]
},
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"appVersion": "1.4.138-rc.2",
"releaseRevision": 23,
"packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
"gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
"files": [
{
"path": "SKILL.md",
"size": 22850,
"executable": false,
"classification": "text",
"exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
"identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
}
]
}
]
}
+475
View File
@@ -0,0 +1,475 @@
{
"schemaVersion": 1,
"releases": [
{
"appVersion": "1.0.81",
"skills": {
"orca-cli": 1
}
},
{
"appVersion": "1.0.86",
"skills": {
"orca-cli": 2
}
},
{
"appVersion": "1.0.94-rc.0",
"skills": {
"orca-cli": 3
}
},
{
"appVersion": "1.3.8-rc.1",
"skills": {
"orca-cli": 4
}
},
{
"appVersion": "1.3.12-rc.4",
"skills": {
"orca-cli": 5
}
},
{
"appVersion": "1.3.24-rc.2",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.33-rc.3",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "0.0.1",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.35-rc.0",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38-rc.3",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.39",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.46-rc.3",
"skills": {
"orca-cli": 7,
"orchestration": 2
}
},
{
"appVersion": "1.3.47-rc.0",
"skills": {
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.3.49-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.4.2-rc.10",
"skills": {
"computer-use": 1,
"orca-cli": 9,
"orchestration": 4
}
},
{
"appVersion": "1.4.2",
"skills": {
"computer-use": 1,
"orca-cli": 10,
"orchestration": 5
}
},
{
"appVersion": "1.4.3-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 11,
"orchestration": 6
}
},
{
"appVersion": "1.4.9-rc.1",
"skills": {
"computer-use": 1,
"orca-cli": 12,
"orchestration": 6
}
},
{
"appVersion": "1.4.10-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 13,
"orchestration": 6
}
},
{
"appVersion": "1.4.11-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 14,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 15,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 6
}
},
{
"appVersion": "1.4.22",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 7
}
},
{
"appVersion": "1.4.36-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 17,
"orchestration": 8
}
},
{
"appVersion": "1.4.36-rc.6",
"skills": {
"computer-use": 1,
"orca-cli": 18,
"orchestration": 9
}
},
{
"appVersion": "1.4.42-rc.8",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 10
}
},
{
"appVersion": "1.4.46-rc.0",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.8",
"skills": {
"computer-use": 3,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.9",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.66-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.67-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.68",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 13
}
},
{
"appVersion": "1.4.74-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 21,
"orca-emulator": 1,
"orchestration": 14
}
},
{
"appVersion": "1.4.78-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 22,
"orca-emulator": 1,
"orchestration": 15
}
},
{
"appVersion": "1.4.78-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 23,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 24,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 25,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.88",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 26,
"orca-emulator": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.90",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 26,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.92-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 27,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.96-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 28,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.105-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.111-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.6",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.9",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 19
}
},
{
"appVersion": "1.4.124",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 20
}
},
{
"appVersion": "1.4.137-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 30,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 21
}
},
{
"appVersion": "1.4.138-rc.0",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 22
}
},
{
"appVersion": "1.4.138-rc.2",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 23
}
}
]
}
File diff suppressed because it is too large Load Diff
+235 -12
View File
@@ -1,13 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
() => ({
handleMock: vi.fn(),
discoverSkillsMock: vi.fn(),
getDefaultWslDistroMock: vi.fn(),
getWslHomeMock: vi.fn()
})
)
const {
handleMock,
discoverSkillsMock,
getDefaultWslDistroMock,
getWslHomeMock,
inventoryManagedSkillsMock,
adoptExactSkillSnapshotMock,
updateManagedSkillMock,
statMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
discoverSkillsMock: vi.fn(),
getDefaultWslDistroMock: vi.fn(),
getWslHomeMock: vi.fn(),
inventoryManagedSkillsMock: vi.fn(),
adoptExactSkillSnapshotMock: vi.fn(),
updateManagedSkillMock: vi.fn(),
statMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
@@ -24,13 +36,39 @@ vi.mock('../wsl', () => ({
getWslHome: getWslHomeMock
}))
vi.mock('node:fs/promises', async () => ({
...(await vi.importActual<typeof FsPromises>('node:fs/promises')),
stat: statMock
}))
vi.mock('../skills/skill-management-inventory', () => ({
inventoryManagedSkills: inventoryManagedSkillsMock
}))
vi.mock('../skills/skill-adoption', () => ({
adoptExactSkillSnapshot: adoptExactSkillSnapshotMock,
skillAdoptionDismissal: vi.fn()
}))
vi.mock('../skills/skill-managed-update', () => ({
updateManagedSkill: updateManagedSkillMock
}))
vi.mock('../skills/skill-explicit-replacement', () => ({
previewExplicitSkillReplacement: vi.fn(),
replaceSkillWithCurrentBundle: vi.fn()
}))
import { registerSkillsHandlers } from './skills'
describe('registerSkillsHandlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }]
const store = {
getRepos: vi.fn(() => repos)
getRepos: vi.fn(() => repos),
getSkillManagementLedger: vi.fn(),
setManagedSkillDestination: vi.fn(),
dismissSkillAdoptionCandidate: vi.fn()
}
beforeEach(() => {
@@ -38,6 +76,18 @@ describe('registerSkillsHandlers', () => {
discoverSkillsMock.mockReset()
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
inventoryManagedSkillsMock.mockReset()
adoptExactSkillSnapshotMock.mockReset()
updateManagedSkillMock.mockReset()
statMock.mockReset()
store.getSkillManagementLedger.mockReset()
store.setManagedSkillDestination.mockReset()
store.dismissSkillAdoptionCandidate.mockReset()
store.getSkillManagementLedger.mockReturnValue({
schemaVersion: 1,
destinations: {},
dismissedAdoptionCandidates: []
})
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
@@ -53,10 +103,22 @@ describe('registerSkillsHandlers', () => {
})
function getDiscoverHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === 'skills:discover')
return getHandler('skills:discover')
}
function getHandler(
channel: string,
runtimeFacts: NonNullable<Parameters<typeof registerSkillsHandlers>[1]> = {
isPackaged: true,
buildIdentity: 'stable',
userDataDir: '/isolated/user-data',
temporaryRoot: '/isolated'
}
) {
registerSkillsHandlers(store as never, runtimeFacts)
const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
if (!call) {
throw new Error('skills:discover handler was not registered')
throw new Error(`${channel} handler was not registered`)
}
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
}
@@ -136,4 +198,165 @@ describe('registerSkillsHandlers', () => {
).rejects.toThrow('Project runtime requires repair before skill discovery')
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('rejects WSL skill mutations before touching a distro path', async () => {
const handler = getHandler('skills:adopt')
await expect(
handler(null, {
installationId: 'candidate',
target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
).rejects.toThrow('Skill management actions are not available on this host yet')
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
})
it('rejects remote execution-host discovery instead of scanning local paths', async () => {
const handler = getDiscoverHandler()
await expect(
handler(null, { executionHostId: 'ssh:production', cwd: '/srv/repo' })
).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('rejects remote execution-host mutations before inventory or filesystem work', async () => {
const handler = getHandler('skills:adopt')
await expect(
handler(null, {
installationId: 'candidate',
target: { executionHostId: 'runtime:environment-1' }
})
).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
})
it('rejects unpackaged mutations against the real home', async () => {
const handler = getHandler('skills:adopt', {
isPackaged: false,
buildIdentity: null,
userDataDir: '/real/user-data',
temporaryRoot: '/tmp'
})
await expect(handler(null, { installationId: 'candidate' })).rejects.toThrow(
'official stable build or isolated dev roots'
)
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
})
it('persists the exact adoption tuple supplied by the displayed candidate', async () => {
inventoryManagedSkillsMock.mockResolvedValue({
schemaVersion: 1,
hostId: 'local',
installations: [],
adoptionCandidateCount: 0,
scannedAt: 1
})
const handler = getHandler('skills:dismissAdoption')
const candidate = {
hostId: 'local',
physicalIdentity: '1:2',
skillName: 'orca-cli',
snapshotDigest: 'a'.repeat(64)
}
await handler(null, { candidate })
expect(store.dismissSkillAdoptionCandidate).toHaveBeenCalledWith({
...candidate,
dismissedAt: expect.any(Number)
})
})
it('does not claim a canonical install that predates the terminal attempt', async () => {
const startedAt = Date.now() - 1_000
const inventory = {
schemaVersion: 1,
hostId: 'local',
installations: [
{
id: 'candidate',
name: 'orca-cli',
rootId: 'home-agents',
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
eligible: true,
managed: false
}
],
adoptionCandidateCount: 1,
scannedAt: 1
}
inventoryManagedSkillsMock.mockResolvedValue(inventory)
statMock.mockResolvedValue({ mtimeMs: startedAt - 10_000 })
const handler = getHandler('skills:recordOrcaInstall')
await handler(null, { skillNames: ['orca-cli'], startedAt })
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
expect(store.setManagedSkillDestination).not.toHaveBeenCalled()
})
it('rejects install ownership without a valid terminal start time', async () => {
const handler = getHandler('skills:recordOrcaInstall')
await expect(handler(null, { skillNames: ['orca-cli'], startedAt: 0 })).rejects.toThrow(
'Invalid skill installation start time'
)
expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
})
it('does not claim an install whose marker mtime is implausibly in the future', async () => {
inventoryManagedSkillsMock.mockResolvedValue({
schemaVersion: 1,
hostId: 'local',
installations: [
{
id: 'candidate',
name: 'orca-cli',
rootId: 'home-agents',
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
eligible: true,
managed: false
}
],
adoptionCandidateCount: 1,
scannedAt: 1
})
statMock.mockResolvedValue({ mtimeMs: Date.now() + 60_000 })
const handler = getHandler('skills:recordOrcaInstall')
await handler(null, { skillNames: ['orca-cli'], startedAt: Date.now() - 1_000 })
expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
})
it('persists a failed managed update for retry diagnostics', async () => {
const prior = { id: 'managed', hostId: 'local', lastOutcome: 'adopted' }
store.getSkillManagementLedger.mockReturnValue({
schemaVersion: 1,
destinations: { managed: prior },
dismissedAdoptionCandidates: []
})
updateManagedSkillMock.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }))
const handler = getHandler('skills:updateManaged')
await expect(handler(null, { installationId: 'managed' })).rejects.toThrow('busy')
expect(store.setManagedSkillDestination).toHaveBeenCalledWith(
expect.objectContaining({
id: 'managed',
lastOutcome: 'failed',
lastErrorCategory: 'filesystem-ebusy'
})
)
})
})
+265 -2
View File
@@ -1,16 +1,60 @@
import { ipcMain } from 'electron'
import { app, ipcMain } from 'electron'
import { homedir, tmpdir } from 'node:os'
import { stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Store } from '../persistence'
import { discoverSkills } from '../skills/discovery'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
import type {
OrcaSkillInstallResultArgs,
SkillAdoptionDismissalArgs,
SkillManagementActionArgs,
SkillManagementInventory,
SkillReplacementPreview
} from '../../shared/skill-management'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { LOCAL_EXECUTION_HOST_ID, toWslExecutionHostId } from '../../shared/execution-host'
import { inventoryManagedSkills } from '../skills/skill-management-inventory'
import { adoptExactSkillSnapshot } from '../skills/skill-adoption'
import {
previewExplicitSkillReplacement,
replaceSkillWithCurrentBundle
} from '../skills/skill-explicit-replacement'
import { updateManagedSkill } from '../skills/skill-managed-update'
import { createSkillAdoptionDismissal } from '../skills/skill-adoption-dismissal'
import { buildSkillDiscoverySources } from '../skills/skill-discovery-sources'
import { sweepOrphanedSkillTransactions } from '../skills/skill-orphan-transaction-sweep'
import { resolveDiagnosticBuildIdentity } from '../observability/diagnostic-upload-endpoint'
import {
canMutateManagedSkills,
type SkillMutationRuntimeFacts
} from '../skills/skill-mutation-authority'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
| { runtime: 'wsl'; wslDistro: string | null | undefined }
const MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS = 15 * 60 * 1_000
type SkillHandlerRuntimeFacts = Omit<SkillMutationRuntimeFacts, 'homeDir'>
function productionSkillHandlerRuntimeFacts(): SkillHandlerRuntimeFacts {
return {
isPackaged: app.isPackaged,
buildIdentity: resolveDiagnosticBuildIdentity(),
userDataDir: app.getPath('userData'),
isolatedDevHomeDir: process.env.ORCA_E2E_SKILL_HOME_DIR,
isolatedDevUserDataDir: process.env.ORCA_E2E_USER_DATA_DIR,
temporaryRoot: tmpdir()
}
}
function getSkillDiscoveryRuntimeTarget(
target: SkillDiscoveryTarget | undefined
): SkillDiscoveryRuntimeTarget {
if (target?.executionHostId && target.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
throw new Error('Skill management is not available for remote execution hosts in Phase 1.')
}
const projectRuntime = target?.projectRuntime
if (!projectRuntime) {
return target?.runtime === 'wsl'
@@ -31,7 +75,75 @@ function getSkillDiscoveryRuntimeTarget(
return { runtime: 'host' }
}
export function registerSkillsHandlers(store: Store): void {
function getSkillManagementHostContext(target: SkillDiscoveryTarget | undefined): {
hostId: ReturnType<typeof toWslExecutionHostId> | typeof LOCAL_EXECUTION_HOST_ID
homeDir: string
} {
const runtimeTarget = getSkillDiscoveryRuntimeTarget(target)
if (runtimeTarget.runtime === 'host') {
return { hostId: LOCAL_EXECUTION_HOST_ID, homeDir: homedir() }
}
if (process.platform !== 'win32') {
throw new Error('WSL skill management is only available on Windows.')
}
const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro()
if (!distro) {
throw new Error('No WSL distribution is available for skill management.')
}
const homeDir = getWslHome(distro)
if (!homeDir) {
throw new Error(`Could not resolve the WSL home directory for ${distro}.`)
}
return { hostId: toWslExecutionHostId(distro), homeDir }
}
function requireSkillMutation(
context: { hostId: string; homeDir: string },
runtimeFacts: SkillHandlerRuntimeFacts
): void {
if (context.hostId !== LOCAL_EXECUTION_HOST_ID) {
// Why: WSL publication belongs inside the distro runtime; UNC mutation
// would apply Windows lock and mode semantics to Linux-owned files.
throw new Error('Skill management actions are not available on this host yet.')
}
if (!canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })) {
throw new Error(
'Skill management actions require an official stable build or isolated dev roots.'
)
}
}
async function recoverSkillTransactionsForExplicitAction(
context: { hostId: string; homeDir: string },
store: Store,
runtimeFacts: SkillHandlerRuntimeFacts
): Promise<void> {
requireSkillMutation(context, runtimeFacts)
const roots = buildSkillDiscoverySources({
homeDir: context.homeDir,
cwd: context.homeDir
}).filter((root) => root.sourceKind === 'home')
// Why: orphan recovery can write, so it runs only inside an explicit
// foreground management action, never inventory or the App-mounted nudge.
await Promise.all(
roots.map((root) => sweepOrphanedSkillTransactions(root.path, store.getSkillManagementLedger()))
)
}
function skillFailureCategory(error: unknown): string {
if (error instanceof Error && /^skill-[a-z-]+$/.test(error.message)) {
return error.message
}
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
return `filesystem-${error.code.toLowerCase()}`
}
return 'unknown-error'
}
export function registerSkillsHandlers(
store: Store,
runtimeFacts: SkillHandlerRuntimeFacts = productionSkillHandlerRuntimeFacts()
): void {
ipcMain.handle(
'skills:discover',
async (_event, target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> => {
@@ -55,4 +167,155 @@ export function registerSkillsHandlers(store: Store): void {
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
}
)
ipcMain.handle(
'skills:managementInventory',
async (_event, target?: SkillDiscoveryTarget): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(target)
return inventoryManagedSkills({
...context,
ledger: store.getSkillManagementLedger(),
actionsSupported: canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })
})
}
)
ipcMain.handle(
'skills:adopt',
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(args.target)
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
await adoptExactSkillSnapshot({
...context,
ledger: store.getSkillManagementLedger(),
installationId: args.installationId,
commit: (next) => store.setManagedSkillDestination(next)
})
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
}
)
ipcMain.handle(
'skills:dismissAdoption',
async (_event, args: SkillAdoptionDismissalArgs): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(args.target)
requireSkillMutation(context, runtimeFacts)
store.dismissSkillAdoptionCandidate(
createSkillAdoptionDismissal(args?.candidate, context.hostId)
)
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
}
)
ipcMain.handle(
'skills:previewReplacement',
async (_event, args: SkillManagementActionArgs): Promise<SkillReplacementPreview> => {
const context = getSkillManagementHostContext(args.target)
return previewExplicitSkillReplacement({
...context,
ledger: store.getSkillManagementLedger(),
installationId: args.installationId
})
}
)
ipcMain.handle(
'skills:replace',
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(args.target)
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
await replaceSkillWithCurrentBundle({
...context,
ledger: store.getSkillManagementLedger(),
installationId: args.installationId,
commit: (next) => store.setManagedSkillDestination(next)
})
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
}
)
ipcMain.handle(
'skills:updateManaged',
async (_event, args: SkillManagementActionArgs): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(args.target)
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
const ledger = store.getSkillManagementLedger()
try {
await updateManagedSkill({
...context,
ledger,
installationId: args.installationId,
commit: (next) => store.setManagedSkillDestination(next)
})
} catch (error) {
const prior = ledger.destinations[args.installationId]
if (prior) {
store.setManagedSkillDestination({
...prior,
lastOutcome: 'failed',
lastErrorCategory: skillFailureCategory(error),
updatedAt: Date.now()
})
}
throw error
}
return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
}
)
ipcMain.handle(
'skills:recordOrcaInstall',
async (_event, args: OrcaSkillInstallResultArgs): Promise<SkillManagementInventory> => {
const context = getSkillManagementHostContext(args.target)
const completedAt = Date.now()
if (
!Number.isFinite(args.startedAt) ||
args.startedAt <= 0 ||
args.startedAt > completedAt ||
completedAt - args.startedAt > MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS
) {
throw new Error('Invalid skill installation start time.')
}
await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
let inventory = await inventoryManagedSkills({
...context,
ledger: store.getSkillManagementLedger()
})
for (const skillName of new Set(args.skillNames)) {
// Why: the global skills CLI owns the canonical .agents placement;
// provider-specific copies discovered beside it were not necessarily
// created by this terminal run and must retain separate consent.
const installation = inventory.installations.find(
(entry) =>
entry.name === skillName &&
entry.rootId === 'home-agents' &&
entry.eligible &&
!entry.managed
)
if (!installation) {
continue
}
const modifiedAt = await stat(join(installation.unresolvedPath, 'SKILL.md'))
.then((file) => file.mtimeMs)
.catch(() => 0)
// Why: exact content proves provenance, but only an mtime from this
// terminal window attributes ownership to Orca's install attempt.
if (modifiedAt + 2_000 < args.startedAt || modifiedAt > completedAt + 2_000) {
continue
}
await adoptExactSkillSnapshot({
...context,
ledger: store.getSkillManagementLedger(),
installationId: installation.id,
commit: (next) =>
store.setManagedSkillDestination({ ...next, adoptedFrom: 'orca-install' })
})
inventory = await inventoryManagedSkills({
...context,
ledger: store.getSkillManagementLedger()
})
}
return inventory
}
)
}
+42
View File
@@ -6711,6 +6711,48 @@ describe('Store', () => {
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
})
it('durably persists managed skill ownership before returning', async () => {
const store = await createStore()
const destination = {
id: 'local-skill',
hostId: 'local' as const,
skillName: 'orca-cli',
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
installedPackageDigest: 'a'.repeat(64)
}
store.setManagedSkillDestination(destination as never)
expect(
(
readDataFile() as {
skillManagementLedger: { destinations: Record<string, unknown> }
}
).skillManagementLedger.destinations['local-skill']
).toMatchObject(destination)
})
it('durably persists an exact skill adoption dismissal before returning', async () => {
const store = await createStore()
const candidate = {
hostId: 'local' as const,
physicalIdentity: '1:2',
skillName: 'orca-cli',
snapshotDigest: 'a'.repeat(64),
dismissedAt: 1
}
store.dismissSkillAdoptionCandidate(candidate)
expect(
(
readDataFile() as {
skillManagementLedger: { dismissedAdoptionCandidates: unknown[] }
}
).skillManagementLedger.dismissedAdoptionCandidates
).toContainEqual(candidate)
})
it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
+49
View File
@@ -73,6 +73,11 @@ import {
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
import type {
DismissedSkillAdoptionCandidate,
ManagedSkillDestination,
SkillManagementLedger
} from '../shared/skill-management'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
@@ -236,6 +241,10 @@ import {
import { track } from './telemetry/client'
import { getCohortAtEmit } from './telemetry/cohort-classifier'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
import {
normalizeSkillManagementLedger,
skillAdoptionDismissalKey
} from './skills/skill-management-ledger'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
@@ -3106,6 +3115,9 @@ export class Store {
featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets(
parsed.featureInteractionTelemetryBuckets
),
// Why: missing or malformed ownership state must never grant write
// authority; exact-snapshot adoption can reconstruct it explicitly.
skillManagementLedger: normalizeSkillManagementLedger(parsed.skillManagementLedger),
projectGroups: normalizedProjectGroups,
folderWorkspaces: normalizeFolderWorkspaces(
parsed.folderWorkspaces,
@@ -3804,6 +3816,43 @@ export class Store {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
getSkillManagementLedger(): SkillManagementLedger {
return structuredClone(this.state.skillManagementLedger)
}
setManagedSkillDestination(destination: ManagedSkillDestination): void {
const previous = this.state.skillManagementLedger.destinations[destination.id]
this.state.skillManagementLedger.destinations[destination.id] = structuredClone(destination)
try {
// Why: transaction cleanup may delete the only rollback copy immediately
// after this callback, so ownership must reach disk before it returns.
this.flushOrThrow()
} catch (error) {
if (previous) {
this.state.skillManagementLedger.destinations[destination.id] = previous
} else {
delete this.state.skillManagementLedger.destinations[destination.id]
}
throw error
}
}
dismissSkillAdoptionCandidate(candidate: DismissedSkillAdoptionCandidate): void {
const previous = this.state.skillManagementLedger.dismissedAdoptionCandidates
const key = skillAdoptionDismissalKey(candidate)
const retained = previous.filter((entry) => skillAdoptionDismissalKey(entry) !== key)
retained.push(structuredClone(candidate))
this.state.skillManagementLedger.dismissedAdoptionCandidates = retained
try {
// Why: toast closure is the user's one suppression action, so the tuple
// must be durable before the renderer treats that lifecycle as complete.
this.flushOrThrow()
} catch (error) {
this.state.skillManagementLedger.dismissedAdoptionCandidates = previous
throw error
}
}
getProjects(): Project[] {
return [...this.state.projects]
}
+19
View File
@@ -57,6 +57,25 @@ describe('skill discovery', () => {
expect(rootPaths).toContain('/workspace/current/.claude/skills')
})
it('hard-excludes reserved updater transactions from skill discovery', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
const stagedSkill = join(
home,
'.agents',
'skills',
'.orca-skill-transactions',
'transaction-1',
'stage'
)
await mkdir(stagedSkill, { recursive: true })
await writeFile(join(stagedSkill, 'SKILL.md'), '# Staged package')
const result = await discoverSkills({ homeDir: home, cwd: home, repos: [] })
expect(result.skills).toEqual([])
})
it('discovers skill packages through symlinked skill directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
+6
View File
@@ -14,6 +14,7 @@ import {
stablePathId,
type SkillScanRoot
} from './skill-discovery-sources'
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
export { buildSkillDiscoverySources } from './skill-discovery-sources'
@@ -92,6 +93,11 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise<strin
return
}
for (const entry of entries) {
if (entry.name === RESERVED_SKILL_TRANSACTION_DIRECTORY) {
// Why: crash-recovery packages may contain a valid SKILL.md but are
// transaction state, never install candidates.
continue
}
const entryPath = join(dirPath, entry.name)
if (entry.name === SKILL_FILE_NAME) {
if (entry.isFile()) {
@@ -0,0 +1,31 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { DismissedSkillAdoptionCandidate } from '../../shared/skill-management'
export function createSkillAdoptionDismissal(
value: unknown,
hostId: ExecutionHostId,
dismissedAt = Date.now()
): DismissedSkillAdoptionCandidate {
if (typeof value !== 'object' || value === null) {
throw new Error('skill-adoption-candidate-invalid')
}
const candidate = value as Partial<DismissedSkillAdoptionCandidate>
if (
candidate.hostId !== hostId ||
typeof candidate.physicalIdentity !== 'string' ||
candidate.physicalIdentity.length === 0 ||
typeof candidate.skillName !== 'string' ||
candidate.skillName.length === 0 ||
typeof candidate.snapshotDigest !== 'string' ||
!/^[a-f0-9]{64}$/.test(candidate.snapshotDigest)
) {
throw new Error('skill-adoption-candidate-invalid')
}
return {
hostId,
physicalIdentity: candidate.physicalIdentity,
skillName: candidate.skillName,
snapshotDigest: candidate.snapshotDigest,
dismissedAt
}
}
+181
View File
@@ -0,0 +1,181 @@
import { execFileSync } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillManagementInventory } from '../../shared/skill-management'
import { adoptExactSkillSnapshot } from './skill-adoption'
import { emptySkillManagementLedger } from './skill-management-ledger'
import { inventoryManagedSkills } from './skill-management-inventory'
const resourceRoot = resolve('resources')
const temporaryHomes: string[] = []
async function installHistoricalOrcaCli(crlf = false): Promise<{
home: string
inventory: SkillManagementInventory
}> {
const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-'))
temporaryHomes.push(home)
const destination = join(home, '.agents', 'skills', 'orca-cli')
await mkdir(destination, { recursive: true })
const releaseMapping = JSON.parse(
await readFile(resolve('resources/skills/release-mapping.json'), 'utf8')
)
const firstRelease = releaseMapping.releases.find(
(release: { skills: Record<string, number> }) => release.skills['orca-cli'] === 1
)
const source = execFileSync(
'git',
['show', `v${firstRelease.appVersion}:skills/orca-cli/SKILL.md`],
{ encoding: 'utf8' }
)
await writeFile(join(destination, 'SKILL.md'), crlf ? source.replace(/\n/g, '\r\n') : source)
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
return { home, inventory }
}
afterEach(async () => {
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
})
describe('exact skill snapshot adoption', () => {
it('adopts and foreground-updates a released stale package', async () => {
const { home, inventory } = await installHistoricalOrcaCli()
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
expect(installation?.status).toBe('known-update-available')
expect(installation?.installedAppVersion).toBe('1.0.81')
const record = await adoptExactSkillSnapshot({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation!.id,
resourceRoot,
commit: () => undefined,
now: 42
})
expect(record).toMatchObject({
installedReleaseRevision: installation!.currentReleaseRevision,
installedPackageDigest: installation!.currentPackageDigest,
lastOutcome: 'updated',
adoptedAt: 42,
updatedAt: 42
})
expect(await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'utf8')).toBe(
await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
)
const managedInventory = await inventoryManagedSkills({
ledger: {
schemaVersion: 1,
destinations: { [record.id]: record },
dismissedAdoptionCandidates: []
},
hostId: 'local',
homeDir: home,
resourceRoot
})
expect(managedInventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject(
{ id: record.id, status: 'managed-current', managed: true }
)
})
it('preserves a Windows-shaped CRLF text installation while updating', async () => {
const { home, inventory } = await installHistoricalOrcaCli(true)
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
await adoptExactSkillSnapshot({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation!.id,
resourceRoot,
commit: () => undefined
})
const installed = await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'))
const current = await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
expect(installed.includes(13)).toBe(true)
expect(installed.toString('utf8').replace(/\r\n/g, '\n')).toBe(current)
})
it('restores the prior package when durable ledger persistence fails', async () => {
const { home, inventory } = await installHistoricalOrcaCli()
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
const skillMarkdownPath = join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md')
const before = await readFile(skillMarkdownPath)
await expect(
adoptExactSkillSnapshot({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation.id,
resourceRoot,
commit: () => {
throw new Error('ledger-write-failed')
}
})
).rejects.toThrow('ledger-write-failed')
expect(await readFile(skillMarkdownPath)).toEqual(before)
})
it('refuses adoption after unknown content replaces the official snapshot', async () => {
const { home, inventory } = await installHistoricalOrcaCli()
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
await writeFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'custom')
await expect(
adoptExactSkillSnapshot({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation!.id,
resourceRoot,
commit: () => undefined
})
).rejects.toThrow('skill-installation-not-adoptable')
})
it('revalidates exact content immediately before committing current adoption', async () => {
const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-race-'))
temporaryHomes.push(home)
const destination = join(home, '.agents', 'skills', 'orca-cli')
await mkdir(destination, { recursive: true })
await writeFile(
join(destination, 'SKILL.md'),
await readFile(resolve('skills/orca-cli/SKILL.md'))
)
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
let committed = false
await expect(
adoptExactSkillSnapshot({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation.id,
resourceRoot,
beforeFinalValidation: () =>
writeFile(join(destination, 'SKILL.md'), 'post-observation independent edit'),
commit: () => {
committed = true
}
})
).rejects.toThrow('skill-content-changed')
expect(committed).toBe(false)
})
})
+130
View File
@@ -0,0 +1,130 @@
import { lstat, realpath } from 'node:fs/promises'
import { resolve } from 'node:path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
DismissedSkillAdoptionCandidate,
ManagedSkillDestination,
SkillManagementLedger
} from '../../shared/skill-management'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import {
matchingReleasedSnapshot,
observeSkillPackage,
persistedObservedFiles
} from './skill-package-identity'
import { inventoryManagedSkills } from './skill-management-inventory'
import { normalizedSkillIdentityPath } from './skill-installation-topology'
import { publishBundledSkill } from './skill-package-publish'
export async function adoptExactSkillSnapshot(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
now?: number
// Why: deterministic fault injection must land after advisory observation
// but before the same final validation used by production commits.
beforeFinalValidation?: () => void | Promise<void>
commit: (destination: ManagedSkillDestination) => void | Promise<void>
}): Promise<ManagedSkillDestination> {
const inventory = await inventoryManagedSkills(args)
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
if (
!installation?.eligible ||
installation.managed ||
!installation.resolvedPath ||
!installation.physicalIdentity ||
installation.installedReleaseRevision === null ||
!installation.installedPackageDigest
) {
throw new Error('skill-installation-not-adoptable')
}
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
const observed = await observeSkillPackage(installation.resolvedPath)
const snapshot = matchingReleasedSnapshot(
observed,
artifacts.releasedSnapshots[installation.name] ?? []
)
// Why: inventory is only advisory; consent records ownership only after a
// fresh exact-content check closes the user/CLI mutation race.
if (
!current ||
!snapshot ||
snapshot.packageDigest !== installation.installedPackageDigest ||
normalizedSkillIdentityPath(await realpath(installation.resolvedPath)) !==
normalizedSkillIdentityPath(installation.resolvedPath)
) {
throw new Error('skill-installation-changed-before-adoption')
}
const logicalStat = await lstat(installation.unresolvedPath)
const now = args.now ?? Date.now()
const record: ManagedSkillDestination = {
id: installation.id,
hostId: args.hostId,
homeIdentity: normalizedSkillIdentityPath(
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
),
rootId: installation.rootId,
unresolvedPath: installation.unresolvedPath,
resolvedPath: installation.resolvedPath,
physicalIdentity: installation.physicalIdentity,
entryType: logicalStat.isSymbolicLink()
? process.platform === 'win32'
? 'junction'
: 'symlink'
: 'directory',
skillName: installation.name,
source: 'stablyai/orca',
sourcePath: current.sourcePath,
sourceRef: snapshot.gitTreeSha,
installedReleaseRevision: snapshot.releaseRevision,
installedPackageDigest: snapshot.packageDigest,
installedFiles: persistedObservedFiles(observed),
lastWrittenPackageDigest: null,
lastAttemptedBundleFingerprint: current.packageDigest,
lastOutcome: 'adopted',
lastErrorCategory: null,
adoptedFrom: 'exact-snapshot',
adoptedAt: now,
updatedAt: now
}
await args.beforeFinalValidation?.()
return publishBundledSkill({
record,
current,
releasedSnapshots: artifacts.releasedSnapshots[installation.name] ?? [],
packagesRoot: artifacts.packagesRoot,
commit: args.commit,
now
})
}
export async function skillAdoptionDismissal(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
now?: number
}): Promise<DismissedSkillAdoptionCandidate> {
const inventory = await inventoryManagedSkills(args)
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
if (
!installation?.eligible ||
!installation.physicalIdentity ||
!installation.installedPackageDigest
) {
throw new Error('skill-adoption-candidate-not-found')
}
return {
hostId: args.hostId,
physicalIdentity: installation.physicalIdentity,
skillName: installation.name,
snapshotDigest: installation.installedPackageDigest,
dismissedAt: args.now ?? Date.now()
}
}
+110
View File
@@ -0,0 +1,110 @@
import { app } from 'electron'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import type {
SkillBundleManifest,
SkillReleaseMapping,
SkillReleasedSnapshot,
SkillSnapshotRegistry
} from '../../shared/skill-management'
export type SkillBundleArtifacts = {
manifest: SkillBundleManifest
registry: SkillSnapshotRegistry
releaseMapping: SkillReleaseMapping
releasedSnapshots: Record<string, SkillReleasedSnapshot[]>
releasedAppVersions: Record<string, Record<number, string>>
packagesRoot: string
}
function assertSupportedSchema(
value: unknown,
label: string
): asserts value is { schemaVersion: 1 } {
if (
typeof value !== 'object' ||
value === null ||
!('schemaVersion' in value) ||
value.schemaVersion !== 1
) {
throw new Error(`Unsupported ${label} schema`)
}
}
export async function loadSkillBundleArtifacts(
resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
): Promise<SkillBundleArtifacts> {
const bundleRoot = join(resourceRoot, 'skills')
const [manifest, registry, releaseMapping] = await Promise.all([
readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse)
])
assertSupportedSchema(manifest, 'skill bundle manifest')
assertSupportedSchema(registry, 'skill snapshot registry')
assertSupportedSchema(releaseMapping, 'skill release mapping')
if (!('skills' in manifest) || !Array.isArray(manifest.skills)) {
throw new Error('Invalid skill bundle manifest')
}
if (
!('appVersion' in manifest) ||
typeof manifest.appVersion !== 'string' ||
!('skills' in registry) ||
typeof registry.skills !== 'object' ||
registry.skills === null
) {
throw new Error('Invalid skill snapshot registry')
}
if (!('releases' in releaseMapping) || !Array.isArray(releaseMapping.releases)) {
throw new Error('Invalid skill release mapping')
}
const mappedRevisions = new Map<string, Set<number>>()
const releasedAppVersions: Record<string, Record<number, string>> = {}
for (const release of releaseMapping.releases) {
if (
typeof release !== 'object' ||
release === null ||
!('appVersion' in release) ||
typeof release.appVersion !== 'string' ||
!('skills' in release) ||
typeof release.skills !== 'object' ||
release.skills === null
) {
throw new Error('Invalid skill release mapping')
}
for (const [name, revision] of Object.entries(release.skills)) {
if (!Number.isSafeInteger(revision) || (revision as number) <= 0) {
throw new Error('Invalid skill release mapping')
}
const revisions = mappedRevisions.get(name) ?? new Set<number>()
revisions.add(revision as number)
mappedRevisions.set(name, revisions)
releasedAppVersions[name] ??= {}
releasedAppVersions[name][revision as number] ??= release.appVersion
}
}
const releasedSnapshots = Object.fromEntries(
Object.entries((registry as SkillSnapshotRegistry).skills).map(([name, snapshots]) => [
name,
snapshots.filter((snapshot) => mappedRevisions.get(name)?.has(snapshot.releaseRevision))
])
)
for (const [name, revisions] of mappedRevisions) {
const known = new Set(
((registry as SkillSnapshotRegistry).skills[name] ?? []).map(
(snapshot) => snapshot.releaseRevision
)
)
if ([...revisions].some((revision) => !known.has(revision))) {
throw new Error('Skill release mapping references an unknown snapshot')
}
}
return {
manifest: manifest as SkillBundleManifest,
registry: registry as SkillSnapshotRegistry,
releaseMapping: releaseMapping as SkillReleaseMapping,
releasedSnapshots,
releasedAppVersions,
packagesRoot: app?.isPackaged ? join(bundleRoot, 'packages') : resolve(process.cwd(), 'skills')
}
}
+2 -1
View File
@@ -32,7 +32,8 @@ export function buildSkillDiscoverySources(
const roots: SkillScanRoot[] = [
source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']),
source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [
'agent-skills'
'agent-skills',
'codex'
]),
source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']),
source(
@@ -0,0 +1,84 @@
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { emptySkillManagementLedger } from './skill-management-ledger'
import { inventoryManagedSkills } from './skill-management-inventory'
import {
previewExplicitSkillReplacement,
replaceSkillWithCurrentBundle
} from './skill-explicit-replacement'
const temporaryHomes: string[] = []
const resourceRoot = resolve('resources')
afterEach(async () => {
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
})
async function customInstallation() {
const home = await mkdtemp(join(tmpdir(), 'orca-skill-replacement-'))
temporaryHomes.push(home)
const destination = join(home, '.agents', 'skills', 'orca-cli')
await mkdir(destination, { recursive: true })
await cp(resolve('skills/orca-cli'), destination, { recursive: true })
await writeFile(join(destination, 'SKILL.md'), 'custom instructions\n')
await writeFile(join(destination, 'notes.txt'), 'keep me until confirmed\n')
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
return {
home,
destination,
installation: inventory.installations.find((entry) => entry.name === 'orca-cli')!
}
}
describe('explicit skill replacement', () => {
it('previews every local file that replacement changes or removes', async () => {
const { home, installation } = await customInstallation()
const preview = await previewExplicitSkillReplacement({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation.id,
resourceRoot
})
expect(preview.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: 'SKILL.md', change: 'modified' }),
expect.objectContaining({ path: 'notes.txt', change: 'removed' })
])
)
})
it('replaces only after explicit invocation and records the verified current bundle', async () => {
const { home, destination, installation } = await customInstallation()
expect(await readFile(join(destination, 'notes.txt'), 'utf8')).toContain('keep me')
const record = await replaceSkillWithCurrentBundle({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
installationId: installation.id,
resourceRoot,
commit: () => undefined,
now: 99
})
expect(record).toMatchObject({
adoptedFrom: 'explicit-replacement',
lastOutcome: 'replaced',
adoptedAt: 99,
updatedAt: 99
})
expect(await readFile(join(destination, 'SKILL.md'), 'utf8')).toBe(
await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
)
await expect(readFile(join(destination, 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
})
})
@@ -0,0 +1,161 @@
import { readFile, realpath } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
ManagedSkillDestination,
SkillManagementLedger,
SkillReplacementFilePreview,
SkillReplacementPreview,
SkillReleasedSnapshot
} from '../../shared/skill-management'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import { normalizedSkillIdentityPath } from './skill-installation-topology'
import { inventoryManagedSkills } from './skill-management-inventory'
import {
observeSkillPackage,
persistedObservedFiles,
type ObservedSkillPackage
} from './skill-package-identity'
import { publishBundledSkill } from './skill-package-publish'
function decodedText(bytes: Buffer): string | null {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch {
return null
}
}
async function replacementFiles(
observed: ObservedSkillPackage,
packageRoot: string,
currentPaths: readonly string[]
): Promise<SkillReplacementFilePreview[]> {
const observedByPath = new Map(observed.files.map((file) => [file.path, file]))
const paths = [...new Set([...observedByPath.keys(), ...currentPaths])].sort()
const previews = await Promise.all(
paths.map(async (path): Promise<SkillReplacementFilePreview | null> => {
const before = observedByPath.get(path)
const afterBytes = currentPaths.includes(path)
? await readFile(join(packageRoot, ...path.split('/')))
: null
if (before && afterBytes && before.bytes.equals(afterBytes)) {
return null
}
return {
path,
change: before ? (afterBytes ? 'modified' : 'removed') : 'added',
beforeText: before ? decodedText(before.bytes) : null,
afterText: afterBytes ? decodedText(afterBytes) : null
}
})
)
return previews.filter((preview): preview is SkillReplacementFilePreview => preview !== null)
}
async function replacementContext(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
}) {
const inventory = await inventoryManagedSkills(args)
const installation = inventory.installations.find((entry) => entry.id === args.installationId)
if (
!installation ||
(installation.status !== 'modified' && installation.status !== 'unknown') ||
!installation.resolvedPath ||
!installation.physicalIdentity ||
(installation.topology !== 'canonical-copy' && installation.topology !== 'independent-copy')
) {
throw new Error('skill-installation-not-replaceable')
}
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
if (!current) {
throw new Error('skill-bundle-entry-missing')
}
const observed = await observeSkillPackage(installation.resolvedPath)
return { installation, artifacts, current, observed }
}
export async function previewExplicitSkillReplacement(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
}): Promise<SkillReplacementPreview> {
const { installation, artifacts, current, observed } = await replacementContext(args)
return {
installationId: installation.id,
skillName: installation.name,
files: await replacementFiles(
observed,
join(artifacts.packagesRoot, installation.name),
current.files.map((file) => file.path)
)
}
}
export async function replaceSkillWithCurrentBundle(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
now?: number
commit: (destination: ManagedSkillDestination) => void | Promise<void>
}): Promise<ManagedSkillDestination> {
const { installation, artifacts, current, observed } = await replacementContext(args)
const now = args.now ?? Date.now()
const installedFiles = persistedObservedFiles(observed)
const observedSnapshot: SkillReleasedSnapshot = {
releaseRevision: 0,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: installedFiles
}
const homeIdentity = normalizedSkillIdentityPath(
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
)
const record: ManagedSkillDestination = {
id: installation.id,
hostId: args.hostId,
homeIdentity,
rootId: installation.rootId,
unresolvedPath: installation.unresolvedPath,
resolvedPath: installation.resolvedPath!,
physicalIdentity: installation.physicalIdentity!,
entryType: 'directory',
skillName: installation.name,
source: 'stablyai/orca',
sourcePath: current.sourcePath,
sourceRef: null,
installedReleaseRevision: 0,
installedPackageDigest: observed.observedDigest,
installedFiles,
lastWrittenPackageDigest: null,
lastAttemptedBundleFingerprint: current.packageDigest,
lastOutcome: 'replaced',
lastErrorCategory: null,
adoptedFrom: 'explicit-replacement',
adoptedAt: now,
updatedAt: now
}
const published = await publishBundledSkill({
record,
current,
releasedSnapshots: [observedSnapshot],
packagesRoot: artifacts.packagesRoot,
commit: (destination) =>
args.commit({
...destination,
lastOutcome: 'replaced',
adoptedFrom: 'explicit-replacement'
}),
now
})
return { ...published, lastOutcome: 'replaced', adoptedFrom: 'explicit-replacement' }
}
@@ -0,0 +1,207 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { access, lstat, realpath, stat } from 'node:fs/promises'
import { dirname, normalize, resolve } from 'node:path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { SkillInstallationTopology } from '../../shared/skill-management'
import type { ManagedSkillDestination } from '../../shared/skill-management'
import type { SkillScanRoot } from './skill-discovery-sources'
export type ClassifiedSkillTopology = {
topology: SkillInstallationTopology
resolvedPath: string | null
identity: string | null
entryType: 'directory' | 'symlink' | 'junction'
errorCategory: string | null
}
export function skillDestinationId(
hostId: ExecutionHostId,
unresolvedPath: string,
name: string
): string {
return createHash('sha256')
.update(hostId)
.update('\0')
.update(normalizedSkillIdentityPath(unresolvedPath))
.update('\0')
.update(name)
.digest('hex')
.slice(0, 24)
}
export function normalizedSkillIdentityPath(value: string): string {
const normalized = normalize(value)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export function skillPhysicalIdentity(
resolvedPath: string,
fileStat: Awaited<ReturnType<typeof stat>>
): string {
const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null
return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath)
}
export function skillTopologyPriority(topology: SkillInstallationTopology): number {
switch (topology) {
case 'canonical-copy':
return 3
case 'independent-copy':
return 2
case 'provider-alias':
return 1
case 'external-link':
case 'broken-link':
case 'read-only':
return 0
}
}
async function writableDestination(path: string): Promise<boolean> {
try {
await Promise.all([
access(path, constants.R_OK | constants.W_OK),
access(dirname(path), constants.W_OK)
])
return true
} catch {
return false
}
}
async function hasSymlinkedAncestor(path: string, boundary: string): Promise<boolean> {
let current = resolve(path)
const stop = resolve(boundary)
for (;;) {
const entry = await lstat(current).catch(() => null)
if (!entry || entry.isSymbolicLink()) {
return true
}
const parent = dirname(current)
if (current === stop) {
return false
}
if (parent === current) {
return true
}
current = parent
}
}
export async function assertManagedSkillTopology(record: ManagedSkillDestination): Promise<string> {
const logical = await lstat(record.unresolvedPath)
const actualEntryType = logical.isSymbolicLink()
? process.platform === 'win32'
? 'junction'
: 'symlink'
: logical.isDirectory()
? 'directory'
: null
const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
if (
actualEntryType !== record.entryType ||
(await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary))
) {
throw new Error('skill-topology-changed')
}
const [resolved, resolvedParent] = await Promise.all([
realpath(record.unresolvedPath),
realpath(dirname(record.unresolvedPath))
])
const resolvedStat = await stat(resolved)
if (
!resolvedStat.isDirectory() ||
normalizedSkillIdentityPath(resolved) !== normalizedSkillIdentityPath(record.resolvedPath) ||
normalizedSkillIdentityPath(resolvedParent) !==
normalizedSkillIdentityPath(dirname(record.resolvedPath)) ||
skillPhysicalIdentity(resolved, resolvedStat) !== record.physicalIdentity
) {
throw new Error('skill-topology-changed')
}
return resolved
}
export async function assertManagedSkillParentTopology(
record: ManagedSkillDestination
): Promise<void> {
const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
if (await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary)) {
throw new Error('skill-topology-changed')
}
const resolvedParent = await realpath(dirname(record.unresolvedPath))
if (
normalizedSkillIdentityPath(resolvedParent) !==
normalizedSkillIdentityPath(dirname(record.resolvedPath))
) {
throw new Error('skill-topology-changed')
}
}
export async function classifySkillInstallationTopology(
root: SkillScanRoot,
unresolvedPath: string,
canonicalRootPath: string
): Promise<ClassifiedSkillTopology> {
let logicalStat: Awaited<ReturnType<typeof lstat>>
try {
logicalStat = await lstat(unresolvedPath)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return {
topology: 'broken-link',
resolvedPath: null,
identity: null,
entryType: 'symlink',
errorCategory: 'missing'
}
}
throw error
}
const linked = logicalStat.isSymbolicLink()
const entryType =
linked && process.platform === 'win32' ? 'junction' : linked ? 'symlink' : 'directory'
let resolvedPath: string
let resolvedStat: Awaited<ReturnType<typeof stat>>
try {
resolvedPath = await realpath(unresolvedPath)
resolvedStat = await stat(resolvedPath)
} catch {
return {
topology: 'broken-link',
resolvedPath: null,
identity: null,
entryType,
errorCategory: 'dangling-link'
}
}
if (!resolvedStat.isDirectory()) {
return {
topology: 'broken-link',
resolvedPath,
identity: null,
entryType,
errorCategory: 'not-directory'
}
}
const identity = skillPhysicalIdentity(resolvedPath, resolvedStat)
const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath))
const homeBoundary = dirname(dirname(canonicalRootPath))
const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary)
const isCanonicalTarget =
normalizedSkillIdentityPath(dirname(resolvedPath)) ===
normalizedSkillIdentityPath(canonicalRoot)
let topology: SkillInstallationTopology
if (linked) {
topology = isCanonicalTarget ? 'provider-alias' : 'external-link'
} else if (rootOrProviderParentLinked) {
topology = 'external-link'
} else {
topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy'
}
if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) {
topology = 'read-only'
}
return { topology, resolvedPath, identity, entryType, errorCategory: null }
}
+43
View File
@@ -0,0 +1,43 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import { publishBundledSkill } from './skill-package-publish'
import { inventoryManagedSkills } from './skill-management-inventory'
export async function updateManagedSkill(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
installationId: string
resourceRoot?: string
now?: number
commit: (destination: ManagedSkillDestination) => void | Promise<void>
}): Promise<ManagedSkillDestination> {
const record = args.ledger.destinations[args.installationId]
if (!record || record.hostId !== args.hostId) {
throw new Error('managed-skill-not-found')
}
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
const inventory = await inventoryManagedSkills(args)
const installation = inventory.installations.find((entry) => entry.id === record.id)
if (
!installation?.managed ||
installation.hostId !== record.hostId ||
installation.physicalIdentity !== record.physicalIdentity ||
installation.resolvedPath !== record.resolvedPath
) {
throw new Error('managed-skill-identity-changed')
}
const current = artifacts.manifest.skills.find((entry) => entry.name === record.skillName)
if (!current) {
throw new Error('skill-bundle-entry-missing')
}
return publishBundledSkill({
record,
current,
releasedSnapshots: artifacts.releasedSnapshots[record.skillName] ?? [],
packagesRoot: artifacts.packagesRoot,
commit: args.commit,
now: args.now
})
}
@@ -0,0 +1,63 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
SkillCurrentBundleEntry,
SkillManagementInstallation
} from '../../shared/skill-management'
import type { SkillScanRoot } from './skill-discovery-sources'
import { skillDestinationId } from './skill-installation-topology'
export type ClassifiedSkillCandidate = {
installation: SkillManagementInstallation
logicalEntryType: 'directory' | 'symlink' | 'junction'
}
function errorCategory(error: unknown, fallback: string): string {
return error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'EACCES' || error.code === 'EPERM')
? 'skill-candidate-access-denied'
: fallback
}
export function createInaccessibleSkillCandidate(args: {
root: SkillScanRoot
name: string
current: SkillCurrentBundleEntry
hostId: ExecutionHostId
unresolvedPath: string
error: unknown
fallbackErrorCategory?: string
}): ClassifiedSkillCandidate {
return {
logicalEntryType: 'directory',
installation: {
id: skillDestinationId(args.hostId, args.unresolvedPath, args.name),
hostId: args.hostId,
name: args.name,
description: null,
rootId: args.root.id,
providers: args.root.providers,
unresolvedPath: args.unresolvedPath,
resolvedPath: null,
physicalIdentity: null,
topology: 'read-only',
status: 'inaccessible',
eligible: false,
managed: false,
installedReleaseRevision: null,
installedAppVersion: null,
installedPackageDigest: null,
currentReleaseRevision: args.current.releaseRevision,
currentPackageDigest: args.current.packageDigest,
currentAppVersion: args.current.appVersion,
errorCategory: errorCategory(
args.error,
args.fallbackErrorCategory ?? 'skill-candidate-lstat-failed'
),
adoptionPromptEligible: false,
lockCorroborated: false,
actionsSupported: false
}
}
}
@@ -0,0 +1,348 @@
import {
cp,
link,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
symlink,
truncate,
unlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
import { emptySkillManagementLedger } from './skill-management-ledger'
import { inventoryManagedSkills } from './skill-management-inventory'
import { classifySkillInstallationTopology } from './skill-installation-topology'
const resourceRoot = resolve('resources')
const temporaryHomes: string[] = []
async function createHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-'))
temporaryHomes.push(home)
return home
}
async function installSkill(
home: string,
root: '.agents' | '.codex',
name: string
): Promise<string> {
const destination = join(home, root, 'skills', name)
await mkdir(dirname(destination), { recursive: true })
await cp(resolve('skills', name), destination, { recursive: true })
return destination
}
afterEach(async () => {
await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
})
describe('managed skill inventory', () => {
it('recognizes an exact current canonical installation as adoptable', async () => {
const home = await createHome()
await installSkill(home, '.agents', 'orca-cli')
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
expect(installation).toMatchObject({
topology: 'canonical-copy',
status: 'known-current',
eligible: true,
managed: false
})
expect(inventory.adoptionCandidateCount).toBe(1)
})
it('suppresses only the explicitly dismissed destination snapshot tuple', async () => {
const home = await createHome()
await installSkill(home, '.agents', 'orca-cli')
await installSkill(home, '.agents', 'orchestration')
const initial = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
const dismissed = initial.installations.find((entry) => entry.name === 'orca-cli')!
const next = await inventoryManagedSkills({
ledger: {
schemaVersion: 1,
destinations: {},
dismissedAdoptionCandidates: [
{
hostId: dismissed.hostId,
physicalIdentity: dismissed.physicalIdentity!,
skillName: dismissed.name,
snapshotDigest: dismissed.installedPackageDigest!,
dismissedAt: 1
}
]
},
hostId: 'local',
homeDir: home,
resourceRoot
})
expect(next.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
adoptionPromptEligible: false
})
expect(next.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
adoptionPromptEligible: true
})
})
it('does not adopt a registry snapshot absent from the release mapping', async () => {
const home = await createHome()
await installSkill(home, '.agents', 'orca-cli')
const isolatedResources = join(home, 'resources')
await cp(resolve('resources/skills'), join(isolatedResources, 'skills'), { recursive: true })
const mappingPath = join(isolatedResources, 'skills', 'release-mapping.json')
const mapping = JSON.parse(await readFile(mappingPath, 'utf8')) as {
releases: { skills: Record<string, number> }[]
}
const manifest = JSON.parse(
await readFile(join(isolatedResources, 'skills', 'current-manifest.json'), 'utf8')
) as { skills: { name: string; releaseRevision: number }[] }
const currentRevision = manifest.skills.find(
(skill) => skill.name === 'orca-cli'
)!.releaseRevision
for (const release of mapping.releases) {
if (release.skills['orca-cli'] === currentRevision) {
delete release.skills['orca-cli']
}
}
await writeFile(mappingPath, JSON.stringify(mapping))
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot: isolatedResources
})
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
status: 'unknown',
eligible: false
})
})
it('recognizes exact hardlinked files because publication replaces directory entries', async () => {
const home = await createHome()
const destination = await installSkill(home, '.agents', 'orca-cli')
const skillMarkdown = join(destination, 'SKILL.md')
const alias = join(home, 'hardlink-alias')
await link(skillMarkdown, alias)
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
// Why: staged publish uses rename/temp+rename, which breaks the hardlink
// instead of writing through to the other linked file.
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
status: 'known-current',
eligible: true
})
await unlink(alias)
})
it('treats CRLF-only installer output as the same released text snapshot', async () => {
const home = await createHome()
const destination = await installSkill(home, '.agents', 'orca-cli')
const skillPath = join(destination, 'SKILL.md')
const source = await readFile(skillPath, 'utf8')
await writeFile(skillPath, source.replace(/\n/g, '\r\n'))
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')?.status).toBe(
'known-current'
)
})
it('fails closed after an adopted package is edited', async () => {
const home = await createHome()
const destination = await installSkill(home, '.agents', 'orca-cli')
const initial = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
const installation = initial.installations.find((entry) => entry.name === 'orca-cli')
expect(installation?.physicalIdentity).toBeTruthy()
expect(installation?.installedPackageDigest).toBeTruthy()
const record: ManagedSkillDestination = {
id: installation!.id,
hostId: 'local',
homeIdentity: home,
rootId: 'home-agents',
unresolvedPath: destination,
resolvedPath: destination,
physicalIdentity: installation!.physicalIdentity!,
entryType: 'directory',
skillName: 'orca-cli',
source: 'stablyai/orca',
sourcePath: 'skills/orca-cli',
sourceRef: null,
installedReleaseRevision: installation!.installedReleaseRevision!,
installedPackageDigest: installation!.installedPackageDigest!,
installedFiles: [],
lastWrittenPackageDigest: null,
lastAttemptedBundleFingerprint: null,
lastOutcome: 'adopted',
lastErrorCategory: null,
adoptedFrom: 'exact-snapshot',
adoptedAt: 1,
updatedAt: 1
}
const ledger: SkillManagementLedger = {
schemaVersion: 1,
destinations: { [record.id]: record },
dismissedAdoptionCandidates: []
}
await writeFile(join(destination, 'extra.txt'), 'local edit')
const inventory = await inventoryManagedSkills({
ledger,
hostId: 'local',
homeDir: home,
resourceRoot
})
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
status: 'modified',
eligible: false,
managed: false
})
})
it('bounds one oversized candidate without hiding healthy siblings', async () => {
const home = await createHome()
const oversized = await installSkill(home, '.agents', 'orca-cli')
await installSkill(home, '.agents', 'orchestration')
const oversizedFile = join(oversized, 'oversized.bin')
await writeFile(oversizedFile, '')
await truncate(oversizedFile, 4 * 1024 * 1024 + 1)
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
status: 'unknown',
errorCategory: 'skill-package-file-size-limit'
})
expect(inventory.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
status: 'known-current',
eligible: true
})
})
it('classifies one candidate lstat failure without rejecting healthy siblings', async () => {
const home = await createHome()
await installSkill(home, '.agents', 'orca-cli')
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot,
candidateLstat: async (path) => {
if (path.includes(`${join(home, '.codex')}`) && path.endsWith('orca-cli')) {
throw Object.assign(new Error('denied'), { code: 'EACCES' })
}
return lstat(path)
}
})
expect(
inventory.installations.find(
(entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
)
).toMatchObject({ errorCategory: 'skill-candidate-access-denied' })
expect(
inventory.installations.find(
(entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
)
).toBeTruthy()
})
it('classifies a topology race without rejecting healthy siblings', async () => {
const home = await createHome()
await installSkill(home, '.agents', 'orca-cli')
await installSkill(home, '.codex', 'orca-cli')
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot,
classifyTopology: async (root, path, canonicalRootPath) => {
if (root.id === 'home-codex' && path.endsWith('orca-cli')) {
throw Object.assign(new Error('raced'), { code: 'EIO' })
}
return classifySkillInstallationTopology(root, path, canonicalRootPath)
}
})
expect(
inventory.installations.find(
(entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
)
).toMatchObject({ errorCategory: 'skill-candidate-topology-failed' })
expect(
inventory.installations.find(
(entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
)
).toBeTruthy()
})
it.runIf(process.platform !== 'win32')(
'deduplicates a provider alias into its canonical physical copy',
async () => {
const home = await createHome()
const destination = await installSkill(home, '.agents', 'orca-cli')
const alias = join(home, '.claude', 'skills', 'orca-cli')
await mkdir(dirname(alias), { recursive: true })
await symlink(relative(dirname(alias), destination), alias)
const inventory = await inventoryManagedSkills({
ledger: emptySkillManagementLedger(),
hostId: 'local',
homeDir: home,
resourceRoot
})
const matches = inventory.installations.filter((entry) => entry.name === 'orca-cli')
expect(matches).toHaveLength(1)
expect(matches[0]).toMatchObject({
topology: 'canonical-copy',
providers: expect.arrayContaining(['agent-skills', 'codex', 'claude'])
})
}
)
})
@@ -0,0 +1,301 @@
import type { Stats } from 'node:fs'
import { lstat, realpath } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import type { ExecutionHostId } from '../../shared/execution-host'
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
import type {
SkillCurrentBundleEntry,
SkillManagementInstallation,
SkillManagementInventory,
SkillManagementLedger,
SkillManagementStatus
} from '../../shared/skill-management'
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
import { loadSkillBundleArtifacts, type SkillBundleArtifacts } from './skill-bundle-artifacts'
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
import { skillAdoptionDismissalKey } from './skill-management-ledger'
import {
createInaccessibleSkillCandidate,
type ClassifiedSkillCandidate
} from './skill-management-inaccessible-candidate'
import {
classifySkillInstallationTopology,
type ClassifiedSkillTopology,
normalizedSkillIdentityPath,
skillDestinationId,
skillTopologyPriority
} from './skill-installation-topology'
import { readOfficialSkillsCliLockEntries } from './skills-cli-lock-provenance'
type CandidateLstat = (path: string) => Promise<Stats>
type CandidateTopology = (
root: SkillScanRoot,
unresolvedPath: string,
canonicalRootPath: string
) => Promise<ClassifiedSkillTopology>
function knownStatus(
managed: boolean,
installedRevision: number,
currentRevision: number
): SkillManagementStatus {
if (installedRevision > currentRevision) {
return 'newer-known'
}
if (installedRevision < currentRevision) {
return managed ? 'managed-update-available' : 'known-update-available'
}
return managed ? 'managed-current' : 'known-current'
}
async function classifyCandidate(args: {
root: SkillScanRoot
name: string
current: SkillCurrentBundleEntry
artifacts: SkillBundleArtifacts
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeIdentity: string
canonicalRootPath: string
lockCorroboratedNames: ReadonlySet<string>
actionsSupported: boolean
candidateLstat: CandidateLstat
classifyTopology: CandidateTopology
}): Promise<ClassifiedSkillCandidate | null> {
const unresolvedPath = join(args.root.path, args.name)
try {
await args.candidateLstat(unresolvedPath)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return null
}
return createInaccessibleSkillCandidate({ ...args, unresolvedPath, error })
}
let topology: ClassifiedSkillTopology
try {
topology = await args.classifyTopology(args.root, unresolvedPath, args.canonicalRootPath)
} catch (error) {
return createInaccessibleSkillCandidate({
...args,
unresolvedPath,
error,
fallbackErrorCategory: 'skill-candidate-topology-failed'
})
}
const base = {
hostId: args.hostId,
name: args.name,
description: null,
rootId: args.root.id,
providers: args.root.providers,
unresolvedPath,
resolvedPath: topology.resolvedPath,
physicalIdentity: topology.identity,
topology: topology.topology,
currentReleaseRevision: args.current.releaseRevision,
currentPackageDigest: args.current.packageDigest,
currentAppVersion: args.current.appVersion,
errorCategory: topology.errorCategory,
adoptionPromptEligible: false,
lockCorroborated: args.root.id === 'home-agents' && args.lockCorroboratedNames.has(args.name),
actionsSupported: args.hostId === 'local' && args.actionsSupported
}
if (!topology.resolvedPath || !topology.identity) {
return {
logicalEntryType: topology.entryType,
installation: {
...base,
id: skillDestinationId(args.hostId, unresolvedPath, args.name),
status: 'inaccessible',
eligible: false,
managed: false,
installedReleaseRevision: null,
installedAppVersion: null,
installedPackageDigest: null
}
}
}
const id = skillDestinationId(args.hostId, unresolvedPath, args.name)
const managedRecord = args.ledger.destinations[id]
if (topology.topology === 'external-link' || topology.topology === 'read-only') {
return {
logicalEntryType: topology.entryType,
installation: {
...base,
id,
status: 'externally-managed',
eligible: false,
managed: false,
installedReleaseRevision: null,
installedAppVersion: null,
installedPackageDigest: null
}
}
}
try {
const observed = await observeSkillPackage(topology.resolvedPath)
const skillMarkdown = observed.files.find((file) => file.path === 'SKILL.md')
const description = skillMarkdown
? summarizeSkillMarkdown(skillMarkdown.bytes.toString('utf8')).description
: null
const match = matchingReleasedSnapshot(
observed,
args.artifacts.releasedSnapshots[args.name] ?? []
)
const managed = Boolean(
managedRecord &&
managedRecord.hostId === args.hostId &&
managedRecord.homeIdentity === args.homeIdentity &&
managedRecord.physicalIdentity === topology.identity &&
match &&
managedRecord.installedPackageDigest === match.packageDigest
)
return {
logicalEntryType: topology.entryType,
installation: {
...base,
description,
id,
status: match
? managed && managedRecord?.lastOutcome === 'failed'
? 'update-failed'
: knownStatus(managed, match.releaseRevision, args.current.releaseRevision)
: managedRecord
? 'modified'
: 'unknown',
eligible: Boolean(match),
managed,
installedReleaseRevision: match?.releaseRevision ?? null,
installedAppVersion: match
? (args.artifacts.releasedAppVersions[args.name]?.[match.releaseRevision] ?? null)
: null,
installedPackageDigest: match?.packageDigest ?? observed.observedDigest
}
}
} catch (error) {
return {
logicalEntryType: topology.entryType,
installation: {
...base,
id,
status: managedRecord ? 'modified' : 'unknown',
eligible: false,
managed: false,
installedReleaseRevision: null,
installedAppVersion: null,
installedPackageDigest: null,
errorCategory: error instanceof Error ? error.message : 'read-failed'
}
}
}
}
export async function inventoryManagedSkills(args: {
ledger: SkillManagementLedger
hostId: ExecutionHostId
homeDir: string
resourceRoot?: string
xdgStateHome?: string | null
actionsSupported?: boolean
candidateLstat?: CandidateLstat
classifyTopology?: CandidateTopology
}): Promise<SkillManagementInventory> {
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
const roots = buildSkillDiscoverySources({ homeDir: args.homeDir, cwd: args.homeDir }).filter(
(root) => root.sourceKind === 'home'
)
const canonicalRootPath = roots.find((root) => root.id === 'home-agents')?.path
if (!canonicalRootPath) {
throw new Error('Missing canonical agent skills root')
}
const homeIdentity = normalizedSkillIdentityPath(
await realpath(args.homeDir).catch(() => resolve(args.homeDir))
)
const lockCorroboratedNames = await readOfficialSkillsCliLockEntries({
homeDir: args.homeDir,
xdgStateHome:
args.xdgStateHome === undefined
? args.hostId === 'local'
? process.env.XDG_STATE_HOME
: null
: args.xdgStateHome
})
const candidates = await Promise.all(
artifacts.manifest.skills.flatMap((current) =>
roots.map((root) =>
classifyCandidate({
root,
name: current.name,
current,
artifacts,
ledger: args.ledger,
hostId: args.hostId,
homeIdentity,
canonicalRootPath,
lockCorroboratedNames,
actionsSupported: args.actionsSupported ?? true,
candidateLstat: args.candidateLstat ?? ((path) => lstat(path)),
classifyTopology: args.classifyTopology ?? classifySkillInstallationTopology
})
)
)
)
const deduped = new Map<string, SkillManagementInstallation>()
for (const candidate of candidates) {
if (!candidate) {
continue
}
const installation = candidate.installation
const key =
installation.physicalIdentity && installation.topology !== 'external-link'
? `${installation.hostId}\0${installation.name}\0${installation.physicalIdentity}`
: `${installation.hostId}\0${installation.unresolvedPath}`
const existing = deduped.get(key)
if (!existing) {
deduped.set(key, installation)
continue
}
const providers = [...new Set([...existing.providers, ...installation.providers])]
if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) {
deduped.set(key, { ...installation, providers })
} else {
existing.providers = providers
}
}
const dismissed = new Set(args.ledger.dismissedAdoptionCandidates.map(skillAdoptionDismissalKey))
const installations = [...deduped.values()].sort(
(left, right) =>
left.name.localeCompare(right.name) || left.unresolvedPath.localeCompare(right.unresolvedPath)
)
for (const installation of installations) {
installation.adoptionPromptEligible = Boolean(
installation.eligible &&
!installation.managed &&
installation.physicalIdentity &&
installation.installedPackageDigest &&
!dismissed.has(
skillAdoptionDismissalKey({
hostId: installation.hostId,
physicalIdentity: installation.physicalIdentity,
skillName: installation.name,
snapshotDigest: installation.installedPackageDigest
})
)
)
}
const adoptionCandidateCount = installations.filter(
(installation) => installation.adoptionPromptEligible
).length
return {
schemaVersion: 1,
hostId: args.hostId,
installations,
adoptionCandidateCount,
scannedAt: Date.now()
}
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import type { ManagedSkillDestination } from '../../shared/skill-management'
import { normalizeSkillManagementLedger } from './skill-management-ledger'
function destination(): ManagedSkillDestination {
const digest = 'a'.repeat(64)
return {
id: 'destination',
hostId: 'local',
homeIdentity: '/home/alice',
rootId: 'home-agents',
unresolvedPath: '/home/alice/.agents/skills/orca-cli',
resolvedPath: '/home/alice/.agents/skills/orca-cli',
physicalIdentity: '1:2',
entryType: 'directory',
skillName: 'orca-cli',
source: 'stablyai/orca',
sourcePath: 'skills/orca-cli',
sourceRef: null,
installedReleaseRevision: 1,
installedPackageDigest: digest,
installedFiles: [
{
path: 'SKILL.md',
size: 1,
executable: false,
classification: 'text',
exactSha256: digest,
textNormalizedSha256: digest,
identitySha256: digest
}
],
lastWrittenPackageDigest: null,
lastAttemptedBundleFingerprint: digest,
lastOutcome: 'adopted',
lastErrorCategory: null,
adoptedFrom: 'exact-snapshot',
adoptedAt: 1,
updatedAt: 1
}
}
describe('skill management ledger parsing', () => {
it('retains a completely valid destination', () => {
const record = destination()
expect(
normalizeSkillManagementLedger({
schemaVersion: 1,
destinations: { [record.id]: record },
dismissedAdoptionCandidates: []
}).destinations[record.id]
).toEqual(record)
})
it('drops a destination when any write-authority identity is malformed', () => {
const record = destination()
const malformed = { ...record, physicalIdentity: undefined }
expect(
normalizeSkillManagementLedger({
schemaVersion: 1,
destinations: { [record.id]: malformed },
dismissedAdoptionCandidates: []
}).destinations
).toEqual({})
})
})
+127
View File
@@ -0,0 +1,127 @@
import type {
DismissedSkillAdoptionCandidate,
ManagedSkillDestination,
SkillManagementLedger
} from '../../shared/skill-management'
import { normalizeExecutionHostId } from '../../shared/execution-host'
const SHA256 = /^[a-f0-9]{64}$/
export function emptySkillManagementLedger(): SkillManagementLedger {
return { schemaVersion: 1, destinations: {}, dismissedAdoptionCandidates: [] }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
function isFileIdentity(value: unknown): boolean {
return (
isRecord(value) &&
typeof value.path === 'string' &&
value.path.length > 0 &&
!value.path.startsWith('/') &&
!value.path.includes('\\') &&
!value.path.split('/').includes('..') &&
Number.isSafeInteger(value.size) &&
(value.size as number) >= 0 &&
typeof value.executable === 'boolean' &&
(value.classification === 'text' || value.classification === 'binary') &&
typeof value.exactSha256 === 'string' &&
SHA256.test(value.exactSha256) &&
(value.textNormalizedSha256 === null ||
(typeof value.textNormalizedSha256 === 'string' &&
SHA256.test(value.textNormalizedSha256))) &&
typeof value.identitySha256 === 'string' &&
SHA256.test(value.identitySha256)
)
}
function isManagedDestination(key: string, value: unknown): value is ManagedSkillDestination {
if (!isRecord(value)) {
return false
}
return (
value.id === key &&
typeof value.hostId === 'string' &&
normalizeExecutionHostId(value.hostId) === value.hostId &&
typeof value.homeIdentity === 'string' &&
typeof value.rootId === 'string' &&
typeof value.unresolvedPath === 'string' &&
typeof value.resolvedPath === 'string' &&
typeof value.physicalIdentity === 'string' &&
(value.entryType === 'directory' ||
value.entryType === 'symlink' ||
value.entryType === 'junction') &&
typeof value.skillName === 'string' &&
value.source === 'stablyai/orca' &&
typeof value.sourcePath === 'string' &&
isNullableString(value.sourceRef) &&
Number.isSafeInteger(value.installedReleaseRevision) &&
(value.installedReleaseRevision as number) >= 0 &&
typeof value.installedPackageDigest === 'string' &&
SHA256.test(value.installedPackageDigest) &&
Array.isArray(value.installedFiles) &&
value.installedFiles.every(isFileIdentity) &&
isNullableString(value.lastWrittenPackageDigest) &&
(value.lastWrittenPackageDigest === null || SHA256.test(value.lastWrittenPackageDigest)) &&
isNullableString(value.lastAttemptedBundleFingerprint) &&
(value.lastAttemptedBundleFingerprint === null ||
SHA256.test(value.lastAttemptedBundleFingerprint)) &&
(value.lastOutcome === 'adopted' ||
value.lastOutcome === 'updated' ||
value.lastOutcome === 'replaced' ||
value.lastOutcome === 'failed') &&
isNullableString(value.lastErrorCategory) &&
(value.adoptedFrom === 'orca-install' ||
value.adoptedFrom === 'exact-snapshot' ||
value.adoptedFrom === 'explicit-replacement') &&
typeof value.adoptedAt === 'number' &&
Number.isFinite(value.adoptedAt) &&
typeof value.updatedAt === 'number' &&
Number.isFinite(value.updatedAt)
)
}
export function normalizeSkillManagementLedger(value: unknown): SkillManagementLedger {
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.destinations)) {
return emptySkillManagementLedger()
}
const destinations = Object.fromEntries(
Object.entries(value.destinations).filter((entry): entry is [string, ManagedSkillDestination] =>
isManagedDestination(entry[0], entry[1])
)
)
const dismissedAdoptionCandidates = Array.isArray(value.dismissedAdoptionCandidates)
? value.dismissedAdoptionCandidates.filter(
(candidate): candidate is DismissedSkillAdoptionCandidate =>
isRecord(candidate) &&
typeof candidate.hostId === 'string' &&
typeof candidate.physicalIdentity === 'string' &&
typeof candidate.skillName === 'string' &&
typeof candidate.snapshotDigest === 'string' &&
typeof candidate.dismissedAt === 'number'
)
: []
return { schemaVersion: 1, destinations, dismissedAdoptionCandidates }
}
export function skillAdoptionDismissalKey(
candidate: Pick<
DismissedSkillAdoptionCandidate,
'hostId' | 'physicalIdentity' | 'skillName' | 'snapshotDigest'
>
): string {
return [
candidate.hostId,
candidate.physicalIdentity,
candidate.skillName,
candidate.snapshotDigest
].join('\0')
}
@@ -0,0 +1,74 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { canMutateManagedSkills, type SkillMutationRuntimeFacts } from './skill-mutation-authority'
const temporaryRoot = join(process.cwd(), '.tmp-authority')
function facts(overrides: Partial<SkillMutationRuntimeFacts> = {}): SkillMutationRuntimeFacts {
return {
isPackaged: true,
buildIdentity: 'stable',
homeDir: '/real/home',
userDataDir: '/real/user-data',
temporaryRoot,
...overrides
}
}
describe('skill mutation authority', () => {
it('allows only the packaged stable build channel by default', () => {
expect(canMutateManagedSkills(facts())).toBe(true)
expect(canMutateManagedSkills(facts({ buildIdentity: 'rc' }))).toBe(false)
expect(canMutateManagedSkills(facts({ buildIdentity: null }))).toBe(false)
})
it('rejects unpackaged access to a real home', () => {
expect(canMutateManagedSkills(facts({ isPackaged: false, buildIdentity: null }))).toBe(false)
})
it('allows unpackaged tests only when both actual roots match isolated temporary roots', () => {
const homeDir = join(temporaryRoot, 'home')
const userDataDir = join(temporaryRoot, 'user-data')
expect(
canMutateManagedSkills(
facts({
isPackaged: false,
buildIdentity: null,
homeDir,
userDataDir,
isolatedDevHomeDir: homeDir,
isolatedDevUserDataDir: userDataDir
})
)
).toBe(true)
})
it('rejects channel and root-isolation mismatches', () => {
const homeDir = join(temporaryRoot, 'home')
const userDataDir = join(temporaryRoot, 'user-data')
expect(
canMutateManagedSkills(
facts({
isPackaged: false,
buildIdentity: 'rc',
homeDir,
userDataDir,
isolatedDevHomeDir: '/real/home',
isolatedDevUserDataDir: userDataDir
})
)
).toBe(false)
expect(
canMutateManagedSkills(
facts({
isPackaged: false,
buildIdentity: null,
homeDir,
userDataDir: homeDir,
isolatedDevHomeDir: homeDir,
isolatedDevUserDataDir: homeDir
})
)
).toBe(false)
})
})
@@ -0,0 +1,47 @@
import { isAbsolute, relative, resolve, sep } from 'node:path'
export type SkillMutationRuntimeFacts = {
isPackaged: boolean
buildIdentity: 'stable' | 'rc' | null
homeDir: string
userDataDir: string
isolatedDevHomeDir?: string | null
isolatedDevUserDataDir?: string | null
temporaryRoot: string
}
function samePath(left: string, right: string): boolean {
const normalize = (value: string): string =>
process.platform === 'win32' ? resolve(value).toLocaleLowerCase('en-US') : resolve(value)
return normalize(left) === normalize(right)
}
function isStrictChild(path: string, root: string): boolean {
const candidate = relative(resolve(root), resolve(path))
return (
candidate.length > 0 &&
candidate !== '..' &&
!candidate.startsWith(`..${sep}`) &&
!isAbsolute(candidate)
)
}
export function canMutateManagedSkills(facts: SkillMutationRuntimeFacts): boolean {
if (facts.isPackaged) {
// Why: the build identity is CI-pinned for official artifacts; runtime or
// renderer flags cannot promote an RC/custom package into a stable writer.
return facts.buildIdentity === 'stable'
}
if (!facts.isolatedDevHomeDir || !facts.isolatedDevUserDataDir) {
return false
}
return (
samePath(facts.homeDir, facts.isolatedDevHomeDir) &&
samePath(facts.userDataDir, facts.isolatedDevUserDataDir) &&
!samePath(facts.homeDir, facts.userDataDir) &&
isStrictChild(facts.homeDir, facts.temporaryRoot) &&
isStrictChild(facts.userDataDir, facts.temporaryRoot) &&
!isStrictChild(facts.homeDir, facts.userDataDir) &&
!isStrictChild(facts.userDataDir, facts.homeDir)
)
}
@@ -0,0 +1,106 @@
import { lstat, mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import type { SkillManagementLedger } from '../../shared/skill-management'
import { normalizedSkillIdentityPath } from './skill-installation-topology'
import {
parseSkillTransactionMarker,
type SkillTransactionMarker
} from './skill-transaction-marker'
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
import { acquireSkillDestinationLock } from './skill-transaction-recovery'
import { recoverTransactionToPrior } from './skill-transaction-restore'
async function assertApprovedDestinationParent(
marker: SkillTransactionMarker,
rootIdentity: string
): Promise<void> {
if (normalizedSkillIdentityPath(dirname(resolve(marker.destinationPath))) !== rootIdentity) {
throw new Error('skill-transaction-destination-outside-root')
}
const [parentStat, parentIdentity] = await Promise.all([
lstat(dirname(marker.destinationPath)),
realpath(dirname(marker.destinationPath)).then(normalizedSkillIdentityPath)
])
if (parentStat.isSymbolicLink() || parentIdentity !== rootIdentity) {
throw new Error('skill-transaction-destination-outside-root')
}
}
export async function sweepOrphanedSkillTransactions(
approvedSkillsRoot: string,
ledger: SkillManagementLedger
): Promise<void> {
const linkedRoot = await Promise.all([
lstat(approvedSkillsRoot),
lstat(dirname(approvedSkillsRoot))
])
.then((entries) => entries.some((entry) => entry.isSymbolicLink()))
.catch(() => true)
if (linkedRoot) {
return
}
const rootIdentity = normalizedSkillIdentityPath(await realpath(approvedSkillsRoot))
const reservedRoots = [
join(dirname(approvedSkillsRoot), RESERVED_SKILL_TRANSACTION_DIRECTORY),
join(approvedSkillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
]
for (const reservedRoot of reservedRoots) {
const reservedRootSafe = await lstat(reservedRoot)
.then((entry) => entry.isDirectory() && !entry.isSymbolicLink())
.catch(() => false)
if (!reservedRootSafe) {
continue
}
const entries = await readdir(reservedRoot, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'locks') {
continue
}
const transactionRoot = join(reservedRoot, entry.name)
const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
.then(parseSkillTransactionMarker)
.catch(() => null)
if (!marker || marker.hostId !== 'local') {
continue
}
try {
await assertApprovedDestinationParent(marker, rootIdentity)
} catch {
continue
}
const locksRoot = join(reservedRoot, 'locks')
await mkdir(locksRoot, { recursive: true })
const locksRootSafe = await lstat(locksRoot).then(
(lockStat) => lockStat.isDirectory() && !lockStat.isSymbolicLink()
)
if (!locksRootSafe) {
continue
}
let release: (() => Promise<void>) | null = null
try {
release = await acquireSkillDestinationLock(join(locksRoot, marker.destinationId))
const record = ledger.destinations[marker.destinationId]
const committed = Boolean(
marker.phase === 'verified' &&
record?.hostId === marker.hostId &&
normalizedSkillIdentityPath(record.resolvedPath) ===
normalizedSkillIdentityPath(marker.destinationPath) &&
record.installedPackageDigest === marker.currentSnapshot.packageDigest
)
await recoverTransactionToPrior({
transactionRoot,
marker,
assertParentAuthority: () => assertApprovedDestinationParent(marker, rootIdentity),
currentDisposition: committed ? 'committed' : 'restore'
})
await rm(transactionRoot, { recursive: true, force: true })
} catch (error) {
if (!(error instanceof Error) || error.message !== 'skill-update-busy') {
throw error
}
} finally {
await release?.()
}
}
}
}
@@ -0,0 +1,108 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, type FileHandle } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { SkillBundleFileIdentity } from '../../shared/skill-management'
import { skillPhysicalIdentity } from './skill-installation-topology'
import { describeObservedSkillFile, matchesFileIdentity } from './skill-package-identity'
import type { PublicationCallbacks } from './skill-package-publication'
import { retrySkillRename } from './skill-transaction-rename'
export type SkillOwnedTempRuntime = {
readSource?: (path: string) => Promise<Buffer>
writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise<void>
chmodTemp?: (handle: FileHandle, mode: number) => Promise<void>
syncTemp?: (handle: FileHandle) => Promise<void>
}
export async function publishSkillFileThroughOwnedTemp(args: {
destinationRoot: string
sourcePath: string
expectedFile: SkillBundleFileIdentity
direction: 'forward' | 'rollback'
packagePhysicalIdentity: string
operationIndex: number
callbacks: PublicationCallbacks
runtime?: SkillOwnedTempRuntime
}): Promise<void> {
const livePath = join(args.destinationRoot, ...args.expectedFile.path.split('/'))
const temporaryRelativePath = `${args.expectedFile.path}.orca-${randomUUID()}.tmp`
const temporaryPath = join(args.destinationRoot, ...temporaryRelativePath.split('/'))
const applying = {
kind: 'in-place',
direction: args.direction,
physicalIdentity: args.packagePhysicalIdentity,
temporary: null,
operationIndex: args.operationIndex,
operationState: 'applying'
} as const
await args.callbacks.assertBoundary(applying)
await mkdir(dirname(livePath), { recursive: true })
await args.callbacks.assertBoundary(applying)
const mode = args.expectedFile.executable ? 0o755 : 0o644
const handle = await open(temporaryPath, 'wx', mode)
const creating = {
...applying,
temporary: {
path: temporaryRelativePath,
physicalIdentity: skillPhysicalIdentity(temporaryPath, await handle.stat()),
expectedFile: args.expectedFile,
state: 'creating'
}
} as const
try {
// Why: partial bytes are recoverable only after the exclusively created
// inode is durably recorded as transaction-owned.
await args.callbacks.journal(creating)
await args.callbacks.assertBoundary(creating)
const bytes = await (args.runtime?.readSource ?? readFile)(args.sourcePath)
if (
!matchesFileIdentity(
describeObservedSkillFile(args.expectedFile.path, bytes, args.expectedFile.executable),
args.expectedFile
)
) {
throw new Error('skill-owned-temp-source-changed')
}
await args.callbacks.assertBoundary(creating)
await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(handle, bytes)
const written = {
...creating,
temporary: { ...creating.temporary, state: 'written' as const }
}
await args.callbacks.assertBoundary(written)
await args.callbacks.journal(written)
if (process.platform !== 'win32') {
await args.callbacks.assertBoundary(written)
await (args.runtime?.chmodTemp ?? ((target, nextMode) => target.chmod(nextMode)))(
handle,
mode
)
}
const ready = {
...written,
temporary: { ...written.temporary, state: 'ready' as const }
}
await args.callbacks.assertBoundary(ready)
await args.callbacks.journal(ready)
await args.callbacks.assertBoundary(ready)
await (args.runtime?.syncTemp ?? ((target) => target.sync()))(handle)
await args.callbacks.assertBoundary(ready)
} finally {
await handle.close()
}
const ready = {
...creating,
temporary: { ...creating.temporary, state: 'ready' as const }
}
await args.callbacks.assertBoundary(ready)
await retrySkillRename(temporaryPath, livePath, () => args.callbacks.assertBoundary(ready))
const applied = {
...ready,
temporary: { ...ready.temporary, state: 'applied' as const }
}
await args.callbacks.assertBoundary(applied)
await args.callbacks.journal(applied)
// Why: do not discard the temp inode proof if another writer replaces the
// exact-content target while the applied marker is being persisted.
await args.callbacks.assertBoundary(applied)
}
+228
View File
@@ -0,0 +1,228 @@
import { createHash } from 'node:crypto'
import type { Dirent } from 'node:fs'
import { lstat, open, opendir } from 'node:fs/promises'
import { isAbsolute, join, relative, sep } from 'node:path'
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
type ObservedSkillFile = SkillBundleFileIdentity & { bytes: Buffer }
export type ObservedSkillPackage = {
files: ObservedSkillFile[]
observedDigest: string
}
export const SKILL_PACKAGE_OBSERVATION_LIMITS = {
maximumDepth: 16,
maximumEntries: 2_048,
maximumFiles: 512,
maximumSingleFileBytes: 4 * 1024 * 1024,
maximumTotalBytes: 32 * 1024 * 1024
} as const
type SkillPackageObservationLimits = {
maximumDepth: number
maximumEntries: number
maximumFiles: number
maximumSingleFileBytes: number
maximumTotalBytes: number
}
async function readBoundedSkillFile(
path: string,
remainingTotalBytes: number,
maximumSingleFileBytes: number
): Promise<Buffer> {
const handle = await open(path, 'r')
try {
const before = await handle.stat()
if (before.size > maximumSingleFileBytes) {
throw new Error('skill-package-file-size-limit')
}
if (before.size > remainingTotalBytes) {
throw new Error('skill-package-total-size-limit')
}
const bytes = Buffer.alloc(before.size)
let offset = 0
while (offset < bytes.length) {
const result = await handle.read(bytes, offset, bytes.length - offset, offset)
if (result.bytesRead === 0) {
throw new Error('skill-package-changed-during-read')
}
offset += result.bytesRead
}
if ((await handle.stat()).size !== before.size) {
throw new Error('skill-package-changed-during-read')
}
return bytes
} finally {
await handle.close()
}
}
function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
function normalizedText(bytes: Buffer): Buffer {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
export function describeObservedSkillFile(
path: string,
bytes: Buffer,
executable: boolean
): ObservedSkillFile {
let normalized: Buffer | null = null
if (!bytes.includes(0)) {
try {
normalized = normalizedText(bytes)
} catch {
normalized = null
}
}
const classification = normalized ? 'text' : 'binary'
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = normalized ? sha256(normalized) : null
return {
path,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: normalized && !executable ? sha256(normalized) : exactSha256,
bytes
}
}
export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
)
)
)
}
export function matchesFileIdentity(
actual: ObservedSkillFile,
expected: SkillBundleFileIdentity
): boolean {
if (
actual.path !== expected.path ||
actual.executable !== expected.executable ||
actual.classification !== expected.classification
) {
return false
}
return expected.classification === 'text' && !expected.executable
? actual.textNormalizedSha256 === expected.textNormalizedSha256
: actual.exactSha256 === expected.exactSha256
}
export async function observeSkillPackage(
packageRoot: string,
limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS
): Promise<ObservedSkillPackage> {
const files: ObservedSkillFile[] = []
const caseFoldedPaths = new Map<string, string>()
let entryCount = 0
let totalBytes = 0
async function visit(directory: string, depth: number): Promise<void> {
const directoryHandle = await opendir(directory)
const entries: Dirent[] = []
try {
for (;;) {
const entry = await directoryHandle.read()
if (!entry) {
break
}
entryCount += 1
if (entryCount > limits.maximumEntries) {
throw new Error('skill-package-entry-limit')
}
entries.push(entry)
}
} finally {
await directoryHandle.close().catch(() => undefined)
}
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
for (const entry of entries) {
const absolutePath = join(directory, entry.name)
const relativePath = relative(packageRoot, absolutePath)
if (
isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${sep}`)
) {
throw new Error('skill-path-escape')
}
const manifestPath = relativePath.split(sep).join('/')
const folded = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(folded)
if (collision && collision !== manifestPath) {
throw new Error('skill-case-collision')
}
caseFoldedPaths.set(folded, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error('skill-package-link')
}
if (fileStat.isDirectory()) {
if (depth >= limits.maximumDepth) {
throw new Error('skill-package-depth-limit')
}
await visit(absolutePath, depth + 1)
} else if (fileStat.isFile()) {
if (files.length >= limits.maximumFiles) {
throw new Error('skill-package-file-count-limit')
}
if (fileStat.size > limits.maximumSingleFileBytes) {
throw new Error('skill-package-file-size-limit')
}
const bytes = await readBoundedSkillFile(
absolutePath,
limits.maximumTotalBytes - totalBytes,
limits.maximumSingleFileBytes
)
totalBytes += bytes.length
files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0))
} else {
throw new Error('skill-package-special-file')
}
}
}
await visit(packageRoot, 0)
return { files, observedDigest: skillPackageDigest(files) }
}
export function matchingReleasedSnapshot(
observed: ObservedSkillPackage,
snapshots: readonly SkillReleasedSnapshot[]
): SkillReleasedSnapshot | null {
for (const snapshot of snapshots.toReversed()) {
if (snapshot.files.length !== observed.files.length) {
continue
}
const matches = snapshot.files.every((expected, index) => {
const actual = observed.files[index]
return Boolean(actual && matchesFileIdentity(actual, expected))
})
if (matches) {
return snapshot
}
}
return null
}
export function persistedObservedFiles(observed: ObservedSkillPackage): SkillBundleFileIdentity[] {
return observed.files.map(({ bytes: _bytes, ...file }) => file)
}
@@ -0,0 +1,73 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { observeSkillPackage } from './skill-package-identity'
const roots: string[] = []
const baseLimits = {
maximumDepth: 4,
maximumEntries: 8,
maximumFiles: 4,
maximumSingleFileBytes: 8,
maximumTotalBytes: 16
}
async function root(): Promise<string> {
const value = await mkdtemp(join(tmpdir(), 'orca-skill-observation-'))
roots.push(value)
return value
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true })))
})
describe('skill package observation limits', () => {
it.each([
{
name: 'depth',
limits: { ...baseLimits, maximumDepth: 1 },
prepare: async (directory: string) =>
mkdir(join(directory, 'one', 'two'), { recursive: true }),
error: 'skill-package-depth-limit'
},
{
name: 'entries',
limits: { ...baseLimits, maximumEntries: 1 },
prepare: async (directory: string) => {
await writeFile(join(directory, 'a'), 'a')
await writeFile(join(directory, 'b'), 'b')
},
error: 'skill-package-entry-limit'
},
{
name: 'files',
limits: { ...baseLimits, maximumFiles: 1 },
prepare: async (directory: string) => {
await writeFile(join(directory, 'a'), 'a')
await writeFile(join(directory, 'b'), 'b')
},
error: 'skill-package-file-count-limit'
},
{
name: 'single file bytes',
limits: { ...baseLimits, maximumSingleFileBytes: 1 },
prepare: async (directory: string) => writeFile(join(directory, 'a'), 'ab'),
error: 'skill-package-file-size-limit'
},
{
name: 'aggregate bytes',
limits: { ...baseLimits, maximumTotalBytes: 2 },
prepare: async (directory: string) => {
await writeFile(join(directory, 'a'), 'ab')
await writeFile(join(directory, 'b'), 'c')
},
error: 'skill-package-total-size-limit'
}
])('fails closed at the $name limit', async ({ limits, prepare, error }) => {
const directory = await root()
await prepare(directory)
await expect(observeSkillPackage(directory, limits)).rejects.toThrow(error)
})
})
@@ -0,0 +1,445 @@
import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
import { publishSkillPackageInPlace } from './skill-package-publication'
import { skillPhysicalIdentity } from './skill-installation-topology'
import type { SkillTransactionMarker } from './skill-transaction-marker'
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
import type { SkillPublicationState } from './skill-transaction-publication-state'
import { retrySkillRename } from './skill-transaction-rename'
const roots: string[] = []
async function createPackage(root: string, values: Record<string, string>): Promise<void> {
await mkdir(root, { recursive: true })
for (const [path, contents] of Object.entries(values)) {
await writeFile(join(root, path), contents)
}
}
async function snapshot(root: string, releaseRevision: number): Promise<SkillReleasedSnapshot> {
const observed = await observeSkillPackage(root)
return {
releaseRevision,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(observed)
}
}
async function swapFixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
roots.push(root)
const live = join(root, 'skills', 'orca-cli')
const transactionRoot = join(root, 'transaction')
const stageRoot = join(transactionRoot, 'stage')
const backupRoot = join(transactionRoot, 'backup')
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
await cp(live, backupRoot, { recursive: true })
const prior = await snapshot(live, 1)
const current = await snapshot(stageRoot, 2)
const record = {
resolvedPath: live,
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
} as ManagedSkillDestination
let marker: SkillTransactionMarker = {
schemaVersion: 1,
transactionId: 'test',
createdAt: 1,
destinationId: '000000000000000000000000',
destinationPath: live,
physicalIdentity: record.physicalIdentity,
hostId: 'local',
skillName: 'orca-cli',
phase: 'publishing',
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
stageBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
operationIndex: current.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
operationIndex: prior.files.length,
pendingPath: null,
activeFile: null
},
publication: null,
priorSnapshot: prior,
currentSnapshot: current
}
const history: SkillPublicationState[] = []
const journal = async (publication: SkillPublicationState): Promise<void> => {
marker = { ...marker, publication }
history.push(publication)
}
const assertBoundary = async (
publication: SkillPublicationState,
expectedLiveState?: Awaited<ReturnType<typeof assertTransactionOwnedState>>
): Promise<void> => {
const liveState = await assertTransactionOwnedState({
transactionRoot,
marker: { ...marker, publication },
assertParent: async () => undefined,
allowAppliedOperation: false
})
if (expectedLiveState && liveState !== expectedLiveState) {
throw new Error('skill-transaction-recovery-required')
}
}
return {
live,
transactionRoot,
stageRoot,
oldLiveRoot: join(transactionRoot, 'old-live'),
record,
prior,
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
callbacks: { journal, assertBoundary },
history,
marker: () => marker
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill package publication boundaries', () => {
it('rejects a temp replacement made while the creating journal is awaited', async () => {
const fixture = await swapFixture()
let replaced = false
const journal = async (publication: SkillPublicationState): Promise<void> => {
await fixture.callbacks.journal(publication)
if (
!replaced &&
publication.kind === 'in-place' &&
publication.temporary?.state === 'creating'
) {
replaced = true
const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
await rm(temporaryPath)
await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), temporaryPath)
}
}
await expect(
publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: { ...fixture.callbacks, journal }
})
).rejects.toThrow('skill-transaction-recovery-required')
expect(replaced).toBe(true)
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
})
it.each([
{
boundary: 'source read',
runtime: (): SkillOwnedTempRuntime => ({
readSource: async (source) => {
const bytes = await readFile(source)
await writeFile(source, 'independent source edit')
return bytes
}
})
},
{
boundary: 'handle write',
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
writeBytes: async (handle, bytes) => {
await handle.writeFile(bytes)
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
}
})
},
{
boundary: 'chmod',
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
chmodTemp: async (handle, mode) => {
await handle.chmod(mode)
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
}
})
},
{
boundary: 'sync',
runtime: (fixture: Awaited<ReturnType<typeof swapFixture>>): SkillOwnedTempRuntime => ({
syncTemp: async (handle) => {
await handle.sync()
await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
}
})
}
])('rechecks authority after the $boundary await', async ({ runtime }) => {
const fixture = await swapFixture()
await expect(
publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: fixture.callbacks,
ownedTempRuntime: runtime(fixture)
})
).rejects.toThrow('skill-transaction-recovery-required')
})
it('retains creating evidence when handle write mutates then throws', async () => {
const fixture = await swapFixture()
await expect(
publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: fixture.callbacks,
ownedTempRuntime: {
writeBytes: async (handle, bytes) => {
await handle.write(bytes.subarray(0, 1))
throw new Error('injected-write-failure')
}
}
})
).rejects.toThrow('injected-write-failure')
expect(fixture.history.at(-1)).toMatchObject({
kind: 'in-place',
temporary: { state: 'creating' }
})
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
})
it('journals each forward temp inode through creating, written, ready, and applied', async () => {
const fixture = await swapFixture()
await publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: fixture.callbacks
})
const states = fixture.history.flatMap((publication) =>
publication.kind === 'in-place' && publication.temporary ? [publication.temporary.state] : []
)
expect(states).toEqual([
'creating',
'written',
'ready',
'applied',
'creating',
'written',
'ready',
'applied'
])
})
it('rejects an exact wrong-inode target replacement after temp rename', async () => {
const fixture = await swapFixture()
let replaced = false
const journal = async (publication: SkillPublicationState): Promise<void> => {
await fixture.callbacks.journal(publication)
if (
!replaced &&
publication.kind === 'in-place' &&
publication.temporary?.state === 'applied'
) {
replaced = true
const target = join(fixture.live, publication.temporary.expectedFile.path)
await rm(target)
await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), target)
}
}
await expect(
publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: { ...fixture.callbacks, journal }
})
).rejects.toThrow('skill-transaction-recovery-required')
expect(replaced).toBe(true)
})
it('does not overwrite an in-place edit made between ordered file operations', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-publication-boundary-'))
roots.push(root)
const live = join(root, 'skills', 'orca-cli')
const transactionRoot = join(root, 'transaction')
const stageRoot = join(transactionRoot, 'stage')
const backupRoot = join(transactionRoot, 'backup')
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
await cp(live, backupRoot, { recursive: true })
const prior = await snapshot(live, 1)
const current = await snapshot(stageRoot, 2)
const record = {
resolvedPath: live,
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
} as ManagedSkillDestination
let marker: SkillTransactionMarker = {
schemaVersion: 1,
transactionId: 'test',
createdAt: 1,
destinationId: '000000000000000000000000',
destinationPath: live,
physicalIdentity: record.physicalIdentity,
hostId: 'local',
skillName: 'orca-cli',
phase: 'publishing',
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
stageBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
operationIndex: current.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
operationIndex: prior.files.length,
pendingPath: null,
activeFile: null
},
publication: null,
priorSnapshot: prior,
currentSnapshot: current
}
const journal = async (publication: SkillPublicationState): Promise<void> => {
marker = { ...marker, publication }
}
let edited = false
const assertBoundary = async (publication: SkillPublicationState): Promise<void> => {
if (
!edited &&
publication.kind === 'in-place' &&
publication.operationState === 'applying' &&
publication.operationIndex === 1
) {
edited = true
await writeFile(join(live, 'SKILL.md'), 'independent edit')
}
await assertTransactionOwnedState({
transactionRoot,
marker: { ...marker, publication },
assertParent: async () => undefined,
allowAppliedOperation: false
})
}
await expect(
publishSkillPackageInPlace({
record,
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
prior,
stageRoot,
callbacks: { journal, assertBoundary }
})
).rejects.toThrow('skill-transaction-recovery-required')
expect(await readFile(join(live, 'asset.txt'), 'utf8')).toBe('new asset')
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independent edit')
expect(marker.publication).toMatchObject({ operationIndex: 1, operationState: 'applying' })
})
it('revalidates after a sharing-violation retry window', async () => {
let renameAttempts = 0
let boundaryChecks = 0
let changedDuringWait = false
const renameEntry = async (): Promise<void> => {
renameAttempts += 1
if (renameAttempts === 1) {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
}
}
await expect(
retrySkillRename(
'source',
'destination',
async () => {
boundaryChecks += 1
if (changedDuringWait) {
throw new Error('skill-content-changed')
}
},
renameEntry,
async () => {
changedDuringWait = true
}
)
).rejects.toThrow('skill-content-changed')
expect(renameAttempts).toBe(1)
expect(boundaryChecks).toBe(2)
})
it.each(['replaced', 'missing'] as const)(
'does not rename a %s journaled temp file',
async (mutation) => {
const fixture = await swapFixture()
let activeMarker = fixture.marker()
let mutated = false
const journal = async (publication: SkillPublicationState): Promise<void> => {
activeMarker = { ...activeMarker, publication }
}
const assertBoundary = async (publication: SkillPublicationState): Promise<void> => {
if (!mutated && publication.kind === 'in-place' && publication.temporary) {
mutated = true
const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
await rm(temporaryPath)
if (mutation === 'replaced') {
await cp(
join(fixture.stageRoot, publication.temporary.expectedFile.path),
temporaryPath
)
}
}
await assertTransactionOwnedState({
transactionRoot: fixture.transactionRoot,
marker: { ...activeMarker, publication },
assertParent: async () => undefined,
allowAppliedOperation: false
})
}
await expect(
publishSkillPackageInPlace({
record: fixture.record,
prior: fixture.prior,
current: fixture.current,
stageRoot: fixture.stageRoot,
callbacks: { journal, assertBoundary }
})
).rejects.toThrow('skill-transaction-recovery-required')
expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
if (mutation === 'replaced') {
expect(
await readFile(
join(
fixture.live,
...(activeMarker.publication?.kind === 'in-place' &&
activeMarker.publication.temporary
? activeMarker.publication.temporary.path.split('/')
: [])
),
'utf8'
)
).toBe('new asset')
}
}
)
})
@@ -0,0 +1,216 @@
import { rmdir, unlink } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
ManagedSkillDestination,
SkillCurrentBundleEntry,
SkillReleasedSnapshot
} from '../../shared/skill-management'
import {
skillInPlaceOperations,
type SkillPublicationState
} from './skill-transaction-publication-state'
import type { SkillTransactionLiveState } from './skill-transaction-owned-state'
import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
import { retrySkillRename, type SkillRenameRuntime } from './skill-transaction-rename'
export type PublicationCallbacks = {
journal: (state: SkillPublicationState) => Promise<void>
assertBoundary: (
state: SkillPublicationState,
expectedLiveState?: SkillTransactionLiveState
) => Promise<void>
}
export type SkillPackageSwapOutcome =
| { kind: 'complete' }
| { kind: 'pre-mutation-sharing-violation'; error: unknown }
export function destinationUsesCrlf(record: ManagedSkillDestination): boolean {
return record.installedFiles.some(
(file) =>
file.classification === 'text' &&
file.textNormalizedSha256 !== null &&
file.exactSha256 !== file.textNormalizedSha256
)
}
export async function publishSkillPackageInPlace(args: {
record: ManagedSkillDestination
current: SkillCurrentBundleEntry
prior: SkillReleasedSnapshot
stageRoot: string
callbacks: PublicationCallbacks
ownedTempRuntime?: SkillOwnedTempRuntime
}): Promise<void> {
const operations = skillInPlaceOperations(args.prior, args.current)
const currentByPath = new Map(args.current.files.map((file) => [file.path, file]))
await args.callbacks.journal({
kind: 'in-place',
direction: 'forward',
physicalIdentity: args.record.physicalIdentity,
temporary: null,
operationIndex: 0,
operationState: 'ready'
})
for (const [operationIndex, operation] of operations.entries()) {
const applying = {
kind: 'in-place',
direction: 'forward',
physicalIdentity: args.record.physicalIdentity,
temporary: null,
operationIndex,
operationState: 'applying'
} as const
await args.callbacks.journal(applying)
const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
if (operation.kind === 'publish') {
const file = currentByPath.get(operation.path)
if (!file) {
throw new Error('skill-publication-operation-invalid')
}
await publishSkillFileThroughOwnedTemp({
destinationRoot: args.record.resolvedPath,
sourcePath: join(args.stageRoot, ...file.path.split('/')),
expectedFile: file,
direction: 'forward',
packagePhysicalIdentity: args.record.physicalIdentity,
operationIndex,
callbacks: args.callbacks,
runtime: args.ownedTempRuntime
})
} else {
await args.callbacks.assertBoundary(applying)
await unlink(livePath)
}
const ready = {
kind: 'in-place',
direction: 'forward',
physicalIdentity: args.record.physicalIdentity,
temporary: null,
operationIndex: operationIndex + 1,
operationState: 'ready'
} as const
await args.callbacks.journal(ready)
if (operation.kind === 'remove') {
let parent = dirname(livePath)
while (parent !== args.record.resolvedPath) {
await args.callbacks.assertBoundary(ready)
await rmdir(parent).catch(() => undefined)
parent = dirname(parent)
}
}
}
}
async function publishPackageSwap(args: {
record: ManagedSkillDestination
stageRoot: string
transactionRoot: string
callbacks: PublicationCallbacks
renameRuntime?: SkillRenameRuntime
}): Promise<SkillPackageSwapOutcome> {
const oldLiveRoot = join(args.transactionRoot, 'old-live')
const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
await args.callbacks.journal(movingLive)
try {
await retrySkillRename(
args.record.resolvedPath,
oldLiveRoot,
() => args.callbacks.assertBoundary(movingLive, 'prior'),
args.renameRuntime?.renameEntry,
args.renameRuntime?.wait
)
} catch (error) {
if (
isSharingViolation(error, args.renameRuntime?.windowsFallback ?? process.platform === 'win32')
) {
return { kind: 'pre-mutation-sharing-violation', error }
}
throw error
}
await args.callbacks.journal({ kind: 'package-swap', step: 'live-moved' })
const movingStage = { kind: 'package-swap', step: 'moving-stage' } as const
await args.callbacks.journal(movingStage)
try {
await retrySkillRename(
args.stageRoot,
args.record.resolvedPath,
() => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
args.renameRuntime?.renameEntry,
args.renameRuntime?.wait
)
} catch (error) {
// Why: only the marker-owned old-live directory may fill the verified gap.
await retrySkillRename(
oldLiveRoot,
args.record.resolvedPath,
() => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
args.renameRuntime?.renameEntry,
args.renameRuntime?.wait
)
await args.callbacks.journal(movingLive)
throw error
}
await args.callbacks.journal({ kind: 'package-swap', step: 'stage-moved' })
// Why: old-live retains the original directory identity until the ledger commit.
await args.callbacks.journal({ kind: 'package-swap', step: 'complete' })
return { kind: 'complete' }
}
function isSharingViolation(error: unknown, enabled: boolean): boolean {
return Boolean(
enabled &&
error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'EPERM' || error.code === 'EBUSY')
)
}
export async function rollbackSkillPackageSwap(args: {
record: ManagedSkillDestination
transactionRoot: string
callbacks: PublicationCallbacks
renameRuntime?: SkillRenameRuntime
}): Promise<void> {
const stageRoot = join(args.transactionRoot, 'stage')
const oldLiveRoot = join(args.transactionRoot, 'old-live')
const movingCurrent = { kind: 'package-swap', step: 'rollback-moving-current' } as const
await args.callbacks.journal(movingCurrent)
await retrySkillRename(
args.record.resolvedPath,
stageRoot,
() => args.callbacks.assertBoundary(movingCurrent, 'current'),
args.renameRuntime?.renameEntry,
args.renameRuntime?.wait
)
await args.callbacks.journal({ kind: 'package-swap', step: 'rollback-current-moved' })
const movingOldLive = { kind: 'package-swap', step: 'rollback-moving-old-live' } as const
await args.callbacks.journal(movingOldLive)
await retrySkillRename(
oldLiveRoot,
args.record.resolvedPath,
() => args.callbacks.assertBoundary(movingOldLive, 'swap-absent'),
args.renameRuntime?.renameEntry,
args.renameRuntime?.wait
)
await args.callbacks.journal({ kind: 'package-swap', step: 'moving-live' })
}
export async function replaceSkillPackage(args: {
record: ManagedSkillDestination
current: SkillCurrentBundleEntry
prior: SkillReleasedSnapshot
stageRoot: string
transactionRoot: string
callbacks: PublicationCallbacks
renameRuntime?: SkillRenameRuntime
}): Promise<void> {
const outcome = await publishPackageSwap(args)
if (outcome.kind === 'pre-mutation-sharing-violation') {
// Why: the in-place journal may replace swap state only while live is untouched.
await args.callbacks.assertBoundary({ kind: 'package-swap', step: 'moving-live' }, 'prior')
await publishSkillPackageInPlace(args)
}
}
@@ -0,0 +1,176 @@
import {
cp,
lstat,
mkdir,
mkdtemp,
realpath,
rename,
rm,
stat,
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 {
ManagedSkillDestination,
SkillCurrentBundleEntry,
SkillReleasedSnapshot
} from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import { publishBundledSkill } from './skill-package-publish'
import { skillPhysicalIdentity } from './skill-installation-topology'
const roots: string[] = []
async function writePackage(root: string, prefix: string): Promise<void> {
await mkdir(root, { recursive: true })
await writeFile(join(root, 'SKILL.md'), `${prefix} entry`)
await writeFile(join(root, 'asset.txt'), `${prefix} asset`)
}
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-publish-identity-'))
roots.push(root)
const live = join(root, 'home', '.agents', 'skills', 'orca-cli')
const packagesRoot = join(root, 'packages')
const currentRoot = join(packagesRoot, 'orca-cli')
await writePackage(live, 'old')
await writePackage(currentRoot, 'new')
const priorObserved = await observeSkillPackage(live)
const currentObserved = await observeSkillPackage(currentRoot)
const prior: SkillReleasedSnapshot = {
releaseRevision: 1,
packageDigest: priorObserved.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(priorObserved)
}
const current: SkillCurrentBundleEntry = {
name: 'orca-cli',
sourcePath: 'skills/orca-cli',
appVersion: '2.0.0',
releaseRevision: 2,
packageDigest: currentObserved.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(currentObserved)
}
const resolvedLive = await realpath(live)
const record: ManagedSkillDestination = {
id: '000000000000000000000000',
hostId: 'local',
homeIdentity: join(root, 'home'),
rootId: 'home-agents',
unresolvedPath: live,
resolvedPath: resolvedLive,
physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
entryType: 'directory',
skillName: 'orca-cli',
source: 'stablyai/orca',
sourcePath: 'skills/orca-cli',
sourceRef: null,
installedReleaseRevision: 1,
installedPackageDigest: prior.packageDigest,
installedFiles: prior.files,
lastWrittenPackageDigest: null,
lastAttemptedBundleFingerprint: null,
lastOutcome: 'adopted',
lastErrorCategory: null,
adoptedFrom: 'exact-snapshot',
adoptedAt: 1,
updatedAt: 1
}
return { root, live, packagesRoot, prior, current, record }
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill package durable identity', () => {
it.each(['package-swap', 'in-place'] as const)(
'does not commit an exact-content independent replacement after %s publication',
async (mode) => {
const value = await fixture()
const replacement = join(value.root, 'independent-replacement')
await cp(join(value.packagesRoot, 'orca-cli'), replacement, { recursive: true })
let committed = false
let replacementInjected = false
await expect(
publishBundledSkill({
record: value.record,
current: value.current,
releasedSnapshots: [value.prior, value.current],
packagesRoot: value.packagesRoot,
commit: () => {
committed = true
},
publicationRenameRuntime:
mode === 'in-place'
? {
windowsFallback: true,
renameEntry: async () => {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
},
wait: async () => undefined
}
: undefined,
beforeDurableCommitValidation: async () => {
replacementInjected = true
await rm(value.live, { recursive: true })
await rename(replacement, value.live)
}
})
).rejects.toThrow()
expect(replacementInjected).toBe(true)
expect(committed).toBe(false)
expect((await observeSkillPackage(value.live)).observedDigest).toBe(
value.current.packageDigest
)
}
)
it.each(['before-entry', 'before-workspace'] as const)(
'writes no workspace through a parent link swapped %s',
async (timing) => {
const value = await fixture()
const agentsRoot = join(value.root, 'home', '.agents')
const originalAgentsRoot = join(value.root, 'original-agents')
const externalAgentsRoot = join(value.root, 'external-agents')
const swapParent = async (): Promise<void> => {
await rename(agentsRoot, originalAgentsRoot)
await mkdir(join(externalAgentsRoot, 'skills'), { recursive: true })
await cp(
join(originalAgentsRoot, 'skills', 'orca-cli'),
join(externalAgentsRoot, 'skills', 'orca-cli'),
{ recursive: true }
)
await symlink(
externalAgentsRoot,
agentsRoot,
process.platform === 'win32' ? 'junction' : 'dir'
)
}
if (timing === 'before-entry') {
await swapParent()
}
await expect(
publishBundledSkill({
record: value.record,
current: value.current,
releasedSnapshots: [value.prior, value.current],
packagesRoot: value.packagesRoot,
commit: () => undefined,
beforeWorkspaceCreationValidation: timing === 'before-workspace' ? swapParent : undefined
})
).rejects.toThrow('skill-topology-changed')
expect(
await lstat(join(externalAgentsRoot, '.orca-skill-transactions')).catch(() => null)
).toBeNull()
}
)
})
+171
View File
@@ -0,0 +1,171 @@
import { mkdir, rm, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type {
ManagedSkillDestination,
SkillCurrentBundleEntry
} from '../../shared/skill-management'
import { skillPhysicalIdentity } from './skill-installation-topology'
import {
matchingReleasedSnapshot,
observeSkillPackage,
persistedObservedFiles
} from './skill-package-identity'
import {
acquireSkillDestinationLock,
recoverMarkedSkillTransactions
} from './skill-transaction-recovery'
import { destinationUsesCrlf, replaceSkillPackage } from './skill-package-publication'
import type { SkillRenameRuntime } from './skill-transaction-rename'
import { createSkillTransactionContext } from './skill-package-transaction-context'
import {
assertManagedDestinationUnchanged,
assertPublishedDestinationOwned
} from './skill-published-destination-authority'
import { recoverTransactionToPrior } from './skill-transaction-restore'
import { buildSkillTransactionPackage } from './skill-transaction-package-build'
import { assertSkillTransactionWorkspace } from './skill-transaction-workspace'
export async function publishBundledSkill(args: {
record: ManagedSkillDestination
current: SkillCurrentBundleEntry
releasedSnapshots: Parameters<typeof matchingReleasedSnapshot>[1]
packagesRoot: string
commit: (destination: ManagedSkillDestination) => void | Promise<void>
now?: number
beforeDurableCommitValidation?: () => void | Promise<void>
beforeWorkspaceCreationValidation?: () => void | Promise<void>
publicationRenameRuntime?: SkillRenameRuntime
}): Promise<ManagedSkillDestination> {
if (args.current.releaseRevision <= args.record.installedReleaseRevision) {
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
await args.commit(args.record)
return args.record
}
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
const transaction = await createSkillTransactionContext({
record: args.record,
current: args.current,
releasedSnapshots: args.releasedSnapshots,
now: args.now ?? Date.now(),
beforeWorkspaceValidation: args.beforeWorkspaceCreationValidation
})
// Why: no workspace directory may be created after a parent or mount swap.
await transaction.assertAuthority(true)
await mkdir(dirname(transaction.paths.lockRoot), { recursive: true })
await transaction.assertAuthority(true)
const releaseLock = await acquireSkillDestinationLock(transaction.paths.lockRoot, () =>
transaction.assertAuthority(true)
)
let retainTransaction = false
let publicationStarted = false
try {
await transaction.assertAuthority(true)
await recoverMarkedSkillTransactions({
reservedRoot: transaction.paths.reservedRoot,
record: args.record
})
await transaction.assertAuthority(true)
await mkdir(transaction.paths.transactionRoot, { recursive: true })
await transaction.assertAuthority(true)
await transaction.writeMarker('staging', true)
await buildSkillTransactionPackage({
sourceRoot: join(args.packagesRoot, args.record.skillName),
root: transaction.paths.stageRoot,
snapshot: transaction.marker('staging').currentSnapshot,
crlf: destinationUsesCrlf(args.record),
assertSourceAuthority: () => transaction.assertAuthority(true),
journal: transaction.journalStageBuild,
assertBoundary: transaction.assertStageBuild
})
await buildSkillTransactionPackage({
sourceRoot: args.record.resolvedPath,
root: transaction.paths.backupRoot,
snapshot: transaction.priorSnapshot,
crlf: false,
assertSourceAuthority: () => transaction.assertAuthority(true),
journal: transaction.journalBackupBuild,
assertBoundary: transaction.assertBackupBuild
})
await transaction.assertAuthority(true)
publicationStarted = true
await replaceSkillPackage({
...args,
prior: transaction.priorSnapshot,
stageRoot: transaction.paths.stageRoot,
transactionRoot: transaction.paths.transactionRoot,
callbacks: transaction.callbacks,
renameRuntime: args.publicationRenameRuntime
})
const installed = await observeSkillPackage(args.record.resolvedPath)
if (!matchingReleasedSnapshot(installed, [args.current])) {
throw new Error('published-skill-package-invalid')
}
const now = args.now ?? Date.now()
const publishingMarker = transaction.marker('publishing')
const nextResolvedPath = await assertPublishedDestinationOwned({
record: args.record,
transactionRoot: transaction.paths.transactionRoot,
marker: publishingMarker
})
const nextPhysicalIdentity = skillPhysicalIdentity(
nextResolvedPath,
await stat(nextResolvedPath)
)
const destination: ManagedSkillDestination = {
...args.record,
resolvedPath: nextResolvedPath,
physicalIdentity: nextPhysicalIdentity,
installedReleaseRevision: args.current.releaseRevision,
installedPackageDigest: args.current.packageDigest,
installedFiles: persistedObservedFiles(installed),
lastWrittenPackageDigest: args.current.packageDigest,
lastAttemptedBundleFingerprint: args.current.packageDigest,
lastOutcome: 'updated',
lastErrorCategory: null,
updatedAt: now
}
const verifiedMarker = transaction.marker('verified')
await transaction.writeVerifiedMarker(async () => {
await assertPublishedDestinationOwned({
record: args.record,
transactionRoot: transaction.paths.transactionRoot,
marker: verifiedMarker
})
})
// Why: the rollback package remains marked until durable Orca ownership
// state agrees with the verified live package.
await args.beforeDurableCommitValidation?.()
await assertPublishedDestinationOwned({
record: args.record,
transactionRoot: transaction.paths.transactionRoot,
marker: verifiedMarker
})
await args.commit(destination)
return destination
} catch (error) {
try {
await recoverTransactionToPrior({
transactionRoot: transaction.paths.transactionRoot,
marker: transaction.marker(publicationStarted ? 'publishing' : 'staging'),
assertParentAuthority: transaction.assertParentAuthority,
currentDisposition: 'restore'
})
} catch (restoreError) {
retainTransaction = true
throw new AggregateError([error, restoreError], 'skill-transaction-recovery-required')
}
throw error
} finally {
const workspaceSafe = await assertSkillTransactionWorkspace(args.record, transaction.workspace)
.then(() => true)
.catch(() => false)
if (workspaceSafe && !retainTransaction) {
await rm(transaction.paths.transactionRoot, { recursive: true, force: true }).catch(
() => undefined
)
}
if (workspaceSafe) {
await releaseLock().catch(() => undefined)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import { lstat, rmdir, unlink } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
import type { PublicationCallbacks } from './skill-package-publication'
import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
import { skillInPlaceOperations } from './skill-transaction-publication-state'
import type { SkillInPlacePublicationState } from './skill-transaction-publication-state'
export async function clearSkillPublicationTemporary(args: {
record: ManagedSkillDestination
publication: SkillInPlacePublicationState
callbacks: PublicationCallbacks
ownedTempRuntime?: SkillOwnedTempRuntime
}): Promise<void> {
if (!args.publication.temporary) {
return
}
await args.callbacks.assertBoundary(args.publication)
const temporaryPath = join(
args.record.resolvedPath,
...args.publication.temporary.path.split('/')
)
if (await lstat(temporaryPath).catch(() => null)) {
await args.callbacks.assertBoundary(args.publication)
await unlink(temporaryPath)
}
await args.callbacks.journal({ ...args.publication, temporary: null })
}
export async function rollbackSkillPackageInPlace(args: {
record: ManagedSkillDestination
prior: SkillReleasedSnapshot
current: SkillReleasedSnapshot
backupRoot: string
startingIndex: number
physicalIdentity: string
callbacks: PublicationCallbacks
ownedTempRuntime?: SkillOwnedTempRuntime
}): Promise<void> {
const operations = skillInPlaceOperations(args.prior, args.current)
const priorByPath = new Map(args.prior.files.map((file) => [file.path, file]))
for (let operationIndex = args.startingIndex; operationIndex > 0; operationIndex -= 1) {
const operation = operations[operationIndex - 1]
if (!operation) {
throw new Error('skill-publication-operation-invalid')
}
const priorFile = priorByPath.get(operation.path)
const applying = {
kind: 'in-place',
direction: 'rollback',
physicalIdentity: args.physicalIdentity,
temporary: null,
operationIndex,
operationState: 'applying'
} as const
await args.callbacks.journal(applying)
const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
if (operation.kind === 'publish' && !priorFile) {
await args.callbacks.assertBoundary(applying)
await unlink(livePath)
} else {
if (!priorFile) {
throw new Error('skill-publication-operation-invalid')
}
await publishSkillFileThroughOwnedTemp({
destinationRoot: args.record.resolvedPath,
sourcePath: join(args.backupRoot, ...priorFile.path.split('/')),
expectedFile: priorFile,
direction: 'rollback',
packagePhysicalIdentity: args.physicalIdentity,
operationIndex,
callbacks: args.callbacks,
runtime: args.ownedTempRuntime
})
}
const ready = {
kind: 'in-place',
direction: 'rollback',
physicalIdentity: args.physicalIdentity,
temporary: null,
operationIndex: operationIndex - 1,
operationState: 'ready'
} as const
await args.callbacks.journal(ready)
if (operation.kind === 'publish' && !priorFile) {
let parent = dirname(livePath)
while (parent !== args.record.resolvedPath) {
await args.callbacks.assertBoundary(ready)
await rmdir(parent).catch(() => undefined)
parent = dirname(parent)
}
}
}
}
@@ -0,0 +1,229 @@
import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import { replaceSkillPackage, rollbackSkillPackageSwap } from './skill-package-publication'
import { skillPhysicalIdentity } from './skill-installation-topology'
import type { SkillTransactionMarker } from './skill-transaction-marker'
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
import type { SkillPublicationState } from './skill-transaction-publication-state'
const roots: string[] = []
async function createPackage(root: string, values: Record<string, string>): Promise<void> {
await mkdir(root, { recursive: true })
for (const [path, contents] of Object.entries(values)) {
await writeFile(join(root, path), contents)
}
}
async function snapshot(root: string, releaseRevision: number): Promise<SkillReleasedSnapshot> {
const observed = await observeSkillPackage(root)
return {
releaseRevision,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(observed)
}
}
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
roots.push(root)
const live = join(root, 'skills', 'orca-cli')
const transactionRoot = join(root, 'transaction')
const stageRoot = join(transactionRoot, 'stage')
const backupRoot = join(transactionRoot, 'backup')
await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
await cp(live, backupRoot, { recursive: true })
const prior = await snapshot(live, 1)
const current = await snapshot(stageRoot, 2)
const record = {
resolvedPath: live,
physicalIdentity: skillPhysicalIdentity(live, await stat(live))
} as ManagedSkillDestination
let marker: SkillTransactionMarker = {
schemaVersion: 1,
transactionId: 'test',
createdAt: 1,
destinationId: '000000000000000000000000',
destinationPath: live,
physicalIdentity: record.physicalIdentity,
hostId: 'local',
skillName: 'orca-cli',
phase: 'publishing',
stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
stageBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
operationIndex: current.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
operationIndex: prior.files.length,
pendingPath: null,
activeFile: null
},
publication: null,
priorSnapshot: prior,
currentSnapshot: current
}
const history: SkillPublicationState[] = []
const journal = async (publication: SkillPublicationState): Promise<void> => {
marker = { ...marker, publication }
history.push(publication)
}
const assertBoundary = async (
publication: SkillPublicationState,
expectedLiveState?: Awaited<ReturnType<typeof assertTransactionOwnedState>>
): Promise<void> => {
const liveState = await assertTransactionOwnedState({
transactionRoot,
marker: { ...marker, publication },
assertParent: async () => undefined,
allowAppliedOperation: false
})
if (expectedLiveState && liveState !== expectedLiveState) {
throw new Error('skill-transaction-recovery-required')
}
}
return {
live,
transactionRoot,
stageRoot,
oldLiveRoot: join(transactionRoot, 'old-live'),
record,
prior,
current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
callbacks: { journal, assertBoundary },
history,
marker: () => marker
}
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill package swap authority', () => {
it('falls back in place only when the first live rename never mutated', async () => {
const value = await fixture()
await replaceSkillPackage({
...value,
callbacks: value.callbacks,
renameRuntime: {
windowsFallback: true,
renameEntry: async () => {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
},
wait: async () => undefined
}
})
expect((await observeSkillPackage(value.live)).observedDigest).toBe(value.current.packageDigest)
expect(value.history.some((state) => state.kind === 'in-place')).toBe(true)
})
it('never falls back after live moved and the stage rename exhausted retries', async () => {
const value = await fixture()
const renameEntry: typeof rename = async (source, destination) => {
if (source.toString() === value.stageRoot) {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
}
await rename(source, destination)
}
await expect(
replaceSkillPackage({
...value,
callbacks: value.callbacks,
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
})
).rejects.toMatchObject({ code: 'EPERM' })
expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-live' })
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
})
it('rejects fallback when a failing first rename nevertheless moved live', async () => {
const value = await fixture()
let firstAttempt = true
const renameEntry: typeof rename = async (source, destination) => {
if (firstAttempt) {
firstAttempt = false
await rename(source, destination)
throw Object.assign(new Error('ambiguous busy result'), { code: 'EPERM' })
}
await rename(source, destination)
}
await expect(
replaceSkillPackage({
...value,
callbacks: value.callbacks,
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
})
).rejects.toThrow('skill-transaction-recovery-required')
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
expect(await stat(value.oldLiveRoot)).toBeTruthy()
await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('retains swap authority when stage publication and old-live restore are busy', async () => {
const value = await fixture()
let firstRename = true
const renameEntry: typeof rename = async (source, destination) => {
if (firstRename) {
firstRename = false
await rename(source, destination)
return
}
throw Object.assign(new Error('busy'), { code: 'EBUSY' })
}
await expect(
replaceSkillPackage({
...value,
callbacks: value.callbacks,
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
})
).rejects.toMatchObject({ code: 'EBUSY' })
expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-stage' })
expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
expect(await stat(value.oldLiveRoot)).toBeTruthy()
await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each(['current', 'old-live'] as const)(
'retains the package-swap rollback journal when %s rename stays busy',
async (failure) => {
const value = await fixture()
await rename(value.live, value.oldLiveRoot)
await rename(value.stageRoot, value.live)
await value.callbacks.journal({ kind: 'package-swap', step: 'complete' })
const renameEntry: typeof rename = async (source, destination) => {
const shouldFail =
(failure === 'current' && source.toString() === value.live) ||
(failure === 'old-live' && source.toString() === value.oldLiveRoot)
if (shouldFail) {
throw Object.assign(new Error('busy'), { code: 'EPERM' })
}
await rename(source, destination)
}
await expect(
rollbackSkillPackageSwap({
record: value.record,
transactionRoot: value.transactionRoot,
callbacks: value.callbacks,
renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
})
).rejects.toMatchObject({ code: 'EPERM' })
expect(value.marker().publication).toEqual({
kind: 'package-swap',
step: failure === 'current' ? 'rollback-moving-current' : 'rollback-moving-old-live'
})
}
)
})
@@ -0,0 +1,202 @@
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import type {
ManagedSkillDestination,
SkillCurrentBundleEntry,
SkillReleasedSnapshot
} from '../../shared/skill-management'
import { assertManagedSkillParentTopology } from './skill-installation-topology'
import type { PublicationCallbacks } from './skill-package-publication'
import { assertManagedDestinationUnchanged } from './skill-published-destination-authority'
import {
writeSkillTransactionMarker,
type SkillTransactionMarker
} from './skill-transaction-marker'
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
import type { SkillPublicationState } from './skill-transaction-publication-state'
import {
assertSkillTransactionPackageBuild,
type SkillTransactionPackageBuildEvidence
} from './skill-transaction-package-build'
import {
assertSkillTransactionWorkspace,
resolveSkillTransactionWorkspace,
type SkillTransactionWorkspace
} from './skill-transaction-workspace'
export type SkillTransactionContext = {
workspace: SkillTransactionWorkspace
paths: {
reservedRoot: string
transactionRoot: string
stageRoot: string
backupRoot: string
lockRoot: string
}
priorSnapshot: SkillReleasedSnapshot
callbacks: PublicationCallbacks
assertAuthority: (checkContent: boolean) => Promise<void>
assertParentAuthority: () => Promise<void>
journalStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
assertStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
journalBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
assertBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
marker: (phase: SkillTransactionMarker['phase']) => SkillTransactionMarker
writeMarker: (phase: SkillTransactionMarker['phase'], checkContent: boolean) => Promise<void>
writeVerifiedMarker: (assertPublished: () => Promise<void>) => Promise<void>
}
export async function createSkillTransactionContext(args: {
record: ManagedSkillDestination
current: SkillCurrentBundleEntry
releasedSnapshots: readonly SkillReleasedSnapshot[]
now: number
beforeWorkspaceValidation?: () => void | Promise<void>
}): Promise<SkillTransactionContext> {
const workspace = await resolveSkillTransactionWorkspace(args.record)
await args.beforeWorkspaceValidation?.()
const assertAuthority = async (checkContent: boolean): Promise<void> => {
if (checkContent) {
await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
}
await assertSkillTransactionWorkspace(args.record, workspace)
}
await assertAuthority(true)
const reservedRoot = workspace.reservedRoot
const transactionRoot = join(reservedRoot, randomUUID())
const stageRoot = join(transactionRoot, 'stage')
const backupRoot = join(transactionRoot, 'backup')
const priorSnapshot: SkillReleasedSnapshot = {
releaseRevision: args.record.installedReleaseRevision,
packageDigest: args.record.installedPackageDigest,
gitTreeSha: args.record.sourceRef ?? '',
files: args.record.installedFiles
}
const currentSnapshot: SkillReleasedSnapshot = {
releaseRevision: args.current.releaseRevision,
packageDigest: args.current.packageDigest,
gitTreeSha: args.current.gitTreeSha,
files: args.current.files
}
let stagePhysicalIdentity: string | null = null
let backupPhysicalIdentity: string | null = null
let stageBuild: SkillTransactionPackageBuildEvidence | null = null
let backupBuild: SkillTransactionPackageBuildEvidence | null = null
let publication: SkillPublicationState | null = null
const transactionId = randomUUID()
const marker = (phase: SkillTransactionMarker['phase']): SkillTransactionMarker => ({
schemaVersion: 1,
transactionId,
createdAt: args.now,
destinationId: args.record.id,
destinationPath: args.record.resolvedPath,
physicalIdentity: args.record.physicalIdentity,
hostId: args.record.hostId,
skillName: args.record.skillName,
phase,
stagePhysicalIdentity,
backupPhysicalIdentity,
stageBuild,
backupBuild,
publication,
priorSnapshot,
currentSnapshot
})
const assertParentAuthority = () => assertManagedSkillParentTopology(args.record)
const assertBuildAuthority = async (): Promise<void> => {
await assertAuthority(true)
if (stageBuild) {
await assertSkillTransactionPackageBuild({
root: stageRoot,
evidence: stageBuild,
snapshot: currentSnapshot
})
}
if (backupBuild) {
await assertSkillTransactionPackageBuild({
root: backupRoot,
evidence: backupBuild,
snapshot: priorSnapshot
})
}
}
const writeBuildMarker = async (): Promise<void> => {
await writeSkillTransactionMarker(transactionRoot, marker('staging'), assertBuildAuthority)
}
const journal = async (next: SkillPublicationState): Promise<void> => {
publication = next
await assertAuthority(false)
await writeSkillTransactionMarker(transactionRoot, marker('publishing'), () =>
assertAuthority(false)
)
}
const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
state,
expectedLiveState
) => {
const liveState = await assertTransactionOwnedState({
transactionRoot,
marker: { ...marker('publishing'), publication: state },
assertParent: assertParentAuthority,
allowAppliedOperation: false
})
await assertAuthority(false)
if (expectedLiveState && liveState !== expectedLiveState) {
throw new Error('skill-transaction-recovery-required')
}
}
return {
workspace,
paths: {
reservedRoot,
transactionRoot,
stageRoot,
backupRoot,
lockRoot: join(reservedRoot, 'locks', args.record.id)
},
priorSnapshot,
callbacks: { journal, assertBoundary },
assertAuthority,
assertParentAuthority,
journalStageBuild: async (evidence) => {
stageBuild = evidence
stagePhysicalIdentity = evidence.physicalIdentity
await writeBuildMarker()
},
assertStageBuild: async (evidence) => {
await assertAuthority(true)
await assertSkillTransactionPackageBuild({
root: stageRoot,
evidence,
snapshot: currentSnapshot
})
},
journalBackupBuild: async (evidence) => {
backupBuild = evidence
backupPhysicalIdentity = evidence.physicalIdentity
await writeBuildMarker()
},
assertBackupBuild: async (evidence) => {
await assertAuthority(true)
await assertSkillTransactionPackageBuild({
root: backupRoot,
evidence,
snapshot: priorSnapshot
})
},
marker,
writeMarker: async (phase, checkContent) => {
await writeSkillTransactionMarker(transactionRoot, marker(phase), () =>
assertAuthority(checkContent)
)
},
writeVerifiedMarker: async (assertPublished) => {
await writeSkillTransactionMarker(transactionRoot, marker('verified'), async () => {
// Why: marker durability must never outlive the exact destination
// identity and content proof it claims.
await assertAuthority(false)
await assertPublished()
})
}
}
}
@@ -0,0 +1,52 @@
import type { ManagedSkillDestination } from '../../shared/skill-management'
import {
assertManagedSkillParentTopology,
assertManagedSkillTopology
} from './skill-installation-topology'
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
import type { SkillTransactionMarker } from './skill-transaction-marker'
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
export async function assertManagedDestinationUnchanged(
record: ManagedSkillDestination,
snapshots: Parameters<typeof matchingReleasedSnapshot>[1]
): Promise<void> {
const resolved = await assertManagedSkillTopology(record)
const observed = await observeSkillPackage(resolved)
const snapshot = matchingReleasedSnapshot(observed, snapshots)
if (!snapshot || snapshot.packageDigest !== record.installedPackageDigest) {
throw new Error('skill-content-changed')
}
}
export async function assertPublishedDestinationOwned(args: {
record: ManagedSkillDestination
transactionRoot: string
marker: SkillTransactionMarker
}): Promise<string> {
const publication = args.marker.publication
if (!publication) {
throw new Error('skill-transaction-recovery-required')
}
const expectedPhysicalIdentity =
publication.kind === 'package-swap'
? args.marker.stagePhysicalIdentity
: args.record.physicalIdentity
if (!expectedPhysicalIdentity) {
throw new Error('skill-transaction-recovery-required')
}
const state = await assertTransactionOwnedState({
transactionRoot: args.transactionRoot,
marker: args.marker,
assertParent: () => assertManagedSkillParentTopology(args.record),
allowAppliedOperation: false
})
if (state !== 'current') {
throw new Error('skill-transaction-recovery-required')
}
// Why: exact bytes do not authorize claiming a directory replaced after publish.
return assertManagedSkillTopology({
...args.record,
physicalIdentity: expectedPhysicalIdentity
})
}
@@ -0,0 +1,235 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
import {
observeSkillPackage,
persistedObservedFiles,
skillPackageDigest
} from './skill-package-identity'
import {
parseSkillTransactionMarker,
type SkillTransactionMarker
} from './skill-transaction-marker'
import { skillInPlaceOperations } from './skill-transaction-publication-state'
const roots: string[] = []
async function snapshot(contents: string, revision: number): Promise<SkillReleasedSnapshot> {
const root = await mkdtemp(join(tmpdir(), 'orca-marker-snapshot-'))
roots.push(root)
await writeFile(join(root, 'SKILL.md'), contents)
await writeFile(join(root, 'asset.txt'), `${contents} asset`)
const observed = await observeSkillPackage(root)
return {
releaseRevision: revision,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(observed)
}
}
async function fixture(): Promise<SkillTransactionMarker> {
const root = await mkdtemp(join(tmpdir(), 'orca-marker-'))
roots.push(root)
const destinationPath = join(root, 'skills', 'orca-cli')
await mkdir(destinationPath, { recursive: true })
const priorSnapshot = await snapshot('old', 1)
const currentSnapshot = await snapshot('new', 2)
return {
schemaVersion: 1,
transactionId: 'transaction',
createdAt: 1,
destinationId: '000000000000000000000000',
destinationPath,
physicalIdentity: 'live-identity',
hostId: 'local',
skillName: 'orca-cli',
phase: 'verified',
stagePhysicalIdentity: 'stage-identity',
backupPhysicalIdentity: 'backup-identity',
stageBuild: {
state: 'ready',
physicalIdentity: 'stage-identity',
operationIndex: currentSnapshot.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: 'backup-identity',
operationIndex: priorSnapshot.files.length,
pendingPath: null,
activeFile: null
},
publication: { kind: 'package-swap', step: 'complete' },
priorSnapshot,
currentSnapshot
}
}
function parse(marker: unknown): SkillTransactionMarker | null {
return parseSkillTransactionMarker(JSON.stringify(marker))
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill transaction marker semantics', () => {
it('requires phase-valid package build evidence', async () => {
const marker = await fixture()
expect(parse({ ...marker, stageBuild: null })).toBeNull()
expect(
parse({
...marker,
phase: 'staging',
publication: null,
stageBuild: { ...marker.stageBuild, state: 'creating' }
})
).toBeNull()
expect(
parse({
...marker,
phase: 'staging',
publication: null,
backupBuild: { ...marker.backupBuild, state: 'creating' },
stageBuild: { ...marker.stageBuild, state: 'creating' }
})
).toBeNull()
expect(
parse({
...marker,
phase: 'staging',
publication: null,
stageBuild: {
state: 'creating',
physicalIdentity: marker.stagePhysicalIdentity,
operationIndex: 0,
pendingPath: null,
activeFile: {
path: marker.currentSnapshot.files[0]!.path,
physicalIdentity: 'active',
contentState: 'invalid'
}
},
backupBuild: null,
backupPhysicalIdentity: null
})
).toBeNull()
})
it('accepts only terminal verified publication states', async () => {
const marker = await fixture()
expect(parse(marker)).not.toBeNull()
expect(
parse({ ...marker, publication: { kind: 'package-swap', step: 'moving-stage' } })
).toBeNull()
const finalIndex = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
const inPlace = {
kind: 'in-place',
direction: 'forward',
physicalIdentity: marker.physicalIdentity,
temporary: null,
operationIndex: finalIndex,
operationState: 'ready'
} as const
expect(parse({ ...marker, publication: inPlace })).not.toBeNull()
expect(
parse({ ...marker, publication: { ...inPlace, operationIndex: finalIndex - 1 } })
).toBeNull()
expect(parse({ ...marker, publication: { ...inPlace, direction: 'rollback' } })).toBeNull()
expect(parse({ ...marker, publication: { ...inPlace, operationState: 'applying' } })).toBeNull()
})
it.each(['digest', 'order', 'duplicate', 'unsafe'])(
'rejects a snapshot with corrupted %s semantics',
async (corruption) => {
const marker = await fixture()
const files = marker.currentSnapshot.files.map((file) => ({ ...file }))
if (corruption === 'order') {
files.reverse()
}
if (corruption === 'duplicate') {
files[1] = { ...files[0]! }
}
if (corruption === 'unsafe') {
files[0] = { ...files[0]!, path: '../escape' }
}
const currentSnapshot = {
...marker.currentSnapshot,
files,
packageDigest: corruption === 'digest' ? '0'.repeat(64) : skillPackageDigest(files)
}
expect(parse({ ...marker, currentSnapshot })).toBeNull()
}
)
it('rejects inconsistent revisions and malformed temp authority', async () => {
const marker = await fixture()
expect(
parse({
...marker,
currentSnapshot: {
...marker.currentSnapshot,
releaseRevision: marker.priorSnapshot.releaseRevision
}
})
).toBeNull()
const operation = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot)[0]!
const expectedFile = marker.currentSnapshot.files.find((file) => file.path === operation.path)!
const publication = {
kind: 'in-place',
direction: 'forward',
physicalIdentity: marker.physicalIdentity,
temporary: {
path: 'asset.txt.orca-test.tmp',
physicalIdentity: '',
expectedFile,
state: 'ready'
},
operationIndex: 0,
operationState: 'applying'
} as const
expect(parse({ ...marker, phase: 'publishing', publication })).toBeNull()
const validPublication = {
...publication,
temporary: { ...publication.temporary, physicalIdentity: 'temp-identity' }
}
expect(parse({ ...marker, phase: 'publishing', publication: validPublication })).not.toBeNull()
expect(
parse({
...marker,
phase: 'publishing',
publication: {
...validPublication,
temporary: { ...validPublication.temporary, state: 'unknown' }
}
})
).toBeNull()
expect(
parse({
...marker,
phase: 'publishing',
publication: {
...validPublication,
temporary: { ...validPublication.temporary, path: 'other.orca-test.tmp' }
}
})
).toBeNull()
expect(
parse({
...marker,
phase: 'publishing',
publication: {
...validPublication,
temporary: {
...validPublication.temporary,
expectedFile: marker.priorSnapshot.files.find((file) => file.path === operation.path)!
}
}
})
).toBeNull()
})
})
+291
View File
@@ -0,0 +1,291 @@
import { randomUUID } from 'node:crypto'
import { rename, rm, writeFile } from 'node:fs/promises'
import { basename, isAbsolute, join, posix } from 'node:path'
import { normalizeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
import { normalizedSkillIdentityPath } from './skill-installation-topology'
import {
isSkillTransactionPackageBuildEvidence,
type SkillTransactionPackageBuildEvidence
} from './skill-transaction-package-build'
import {
sameCompleteFileIdentity,
skillInPlaceOperations,
type SkillInPlacePublicationState,
type SkillPublicationState
} from './skill-transaction-publication-state'
import {
isSkillBundleFileIdentity,
isSkillReleasedSnapshot
} from './skill-transaction-snapshot-validation'
export type SkillTransactionMarker = {
schemaVersion: 1
transactionId: string
createdAt: number
destinationId: string
destinationPath: string
physicalIdentity: string
hostId: ExecutionHostId
skillName: string
phase: 'staging' | 'publishing' | 'verified'
stagePhysicalIdentity: string | null
backupPhysicalIdentity: string | null
stageBuild: SkillTransactionPackageBuildEvidence | null
backupBuild: SkillTransactionPackageBuildEvidence | null
publication: SkillPublicationState | null
priorSnapshot: SkillReleasedSnapshot
currentSnapshot: SkillReleasedSnapshot
}
function validInPlaceSemantics(
publication: SkillInPlacePublicationState,
prior: SkillReleasedSnapshot,
current: SkillReleasedSnapshot
): boolean {
const operations = skillInPlaceOperations(prior, current)
if (publication.operationIndex > operations.length) {
return false
}
if (publication.operationState === 'ready') {
return publication.temporary === null
}
const operation =
operations[
publication.direction === 'forward'
? publication.operationIndex
: publication.operationIndex - 1
]
if (!operation) {
return false
}
const expected =
publication.direction === 'forward'
? current.files.find((file) => file.path === operation.path)
: prior.files.find((file) => file.path === operation.path)
if (!publication.temporary) {
return true
}
return Boolean(
expected &&
publication.temporary.path.startsWith(`${operation.path}.orca-`) &&
sameCompleteFileIdentity(publication.temporary.expectedFile, expected)
)
}
function validBuildSemantics(
evidence: SkillTransactionPackageBuildEvidence | null,
snapshot: SkillReleasedSnapshot
): boolean {
if (!evidence) {
return true
}
if (evidence.operationIndex > snapshot.files.length) {
return false
}
if (evidence.state === 'ready') {
return (
evidence.operationIndex === snapshot.files.length &&
evidence.pendingPath === null &&
evidence.activeFile === null
)
}
if (evidence.pendingPath !== null) {
return (
evidence.activeFile === null &&
snapshot.files[evidence.operationIndex]?.path === evidence.pendingPath
)
}
if (!evidence.activeFile) {
return true
}
return snapshot.files[evidence.operationIndex]?.path === evidence.activeFile.path
}
function validMarkerSemantics(marker: SkillTransactionMarker): boolean {
if (marker.currentSnapshot.releaseRevision <= marker.priorSnapshot.releaseRevision) {
return false
}
if (
!validBuildSemantics(marker.stageBuild, marker.currentSnapshot) ||
!validBuildSemantics(marker.backupBuild, marker.priorSnapshot) ||
marker.stagePhysicalIdentity !== (marker.stageBuild?.physicalIdentity ?? null) ||
marker.backupPhysicalIdentity !== (marker.backupBuild?.physicalIdentity ?? null) ||
(marker.backupBuild && marker.stageBuild?.state !== 'ready')
) {
return false
}
if (!marker.publication) {
return marker.phase === 'staging'
}
if (marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready') {
return false
}
if (
marker.publication.kind === 'in-place' &&
!validInPlaceSemantics(marker.publication, marker.priorSnapshot, marker.currentSnapshot)
) {
return false
}
if (marker.phase !== 'verified') {
return true
}
if (marker.publication.kind === 'package-swap') {
return marker.publication.step === 'complete'
}
return (
marker.publication.direction === 'forward' &&
marker.publication.operationState === 'ready' &&
marker.publication.temporary === null &&
marker.publication.operationIndex ===
skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
)
}
function isPublicationState(value: unknown): value is SkillPublicationState {
if (typeof value !== 'object' || value === null || !('kind' in value)) {
return false
}
if (value.kind === 'in-place') {
return (
'operationIndex' in value &&
typeof value.operationIndex === 'number' &&
Number.isSafeInteger(value.operationIndex) &&
value.operationIndex >= 0 &&
'direction' in value &&
(value.direction === 'forward' || value.direction === 'rollback') &&
'physicalIdentity' in value &&
typeof value.physicalIdentity === 'string' &&
value.physicalIdentity.length > 0 &&
'temporary' in value &&
(value.temporary === null ||
(typeof value.temporary === 'object' &&
value.temporary !== null &&
'path' in value.temporary &&
typeof value.temporary.path === 'string' &&
value.temporary.path.endsWith('.tmp') &&
!posix.isAbsolute(value.temporary.path) &&
posix.normalize(value.temporary.path) === value.temporary.path &&
!value.temporary.path.split('/').includes('..') &&
!value.temporary.path.includes('\\') &&
'physicalIdentity' in value.temporary &&
typeof value.temporary.physicalIdentity === 'string' &&
value.temporary.physicalIdentity.length > 0 &&
'state' in value.temporary &&
(value.temporary.state === 'creating' ||
value.temporary.state === 'written' ||
value.temporary.state === 'ready' ||
value.temporary.state === 'applied') &&
'expectedFile' in value.temporary &&
isSkillBundleFileIdentity(value.temporary.expectedFile))) &&
'operationState' in value &&
(value.operationState === 'ready' || value.operationState === 'applying')
)
}
return (
value.kind === 'package-swap' &&
'step' in value &&
(value.step === 'moving-live' ||
value.step === 'live-moved' ||
value.step === 'moving-stage' ||
value.step === 'stage-moved' ||
value.step === 'rollback-moving-current' ||
value.step === 'rollback-current-moved' ||
value.step === 'rollback-moving-old-live' ||
value.step === 'complete')
)
}
export function parseSkillTransactionMarker(value: string): SkillTransactionMarker | null {
try {
const parsed: unknown = JSON.parse(value)
if (typeof parsed !== 'object' || parsed === null) {
return null
}
const marker = parsed as Partial<SkillTransactionMarker>
const hostId = normalizeExecutionHostId(marker.hostId)
if (
marker.schemaVersion !== 1 ||
typeof marker.transactionId !== 'string' ||
marker.transactionId.length === 0 ||
typeof marker.createdAt !== 'number' ||
!Number.isFinite(marker.createdAt) ||
typeof marker.destinationId !== 'string' ||
!/^[a-f0-9]{24}$/.test(marker.destinationId) ||
typeof marker.destinationPath !== 'string' ||
!isAbsolute(marker.destinationPath) ||
typeof marker.physicalIdentity !== 'string' ||
marker.physicalIdentity.length === 0 ||
typeof marker.skillName !== 'string' ||
marker.skillName.length === 0 ||
(marker.stagePhysicalIdentity !== null &&
(typeof marker.stagePhysicalIdentity !== 'string' ||
marker.stagePhysicalIdentity.length === 0)) ||
(marker.backupPhysicalIdentity !== null &&
(typeof marker.backupPhysicalIdentity !== 'string' ||
marker.backupPhysicalIdentity.length === 0)) ||
(marker.stageBuild !== null && !isSkillTransactionPackageBuildEvidence(marker.stageBuild)) ||
(marker.backupBuild !== null &&
!isSkillTransactionPackageBuildEvidence(marker.backupBuild)) ||
!hostId ||
(marker.phase !== 'staging' &&
marker.phase !== 'publishing' &&
marker.phase !== 'verified') ||
!isSkillReleasedSnapshot(marker.priorSnapshot) ||
!isSkillReleasedSnapshot(marker.currentSnapshot)
) {
return null
}
const validated = { ...marker, hostId } as SkillTransactionMarker
if (!validMarkerSemantics(validated)) {
return null
}
if (
(marker.phase === 'staging' && marker.publication !== null) ||
(marker.phase !== 'staging' && !isPublicationState(marker.publication)) ||
(marker.phase !== 'staging' &&
(marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready'))
) {
return null
}
if (
normalizedSkillIdentityPath(basename(marker.destinationPath)) !==
normalizedSkillIdentityPath(marker.skillName)
) {
return null
}
return validated
} catch {
return null
}
}
export async function writeSkillTransactionMarker(
transactionRoot: string,
marker: SkillTransactionMarker,
beforeMutation: () => Promise<void> = async () => undefined
): Promise<void> {
const markerPath = join(transactionRoot, 'transaction.json')
const temporaryPath = join(transactionRoot, `.transaction-${randomUUID()}.tmp`)
const serialized = JSON.stringify(marker)
if (!parseSkillTransactionMarker(serialized)) {
throw new Error('skill-transaction-marker-invalid')
}
try {
await beforeMutation()
await writeFile(temporaryPath, serialized)
// Why: recovery must observe either the old or new phase, never a torn
// marker that could discard the only verified rollback package.
await beforeMutation()
await rename(temporaryPath, markerPath)
} finally {
// Why: a redirected transaction path is no longer Orca-owned cleanup.
if (
await beforeMutation()
.then(() => true)
.catch(() => false)
) {
await rm(temporaryPath, { force: true }).catch(() => undefined)
}
}
}
@@ -0,0 +1,305 @@
import { lstat, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
import { skillPhysicalIdentity } from './skill-installation-topology'
import type { SkillTransactionMarker } from './skill-transaction-marker'
import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
import {
matchesInPlacePublicationState,
skillInPlaceOperations
} from './skill-transaction-publication-state'
export type SkillTransactionLiveState = 'prior' | 'partial' | 'current' | 'swap-absent'
async function isAbsent(path: string): Promise<boolean> {
return lstat(path)
.then(() => false)
.catch((error) => {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return true
}
throw error
})
}
async function matchesOwnedPackage(args: {
path: string
physicalIdentity: string
snapshot: SkillTransactionMarker['priorSnapshot']
}): Promise<boolean> {
try {
const entry = await lstat(args.path)
if (!entry.isDirectory() || entry.isSymbolicLink()) {
return false
}
const pathStat = await stat(args.path)
if (skillPhysicalIdentity(args.path, pathStat) !== args.physicalIdentity) {
return false
}
return Boolean(matchingReleasedSnapshot(await observeSkillPackage(args.path), [args.snapshot]))
} catch {
return false
}
}
async function assertStageCurrent(
transactionRoot: string,
marker: SkillTransactionMarker
): Promise<void> {
if (
!marker.stageBuild ||
marker.stageBuild.state !== 'ready' ||
marker.stageBuild.physicalIdentity !== marker.stagePhysicalIdentity
) {
throw new Error('skill-transaction-recovery-required')
}
await assertSkillTransactionPackageBuild({
root: join(transactionRoot, 'stage'),
evidence: marker.stageBuild,
snapshot: marker.currentSnapshot
}).catch(() => {
throw new Error('skill-transaction-recovery-required')
})
}
async function assertOldLivePrior(
transactionRoot: string,
marker: SkillTransactionMarker
): Promise<void> {
if (
!(await matchesOwnedPackage({
path: join(transactionRoot, 'old-live'),
physicalIdentity: marker.physicalIdentity,
snapshot: marker.priorSnapshot
}))
) {
throw new Error('skill-transaction-recovery-required')
}
}
async function assertBackupPrior(
transactionRoot: string,
marker: SkillTransactionMarker
): Promise<void> {
if (
!marker.backupBuild ||
marker.backupBuild.state !== 'ready' ||
marker.backupBuild.physicalIdentity !== marker.backupPhysicalIdentity
) {
throw new Error('skill-transaction-recovery-required')
}
await assertSkillTransactionPackageBuild({
root: join(transactionRoot, 'backup'),
evidence: marker.backupBuild,
snapshot: marker.priorSnapshot
}).catch(() => {
throw new Error('skill-transaction-recovery-required')
})
}
async function liveOwnedSnapshot(
marker: SkillTransactionMarker,
snapshot: SkillTransactionMarker['priorSnapshot'],
physicalIdentity: string
): Promise<boolean> {
return matchesOwnedPackage({ path: marker.destinationPath, physicalIdentity, snapshot })
}
async function assertInPlaceState(args: {
transactionRoot: string
marker: SkillTransactionMarker
allowAppliedOperation: boolean
}): Promise<SkillTransactionLiveState> {
const publication = args.marker.publication
if (!publication || publication.kind !== 'in-place' || !args.marker.stagePhysicalIdentity) {
throw new Error('skill-transaction-recovery-required')
}
if (publication.direction === 'forward') {
await assertStageCurrent(args.transactionRoot, args.marker)
}
const liveStat = await stat(args.marker.destinationPath).catch(() => null)
if (
!liveStat ||
skillPhysicalIdentity(args.marker.destinationPath, liveStat) !== publication.physicalIdentity
) {
throw new Error('skill-transaction-recovery-required')
}
if (publication.temporary) {
const temporaryPath = join(
args.marker.destinationPath,
...publication.temporary.path.split('/')
)
const temporaryEntry = await lstat(temporaryPath).catch(() => null)
if (
temporaryEntry &&
(!temporaryEntry.isFile() ||
temporaryEntry.isSymbolicLink() ||
skillPhysicalIdentity(temporaryPath, await stat(temporaryPath)) !==
publication.temporary.physicalIdentity)
) {
throw new Error('skill-transaction-recovery-required')
}
if (!temporaryEntry) {
const operations = skillInPlaceOperations(
args.marker.priorSnapshot,
args.marker.currentSnapshot
)
const operation =
operations[
publication.direction === 'forward'
? publication.operationIndex
: publication.operationIndex - 1
]
const targetPath = operation
? join(args.marker.destinationPath, ...operation.path.split('/'))
: null
const targetEntry = targetPath ? await lstat(targetPath).catch(() => null) : null
// Why: a missing ready temp proves a completed rename only when its inode
// is now the exact operation target; equal bytes from another writer do not.
if (
publication.temporary.state === 'creating' ||
publication.temporary.state === 'written' ||
!targetPath ||
!targetEntry?.isFile() ||
targetEntry.isSymbolicLink() ||
skillPhysicalIdentity(targetPath, await stat(targetPath)) !==
publication.temporary.physicalIdentity
) {
throw new Error('skill-transaction-recovery-required')
}
} else if (publication.temporary.state === 'applied') {
throw new Error('skill-transaction-recovery-required')
}
}
const observed = await observeSkillPackage(args.marker.destinationPath).catch(() => null)
if (
!observed ||
!matchesInPlacePublicationState({
observed,
prior: args.marker.priorSnapshot,
current: args.marker.currentSnapshot,
publication,
allowAppliedOperation: args.allowAppliedOperation
})
) {
throw new Error('skill-transaction-recovery-required')
}
if (matchingReleasedSnapshot(observed, [args.marker.priorSnapshot])) {
return 'prior'
}
if (matchingReleasedSnapshot(observed, [args.marker.currentSnapshot])) {
return 'current'
}
return 'partial'
}
async function assertSwapState(args: {
transactionRoot: string
marker: SkillTransactionMarker
}): Promise<SkillTransactionLiveState> {
const publication = args.marker.publication
if (!publication || publication.kind !== 'package-swap' || !args.marker.stagePhysicalIdentity) {
throw new Error('skill-transaction-recovery-required')
}
const stageRoot = join(args.transactionRoot, 'stage')
const oldLiveRoot = join(args.transactionRoot, 'old-live')
if (publication.step === 'moving-live') {
const livePrior = await liveOwnedSnapshot(
args.marker,
args.marker.priorSnapshot,
args.marker.physicalIdentity
)
if (livePrior && (await isAbsent(oldLiveRoot))) {
await assertStageCurrent(args.transactionRoot, args.marker)
return 'prior'
}
}
if (publication.step === 'moving-stage') {
const liveCurrent = await liveOwnedSnapshot(
args.marker,
args.marker.currentSnapshot,
args.marker.stagePhysicalIdentity
)
if (liveCurrent && (await isAbsent(stageRoot))) {
await assertOldLivePrior(args.transactionRoot, args.marker)
return 'current'
}
}
if (
publication.step === 'moving-live' ||
publication.step === 'live-moved' ||
publication.step === 'moving-stage'
) {
if (!(await isAbsent(args.marker.destinationPath))) {
throw new Error('skill-transaction-recovery-required')
}
await Promise.all([
assertOldLivePrior(args.transactionRoot, args.marker),
assertStageCurrent(args.transactionRoot, args.marker)
])
return 'swap-absent'
}
if (publication.step === 'rollback-moving-old-live') {
const livePrior = await liveOwnedSnapshot(
args.marker,
args.marker.priorSnapshot,
args.marker.physicalIdentity
)
if (livePrior && (await isAbsent(oldLiveRoot))) {
await assertStageCurrent(args.transactionRoot, args.marker)
return 'prior'
}
}
if (
publication.step === 'rollback-moving-current' ||
publication.step === 'rollback-current-moved' ||
publication.step === 'rollback-moving-old-live'
) {
if (publication.step === 'rollback-moving-current') {
const liveCurrent = await liveOwnedSnapshot(
args.marker,
args.marker.currentSnapshot,
args.marker.stagePhysicalIdentity
)
if (liveCurrent && (await isAbsent(stageRoot))) {
await assertOldLivePrior(args.transactionRoot, args.marker)
return 'current'
}
}
if (!(await isAbsent(args.marker.destinationPath))) {
throw new Error('skill-transaction-recovery-required')
}
await Promise.all([
assertOldLivePrior(args.transactionRoot, args.marker),
assertStageCurrent(args.transactionRoot, args.marker)
])
return 'swap-absent'
}
const liveCurrent = await liveOwnedSnapshot(
args.marker,
args.marker.currentSnapshot,
args.marker.stagePhysicalIdentity
)
if (!liveCurrent || !(await isAbsent(stageRoot))) {
throw new Error('skill-transaction-recovery-required')
}
await assertOldLivePrior(args.transactionRoot, args.marker)
return 'current'
}
export async function assertTransactionOwnedState(args: {
transactionRoot: string
marker: SkillTransactionMarker
assertParent: () => Promise<void>
allowAppliedOperation: boolean
}): Promise<SkillTransactionLiveState> {
await args.assertParent()
if (args.marker.phase === 'staging' || !args.marker.publication) {
throw new Error('skill-transaction-recovery-required')
}
await assertBackupPrior(args.transactionRoot, args.marker)
if (args.marker.publication.kind === 'in-place') {
return assertInPlaceState(args)
}
return assertSwapState(args)
}
@@ -0,0 +1,187 @@
import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import {
assertSkillTransactionPackageBuild,
buildSkillTransactionPackage,
type SkillTransactionPackageBuildEvidence,
type SkillTransactionPackageBuildRuntime
} from './skill-transaction-package-build'
const roots: string[] = []
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-package-build-'))
roots.push(root)
const sourceRoot = join(root, 'source')
const packageRoot = join(root, 'stage')
await mkdir(join(sourceRoot, 'assets'), { recursive: true })
await writeFile(join(sourceRoot, 'SKILL.md'), '# Test\n')
await writeFile(join(sourceRoot, 'assets', 'run.bin'), Buffer.from([0, 1, 2, 3]))
await chmod(join(sourceRoot, 'assets', 'run.bin'), 0o755)
const observed = await observeSkillPackage(sourceRoot)
const snapshot: SkillReleasedSnapshot = {
releaseRevision: 2,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(observed)
}
let lastEvidence: SkillTransactionPackageBuildEvidence | null = null
const build = (runtime?: SkillTransactionPackageBuildRuntime) =>
buildSkillTransactionPackage({
root: packageRoot,
sourceRoot,
snapshot,
crlf: false,
assertSourceAuthority: async () => undefined,
journal: async (evidence) => {
lastEvidence = structuredClone(evidence)
},
assertBoundary: async (evidence) =>
assertSkillTransactionPackageBuild({ root: packageRoot, evidence, snapshot }),
runtime
})
return { root, sourceRoot, packageRoot, snapshot, build, lastEvidence: () => lastEvidence }
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill transaction package build authority', () => {
it('journals deterministic prefixes and a ready exact package', async () => {
const value = await fixture()
const ready = await value.build()
expect(ready).toEqual({
state: 'ready',
physicalIdentity: expect.any(String),
operationIndex: value.snapshot.files.length,
pendingPath: null,
activeFile: null
})
await expect(
assertSkillTransactionPackageBuild({
root: value.packageRoot,
evidence: ready,
snapshot: value.snapshot
})
).resolves.toBeUndefined()
})
it('does not fill a replacement root introduced while the root journal is awaited', async () => {
const value = await fixture()
const ownedRoot = `${value.packageRoot}-owned`
let replaced = false
await expect(
buildSkillTransactionPackage({
root: value.packageRoot,
sourceRoot: value.sourceRoot,
snapshot: value.snapshot,
crlf: false,
assertSourceAuthority: async () => undefined,
journal: async () => {
if (!replaced) {
replaced = true
await rename(value.packageRoot, ownedRoot)
await mkdir(value.packageRoot)
}
},
assertBoundary: async (evidence) =>
assertSkillTransactionPackageBuild({
root: value.packageRoot,
evidence,
snapshot: value.snapshot
})
})
).rejects.toThrow('skill-transaction-package-build-changed')
expect(await observeSkillPackage(value.packageRoot)).toMatchObject({ files: [] })
expect(await observeSkillPackage(ownedRoot)).toMatchObject({ files: [] })
})
it.each(['write', 'chmod', 'sync'] as const)(
'retains phase-valid evidence when %s mutates and throws',
async (boundary) => {
const value = await fixture()
const runtime: SkillTransactionPackageBuildRuntime = {}
if (boundary === 'write') {
runtime.writeBytes = async (handle, bytes) => {
await handle.writeFile(bytes.subarray(0, 1))
throw new Error('injected-write-failure')
}
} else if (boundary === 'chmod') {
runtime.chmodFile = async (handle, mode) => {
await handle.chmod(mode)
throw new Error('injected-chmod-failure')
}
} else {
runtime.syncFile = async (handle) => {
await handle.sync()
throw new Error('injected-sync-failure')
}
}
await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
const evidence = value.lastEvidence()
expect(evidence?.state).toBe('creating')
await expect(
assertSkillTransactionPackageBuild({
root: value.packageRoot,
evidence: evidence!,
snapshot: value.snapshot
})
).resolves.toBeUndefined()
}
)
it.each(['parent', 'file'] as const)(
'classifies a mutate-and-throw %s boundary exactly',
async (boundary) => {
const value = await fixture()
const runtime: SkillTransactionPackageBuildRuntime =
boundary === 'parent'
? {
createParent: async (path) => {
await mkdir(path, { recursive: true })
throw new Error('injected-parent-failure')
}
}
: {
openFile: async (path, mode) => {
const handle = await open(path, 'wx', mode)
await handle.close()
throw new Error('injected-file-failure')
}
}
await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
const evidence = value.lastEvidence()
expect(evidence?.activeFile).toBeNull()
const assertion = assertSkillTransactionPackageBuild({
root: value.packageRoot,
evidence: evidence!,
snapshot: value.snapshot
})
if (boundary === 'parent') {
expect(evidence?.pendingPath).toBe(value.snapshot.files[0]!.path)
await expect(assertion).resolves.toBeUndefined()
} else {
await expect(assertion).rejects.toThrow('skill-transaction-package-build-changed')
}
}
)
it('rejects unknown directory extras even when file content is exact', async () => {
const value = await fixture()
const ready = await value.build()
await mkdir(join(value.packageRoot, 'unknown'))
await expect(
assertSkillTransactionPackageBuild({
root: value.packageRoot,
evidence: ready,
snapshot: value.snapshot
})
).rejects.toThrow('skill-transaction-package-build-changed')
expect(await readFile(join(value.packageRoot, 'SKILL.md'), 'utf8')).toBe('# Test\n')
})
})
@@ -0,0 +1,290 @@
import { lstat, mkdir, open, readFile, readdir, stat, type FileHandle } from 'node:fs/promises'
import { dirname, join, posix } from 'node:path'
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
import { skillPhysicalIdentity } from './skill-installation-topology'
import {
describeObservedSkillFile,
matchesFileIdentity,
observeSkillPackage
} from './skill-package-identity'
export type SkillTransactionPackageBuildEvidence = {
state: 'creating' | 'ready'
physicalIdentity: string
operationIndex: number
pendingPath: string | null
activeFile: {
path: string
physicalIdentity: string
contentState: 'created' | 'written' | 'ready'
} | null
}
export type SkillTransactionPackageBuildRuntime = {
createRoot?: (path: string) => Promise<void>
createParent?: (path: string) => Promise<void>
openFile?: (path: string, mode: number) => Promise<FileHandle>
readSource?: (path: string) => Promise<Buffer>
writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise<void>
chmodFile?: (handle: FileHandle, mode: number) => Promise<void>
syncFile?: (handle: FileHandle) => Promise<void>
}
export function isSkillTransactionPackageBuildEvidence(
value: unknown
): value is SkillTransactionPackageBuildEvidence {
if (typeof value !== 'object' || value === null) {
return false
}
const evidence = value as Partial<SkillTransactionPackageBuildEvidence>
const validPath = (path: unknown): path is string =>
typeof path === 'string' &&
path.length > 0 &&
!path.includes('\\') &&
!posix.isAbsolute(path) &&
posix.normalize(path) === path &&
!path.split('/').includes('..')
const activeFile = evidence.activeFile
return Boolean(
(evidence.state === 'creating' || evidence.state === 'ready') &&
typeof evidence.physicalIdentity === 'string' &&
evidence.physicalIdentity.length > 0 &&
typeof evidence.operationIndex === 'number' &&
Number.isSafeInteger(evidence.operationIndex) &&
evidence.operationIndex >= 0 &&
(evidence.pendingPath === null || validPath(evidence.pendingPath)) &&
(activeFile === null ||
(typeof activeFile === 'object' &&
activeFile !== null &&
validPath(activeFile.path) &&
typeof activeFile.physicalIdentity === 'string' &&
activeFile.physicalIdentity.length > 0 &&
(activeFile.contentState === 'created' ||
activeFile.contentState === 'written' ||
activeFile.contentState === 'ready')))
)
}
function expectedDirectories(paths: readonly string[]): Set<string> {
const directories = new Set<string>()
for (const path of paths) {
const parts = path.split('/')
for (let index = 1; index < parts.length; index += 1) {
directories.add(parts.slice(0, index).join('/'))
}
}
return directories
}
async function assertDirectoryShape(root: string, paths: readonly string[]): Promise<void> {
const allowed = expectedDirectories(paths)
const visit = async (directory: string, relativeDirectory: string): Promise<void> => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name
if (entry.isDirectory()) {
if (!allowed.has(relativePath)) {
throw new Error('skill-transaction-package-build-changed')
}
await visit(join(directory, entry.name), relativePath)
} else if (!entry.isFile()) {
throw new Error('skill-transaction-package-build-changed')
}
}
}
await visit(root, '')
}
function matchesPrefix(
observed: Awaited<ReturnType<typeof observeSkillPackage>>,
snapshot: SkillReleasedSnapshot,
count: number
): boolean {
return (
observed.files.length === count &&
snapshot.files
.slice(0, count)
.every((expected, index) => matchesFileIdentity(observed.files[index]!, expected))
)
}
export async function assertSkillTransactionPackageBuild(args: {
root: string
evidence: SkillTransactionPackageBuildEvidence
snapshot: SkillReleasedSnapshot
}): Promise<void> {
const rootEntry = await lstat(args.root)
if (
!rootEntry.isDirectory() ||
rootEntry.isSymbolicLink() ||
skillPhysicalIdentity(args.root, rootEntry) !== args.evidence.physicalIdentity
) {
throw new Error('skill-transaction-package-build-changed')
}
const observed = await observeSkillPackage(args.root)
const expectedPaths = args.snapshot.files
.slice(0, args.evidence.operationIndex)
.map((file) => file.path)
if (args.evidence.activeFile) {
const expected = args.snapshot.files[args.evidence.operationIndex]
const activePath = join(args.root, ...args.evidence.activeFile.path.split('/'))
const activeEntry = await lstat(activePath)
const withoutActive = {
...observed,
files: observed.files.filter((file) => file.path !== args.evidence.activeFile?.path)
}
if (
!expected ||
expected.path !== args.evidence.activeFile.path ||
!activeEntry.isFile() ||
activeEntry.isSymbolicLink() ||
skillPhysicalIdentity(activePath, await stat(activePath)) !==
args.evidence.activeFile.physicalIdentity ||
observed.files.length !== withoutActive.files.length + 1 ||
!matchesPrefix(withoutActive, args.snapshot, args.evidence.operationIndex)
) {
throw new Error('skill-transaction-package-build-changed')
}
const activeObserved = observed.files.find(
(file) => file.path === args.evidence.activeFile?.path
)
if (
!activeObserved ||
(args.evidence.activeFile.contentState === 'written' &&
!matchesFileIdentity({ ...activeObserved, executable: expected.executable }, expected)) ||
(args.evidence.activeFile.contentState === 'ready' &&
!matchesFileIdentity(activeObserved, expected))
) {
throw new Error('skill-transaction-package-build-changed')
}
await assertDirectoryShape(args.root, [...expectedPaths, expected.path])
return
}
if (!matchesPrefix(observed, args.snapshot, args.evidence.operationIndex)) {
throw new Error('skill-transaction-package-build-changed')
}
await assertDirectoryShape(
args.root,
args.evidence.pendingPath ? [...expectedPaths, args.evidence.pendingPath] : expectedPaths
)
}
function renderedBytes(source: Buffer, expected: SkillBundleFileIdentity, crlf: boolean): Buffer {
if (expected.classification !== 'text' || expected.executable || !crlf) {
return source
}
const text = new TextDecoder('utf-8', { fatal: true }).decode(source)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'), 'utf8')
}
export async function buildSkillTransactionPackage(args: {
root: string
sourceRoot: string
snapshot: SkillReleasedSnapshot
crlf: boolean
assertSourceAuthority: () => Promise<void>
journal: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
assertBoundary: (evidence: SkillTransactionPackageBuildEvidence) => Promise<void>
runtime?: SkillTransactionPackageBuildRuntime
}): Promise<SkillTransactionPackageBuildEvidence> {
await args.assertSourceAuthority()
await (args.runtime?.createRoot ?? ((path) => mkdir(path)))(args.root)
const rootEntry = await lstat(args.root)
let evidence: SkillTransactionPackageBuildEvidence = {
state: 'creating',
physicalIdentity: skillPhysicalIdentity(args.root, rootEntry),
operationIndex: 0,
pendingPath: null,
activeFile: null
}
await args.journal(evidence)
for (const [operationIndex, expected] of args.snapshot.files.entries()) {
evidence = { ...evidence, operationIndex, pendingPath: expected.path, activeFile: null }
await args.journal(evidence)
await args.assertBoundary(evidence)
const destination = join(args.root, ...expected.path.split('/'))
await (args.runtime?.createParent ?? ((path) => mkdir(path, { recursive: true })))(
dirname(destination)
)
await args.assertBoundary(evidence)
const mode = expected.executable ? 0o755 : 0o644
const handle = await (
args.runtime?.openFile ?? ((path, fileMode) => open(path, 'wx', fileMode))
)(destination, mode)
const activeFileIdentity = skillPhysicalIdentity(destination, await handle.stat())
evidence = {
...evidence,
operationIndex,
pendingPath: null,
activeFile: {
path: expected.path,
physicalIdentity: activeFileIdentity,
contentState: 'created'
}
}
try {
await args.journal(evidence)
await args.assertSourceAuthority()
const bytes = renderedBytes(
await (args.runtime?.readSource ?? readFile)(
join(args.sourceRoot, ...expected.path.split('/'))
),
expected,
args.crlf
)
const observedSource = describeObservedSkillFile(expected.path, bytes, expected.executable)
if (!matchesFileIdentity(observedSource, expected)) {
throw new Error('skill-transaction-package-source-changed')
}
await args.assertSourceAuthority()
await args.assertBoundary(evidence)
await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(
handle,
bytes
)
evidence = {
...evidence,
activeFile: {
path: expected.path,
physicalIdentity: activeFileIdentity,
contentState: 'written'
}
}
await args.assertBoundary(evidence)
await args.journal(evidence)
if (process.platform !== 'win32') {
await args.assertBoundary(evidence)
await (args.runtime?.chmodFile ?? ((target, fileMode) => target.chmod(fileMode)))(
handle,
mode
)
}
evidence = {
...evidence,
activeFile: {
path: expected.path,
physicalIdentity: activeFileIdentity,
contentState: 'ready'
}
}
await args.assertBoundary(evidence)
await args.journal(evidence)
await args.assertBoundary(evidence)
await (args.runtime?.syncFile ?? ((target) => target.sync()))(handle)
await args.assertBoundary(evidence)
} finally {
await handle.close()
}
evidence = {
...evidence,
operationIndex: operationIndex + 1,
pendingPath: null,
activeFile: null
}
await args.assertBoundary(evidence)
await args.journal(evidence)
}
evidence = { ...evidence, state: 'ready' }
await args.assertBoundary(evidence)
await args.journal(evidence)
return evidence
}
@@ -0,0 +1 @@
export const RESERVED_SKILL_TRANSACTION_DIRECTORY = '.orca-skill-transactions'
@@ -0,0 +1,220 @@
import { chmod, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { SkillReleasedSnapshot } from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import {
matchesInPlacePublicationState,
skillInPlaceOperations
} from './skill-transaction-publication-state'
const roots: string[] = []
async function packageRoot(files: Record<string, string | Buffer>): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-publication-state-'))
roots.push(root)
for (const [path, contents] of Object.entries(files)) {
const destination = join(root, ...path.split('/'))
await mkdir(dirname(destination), { recursive: true })
await writeFile(destination, contents)
}
return root
}
async function snapshot(root: string, revision: number): Promise<SkillReleasedSnapshot> {
const observed = await observeSkillPackage(root)
return {
releaseRevision: revision,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(observed)
}
}
async function fixture() {
const priorRoot = await packageRoot({
'SKILL.md': 'old entry',
'asset-a.txt': 'old a',
'obsolete-a.txt': 'old obsolete a',
'obsolete-b.txt': 'old obsolete b'
})
const currentRoot = await packageRoot({
'SKILL.md': 'new entry',
'asset-a.txt': 'new a',
'asset-b.txt': 'new b'
})
return {
priorRoot,
currentRoot,
prior: await snapshot(priorRoot, 1),
current: await snapshot(currentRoot, 2)
}
}
async function stateRoot(currentRoot: string, completed: number): Promise<string> {
const root = await packageRoot({
'SKILL.md': 'old entry',
'asset-a.txt': 'old a',
'obsolete-a.txt': 'old obsolete a',
'obsolete-b.txt': 'old obsolete b'
})
const operations = [
['publish', 'asset-a.txt'],
['publish', 'asset-b.txt'],
['publish', 'SKILL.md'],
['remove', 'obsolete-a.txt'],
['remove', 'obsolete-b.txt']
] as const
for (const [kind, path] of operations.slice(0, completed)) {
await (kind === 'remove'
? unlink(join(root, path))
: writeFile(join(root, path), await readFile(join(currentRoot, path))))
}
return root
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('skill transaction publication state', () => {
it('accepts every exact publication prefix in asset, semantic-commit, removal order', async () => {
const { currentRoot, prior, current } = await fixture()
expect(skillInPlaceOperations(prior, current)).toEqual([
{ kind: 'publish', path: 'asset-a.txt' },
{ kind: 'publish', path: 'asset-b.txt' },
{ kind: 'publish', path: 'SKILL.md' },
{ kind: 'remove', path: 'obsolete-a.txt' },
{ kind: 'remove', path: 'obsolete-b.txt' }
])
for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
const observed = await observeSkillPackage(await stateRoot(currentRoot, operationIndex))
expect(
matchesInPlacePublicationState({
observed,
prior,
current,
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: 'test-identity',
temporary: null,
operationIndex,
operationState: 'ready'
},
allowAppliedOperation: false
})
).toBe(true)
}
})
it.each([
['semantic commit before assets', { 'SKILL.md': 'new entry' }],
['later asset before earlier asset', { 'asset-b.txt': 'new b' }],
['obsolete removal before commit', { 'obsolete-a.txt': null }],
['unknown extra', { 'unknown.txt': 'unknown' }]
])('rejects impossible state: %s', async (_name, edits) => {
const { priorRoot, prior, current } = await fixture()
const root = await stateRoot(priorRoot, 0)
for (const [path, contents] of Object.entries(edits)) {
await (contents === null ? unlink(join(root, path)) : writeFile(join(root, path), contents))
}
expect(
matchesInPlacePublicationState({
observed: await observeSkillPackage(root),
prior,
current,
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: 'test-identity',
temporary: null,
operationIndex: 0,
operationState: 'applying'
},
allowAppliedOperation: true
})
).toBe(false)
})
it('rejects a mode-only same-content edit', async () => {
if (process.platform === 'win32') {
return
}
const { priorRoot, prior, current } = await fixture()
const root = await stateRoot(priorRoot, 0)
await chmod(join(root, 'asset-a.txt'), 0o755)
expect(
matchesInPlacePublicationState({
observed: await observeSkillPackage(root),
prior,
current,
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: 'test-identity',
temporary: null,
operationIndex: 0,
operationState: 'ready'
},
allowAppliedOperation: false
})
).toBe(false)
})
it('rejects same-inode independent edits at every publication prefix', async () => {
const { currentRoot, prior, current } = await fixture()
for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
const root = await stateRoot(currentRoot, operationIndex)
await writeFile(join(root, 'SKILL.md'), `independent edit at ${operationIndex}`)
expect(
matchesInPlacePublicationState({
observed: await observeSkillPackage(root),
prior,
current,
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: 'same-inode',
temporary: null,
operationIndex,
operationState: 'ready'
},
allowAppliedOperation: false
})
).toBe(false)
}
})
it('rejects a binary byte mismatch outside both exact snapshots', async () => {
const priorRoot = await packageRoot({
'SKILL.md': 'old entry',
'payload.bin': Buffer.from([0, 1])
})
const currentRoot = await packageRoot({
'SKILL.md': 'new entry',
'payload.bin': Buffer.from([0, 2])
})
const observedRoot = await packageRoot({
'SKILL.md': 'old entry',
'payload.bin': Buffer.from([0, 3])
})
expect(
matchesInPlacePublicationState({
observed: await observeSkillPackage(observedRoot),
prior: await snapshot(priorRoot, 1),
current: await snapshot(currentRoot, 2),
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: 'same-inode',
temporary: null,
operationIndex: 0,
operationState: 'applying'
},
allowAppliedOperation: true
})
).toBe(false)
})
})
@@ -0,0 +1,224 @@
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
import { matchesFileIdentity, type ObservedSkillPackage } from './skill-package-identity'
export type SkillInPlacePublicationState = {
kind: 'in-place'
direction: 'forward' | 'rollback'
physicalIdentity: string
temporary: SkillPublicationTemporary | null
operationIndex: number
operationState: 'ready' | 'applying'
}
export type SkillPublicationTemporary = {
path: string
physicalIdentity: string
expectedFile: SkillBundleFileIdentity
state: 'creating' | 'written' | 'ready' | 'applied'
}
export type SkillPackageSwapState = {
kind: 'package-swap'
step:
| 'moving-live'
| 'live-moved'
| 'moving-stage'
| 'stage-moved'
| 'rollback-moving-current'
| 'rollback-current-moved'
| 'rollback-moving-old-live'
| 'complete'
}
export type SkillPublicationState = SkillInPlacePublicationState | SkillPackageSwapState
export type SkillInPlaceOperation = {
kind: 'publish' | 'remove'
path: string
}
function sameFileIdentity(
left: SkillBundleFileIdentity | undefined,
right: SkillBundleFileIdentity
): boolean {
return Boolean(
left &&
left.path === right.path &&
left.executable === right.executable &&
left.classification === right.classification &&
left.identitySha256 === right.identitySha256
)
}
export function sameCompleteFileIdentity(
left: SkillBundleFileIdentity | undefined,
right: SkillBundleFileIdentity
): boolean {
return Boolean(
sameFileIdentity(left, right) &&
left?.size === right.size &&
left.exactSha256 === right.exactSha256 &&
left.textNormalizedSha256 === right.textNormalizedSha256
)
}
export function skillInPlaceOperations(
prior: SkillReleasedSnapshot,
current: SkillReleasedSnapshot
): SkillInPlaceOperation[] {
const priorByPath = new Map(prior.files.map((file) => [file.path, file]))
const currentPaths = new Set(current.files.map((file) => file.path))
const changed = current.files
.filter((file) => !sameFileIdentity(priorByPath.get(file.path), file))
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
const entryPoint = changed.filter((file) => file.path === 'SKILL.md')
const assets = changed.filter((file) => file.path !== 'SKILL.md')
const obsolete = prior.files
.filter((file) => !currentPaths.has(file.path))
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
return [
...assets.map((file) => ({ kind: 'publish' as const, path: file.path })),
...entryPoint.map((file) => ({ kind: 'publish' as const, path: file.path })),
...obsolete.map((file) => ({ kind: 'remove' as const, path: file.path }))
]
}
function expectedFilesAfter(
prior: SkillReleasedSnapshot,
current: SkillReleasedSnapshot,
completedOperations: number
): SkillBundleFileIdentity[] {
const files = new Map(prior.files.map((file) => [file.path, file]))
const currentByPath = new Map(current.files.map((file) => [file.path, file]))
for (const operation of skillInPlaceOperations(prior, current).slice(0, completedOperations)) {
if (operation.kind === 'remove') {
files.delete(operation.path)
} else {
const next = currentByPath.get(operation.path)
if (next) {
files.set(operation.path, next)
}
}
}
return [...files.values()].sort((left, right) => left.path.localeCompare(right.path, 'en'))
}
function matchesExactFiles(
observed: ObservedSkillPackage,
expected: readonly SkillBundleFileIdentity[]
): boolean {
return (
observed.files.length === expected.length &&
expected.every((file, index) => {
const actual = observed.files[index]
return Boolean(actual && matchesFileIdentity(actual, file))
})
)
}
export function matchingInPlaceOperationIndex(args: {
observed: ObservedSkillPackage
prior: SkillReleasedSnapshot
current: SkillReleasedSnapshot
}): number | null {
const operationCount = skillInPlaceOperations(args.prior, args.current).length
for (let index = 0; index <= operationCount; index += 1) {
if (matchesExactFiles(args.observed, expectedFilesAfter(args.prior, args.current, index))) {
return index
}
}
return null
}
export function matchesInPlacePublicationState(args: {
observed: ObservedSkillPackage
prior: SkillReleasedSnapshot
current: SkillReleasedSnapshot
publication: SkillInPlacePublicationState
allowAppliedOperation: boolean
}): boolean {
const operations = skillInPlaceOperations(args.prior, args.current)
if (args.publication.operationIndex < 0 || args.publication.operationIndex > operations.length) {
return false
}
let observed = args.observed
const operation =
operations[
args.publication.direction === 'forward'
? args.publication.operationIndex
: args.publication.operationIndex - 1
]
const expectedTemporaryFile =
operation &&
(args.publication.direction === 'forward'
? args.current.files.find((file) => file.path === operation.path)
: args.prior.files.find((file) => file.path === operation.path))
let temporaryPresent = false
if (args.publication.temporary) {
const temporary = observed.files.find((file) => file.path === args.publication.temporary?.path)
temporaryPresent = Boolean(temporary)
if (
!expectedTemporaryFile ||
!sameCompleteFileIdentity(args.publication.temporary.expectedFile, expectedTemporaryFile) ||
(temporary &&
args.publication.temporary.state === 'written' &&
!matchesFileIdentity(
{
...temporary,
executable: args.publication.temporary.expectedFile.executable
},
{
...args.publication.temporary.expectedFile,
path: args.publication.temporary.path
}
)) ||
(temporary &&
(args.publication.temporary.state === 'ready' ||
args.publication.temporary.state === 'applied') &&
!matchesFileIdentity(temporary, {
...args.publication.temporary.expectedFile,
path: args.publication.temporary.path
}))
) {
return false
}
if (temporary) {
observed = { ...observed, files: observed.files.filter((file) => file !== temporary) }
}
}
const candidates = [args.publication.operationIndex]
const operationCanHaveApplied =
operation?.kind === 'remove' ||
args.publication.temporary?.state === 'ready' ||
args.publication.temporary?.state === 'applied'
if (
(args.allowAppliedOperation || args.publication.temporary?.state === 'applied') &&
operationCanHaveApplied &&
args.publication.operationState === 'applying' &&
(args.publication.direction === 'forward'
? args.publication.operationIndex < operations.length
: args.publication.operationIndex > 0)
) {
const appliedIndex =
args.publication.direction === 'forward'
? args.publication.operationIndex + 1
: args.publication.operationIndex - 1
candidates.push(appliedIndex)
if (args.publication.temporary && !temporaryPresent) {
candidates.shift()
}
}
if (
args.publication.temporary &&
!temporaryPresent &&
(!args.allowAppliedOperation || args.publication.temporary.state === 'creating') &&
args.publication.temporary.state !== 'applied'
) {
return false
}
return candidates
.filter((count) => count >= 0)
.some((count) =>
matchesExactFiles(observed, expectedFilesAfter(args.prior, args.current, count))
)
}
@@ -0,0 +1,618 @@
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
realpath,
rename,
rm,
stat,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
import { skillDestinationId, skillPhysicalIdentity } from './skill-installation-topology'
import {
acquireSkillDestinationLock,
recoverMarkedSkillTransactions
} from './skill-transaction-recovery'
import { sweepOrphanedSkillTransactions } from './skill-orphan-transaction-sweep'
import {
parseSkillTransactionMarker,
writeSkillTransactionMarker,
type SkillTransactionMarker
} from './skill-transaction-marker'
import { emptySkillManagementLedger } from './skill-management-ledger'
const temporaryRoots: string[] = []
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-recovery-'))
temporaryRoots.push(root)
const live = join(root, 'skills', 'orca-cli')
await mkdir(join(root, 'skills'), { recursive: true })
await cp(resolve('skills/orca-cli'), live, { recursive: true })
const resolvedLive = await realpath(live)
const observed = await observeSkillPackage(resolvedLive)
const files = persistedObservedFiles(observed)
const snapshot: SkillReleasedSnapshot = {
releaseRevision: 1,
packageDigest: observed.observedDigest,
gitTreeSha: '',
files
}
const record = {
id: skillDestinationId('local', live, 'orca-cli'),
hostId: 'local',
skillName: 'orca-cli',
resolvedPath: resolvedLive,
unresolvedPath: live,
physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
entryType: 'directory',
topology: 'canonical-copy',
installedReleaseRevision: snapshot.releaseRevision,
installedFiles: files,
installedPackageDigest: observed.observedDigest
} as unknown as ManagedSkillDestination
return { root, live, snapshot, record }
}
function marker(
record: ManagedSkillDestination,
snapshot: SkillReleasedSnapshot,
phase: SkillTransactionMarker['phase']
): SkillTransactionMarker {
return {
schemaVersion: 1,
transactionId: 'test-transaction',
createdAt: 1,
destinationId: record.id,
destinationPath: record.resolvedPath,
physicalIdentity: record.physicalIdentity,
hostId: record.hostId,
skillName: record.skillName,
phase,
stagePhysicalIdentity: null,
backupPhysicalIdentity: null,
stageBuild: null,
backupBuild: null,
publication: null,
priorSnapshot: snapshot,
currentSnapshot: { ...snapshot, releaseRevision: snapshot.releaseRevision + 1 }
}
}
async function writePublishingMarker(args: {
transaction: string
record: ManagedSkillDestination
prior: SkillReleasedSnapshot
current?: SkillReleasedSnapshot
stageSource: string
operationIndex?: number
}): Promise<void> {
const stageRoot = join(args.transaction, 'stage')
await cp(args.stageSource, stageRoot, { recursive: true })
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
const backupRoot = join(args.transaction, 'backup')
const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
const currentSnapshot = args.current ?? {
...args.prior,
releaseRevision: args.prior.releaseRevision + 1
}
await writeSkillTransactionMarker(args.transaction, {
...marker(args.record, args.prior, 'publishing'),
currentSnapshot,
stagePhysicalIdentity,
backupPhysicalIdentity,
stageBuild: {
state: 'ready',
physicalIdentity: stagePhysicalIdentity,
operationIndex: currentSnapshot.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: backupPhysicalIdentity,
operationIndex: args.prior.files.length,
pendingPath: null,
activeFile: null
},
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: args.record.physicalIdentity,
temporary: null,
operationIndex: args.operationIndex ?? 0,
operationState: 'ready'
}
})
}
async function writeSwapMarker(args: {
transaction: string
record: ManagedSkillDestination
snapshot: SkillReleasedSnapshot
stageSource: string
step: Extract<SkillTransactionMarker['publication'], { kind: 'package-swap' }>['step']
}): Promise<void> {
const stageRoot = join(args.transaction, 'stage')
const backupRoot = join(args.transaction, 'backup')
await cp(args.stageSource, stageRoot, { recursive: true })
await cp(args.stageSource, backupRoot, { recursive: true })
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
await writeSkillTransactionMarker(args.transaction, {
...marker(args.record, args.snapshot, 'publishing'),
stagePhysicalIdentity,
backupPhysicalIdentity,
stageBuild: {
state: 'ready',
physicalIdentity: stagePhysicalIdentity,
operationIndex: args.snapshot.files.length,
pendingPath: null,
activeFile: null
},
backupBuild: {
state: 'ready',
physicalIdentity: backupPhysicalIdentity,
operationIndex: args.snapshot.files.length,
pendingPath: null,
activeFile: null
},
publication: { kind: 'package-swap', step: args.step }
})
}
async function writeCreatingStageMarker(args: {
transaction: string
record: ManagedSkillDestination
snapshot: SkillReleasedSnapshot
}): Promise<string> {
const stageRoot = join(args.transaction, 'stage')
const firstFile = args.snapshot.files[0]!
const activePath = join(stageRoot, ...firstFile.path.split('/'))
await mkdir(dirname(activePath), { recursive: true })
await writeFile(activePath, 'partial')
const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
await writeSkillTransactionMarker(args.transaction, {
...marker(args.record, args.snapshot, 'staging'),
stagePhysicalIdentity,
stageBuild: {
state: 'creating',
physicalIdentity: stagePhysicalIdentity,
operationIndex: 0,
pendingPath: null,
activeFile: {
path: firstFile.path,
physicalIdentity: skillPhysicalIdentity(activePath, await stat(activePath)),
contentState: 'created'
}
}
})
return stageRoot
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skill transaction recovery', () => {
it('removes only marked staging transactions', async () => {
const { root, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const marked = join(reservedRoot, 'marked')
const unmarked = join(reservedRoot, 'user-data')
await mkdir(marked, { recursive: true })
await mkdir(unmarked, { recursive: true })
await writeSkillTransactionMarker(marked, marker(record, snapshot, 'staging'))
await recoverMarkedSkillTransactions({
reservedRoot,
record
})
await expect(readFile(join(marked, 'transaction.json'))).rejects.toMatchObject({
code: 'ENOENT'
})
await expect(readFile(join(unmarked, 'transaction.json'))).rejects.toMatchObject({
code: 'ENOENT'
})
expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
})
it('cleans only an identity-matched partial stage package', async () => {
const { root, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'partial-stage')
await mkdir(transaction, { recursive: true })
await writeCreatingStageMarker({ transaction, record, snapshot })
await recoverMarkedSkillTransactions({ reservedRoot, record })
await expect(lstat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('retains a transaction when the recorded partial stage root was replaced', async () => {
const { root, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'replaced-stage')
await mkdir(transaction, { recursive: true })
const stageRoot = await writeCreatingStageMarker({ transaction, record, snapshot })
await rm(stageRoot, { recursive: true })
await mkdir(stageRoot)
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
await expect(lstat(transaction)).resolves.toBeDefined()
})
it('retains rollback instead of overwriting an unknown same-inode edit', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'interrupted')
await cp(live, join(transaction, 'backup'), { recursive: true })
await writePublishingMarker({
transaction,
record,
prior: snapshot,
stageSource: live
})
await writeFile(join(live, 'SKILL.md'), 'partial new package')
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('partial new package')
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
})
it('restores a genuine mixed old/new package owned by the transaction', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'expected-partial')
const currentRoot = join(root, 'current-package')
await cp(live, join(transaction, 'backup'), { recursive: true })
await cp(live, currentRoot, { recursive: true })
await writeFile(join(currentRoot, 'SKILL.md'), 'expected current entry point')
await writeFile(join(currentRoot, 'asset.txt'), 'expected new asset')
const currentObserved = await observeSkillPackage(currentRoot)
const currentSnapshot: SkillReleasedSnapshot = {
releaseRevision: 2,
packageDigest: currentObserved.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(currentObserved)
}
await writePublishingMarker({
transaction,
record,
prior: snapshot,
current: currentSnapshot,
stageSource: currentRoot,
operationIndex: 1
})
await writeFile(join(live, 'asset.txt'), 'expected new asset')
await recoverMarkedSkillTransactions({ reservedRoot, record })
expect(await observeSkillPackage(live)).toMatchObject({
observedDigest: snapshot.packageDigest
})
})
it.each(['creating', 'ready'] as const)(
'removes an identity-owned %s publication temp before resuming recovery',
async (temporaryState) => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'publication-temp')
const currentRoot = join(root, 'temp-current')
await cp(live, join(transaction, 'backup'), { recursive: true })
await cp(live, currentRoot, { recursive: true })
await writeFile(join(currentRoot, 'asset.txt'), 'new asset')
const currentObserved = await observeSkillPackage(currentRoot)
const currentSnapshot: SkillReleasedSnapshot = {
releaseRevision: 2,
packageDigest: currentObserved.observedDigest,
gitTreeSha: '',
files: persistedObservedFiles(currentObserved)
}
await writePublishingMarker({
transaction,
record,
prior: snapshot,
current: currentSnapshot,
stageSource: currentRoot
})
const writtenMarker = parseSkillTransactionMarker(
await readFile(join(transaction, 'transaction.json'), 'utf8')
)
expect(writtenMarker).not.toBeNull()
const temporaryPath = 'asset.txt.orca-test.tmp'
await writeFile(
join(live, temporaryPath),
temporaryState === 'creating' ? 'partial' : await readFile(join(currentRoot, 'asset.txt'))
)
await writeSkillTransactionMarker(transaction, {
...writtenMarker!,
publication: {
kind: 'in-place',
direction: 'forward',
physicalIdentity: record.physicalIdentity,
temporary: {
path: temporaryPath,
physicalIdentity: skillPhysicalIdentity(
join(live, temporaryPath),
await stat(join(live, temporaryPath))
),
expectedFile: currentSnapshot.files.find((file) => file.path === 'asset.txt')!,
state: temporaryState
},
operationIndex: 0,
operationState: 'applying'
}
})
await recoverMarkedSkillTransactions({ reservedRoot, record })
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
await expect(stat(join(live, temporaryPath))).rejects.toMatchObject({ code: 'ENOENT' })
}
)
it('does not overwrite unknown content with a different physical identity', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'independent-replacement')
await cp(live, join(transaction, 'backup'), { recursive: true })
await writePublishingMarker({
transaction,
record,
prior: snapshot,
stageSource: live
})
await rm(live, { recursive: true })
await mkdir(live, { recursive: true })
await writeFile(join(live, 'SKILL.md'), 'independently replaced')
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independently replaced')
})
it('fails closed when the approved parent is replaced by a link before recovery', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'parent-link-swap')
const originalSkills = join(root, 'original-skills')
const outsideSkills = join(root, 'outside-skills')
await cp(live, join(transaction, 'backup'), { recursive: true })
await writePublishingMarker({
transaction,
record,
prior: snapshot,
stageSource: live
})
await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
await mkdir(outsideSkills)
await rename(join(root, 'skills'), originalSkills)
await symlink(
outsideSkills,
join(root, 'skills'),
process.platform === 'win32' ? 'junction' : 'dir'
)
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-topology-changed'
)
await expect(readFile(join(outsideSkills, 'orca-cli', 'SKILL.md'))).rejects.toMatchObject({
code: 'ENOENT'
})
})
it('recovers the one marker-owned package-swap gap with an absent destination', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'swap-gap')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'moving-stage'
})
await rename(live, join(transaction, 'old-live'))
await recoverMarkedSkillTransactions({ reservedRoot, record })
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
await expect(stat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rejects an absent destination outside the package-swap gap phase', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'invalid-swap-gap')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'complete'
})
await rename(live, join(transaction, 'old-live'))
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
expect(await stat(join(transaction, 'old-live'))).toBeTruthy()
})
it('retains the package-swap gap when old-live was edited in place', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'edited-old-live')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'moving-stage'
})
const oldLive = join(transaction, 'old-live')
await rename(live, oldLive)
await writeFile(join(oldLive, 'SKILL.md'), 'independent same-inode edit')
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
expect(await readFile(join(oldLive, 'SKILL.md'), 'utf8')).toBe('independent same-inode edit')
})
it('rejects an exact stage copy whose recorded physical identity was replaced', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'replaced-stage')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'moving-stage'
})
await rename(live, join(transaction, 'old-live'))
const stage = join(transaction, 'stage')
const replacement = join(transaction, 'replacement-stage')
await cp(stage, replacement, { recursive: true })
await rm(stage, { recursive: true })
await rename(replacement, stage)
await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
'skill-transaction-recovery-required'
)
await expect(stat(live)).rejects.toMatchObject({ code: 'ENOENT' })
})
it('rolls back a completed package swap without losing the original identity', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'completed-swap')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'complete'
})
await rename(live, join(transaction, 'old-live'))
await rename(join(transaction, 'stage'), live)
await recoverMarkedSkillTransactions({ reservedRoot, record })
expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
})
it('recovers when the stage rename completed before its journal advancement', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'stage-rename-boundary')
await writeSwapMarker({
transaction,
record,
snapshot,
stageSource: live,
step: 'moving-stage'
})
await rename(live, join(transaction, 'old-live'))
await rename(join(transaction, 'stage'), live)
await recoverMarkedSkillTransactions({ reservedRoot, record })
expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
})
it('reclaims only a marked lock whose owner process is gone', async () => {
const { root } = await fixture()
const lockRoot = join(root, 'lock')
await mkdir(lockRoot)
await writeFile(
join(lockRoot, 'owner.json'),
JSON.stringify({ schemaVersion: 1, pid: 2_147_483_647 })
)
const release = await acquireSkillDestinationLock(lockRoot)
expect(JSON.parse(await readFile(join(lockRoot, 'owner.json'), 'utf8')).pid).toBe(process.pid)
await release()
})
it('restores a self-contained orphan before inventory has loaded a ledger', async () => {
const { root, live, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'orphan')
const unmarked = join(reservedRoot, 'keep-me')
await cp(live, join(transaction, 'backup'), { recursive: true })
await mkdir(unmarked, { recursive: true })
await writePublishingMarker({
transaction,
record,
prior: snapshot,
stageSource: live
})
await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
await expect(
sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
).rejects.toThrow('skill-transaction-recovery-required')
expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('interrupted publication')
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
})
it('leaves a transaction untouched while its destination lock is live', async () => {
const { root, record, snapshot } = await fixture()
const reservedRoot = join(root, '.orca-skill-transactions')
const transaction = join(reservedRoot, 'busy')
await mkdir(transaction, { recursive: true })
await writeSkillTransactionMarker(transaction, marker(record, snapshot, 'staging'))
await mkdir(join(reservedRoot, 'locks'), { recursive: true })
const release = await acquireSkillDestinationLock(join(reservedRoot, 'locks', record.id))
await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
expect(
parseSkillTransactionMarker(await readFile(join(transaction, 'transaction.json'), 'utf8'))
).not.toBeNull()
await release()
})
it('ignores a valid marker whose destination is outside the approved root', async () => {
const { root, snapshot } = await fixture()
const outside = join(root, 'outside', 'orca-cli')
await mkdir(outside, { recursive: true })
const outsideRecord = {
id: skillDestinationId('local', outside, 'orca-cli'),
hostId: 'local',
skillName: 'orca-cli',
resolvedPath: outside,
physicalIdentity: 'outside-identity'
} as ManagedSkillDestination
const transaction = join(root, '.orca-skill-transactions', 'outside-target')
await mkdir(transaction, { recursive: true })
await writeSkillTransactionMarker(transaction, marker(outsideRecord, snapshot, 'staging'))
await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
})
})
@@ -0,0 +1,101 @@
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { ManagedSkillDestination } from '../../shared/skill-management'
import {
assertManagedSkillParentTopology,
normalizedSkillIdentityPath
} from './skill-installation-topology'
import { parseSkillTransactionMarker } from './skill-transaction-marker'
import { recoverTransactionToPrior } from './skill-transaction-restore'
export async function recoverMarkedSkillTransactions(args: {
reservedRoot: string
record: ManagedSkillDestination
}): Promise<void> {
const entries = await readdir(args.reservedRoot, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isDirectory() || entry.name === 'locks') {
continue
}
const transactionRoot = join(args.reservedRoot, entry.name)
const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
.then(parseSkillTransactionMarker)
.catch(() => null)
const recordMatchesPrior = marker
? marker.priorSnapshot.packageDigest === args.record.installedPackageDigest
: false
const recordMatchesCurrent = marker
? marker.phase === 'verified' &&
marker.currentSnapshot.packageDigest === args.record.installedPackageDigest
: false
if (
!marker ||
marker.destinationId !== args.record.id ||
marker.hostId !== args.record.hostId ||
marker.skillName !== args.record.skillName ||
normalizedSkillIdentityPath(marker.destinationPath) !==
normalizedSkillIdentityPath(args.record.resolvedPath) ||
(!recordMatchesPrior && !recordMatchesCurrent)
) {
continue
}
await recoverTransactionToPrior({
transactionRoot,
marker,
assertParentAuthority: () => assertManagedSkillParentTopology(args.record),
currentDisposition:
marker.phase === 'verified' && recordMatchesCurrent ? 'committed' : 'restore'
})
await rm(transactionRoot, { recursive: true, force: true })
}
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM')
}
}
export async function acquireSkillDestinationLock(
lockRoot: string,
beforeMutation: () => Promise<void> = async () => undefined
): Promise<() => Promise<void>> {
const create = async (): Promise<void> => {
await beforeMutation()
await mkdir(lockRoot)
await beforeMutation()
await writeFile(
join(lockRoot, 'owner.json'),
JSON.stringify({ schemaVersion: 1, pid: process.pid, createdAt: Date.now() })
)
}
try {
await create()
} catch (error) {
if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
throw error
}
const owner = await readFile(join(lockRoot, 'owner.json'), 'utf8')
.then((value) => JSON.parse(value) as unknown)
.catch(() => null)
if (
typeof owner !== 'object' ||
owner === null ||
!('schemaVersion' in owner) ||
owner.schemaVersion !== 1 ||
!('pid' in owner) ||
typeof owner.pid !== 'number' ||
processIsAlive(owner.pid)
) {
throw new Error('skill-update-busy')
}
// Why: only an Orca-marked lock whose owner is gone is safe to reclaim.
await beforeMutation()
await rm(lockRoot, { recursive: true })
await create()
}
return () => rm(lockRoot, { recursive: true, force: true })
}
@@ -0,0 +1,35 @@
import { rename } from 'node:fs/promises'
export type SkillRenameRuntime = {
renameEntry?: typeof rename
wait?: (milliseconds: number) => Promise<void>
windowsFallback?: boolean
}
export async function retrySkillRename(
source: string,
destination: string,
beforeAttempt: () => Promise<void>,
renameEntry: typeof rename = rename,
wait: (milliseconds: number) => Promise<void> = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
// Why: another manager can edit during a Windows sharing-violation delay.
await beforeAttempt()
try {
await renameEntry(source, destination)
return
} catch (error) {
const retryable =
error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'EPERM' || error.code === 'EBUSY')
if (!retryable || attempt === 3) {
throw error
}
await wait(25 * 2 ** attempt)
}
}
}
@@ -0,0 +1,136 @@
import { lstat, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { ManagedSkillDestination } from '../../shared/skill-management'
import { skillPhysicalIdentity } from './skill-installation-topology'
import { observeSkillPackage } from './skill-package-identity'
import { rollbackSkillPackageSwap, type PublicationCallbacks } from './skill-package-publication'
import {
clearSkillPublicationTemporary,
rollbackSkillPackageInPlace
} from './skill-package-rollback'
import {
writeSkillTransactionMarker,
type SkillTransactionMarker
} from './skill-transaction-marker'
import { assertTransactionOwnedState } from './skill-transaction-owned-state'
import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
import {
matchingInPlaceOperationIndex,
type SkillPublicationState
} from './skill-transaction-publication-state'
import { retrySkillRename } from './skill-transaction-rename'
export async function recoverTransactionToPrior(args: {
transactionRoot: string
marker: SkillTransactionMarker
assertParentAuthority: () => Promise<void>
currentDisposition: 'committed' | 'restore'
}): Promise<void> {
if (args.marker.phase === 'staging') {
await args.assertParentAuthority()
for (const [rootName, evidence, snapshot] of [
['stage', args.marker.stageBuild, args.marker.currentSnapshot],
['backup', args.marker.backupBuild, args.marker.priorSnapshot]
] as const) {
const root = join(args.transactionRoot, rootName)
if (!evidence) {
if (await lstat(root).catch(() => null)) {
throw new Error('skill-transaction-recovery-required')
}
continue
}
await assertSkillTransactionPackageBuild({ root, evidence, snapshot }).catch(() => {
throw new Error('skill-transaction-recovery-required')
})
}
return
}
const record = { resolvedPath: args.marker.destinationPath } as ManagedSkillDestination
let activeMarker = args.marker
const assertOwned = () =>
assertTransactionOwnedState({
transactionRoot: args.transactionRoot,
marker: activeMarker,
assertParent: args.assertParentAuthority,
allowAppliedOperation: true
})
const journal = async (publication: SkillPublicationState): Promise<void> => {
activeMarker = { ...activeMarker, publication }
await writeSkillTransactionMarker(
args.transactionRoot,
activeMarker,
args.assertParentAuthority
)
}
const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
publication,
expectedLiveState
) => {
const liveState = await assertTransactionOwnedState({
transactionRoot: args.transactionRoot,
marker: { ...activeMarker, publication },
assertParent: args.assertParentAuthority,
allowAppliedOperation: true
})
if (expectedLiveState && liveState !== expectedLiveState) {
throw new Error('skill-transaction-recovery-required')
}
}
const callbacks = { journal, assertBoundary }
const state = await assertOwned()
if (state === 'prior' || (state === 'current' && args.currentDisposition === 'committed')) {
return
}
if (state === 'current' && activeMarker.publication?.kind === 'package-swap') {
await rollbackSkillPackageSwap({
record,
transactionRoot: args.transactionRoot,
callbacks
})
} else if (state === 'swap-absent') {
const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
await retrySkillRename(
join(args.transactionRoot, 'old-live'),
args.marker.destinationPath,
async () => {
if ((await assertOwned()) !== 'swap-absent') {
throw new Error('skill-transaction-recovery-required')
}
}
)
// Why: recovery journals both sides of the ambiguous rename before cleanup.
await journal(movingLive)
} else {
if (activeMarker.publication?.kind === 'in-place') {
await clearSkillPublicationTemporary({
record,
publication: activeMarker.publication,
callbacks
})
}
const observed = await observeSkillPackage(args.marker.destinationPath)
const startingIndex = matchingInPlaceOperationIndex({
observed,
prior: args.marker.priorSnapshot,
current: args.marker.currentSnapshot
})
if (startingIndex === null) {
throw new Error('skill-transaction-recovery-required')
}
await rollbackSkillPackageInPlace({
record,
prior: args.marker.priorSnapshot,
current: args.marker.currentSnapshot,
backupRoot: join(args.transactionRoot, 'backup'),
startingIndex,
physicalIdentity: skillPhysicalIdentity(
args.marker.destinationPath,
await stat(args.marker.destinationPath)
),
callbacks
})
}
if ((await assertOwned()) !== 'prior') {
throw new Error('skill-transaction-recovery-required')
}
}
@@ -0,0 +1,62 @@
import { posix } from 'node:path'
import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
import { skillPackageDigest } from './skill-package-identity'
const SHA256_PATTERN = /^[a-f0-9]{64}$/
export function isSkillBundleFileIdentity(value: unknown): value is SkillBundleFileIdentity {
if (typeof value !== 'object' || value === null) {
return false
}
const file = value as Partial<SkillBundleFileIdentity>
return (
typeof file.path === 'string' &&
file.path.length > 0 &&
!file.path.includes('\\') &&
!posix.isAbsolute(file.path) &&
posix.normalize(file.path) === file.path &&
!file.path.split('/').includes('..') &&
typeof file.size === 'number' &&
Number.isSafeInteger(file.size) &&
file.size >= 0 &&
typeof file.executable === 'boolean' &&
(file.classification === 'text' || file.classification === 'binary') &&
typeof file.exactSha256 === 'string' &&
SHA256_PATTERN.test(file.exactSha256) &&
(file.textNormalizedSha256 === null ||
(typeof file.textNormalizedSha256 === 'string' &&
SHA256_PATTERN.test(file.textNormalizedSha256))) &&
typeof file.identitySha256 === 'string' &&
SHA256_PATTERN.test(file.identitySha256) &&
(file.classification !== 'text' || file.textNormalizedSha256 !== null) &&
file.identitySha256 ===
(file.classification === 'text' && !file.executable
? file.textNormalizedSha256
: file.exactSha256)
)
}
export function isSkillReleasedSnapshot(value: unknown): value is SkillReleasedSnapshot {
if (typeof value !== 'object' || value === null) {
return false
}
const snapshot = value as Partial<SkillReleasedSnapshot>
const structurallyValid =
typeof snapshot.releaseRevision === 'number' &&
Number.isSafeInteger(snapshot.releaseRevision) &&
snapshot.releaseRevision >= 0 &&
typeof snapshot.packageDigest === 'string' &&
SHA256_PATTERN.test(snapshot.packageDigest) &&
typeof snapshot.gitTreeSha === 'string' &&
Array.isArray(snapshot.files) &&
snapshot.files.every(isSkillBundleFileIdentity)
if (!structurallyValid) {
return false
}
const files = snapshot.files as SkillBundleFileIdentity[]
const paths = files.map((file) => file.path)
return (
paths.every((path, index) => index === 0 || paths[index - 1]!.localeCompare(path, 'en') < 0) &&
snapshot.packageDigest === skillPackageDigest(files)
)
}
@@ -0,0 +1,32 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
import { skillTransactionReservedRoot } from './skill-transaction-workspace'
describe('skill transaction workspace selection', () => {
it('uses the adjacent parent only when it shares the destination mount', () => {
const skillsRoot = join('root', 'skills')
const parentRoot = join('root')
expect(
skillTransactionReservedRoot({
skillsRoot,
skillsRootDevice: 1,
parentRoot,
parentRootDevice: 1
})
).toBe(join(parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
})
it('keeps the workspace inside the skills root across a mount boundary', () => {
const skillsRoot = join('mounted', 'skills')
const parentRoot = join('mounted')
expect(
skillTransactionReservedRoot({
skillsRoot,
skillsRootDevice: 2,
parentRoot,
parentRootDevice: 1
})
).toBe(join(skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
})
})
@@ -0,0 +1,90 @@
import { lstat, realpath } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { ManagedSkillDestination } from '../../shared/skill-management'
import {
assertManagedSkillParentTopology,
normalizedSkillIdentityPath,
skillPhysicalIdentity
} from './skill-installation-topology'
import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
export type SkillTransactionWorkspace = {
skillsRoot: string
skillsRootIdentity: string
skillsRootDevice: number
parentRoot: string
parentRootIdentity: string
parentRootDevice: number
reservedRoot: string
}
export function skillTransactionReservedRoot(args: {
skillsRoot: string
skillsRootDevice: number
parentRoot: string
parentRootDevice: number
}): string {
// Why: an adjacent workspace is atomic only on the destination mount;
// a mount boundary requires the reserved child of the skills root.
return args.skillsRootDevice === args.parentRootDevice
? join(args.parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
: join(args.skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
}
async function inspectWorkspaceRoots(record: ManagedSkillDestination) {
await assertManagedSkillParentTopology(record)
const skillsRoot = dirname(record.resolvedPath)
const parentRoot = dirname(skillsRoot)
const [skillsRootEntry, parentRootEntry, resolvedSkillsRoot, resolvedParentRoot] =
await Promise.all([
lstat(skillsRoot),
lstat(parentRoot),
realpath(skillsRoot),
realpath(parentRoot)
])
if (
!skillsRootEntry.isDirectory() ||
skillsRootEntry.isSymbolicLink() ||
!parentRootEntry.isDirectory() ||
parentRootEntry.isSymbolicLink() ||
normalizedSkillIdentityPath(resolvedSkillsRoot) !== normalizedSkillIdentityPath(skillsRoot) ||
normalizedSkillIdentityPath(resolvedParentRoot) !== normalizedSkillIdentityPath(parentRoot)
) {
throw new Error('skill-topology-changed')
}
return {
skillsRoot,
skillsRootIdentity: skillPhysicalIdentity(skillsRoot, skillsRootEntry),
skillsRootDevice: skillsRootEntry.dev,
parentRoot,
parentRootIdentity: skillPhysicalIdentity(parentRoot, parentRootEntry),
parentRootDevice: parentRootEntry.dev
}
}
export async function resolveSkillTransactionWorkspace(
record: ManagedSkillDestination
): Promise<SkillTransactionWorkspace> {
const roots = await inspectWorkspaceRoots(record)
return {
...roots,
reservedRoot: skillTransactionReservedRoot(roots)
}
}
export async function assertSkillTransactionWorkspace(
record: ManagedSkillDestination,
expected: SkillTransactionWorkspace
): Promise<void> {
const actual = await inspectWorkspaceRoots(record)
if (
actual.skillsRoot !== expected.skillsRoot ||
actual.skillsRootIdentity !== expected.skillsRootIdentity ||
actual.skillsRootDevice !== expected.skillsRootDevice ||
actual.parentRoot !== expected.parentRoot ||
actual.parentRootIdentity !== expected.parentRootIdentity ||
actual.parentRootDevice !== expected.parentRootDevice
) {
throw new Error('skill-topology-changed')
}
}
@@ -0,0 +1,59 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readOfficialSkillsCliLockEntries, skillsCliLockPath } from './skills-cli-lock-provenance'
const temporaryRoots: string[] = []
async function createRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-lock-'))
temporaryRoots.push(root)
return root
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skills CLI lock provenance', () => {
it('uses XDG_STATE_HOME instead of the default lock location', async () => {
const homeDir = await createRoot()
const xdgStateHome = join(homeDir, 'state')
expect(skillsCliLockPath({ homeDir, xdgStateHome })).toBe(
join(xdgStateHome, 'skills', '.skill-lock.json')
)
})
it('accepts only official Orca source entries from supported schemas', async () => {
const homeDir = await createRoot()
const lockPath = skillsCliLockPath({ homeDir })
await mkdir(dirname(lockPath), { recursive: true })
await writeFile(
lockPath,
JSON.stringify({
version: 3,
skills: {
'orca-cli': { source: 'stablyai/orca' },
orchestration: { sourceUrl: 'https://github.com/stablyai/orca.git' },
spoofed: { source: 'someone/orca' }
}
})
)
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(
new Set(['orca-cli', 'orchestration'])
)
})
it('fails closed for missing, corrupt, and unsupported locks', async () => {
const homeDir = await createRoot()
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
const lockPath = skillsCliLockPath({ homeDir })
await mkdir(dirname(lockPath), { recursive: true })
await writeFile(lockPath, '{')
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
await writeFile(lockPath, JSON.stringify({ version: 99, skills: { 'orca-cli': {} } }))
expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
})
})
@@ -0,0 +1,53 @@
import { readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
const MAX_LOCK_BYTES = 1024 * 1024
const SUPPORTED_LOCK_VERSIONS = new Set([1, 2, 3])
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function officialOrcaSource(entry: Record<string, unknown>): boolean {
const source = typeof entry.source === 'string' ? entry.source : ''
const sourceUrl = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : ''
return (
source === 'stablyai/orca' ||
source === 'https://github.com/stablyai/orca' ||
/^https:\/\/github\.com\/stablyai\/orca(?:\.git)?\/?$/.test(sourceUrl)
)
}
export function skillsCliLockPath(args: { homeDir: string; xdgStateHome?: string | null }): string {
return args.xdgStateHome
? join(args.xdgStateHome, 'skills', '.skill-lock.json')
: join(args.homeDir, '.agents', '.skill-lock.json')
}
export async function readOfficialSkillsCliLockEntries(args: {
homeDir: string
xdgStateHome?: string | null
}): Promise<Set<string>> {
const lockPath = skillsCliLockPath(args)
try {
if ((await stat(lockPath)).size > MAX_LOCK_BYTES) {
return new Set()
}
const parsed: unknown = JSON.parse(await readFile(lockPath, 'utf8'))
if (
!isRecord(parsed) ||
!SUPPORTED_LOCK_VERSIONS.has(Number(parsed.version)) ||
!isRecord(parsed.skills)
) {
return new Set()
}
return new Set(
Object.entries(parsed.skills)
.filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1]))
.filter(([, entry]) => officialOrcaSource(entry))
.map(([name]) => name)
)
} catch {
return new Set()
}
}
+14
View File
@@ -314,6 +314,13 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c
import type { SourceControlAiSettings } from '../shared/source-control-ai-types'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
OrcaSkillInstallResultArgs,
SkillAdoptionDismissalArgs,
SkillManagementActionArgs,
SkillManagementInventory,
SkillReplacementPreview
} from '../shared/skill-management'
import type {
CrashReportBreadcrumbData,
CrashReportCopyDiagnosticsArgs,
@@ -2201,6 +2208,13 @@ export type PreloadApi = {
}
skills: {
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
managementInventory: (target?: SkillDiscoveryTarget) => Promise<SkillManagementInventory>
adopt: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
dismissAdoption: (args: SkillAdoptionDismissalArgs) => Promise<SkillManagementInventory>
previewReplacement: (args: SkillManagementActionArgs) => Promise<SkillReplacementPreview>
replace: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
updateManaged: (args: SkillManagementActionArgs) => Promise<SkillManagementInventory>
recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise<SkillManagementInventory>
}
pet: {
import: () => Promise<CustomPet | null>
+22 -1
View File
@@ -69,6 +69,13 @@ import type {
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
import type { ShellOpenLocalPathResult } from '../shared/shell-open-types'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills'
import type {
OrcaSkillInstallResultArgs,
SkillAdoptionDismissalArgs,
SkillManagementActionArgs,
SkillManagementInventory,
SkillReplacementPreview
} from '../shared/skill-management'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -2166,7 +2173,21 @@ const api = {
skills: {
discover: (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> =>
ipcRenderer.invoke('skills:discover', target)
ipcRenderer.invoke('skills:discover', target),
managementInventory: (target?: SkillDiscoveryTarget): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:managementInventory', target),
adopt: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:adopt', args),
dismissAdoption: (args: SkillAdoptionDismissalArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:dismissAdoption', args),
previewReplacement: (args: SkillManagementActionArgs): Promise<SkillReplacementPreview> =>
ipcRenderer.invoke('skills:previewReplacement', args),
replace: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:replace', args),
updateManaged: (args: SkillManagementActionArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:updateManaged', args),
recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise<SkillManagementInventory> =>
ipcRenderer.invoke('skills:recordOrcaInstall', args)
},
pet: {
+2
View File
@@ -59,6 +59,7 @@ import RightSidebar from './components/right-sidebar'
import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
import { ManagedSkillAdoptionNudge } from './components/managed-skills/ManagedSkillAdoptionNudge'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@@ -2827,6 +2828,7 @@ function App(): React.JSX.Element {
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<ManagedSkillAdoptionNudge />
<PinnedTabCloseDialog />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and
@@ -1,7 +1,10 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal
@@ -138,6 +141,7 @@ export function MobileEmulatorAgentSetupGuideSteps({
)}
</p>
<AgentSkillSetupPanel
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
variant="inline"
hideHeader
className="min-w-0"
@@ -1,12 +1,20 @@
import { Copy } from 'lucide-react'
import { useRef } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal'
import { ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
import {
ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { translate } from '@/i18n/i18n'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { isSuccessfulSkillInstallExit } from '@/components/settings/skill-install-terminal-exit'
export function CliSkillSetupTerminal(): React.JSX.Element {
const installAttemptStartedAtRef = useRef<number | null>(null)
const handleCopySkillCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND)
@@ -76,6 +84,23 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
worktreeId="feature-tip-cli-skills-terminal"
onOpened={() => {
installAttemptStartedAtRef.current ??= Date.now()
}}
onTerminalExit={(result) => {
notifyInstalledAgentSkillsChanged()
const startedAt = installAttemptStartedAtRef.current
if (!isSuccessfulSkillInstallExit(result, startedAt)) {
return
}
void window.api.skills
.recordOrcaInstall({
skillNames: [ORCA_CLI_SKILL_NAME, ORCHESTRATION_SKILL_NAME],
startedAt
})
.then(notifyInstalledAgentSkillsChanged)
.catch(() => undefined)
}}
/>
</div>
)
@@ -1,6 +1,7 @@
import type { JSX } from 'react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
import {
@@ -43,6 +44,8 @@ export function BrowserUseSkillSetupCard(props: {
const setupPanel = (
<AgentSkillSetupPanel
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
className={compact ? 'w-full max-w-[520px]' : undefined}
title={translate(
'auto.components.feature.wall.BrowserUseSkillSetupCard.d5bb1cd4ba',
@@ -118,6 +118,8 @@ export function FloatingTerminalOrchestrationDialog({
</DialogHeader>
<AgentSkillSetupPanel
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
title={translate(
'auto.components.floating.terminal.FloatingTerminalOrchestrationDialog.1cd3f8af64',
'Orchestration skill'
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ManagedSkillAdoptionNudge } from './ManagedSkillAdoptionNudge'
const mocks = vi.hoisted(() => ({
dismiss: vi.fn(async () => {}),
info: vi.fn(),
openSettingsPage: vi.fn(),
openSettingsTarget: vi.fn(),
inventory: {
schemaVersion: 1,
hostId: 'local',
installations: [
{
id: 'candidate-a',
hostId: 'local',
physicalIdentity: '1:1',
name: 'orca-cli',
installedPackageDigest: 'a'.repeat(64),
adoptionPromptEligible: true
},
{
id: 'candidate-b',
hostId: 'local',
physicalIdentity: '1:2',
name: 'orchestration',
installedPackageDigest: 'b'.repeat(64),
adoptionPromptEligible: false
}
],
adoptionCandidateCount: 1,
scannedAt: 1
}
}))
vi.mock('sonner', () => ({ toast: { info: mocks.info } }))
vi.mock('@/hooks/useManagedAgentSkills', () => ({
useManagedAgentSkills: () => ({ inventory: mocks.inventory, dismiss: mocks.dismiss })
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
openSettingsPage: mocks.openSettingsPage,
openSettingsTarget: mocks.openSettingsTarget
})
}))
describe('ManagedSkillAdoptionNudge', () => {
beforeEach(() => {
mocks.dismiss.mockClear()
mocks.info.mockClear()
mocks.openSettingsPage.mockClear()
mocks.openSettingsTarget.mockClear()
mocks.inventory.installations[0]!.installedPackageDigest = 'a'.repeat(64)
mocks.inventory.installations[0]!.adoptionPromptEligible = true
})
afterEach(() => {
document.body.replaceChildren()
})
it('persists only displayed tuples once across review and close lifecycles', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => root.render(<ManagedSkillAdoptionNudge />))
expect(mocks.info).toHaveBeenCalledTimes(1)
expect(mocks.dismiss).not.toHaveBeenCalled()
const options = mocks.info.mock.calls[0]?.[1] as {
action: { onClick: () => void }
onDismiss: () => void
onAutoClose: () => void
}
await act(async () => options.action.onClick())
await act(async () => options.onDismiss())
await act(async () => options.onAutoClose())
await vi.waitFor(() =>
expect(mocks.dismiss).toHaveBeenCalledWith(mocks.inventory.installations[0])
)
expect(mocks.dismiss).toHaveBeenCalledTimes(1)
expect(mocks.dismiss).not.toHaveBeenCalledWith(mocks.inventory.installations[1])
root.unmount()
})
it('stays suppressed after remount but prompts a different snapshot tuple', async () => {
const mount = async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => root.render(<ManagedSkillAdoptionNudge />))
return root
}
let root = await mount()
const options = mocks.info.mock.calls[0]?.[1] as { onAutoClose: () => void }
await act(async () => options.onAutoClose())
await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
root.unmount()
mocks.info.mockClear()
mocks.inventory.installations[0]!.adoptionPromptEligible = false
root = await mount()
expect(mocks.info).not.toHaveBeenCalled()
root.unmount()
mocks.inventory.installations[0]!.installedPackageDigest = 'c'.repeat(64)
mocks.inventory.installations[0]!.adoptionPromptEligible = true
root = await mount()
expect(mocks.info).toHaveBeenCalledTimes(1)
root.unmount()
})
})
@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
export function ManagedSkillAdoptionNudge(): null {
const state = useManagedAgentSkills()
const shownKeys = useRef(new Set<string>())
const persistedKeys = useRef(new Set<string>())
const openSettingsPage = useAppStore((store) => store.openSettingsPage)
const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
useEffect(() => {
const candidates =
state.inventory?.installations.filter(
(installation) => installation.adoptionPromptEligible
) ?? []
const candidateKey = (candidate: (typeof candidates)[number]): string =>
[
candidate.hostId,
candidate.physicalIdentity,
candidate.name,
candidate.installedPackageDigest
].join('\0')
const unseen = candidates.filter((candidate) => !shownKeys.current.has(candidateKey(candidate)))
if (unseen.length === 0) {
return
}
for (const candidate of unseen) {
shownKeys.current.add(candidateKey(candidate))
}
const persistDismissal = (): void => {
const pending = unseen.filter((candidate) => {
const key = candidateKey(candidate)
if (persistedKeys.current.has(key)) {
return false
}
persistedKeys.current.add(key)
return true
})
// Why: Sonner may report action, dismiss, and auto-close for one toast;
// each exact tuple must reach the durable ledger only once.
void pending.reduce(
(settled, candidate) => settled.then(() => state.dismiss(candidate)),
Promise.resolve()
)
}
const count = unseen.length
toast.info(
translate(
'auto.components.managedSkills.adoptionNudge.title',
'{{value0}} installed Orca skills can be kept up to date',
{ value0: count }
),
{
description: translate(
'auto.components.managedSkills.adoptionNudge.description',
'Review exact official copies before Orca manages them.'
),
duration: 12_000,
onDismiss: persistDismissal,
onAutoClose: persistDismissal,
action: {
label: translate('auto.components.managedSkills.adoptionNudge.review', 'Review'),
onClick: () => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
persistDismissal()
}
}
}
)
}, [openSettingsPage, openSettingsTarget, state])
return null
}
@@ -4,9 +4,11 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkil
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
import {
onboardingFeatureSetupTelemetrySelection,
selectedOnboardingSkillNames,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
import { translate } from '@/i18n/i18n'
import { isSuccessfulSkillInstallExit } from '../settings/skill-install-terminal-exit'
type FeatureSetupInlineTerminalProps = {
command: string
@@ -18,6 +20,7 @@ export function FeatureSetupInlineTerminal({
selection
}: FeatureSetupInlineTerminalProps): React.JSX.Element {
const terminalOpenedTrackedRef = useRef(false)
const installAttemptStartedAtRef = useRef<number | null>(null)
const terminalInteractedTrackedRef = useRef(false)
const selectionTelemetry = useMemo(
@@ -26,6 +29,7 @@ export function FeatureSetupInlineTerminal({
)
const trackTerminalOpened = useCallback(() => {
installAttemptStartedAtRef.current ??= Date.now()
if (terminalOpenedTrackedRef.current) {
return
}
@@ -74,7 +78,20 @@ export function FeatureSetupInlineTerminal({
autoScrollIntoView={false}
onOpened={trackTerminalOpened}
onInteracted={trackTerminalInteraction}
onTerminalExit={notifyInstalledAgentSkillsChanged}
onTerminalExit={(result) => {
notifyInstalledAgentSkillsChanged()
const startedAt = installAttemptStartedAtRef.current
if (!isSuccessfulSkillInstallExit(result, startedAt)) {
return
}
void window.api.skills
.recordOrcaInstall({
skillNames: selectedOnboardingSkillNames(selection),
startedAt
})
.then(notifyInstalledAgentSkillsChanged)
.catch(() => undefined)
}}
/>
)
}
@@ -28,7 +28,7 @@ type OnboardingInlineCommandTerminalProps = {
shellOverride?: string
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent<HTMLElement>) => void
onTerminalExit?: () => void
onTerminalExit?: (result: { code: number | null }) => void
}
/**
@@ -294,8 +294,8 @@ export function OnboardingInlineCommandTerminal({
cwd={cwd}
isActive
isVisible
onPtyExit={() => {
onTerminalExit?.()
onPtyExit={(_ptyId, code) => {
onTerminalExit?.({ code: code ?? null })
closeTab(tabId, { recordInteraction: false })
}}
onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
@@ -55,6 +55,10 @@ const FEATURE_SKILL_NAMES: Record<OnboardingFeatureSetupId, string> = {
linearTickets: ORCA_LINEAR_SKILL_NAME
}
export function selectedOnboardingSkillNames(selection: OnboardingFeatureSetupSelection): string[] {
return selectedOnboardingFeatureSetupIds(selection).map((id) => FEATURE_SKILL_NAMES[id])
}
const FEATURE_TELEMETRY_IDS: Record<
OnboardingFeatureSetupId,
EventProps<'onboarding_feature_setup_toggled'>['feature']
@@ -112,9 +116,7 @@ export function buildOnboardingFeatureSetupClipboardText(
export function buildOnboardingFeatureSetupSkillCommand(
selection: OnboardingFeatureSetupSelection
): string | null {
const skillNames = selectedOnboardingFeatureSetupIds(selection).map(
(id) => FEATURE_SKILL_NAMES[id]
)
const skillNames = selectedOnboardingSkillNames(selection)
if (skillNames.length === 0) {
return null
}
@@ -140,6 +140,10 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio
return { kind: 'ssh', connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', environmentId: parsed.environmentId }
case 'wsl':
// Why: WSL skill state needs a durable host key before general file
// operations gain an in-distro routing contract; destructive work fails closed.
return { kind: 'unresolved' }
case undefined:
return { kind: 'unresolved' }
}
@@ -157,6 +157,18 @@ describe('AgentSkillSetupPanel', () => {
expect(buttonLabels(html)).toContain('Re-check')
})
it('suppresses the raw updater while managed status is still loading', () => {
const html = renderPanel({
installed: true,
installedCommand: UPDATE_COMMAND,
managedSkillNames: ['orca-cli']
})
expect(buttonLabels(html)).toContain('Checking…')
expect(buttonLabels(html)).not.toContain('Update')
expect(html).not.toContain(UPDATE_COMMAND)
})
it('hides only re-check when installed re-checks are disabled', () => {
const html = renderPanel({ installed: true, showRecheckWhenInstalled: false })
@@ -5,11 +5,14 @@ import { IntegrationStatusPill } from '../integration-status-pill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { useMountedRef } from '@/hooks/useMountedRef'
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
const EMPTY_MANAGED_SKILL_NAMES: readonly string[] = []
type AgentSkillSetupPanelVariant = 'card' | 'inline'
type SkillPrerequisiteStatus = Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>
@@ -46,6 +49,8 @@ type AgentSkillSetupPanelProps = {
actionHint?: ReactNode
openingHint?: ReactNode
footer?: ReactNode
managedSkillNames?: readonly string[]
managementTarget?: SkillDiscoveryTarget
onRecheck: () => void | Promise<unknown>
}
@@ -79,6 +84,8 @@ export function AgentSkillSetupPanel({
actionHint,
openingHint,
footer,
managedSkillNames = EMPTY_MANAGED_SKILL_NAMES,
managementTarget,
onRecheck
}: AgentSkillSetupPanelProps): React.JSX.Element {
const [terminalOpen, setTerminalOpen] = useState(false)
@@ -88,6 +95,11 @@ export function AgentSkillSetupPanel({
Boolean(preInstallNotice && !installed)
)
const mountedRef = useMountedRef()
const skillManagement = useAgentSkillManagementActions({
installed,
skillNames: managedSkillNames,
target: managementTarget
})
const readPrerequisiteStatus = useCallback(
() => (getPrerequisiteStatus ?? window.api.cli.getInstallStatus)(),
[getPrerequisiteStatus]
@@ -161,7 +173,25 @@ export function AgentSkillSetupPanel({
const actionRow = (
<div className="mt-3 flex flex-wrap items-center gap-2">
{!installed || showInstallWhenInstalled ? (
{skillManagement.applicable ? (
skillManagement.actionLabel ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={skillManagement.busy}
onClick={() => void skillManagement.run()}
>
{skillManagement.actionBusy ? <Loader2 className="size-3.5 animate-spin" /> : null}
{skillManagement.actionBusy && !skillManagement.installation
? translate(
'auto.components.settings.AgentSkillSetupPanel.checkingManagement',
'Checking…'
)
: skillManagement.actionLabel}
</Button>
) : null
) : !installed || showInstallWhenInstalled ? (
<Button
type="button"
variant="outline"
@@ -184,6 +214,7 @@ export function AgentSkillSetupPanel({
if (mountedRef.current) {
setTerminalOpening(false)
if (shouldOpenTerminal) {
skillManagement.beginInstallAttempt()
setTerminalCommand(nextCommand)
setTerminalOpen(true)
}
@@ -263,6 +294,12 @@ export function AgentSkillSetupPanel({
'Checking...'
)}
</IntegrationStatusPill>
) : installed && skillManagement.installation ? (
<IntegrationStatusPill
tone={skillManagement.installation.managed ? 'connected' : 'neutral'}
>
{skillManagement.statusLabel}
</IntegrationStatusPill>
) : installed ? (
<IntegrationStatusPill tone="connected">
{translate(
@@ -350,7 +387,7 @@ export function AgentSkillSetupPanel({
terminalTopMarginPx={8}
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
onTerminalExit={notifyInstalledAgentSkillsChanged}
onTerminalExit={skillManagement.recordTerminalInstall}
/>
</div>
) : null}
@@ -48,6 +48,7 @@ import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft'
import { ManagedOrcaSkillsSection } from './ManagedOrcaSkillsSection'
export { getAgentsPaneSearchEntries } from './agents-search'
@@ -829,6 +830,8 @@ export function AgentsPane({
wslCapabilitiesLoading={wslCapabilitiesLoading}
/>
<ManagedOrcaSkillsSection />
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
@@ -274,6 +274,7 @@ export function BrowserUseSetup({
)}
>
<BrowserUseSkillStep
managementTarget={activeSkillRuntime.discoveryTarget}
command={browserUseInstallCommand}
installedCommand={browserUseUpdateCommand}
skillDetected={skillDetected}
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { StepBadge } from './BrowserUseStepBadge'
import { translate } from '@/i18n/i18n'
import { ORCA_CLI_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
type Props = {
command: string
@@ -14,6 +16,7 @@ type Props = {
preInstallNotice?: ReactNode
getPrerequisiteStatus?: () => Promise<Awaited<ReturnType<typeof window.api.cli.getInstallStatus>>>
onBeforeOpenTerminal?: () => void | Promise<void>
managementTarget?: SkillDiscoveryTarget
onRecheck: () => void | Promise<unknown>
}
@@ -28,10 +31,13 @@ export function BrowserUseSkillStep({
preInstallNotice,
getPrerequisiteStatus,
onBeforeOpenTerminal,
managementTarget,
onRecheck
}: Props): React.JSX.Element {
return (
<AgentSkillSetupPanel
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
managementTarget={managementTarget}
variant="inline"
title={translate(
'auto.components.settings.BrowserUseSkillStep.459e24eebc',
@@ -363,6 +363,8 @@ export function CliSection({
</div>
<AgentSkillSetupPanel
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
managementTarget={cliSkillDiscoveryTarget}
className="mt-3"
variant="inline"
title={translate('auto.components.settings.CliSection.6053cf736c', 'CLI skill')}
@@ -48,6 +48,8 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
return (
<AgentSkillSetupPanel
managedSkillNames={[COMPUTER_USE_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
title={translate('auto.components.settings.ComputerUsePane.93255aaf18', 'Computer Use skill')}
description={translate(
'auto.components.settings.ComputerUsePane.1735461723',
@@ -130,6 +130,8 @@ export function EphemeralVmsPane(): React.JSX.Element {
return (
<div className="space-y-6" data-settings-section="ephemeral-vms">
<AgentSkillSetupPanel
managedSkillNames={[EPHEMERAL_VMS_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
title={translate(
'auto.components.settings.EphemeralVmsPane.skillTitle',
'Per-Workspace Environments skill'
@@ -0,0 +1,330 @@
import { useState } from 'react'
import { Loader2, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type {
SkillManagementInstallation,
SkillManagementStatus,
SkillReplacementPreview
} from '../../../../shared/skill-management'
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
import { translate } from '@/i18n/i18n'
import { Button } from '../ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog'
import { SettingsBadge, SettingsSubsectionHeader } from './SettingsFormControls'
import { managedSkillStatusCopy } from './managed-skill-status-copy'
function statusTone(
status: SkillManagementStatus
): React.ComponentProps<typeof SettingsBadge>['tone'] {
switch (status) {
case 'managed-current':
return 'accent'
case 'known-current':
case 'known-update-available':
case 'managed-update-available':
return 'muted'
case 'newer-known':
case 'modified':
case 'unknown':
case 'externally-managed':
case 'inaccessible':
case 'update-failed':
return 'muted'
}
}
function diagnosticCopy(installation: SkillManagementInstallation): string {
const revision = installation.installedReleaseRevision
? `r${installation.installedReleaseRevision}`
: translate('auto.components.settings.ManagedOrcaSkills.unverified', 'unverified')
const digest = installation.installedPackageDigest?.slice(0, 8) ?? '—'
return `${revision} · Orca ${installation.installedAppVersion ?? installation.currentAppVersion} · ${digest}`
}
function missingVisibilityCopy(installation: SkillManagementInstallation): string | null {
if (!installation.managed) {
return null
}
const missing = [
!installation.providers.includes('codex') ? 'Codex' : null,
!installation.providers.includes('claude') ? 'Claude' : null
].filter((provider): provider is string => provider !== null)
return missing.length > 0
? translate(
'auto.components.settings.ManagedOrcaSkills.notVisible',
'Managed but not visible in {{value0}}.',
{ value0: missing.join(', ') }
)
: null
}
function ReplacementDialog({
preview,
busy,
onClose,
onConfirm
}: {
preview: SkillReplacementPreview
busy: boolean
onClose: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.settings.ManagedOrcaSkills.replaceTitle',
'Replace {{value0}}?',
{ value0: preview.skillName }
)}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.settings.ManagedOrcaSkills.replaceDescription',
'This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.'
)}
</DialogDescription>
</DialogHeader>
<div className="scrollbar-sleek max-h-[55vh] space-y-3 overflow-y-auto rounded-md border border-border bg-muted/20 p-3">
{preview.files.map((file) => (
<div key={file.path} className="space-y-2">
<div className="flex items-center gap-2">
<code className="font-mono text-xs text-foreground">{file.path}</code>
<SettingsBadge tone="muted">{file.change}</SettingsBadge>
</div>
{file.beforeText !== null || file.afterText !== null ? (
<div className="grid gap-2 md:grid-cols-2">
<pre className="scrollbar-sleek max-h-48 overflow-auto rounded-md border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap text-muted-foreground">
{file.beforeText ?? '—'}
</pre>
<pre className="scrollbar-sleek max-h-48 overflow-auto rounded-md border border-border bg-background p-2 font-mono text-[11px] whitespace-pre-wrap text-muted-foreground">
{file.afterText ?? '—'}
</pre>
</div>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ManagedOrcaSkills.binaryChange',
'Binary file contents differ.'
)}
</p>
)}
</div>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={busy}>
{translate('auto.components.settings.ManagedOrcaSkills.cancel', 'Cancel')}
</Button>
</DialogClose>
<Button variant="destructive" disabled={busy} onClick={onConfirm}>
{busy ? <Loader2 className="animate-spin" /> : null}
{translate('auto.components.settings.ManagedOrcaSkills.replace', 'Replace skill')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function ManagedOrcaSkillsSection(): React.JSX.Element {
const state = useManagedAgentSkills()
const [preview, setPreview] = useState<SkillReplacementPreview | null>(null)
const reportFailure = (error: unknown): void => {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.ManagedOrcaSkills.actionFailed',
'Skill action failed.'
)
)
}
const reviewReplacement = async (installationId: string): Promise<void> => {
try {
setPreview(await state.previewReplacement(installationId))
} catch (error) {
reportFailure(error)
}
}
const run = async (action: () => Promise<void>, success: string): Promise<boolean> => {
try {
await action()
toast.success(success)
return true
} catch (error) {
reportFailure(error)
return false
}
}
return (
<section className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.ManagedOrcaSkills.title', 'Orca agent skills')}
description={translate(
'auto.components.settings.ManagedOrcaSkills.description',
'Orca updates only exact official copies you explicitly choose to manage.'
)}
action={
<Button
variant="ghost"
size="xs"
disabled={state.loading}
onClick={() => void state.refresh()}
>
<RefreshCw className={state.loading ? 'animate-spin' : undefined} />
{translate('auto.components.settings.ManagedOrcaSkills.refresh', 'Refresh')}
</Button>
}
/>
{state.error ? <p className="text-xs text-destructive">{state.error}</p> : null}
{state.inventory?.installations.length ? (
<div className="divide-y divide-border/40">
{state.inventory.installations.map((installation) => {
const busy = state.busyInstallationId === installation.id
const visibility = missingVisibilityCopy(installation)
const replaceable =
installation.status === 'modified' || installation.status === 'unknown'
return (
<div key={installation.id} className="flex items-start gap-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{installation.name}</span>
<SettingsBadge tone={statusTone(installation.status)}>
{managedSkillStatusCopy(installation.status)}
</SettingsBadge>
</div>
<p className="font-mono text-[11px] text-muted-foreground">
{diagnosticCopy(installation)}
</p>
{installation.description ? (
<p className="text-xs text-muted-foreground">{installation.description}</p>
) : null}
<p className="truncate font-mono text-[11px] text-muted-foreground">
{installation.unresolvedPath}
</p>
{visibility ? (
<p className="text-[11px] text-muted-foreground">{visibility}</p>
) : null}
{!installation.actionsSupported ? (
<p className="text-[11px] text-muted-foreground">
{translate(
'auto.components.settings.ManagedOrcaSkills.hostPending',
'Detection only on this host; management actions are not available yet.'
)}
</p>
) : null}
</div>
{!installation.actionsSupported ? null : installation.status === 'known-current' ||
installation.status === 'known-update-available' ? (
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() =>
void run(
() => state.manage(installation.id),
translate(
'auto.components.settings.ManagedOrcaSkills.managedToast',
'Skill is now managed and current.'
)
)
}
>
{busy ? <Loader2 className="animate-spin" /> : null}
{installation.status === 'known-update-available'
? translate(
'auto.components.settings.ManagedOrcaSkills.manageUpdateAction',
'Manage and update'
)
: translate(
'auto.components.settings.ManagedOrcaSkills.manageAction',
'Manage'
)}
</Button>
) : installation.status === 'managed-update-available' ||
installation.status === 'update-failed' ? (
<Button
variant="outline"
size="xs"
disabled={busy}
onClick={() =>
void run(
() => state.update(installation.id),
translate(
'auto.components.settings.ManagedOrcaSkills.updatedToast',
'Skill updated.'
)
)
}
>
{busy ? <Loader2 className="animate-spin" /> : null}
{installation.status === 'update-failed'
? translate('auto.components.settings.ManagedOrcaSkills.retryAction', 'Retry')
: translate(
'auto.components.settings.ManagedOrcaSkills.updateAction',
'Update'
)}
</Button>
) : replaceable ? (
<Button
variant="outline"
size="xs"
onClick={() => void reviewReplacement(installation.id)}
>
{translate('auto.components.settings.ManagedOrcaSkills.reviewAction', 'Review')}
</Button>
) : null}
</div>
)
})}
</div>
) : state.loading ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ManagedOrcaSkills.checking',
'Checking installed skills…'
)}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ManagedOrcaSkills.none',
'No installed Orca skills found in global agent homes.'
)}
</p>
)}
{preview ? (
<ReplacementDialog
preview={preview}
busy={state.busyInstallationId === preview.installationId}
onClose={() => setPreview(null)}
onConfirm={() =>
void run(
() => state.replace(preview.installationId),
translate(
'auto.components.settings.ManagedOrcaSkills.replacedToast',
'Skill replaced with the bundled version.'
)
).then((succeeded) => succeeded && setPreview(null))
}
/>
) : null}
</section>
)
}
@@ -1,6 +1,7 @@
import { Import, Loader2 } from 'lucide-react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
import {
@@ -143,6 +144,7 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
<div className={cn('py-3', setup.step2Blocked && 'opacity-60')}>
<AgentSkillSetupPanel
managedSkillNames={[ORCA_CLI_SKILL_NAME]}
variant="inline"
title={translate(
'auto.components.settings.MobileEmulatorAgentControlRow.67e19ee03c',
@@ -86,6 +86,8 @@ export function OrchestrationPane(): React.JSX.Element {
className="space-y-5 py-2"
>
<AgentSkillSetupPanel
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
title={translate(
'auto.components.settings.OrchestrationPane.07641b9768',
'Orchestration skill'
@@ -17,6 +17,7 @@ import {
} from './CliSkillRuntimeSetup'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
export function OrchestrationSetupCard(props: {
compact?: boolean
@@ -40,6 +41,8 @@ export function OrchestrationSetupCard(props: {
const setupPanel = (
<AgentSkillSetupPanel
managedSkillNames={[ORCHESTRATION_SKILL_NAME]}
managementTarget={activeSkillRuntime.discoveryTarget}
className={compact ? 'w-full max-w-[520px]' : undefined}
title={translate(
'auto.components.settings.OrchestrationSetupCard.2777ff0fdc',
@@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
refresh: vi.fn(async () => true)
},
useInstalledAgentSkillNames: vi.fn(),
managementRun: vi.fn(async () => {}),
useAgentSkillManagementActions: vi.fn(),
clipboardWrite: vi.fn(async () => {}),
toastSuccess: vi.fn(),
toastError: vi.fn()
@@ -30,6 +32,10 @@ vi.mock('./CliSkillRuntimeSetup', () => ({
buildSkillCommandForRuntime: (command: string) => command
}))
vi.mock('./use-agent-skill-management-actions', () => ({
useAgentSkillManagementActions: mocks.useAgentSkillManagementActions
}))
vi.mock('sonner', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError }
}))
@@ -86,6 +92,15 @@ describe('LinearAgentSkillInstallCta', () => {
mocks.skillState.refresh.mockClear()
mocks.useInstalledAgentSkillNames.mockReset()
mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState)
mocks.managementRun.mockClear()
mocks.useAgentSkillManagementActions.mockReset()
mocks.useAgentSkillManagementActions.mockReturnValue({
installation: null,
actionLabel: null,
statusLabel: null,
busy: false,
run: mocks.managementRun
})
mocks.clipboardWrite.mockClear()
mocks.toastSuccess.mockClear()
mocks.toastError.mockClear()
@@ -134,19 +149,33 @@ describe('LinearAgentSkillInstallCta', () => {
expect(mocks.toastSuccess).toHaveBeenCalled()
})
it('shows a subtle confirmation and the update command when installed', async () => {
it('shows the ledger-backed status and action without a raw update command', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
mocks.useAgentSkillManagementActions.mockReturnValue({
installation: { managed: true },
actionLabel: 'Update',
statusLabel: 'Update available',
busy: false,
run: mocks.managementRun
})
const rendered = await renderCta()
expect(rendered.textContent).toContain('Installed')
expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
expect(rendered.textContent).toContain('npx skills update orca-linear --global')
expect(rendered.textContent).toContain('Update available')
expect(rendered.textContent).toContain('Update')
expect(rendered.textContent).not.toContain('npx skills update')
expect(rendered.textContent).not.toContain('Not installed')
await act(async () => {
Array.from(rendered.querySelectorAll('button'))
.find((button) => button.textContent === 'Update')
?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(mocks.managementRun).toHaveBeenCalledTimes(1)
})
it('updates through the legacy skill name when only linear-tickets is installed', async () => {
it('does not expose a legacy raw updater when only linear-tickets is installed', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [
discoveredSkill({
@@ -158,7 +187,8 @@ describe('LinearAgentSkillInstallCta', () => {
const rendered = await renderCta()
expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
expect(rendered.textContent).toContain('Installed')
expect(rendered.textContent).not.toContain('npx skills update')
})
it('notes that remote agent environments need their own setup', async () => {
@@ -13,7 +13,6 @@ import {
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
ORCA_LINEAR_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
import { cn } from '@/lib/utils'
import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy'
import {
@@ -28,6 +27,7 @@ import {
useIntegrationSubordinateRowClass
} from './integration-card-presentation'
import { translate } from '@/i18n/i18n'
import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
type LinearAgentSkillInstallCtaProps = {
settings: LinearAgentSkillPromptSettings | null | undefined
@@ -55,15 +55,14 @@ export function LinearAgentSkillInstallCta({
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const command = useMemo(
() =>
buildSkillCommandForRuntime(
skill.installed
? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed)
: ORCA_LINEAR_SKILL_INSTALL_COMMAND,
agentRuntime
),
[agentRuntime, skill.installed, skill.skills]
() => buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime),
[agentRuntime]
)
const management = useAgentSkillManagementActions({
installed: skill.installed,
skillNames: LINEAR_AGENT_SKILL_NAMES,
target: skillDiscoveryTarget
})
const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5')
const commandRowClass = useIntegrationCommandRowClass()
@@ -106,11 +105,12 @@ export function LinearAgentSkillInstallCta({
)}
</IntegrationStatusPill>
) : skill.installed ? (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.linear.agent.skill.install.cta.installed',
'Installed'
)}
<IntegrationStatusPill tone={management.installation?.managed ? 'connected' : 'neutral'}>
{management.statusLabel ??
translate(
'auto.components.settings.linear.agent.skill.install.cta.installed',
'Installed'
)}
</IntegrationStatusPill>
) : (
<IntegrationStatusPill tone="attention">
@@ -136,44 +136,53 @@ export function LinearAgentSkillInstallCta({
{!skill.loading && (
<>
<p className="text-xs text-muted-foreground">
{skill.installed
? translate(
'auto.components.settings.linear.agent.skill.install.cta.installedDescription',
'Agent skill installed. To update it, run:'
)
: translate(
'auto.components.settings.linear.agent.skill.install.cta.description',
'Let your agents read and edit Linear tasks.'
)}
{translate(
'auto.components.settings.linear.agent.skill.install.cta.description',
'Let your agents read and edit Linear tasks.'
)}
</p>
<div className={commandRowClass}>
<code className="scrollbar-sleek min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{command}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label={translate(
{skill.installed ? (
management.actionLabel ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={management.busy}
onClick={() => void management.run()}
>
{management.actionLabel}
</Button>
) : null
) : (
<div className={commandRowClass}>
<code className="scrollbar-sleek min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{command}
</code>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label={translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
onClick={() => void copyCommand()}
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
onClick={() => void copyCommand()}
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
</TooltipContent>
</Tooltip>
</div>
</TooltipContent>
</Tooltip>
</div>
)}
{remote || agentRuntime.runtime === 'wsl' ? (
<p className="text-[11px] text-muted-foreground/70">
{getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)}
@@ -0,0 +1,36 @@
import type { SkillManagementStatus } from '../../../../shared/skill-management'
import { translate } from '@/i18n/i18n'
export function managedSkillStatusCopy(status: SkillManagementStatus): string {
switch (status) {
case 'managed-current':
return translate('auto.components.settings.ManagedOrcaSkills.managedCurrent', 'Managed')
case 'managed-update-available':
return translate(
'auto.components.settings.ManagedOrcaSkills.managedUpdate',
'Update available'
)
case 'known-current':
return translate(
'auto.components.settings.ManagedOrcaSkills.availableToManage',
'Available to manage'
)
case 'known-update-available':
return translate(
'auto.components.settings.ManagedOrcaSkills.manageAndUpdate',
'Manage and update'
)
case 'newer-known':
return translate('auto.components.settings.ManagedOrcaSkills.newerKnown', 'Newer release')
case 'modified':
return translate('auto.components.settings.ManagedOrcaSkills.modified', 'Modified')
case 'unknown':
return translate('auto.components.settings.ManagedOrcaSkills.unknown', 'Unknown copy')
case 'externally-managed':
return translate('auto.components.settings.ManagedOrcaSkills.external', 'Externally managed')
case 'inaccessible':
return translate('auto.components.settings.ManagedOrcaSkills.inaccessible', 'Inaccessible')
case 'update-failed':
return translate('auto.components.settings.ManagedOrcaSkills.failed', 'Update failed')
}
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
describe('skill install terminal exit', () => {
it('accepts only a successful process exit after the install window began', () => {
expect(isSuccessfulSkillInstallExit({ code: 0 }, 1)).toBe(true)
expect(isSuccessfulSkillInstallExit({ code: 1 }, 1)).toBe(false)
expect(isSuccessfulSkillInstallExit({ code: null }, 1)).toBe(false)
expect(isSuccessfulSkillInstallExit({ code: 0 }, null)).toBe(false)
})
})
@@ -0,0 +1,8 @@
export type SkillInstallTerminalExit = { code: number | null }
export function isSuccessfulSkillInstallExit(
result: SkillInstallTerminalExit,
startedAt: number | null
): startedAt is number {
return result.code === 0 && startedAt !== null
}
@@ -0,0 +1,133 @@
import { useMemo, useRef } from 'react'
import { toast } from 'sonner'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { managedSkillStatusCopy } from './managed-skill-status-copy'
import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
export function useAgentSkillManagementActions(args: {
installed: boolean
skillNames: readonly string[]
target?: SkillDiscoveryTarget
}) {
const state = useManagedAgentSkills(args.target)
const installAttemptStartedAtRef = useRef<number | null>(null)
const openSettingsPage = useAppStore((store) => store.openSettingsPage)
const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
const skillNamesKey = args.skillNames.join('\0')
const installation = useMemo(() => {
const names = new Set(skillNamesKey.split('\0').filter(Boolean))
return state.inventory?.installations.find((entry) => names.has(entry.name))
}, [skillNamesKey, state.inventory])
const openSettings = (): void => {
openSettingsTarget({ pane: 'agents', repoId: null })
openSettingsPage()
}
const actionLabel = (() => {
if (installation && !installation.actionsSupported) {
return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
}
switch (installation?.status) {
case 'known-current':
return translate('auto.components.settings.AgentSkillSetupPanel.manage', 'Manage')
case 'known-update-available':
return translate(
'auto.components.settings.AgentSkillSetupPanel.manageUpdate',
'Manage and update'
)
case 'managed-update-available':
return translate('auto.components.settings.AgentSkillSetupPanel.update', 'Update')
case 'update-failed':
return translate(
'auto.components.settings.AgentSkillSetupPanel.retryUpdate',
'Retry update'
)
case 'managed-current':
return null
case 'newer-known':
case 'modified':
case 'unknown':
case 'externally-managed':
case 'inaccessible':
case undefined:
return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
}
})()
const run = async (): Promise<void> => {
if (!installation || !installation.actionsSupported) {
openSettings()
return
}
try {
if (
installation.status === 'known-current' ||
installation.status === 'known-update-available'
) {
await state.manage(installation.id)
} else if (
installation.status === 'managed-update-available' ||
installation.status === 'update-failed'
) {
await state.update(installation.id)
} else {
openSettings()
return
}
toast.success(
translate(
'auto.components.settings.AgentSkillSetupPanel.managedCurrent',
'Skill is managed and current.'
)
)
notifyInstalledAgentSkillsChanged()
} catch (error) {
toast.error(
error instanceof Error
? error.message
: translate(
'auto.components.settings.AgentSkillSetupPanel.actionFailed',
'Skill action failed.'
)
)
}
}
const recordTerminalInstall = (result: { code: number | null }): void => {
notifyInstalledAgentSkillsChanged()
const startedAt = installAttemptStartedAtRef.current
if (
!isSuccessfulSkillInstallExit(result, startedAt) ||
args.installed ||
args.skillNames.length === 0
) {
return
}
void window.api.skills
.recordOrcaInstall({
skillNames: [...args.skillNames],
startedAt,
target: args.target
})
.then(notifyInstalledAgentSkillsChanged)
.catch(() => undefined)
}
return {
applicable: args.installed && args.skillNames.length > 0,
actionLabel,
busy: state.loading || state.busyInstallationId !== null,
actionBusy: state.loading || state.busyInstallationId === installation?.id,
installation,
statusLabel: installation ? managedSkillStatusCopy(installation.status) : null,
run,
recordTerminalInstall,
beginInstallAttempt: () => {
installAttemptStartedAtRef.current = Date.now()
}
}
}
@@ -16,6 +16,8 @@ import {
isOrcaCliAvailableOnPath
} from '@/lib/agent-skill-cli-prerequisite'
import { translate } from '@/i18n/i18n'
import { LINEAR_AGENT_SKILL_NAMES } from '@/lib/agent-feature-install-commands'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
type AgentSkillSetupPanelProps = ComponentProps<typeof AgentSkillSetupPanel>
@@ -33,6 +35,7 @@ type LinearAgentSkillSetupDialogProps = {
getPrerequisiteStatus?: AgentSkillSetupPanelProps['getPrerequisiteStatus']
onBeforeOpenTerminal: AgentSkillSetupPanelProps['onBeforeOpenTerminal']
onRecheck: AgentSkillSetupPanelProps['onRecheck']
managementTarget?: SkillDiscoveryTarget
onOpenChange: (open: boolean) => void
onDismissPermanently: () => void
onSnoozeForSession: () => void
@@ -53,6 +56,7 @@ export function LinearAgentSkillSetupDialog({
getPrerequisiteStatus,
onBeforeOpenTerminal,
onRecheck,
managementTarget,
onOpenChange,
onDismissPermanently,
onSnoozeForSession,
@@ -117,6 +121,8 @@ export function LinearAgentSkillSetupDialog({
</div>
</div>
<AgentSkillSetupPanel
managedSkillNames={LINEAR_AGENT_SKILL_NAMES}
managementTarget={managementTarget}
className="px-6 pt-4 pb-3"
variant="inline"
hideHeader
@@ -277,6 +277,7 @@ export function LinearAgentSkillSetupPrompt({
const setupDialog = (
<LinearAgentSkillSetupDialog
managementTarget={skillDiscoveryTarget}
open={setupDialogOpen}
showSuccess={showSuccessModal}
successDescription={successDescription}
@@ -52,6 +52,7 @@ export function buildHostHeaderMenuModel(input: HostHeaderMenuInput): HostHeader
actions.push('runtime-check-connection')
break
case 'local':
case 'wsl':
break
}
@@ -2,7 +2,8 @@ import type { GlobalSettings, Repo, WorkspaceHostScope } from '../../../../share
import {
ALL_EXECUTION_HOSTS_SCOPE,
LOCAL_EXECUTION_HOST_ID,
type ExecutionHostId
type ExecutionHostId,
type ExecutionHostKind
} from '../../../../shared/execution-host'
import {
buildExecutionHostRegistry,
@@ -18,7 +19,7 @@ export type SidebarHostOption = {
id: ExecutionHostId
label: string
detail: string
kind: 'local' | 'ssh' | 'runtime'
kind: ExecutionHostKind
health: ExecutionHostHealth
presence: 'local' | 'configured' | 'project' | 'active'
// Why: surfaced to the sidebar host-header menu so it can warn on version skew.

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