From f4a12bcd6bd29b1bbd7e0eca0f1b2a5e1dd0a3fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:10:16 -0700 Subject: [PATCH] feat(skills): manage bundled skill updates --- .github/workflows/pr.yml | 7 + .github/workflows/release-cut.yml | 6 +- .github/workflows/skill-cli-roundtrip.yml | 38 + .../workflows/skill-manifest-merge-gate.yml | 21 + config/electron-builder.config.cjs | 12 +- config/reliability-gates.jsonc | 156 +++ .../scripts/electron-builder-config.test.mjs | 8 + .../generate-skill-bundle-manifest.mjs | 463 +++++++ .../generate-skill-bundle-manifest.test.mjs | 98 ++ config/scripts/verify-skill-cli-roundtrip.mjs | 134 ++ package.json | 6 +- resources/skills/current-manifest.json | 158 +++ resources/skills/release-mapping.json | 475 +++++++ resources/skills/snapshot-registry.json | 1159 +++++++++++++++++ src/main/ipc/skills.test.ts | 247 +++- src/main/ipc/skills.ts | 267 +++- src/main/persistence.test.ts | 42 + src/main/persistence.ts | 49 + src/main/skills/discovery.test.ts | 19 + src/main/skills/discovery.ts | 6 + src/main/skills/skill-adoption-dismissal.ts | 31 + src/main/skills/skill-adoption.test.ts | 181 +++ src/main/skills/skill-adoption.ts | 130 ++ src/main/skills/skill-bundle-artifacts.ts | 110 ++ src/main/skills/skill-discovery-sources.ts | 3 +- .../skills/skill-explicit-replacement.test.ts | 84 ++ src/main/skills/skill-explicit-replacement.ts | 161 +++ .../skills/skill-installation-topology.ts | 207 +++ src/main/skills/skill-managed-update.ts | 43 + ...skill-management-inaccessible-candidate.ts | 63 + .../skills/skill-management-inventory.test.ts | 348 +++++ src/main/skills/skill-management-inventory.ts | 301 +++++ .../skills/skill-management-ledger.test.ts | 66 + src/main/skills/skill-management-ledger.ts | 127 ++ .../skills/skill-mutation-authority.test.ts | 74 ++ src/main/skills/skill-mutation-authority.ts | 47 + .../skills/skill-orphan-transaction-sweep.ts | 106 ++ .../skills/skill-owned-temp-publication.ts | 108 ++ src/main/skills/skill-package-identity.ts | 228 ++++ .../skill-package-observation-limits.test.ts | 73 ++ .../skills/skill-package-publication.test.ts | 445 +++++++ src/main/skills/skill-package-publication.ts | 216 +++ src/main/skills/skill-package-publish.test.ts | 176 +++ src/main/skills/skill-package-publish.ts | 171 +++ src/main/skills/skill-package-rollback.ts | 95 ++ .../skill-package-swap-authority.test.ts | 229 ++++ .../skill-package-transaction-context.ts | 202 +++ .../skill-published-destination-authority.ts | 52 + .../skills/skill-transaction-marker.test.ts | 235 ++++ src/main/skills/skill-transaction-marker.ts | 291 +++++ .../skills/skill-transaction-owned-state.ts | 305 +++++ .../skill-transaction-package-build.test.ts | 187 +++ .../skills/skill-transaction-package-build.ts | 290 +++++ src/main/skills/skill-transaction-paths.ts | 1 + ...kill-transaction-publication-state.test.ts | 220 ++++ .../skill-transaction-publication-state.ts | 224 ++++ .../skills/skill-transaction-recovery.test.ts | 618 +++++++++ src/main/skills/skill-transaction-recovery.ts | 101 ++ src/main/skills/skill-transaction-rename.ts | 35 + src/main/skills/skill-transaction-restore.ts | 136 ++ .../skill-transaction-snapshot-validation.ts | 62 + .../skill-transaction-workspace.test.ts | 32 + .../skills/skill-transaction-workspace.ts | 90 ++ .../skills/skills-cli-lock-provenance.test.ts | 59 + src/main/skills/skills-cli-lock-provenance.ts | 53 + src/preload/api-types.ts | 14 + src/preload/index.ts | 23 +- src/renderer/src/App.tsx | 2 + .../MobileEmulatorAgentSetupGuideSteps.tsx | 6 +- .../feature-tips/CliSkillSetupTerminal.tsx | 27 +- .../feature-wall/BrowserUseSkillSetupCard.tsx | 3 + .../FloatingTerminalOrchestrationDialog.tsx | 2 + .../ManagedSkillAdoptionNudge.test.tsx | 116 ++ .../ManagedSkillAdoptionNudge.tsx | 77 ++ .../onboarding/FeatureSetupInlineTerminal.tsx | 19 +- .../OnboardingInlineCommandTerminal.tsx | 6 +- .../onboarding/onboarding-feature-setup.ts | 8 +- .../file-explorer-operation-owner.ts | 4 + .../settings/AgentSkillSetupPanel.test.tsx | 12 + .../settings/AgentSkillSetupPanel.tsx | 43 +- .../src/components/settings/AgentsPane.tsx | 3 + .../components/settings/BrowserUsePane.tsx | 1 + .../settings/BrowserUseSkillStep.tsx | 6 + .../src/components/settings/CliSection.tsx | 2 + .../settings/ComputerUseSkillSetupPanel.tsx | 2 + .../components/settings/EphemeralVmsPane.tsx | 2 + .../settings/ManagedOrcaSkillsSection.tsx | 330 +++++ .../MobileEmulatorAgentControlRow.tsx | 2 + .../components/settings/OrchestrationPane.tsx | 2 + .../settings/OrchestrationSetupCard.tsx | 3 + .../linear-agent-skill-install-cta.test.tsx | 42 +- .../linear-agent-skill-install-cta.tsx | 105 +- .../settings/managed-skill-status-copy.ts | 36 + .../skill-install-terminal-exit.test.ts | 11 + .../settings/skill-install-terminal-exit.ts | 8 + .../use-agent-skill-management-actions.ts | 133 ++ .../sidebar/LinearAgentSkillSetupDialog.tsx | 6 + .../sidebar/LinearAgentSkillSetupPrompt.tsx | 1 + .../sidebar/host-header-menu-items.ts | 1 + .../sidebar/sidebar-host-options.ts | 5 +- .../components/terminal-pane/TerminalPane.tsx | 2 +- .../terminal-pane/pty-connection-types.ts | 2 +- .../terminal-pane/pty-connection.ts | 8 +- .../terminal-pane/pty-transport-types.ts | 2 +- .../terminal-pane/pty-transport.test.ts | 6 +- .../components/terminal-pane/pty-transport.ts | 2 +- .../remote-runtime-pty-transport.test.ts | 6 +- .../remote-runtime-pty-transport.ts | 9 +- .../use-terminal-pane-lifecycle.ts | 2 +- .../src/hooks/useManagedAgentSkills.ts | 189 +++ src/renderer/src/i18n/locales/en.json | 51 +- src/renderer/src/i18n/locales/es.json | 51 +- src/renderer/src/i18n/locales/ja.json | 51 +- src/renderer/src/i18n/locales/ko.json | 51 +- src/renderer/src/i18n/locales/zh.json | 51 +- .../src/lib/ai-vault-resume-target.ts | 4 +- src/renderer/src/web/web-preload-api.ts | 20 +- src/shared/constants.ts | 7 +- src/shared/execution-host.test.ts | 8 +- src/shared/execution-host.ts | 27 +- src/shared/skill-management.ts | 164 +++ src/shared/skills.ts | 2 + src/shared/types.ts | 7 +- tests/e2e/settings-skill-detection.spec.ts | 111 +- ...ttings-skill-management-production.spec.ts | 47 + 125 files changed, 12210 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/skill-cli-roundtrip.yml create mode 100644 .github/workflows/skill-manifest-merge-gate.yml create mode 100644 config/scripts/generate-skill-bundle-manifest.mjs create mode 100644 config/scripts/generate-skill-bundle-manifest.test.mjs create mode 100644 config/scripts/verify-skill-cli-roundtrip.mjs create mode 100644 resources/skills/current-manifest.json create mode 100644 resources/skills/release-mapping.json create mode 100644 resources/skills/snapshot-registry.json create mode 100644 src/main/skills/skill-adoption-dismissal.ts create mode 100644 src/main/skills/skill-adoption.test.ts create mode 100644 src/main/skills/skill-adoption.ts create mode 100644 src/main/skills/skill-bundle-artifacts.ts create mode 100644 src/main/skills/skill-explicit-replacement.test.ts create mode 100644 src/main/skills/skill-explicit-replacement.ts create mode 100644 src/main/skills/skill-installation-topology.ts create mode 100644 src/main/skills/skill-managed-update.ts create mode 100644 src/main/skills/skill-management-inaccessible-candidate.ts create mode 100644 src/main/skills/skill-management-inventory.test.ts create mode 100644 src/main/skills/skill-management-inventory.ts create mode 100644 src/main/skills/skill-management-ledger.test.ts create mode 100644 src/main/skills/skill-management-ledger.ts create mode 100644 src/main/skills/skill-mutation-authority.test.ts create mode 100644 src/main/skills/skill-mutation-authority.ts create mode 100644 src/main/skills/skill-orphan-transaction-sweep.ts create mode 100644 src/main/skills/skill-owned-temp-publication.ts create mode 100644 src/main/skills/skill-package-identity.ts create mode 100644 src/main/skills/skill-package-observation-limits.test.ts create mode 100644 src/main/skills/skill-package-publication.test.ts create mode 100644 src/main/skills/skill-package-publication.ts create mode 100644 src/main/skills/skill-package-publish.test.ts create mode 100644 src/main/skills/skill-package-publish.ts create mode 100644 src/main/skills/skill-package-rollback.ts create mode 100644 src/main/skills/skill-package-swap-authority.test.ts create mode 100644 src/main/skills/skill-package-transaction-context.ts create mode 100644 src/main/skills/skill-published-destination-authority.ts create mode 100644 src/main/skills/skill-transaction-marker.test.ts create mode 100644 src/main/skills/skill-transaction-marker.ts create mode 100644 src/main/skills/skill-transaction-owned-state.ts create mode 100644 src/main/skills/skill-transaction-package-build.test.ts create mode 100644 src/main/skills/skill-transaction-package-build.ts create mode 100644 src/main/skills/skill-transaction-paths.ts create mode 100644 src/main/skills/skill-transaction-publication-state.test.ts create mode 100644 src/main/skills/skill-transaction-publication-state.ts create mode 100644 src/main/skills/skill-transaction-recovery.test.ts create mode 100644 src/main/skills/skill-transaction-recovery.ts create mode 100644 src/main/skills/skill-transaction-rename.ts create mode 100644 src/main/skills/skill-transaction-restore.ts create mode 100644 src/main/skills/skill-transaction-snapshot-validation.ts create mode 100644 src/main/skills/skill-transaction-workspace.test.ts create mode 100644 src/main/skills/skill-transaction-workspace.ts create mode 100644 src/main/skills/skills-cli-lock-provenance.test.ts create mode 100644 src/main/skills/skills-cli-lock-provenance.ts create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.test.tsx create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx create mode 100644 src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx create mode 100644 src/renderer/src/components/settings/managed-skill-status-copy.ts create mode 100644 src/renderer/src/components/settings/skill-install-terminal-exit.test.ts create mode 100644 src/renderer/src/components/settings/skill-install-terminal-exit.ts create mode 100644 src/renderer/src/components/settings/use-agent-skill-management-actions.ts create mode 100644 src/renderer/src/hooks/useManagedAgentSkills.ts create mode 100644 src/shared/skill-management.ts create mode 100644 tests/e2e/settings-skill-management-production.spec.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0a4169c6b7d..b93661233dd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 397772bfb7e..372dce928ff 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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 diff --git a/.github/workflows/skill-cli-roundtrip.yml b/.github/workflows/skill-cli-roundtrip.yml new file mode 100644 index 00000000000..f4e7bbcf03a --- /dev/null +++ b/.github/workflows/skill-cli-roundtrip.yml @@ -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 diff --git a/.github/workflows/skill-manifest-merge-gate.yml b/.github/workflows/skill-manifest-merge-gate.yml new file mode 100644 index 00000000000..20e2125e712 --- /dev/null +++ b/.github/workflows/skill-manifest-merge-gate.yml @@ -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^ diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index b15ffc07f5d..1b72e9380f7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -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}' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c2cbd6e62d0..670f3435a33 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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." } ] } diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index faf29fa244f..ac9016119f9 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -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 diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs new file mode 100644 index 00000000000..6d7bd631e49 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -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 +} diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs new file mode 100644 index 00000000000..1c694310084 --- /dev/null +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -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}$/) + }) +}) diff --git a/config/scripts/verify-skill-cli-roundtrip.mjs b/config/scripts/verify-skill-cli-roundtrip.mjs new file mode 100644 index 00000000000..7a28572d600 --- /dev/null +++ b/config/scripts/verify-skill-cli-roundtrip.mjs @@ -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= --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 }) +} diff --git a/package.json b/package.json index 6ed66c79734..c811e3f9c30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json new file mode 100644 index 00000000000..a580f3592a9 --- /dev/null +++ b/resources/skills/current-manifest.json @@ -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" + } + ] + } + ] +} diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json new file mode 100644 index 00000000000..076d6aed0f3 --- /dev/null +++ b/resources/skills/release-mapping.json @@ -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 + } + } + ] +} diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json new file mode 100644 index 00000000000..87601a19487 --- /dev/null +++ b/resources/skills/snapshot-registry.json @@ -0,0 +1,1159 @@ +{ + "schemaVersion": 1, + "skills": { + "orca-cli": [ + { + "releaseRevision": 1, + "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6", + "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84", + "files": [ + { + "path": "SKILL.md", + "size": 5547, + "executable": false, + "classification": "text", + "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1", + "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a", + "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304", + "files": [ + { + "path": "SKILL.md", + "size": 5850, + "executable": false, + "classification": "text", + "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb", + "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878", + "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26", + "files": [ + { + "path": "SKILL.md", + "size": 8871, + "executable": false, + "classification": "text", + "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681", + "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba", + "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64", + "files": [ + { + "path": "SKILL.md", + "size": 24622, + "executable": false, + "classification": "text", + "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56", + "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19", + "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5", + "files": [ + { + "path": "SKILL.md", + "size": 26576, + "executable": false, + "classification": "text", + "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc", + "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f", + "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740", + "files": [ + { + "path": "SKILL.md", + "size": 26970, + "executable": false, + "classification": "text", + "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a", + "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02", + "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6", + "files": [ + { + "path": "SKILL.md", + "size": 27011, + "executable": false, + "classification": "text", + "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1", + "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8", + "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774", + "files": [ + { + "path": "SKILL.md", + "size": 27038, + "executable": false, + "classification": "text", + "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347", + "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233", + "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde", + "files": [ + { + "path": "SKILL.md", + "size": 27197, + "executable": false, + "classification": "text", + "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e", + "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002", + "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28", + "files": [ + { + "path": "SKILL.md", + "size": 28908, + "executable": false, + "classification": "text", + "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137", + "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74", + "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6", + "files": [ + { + "path": "SKILL.md", + "size": 30358, + "executable": false, + "classification": "text", + "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07", + "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539", + "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed", + "files": [ + { + "path": "SKILL.md", + "size": 30787, + "executable": false, + "classification": "text", + "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1", + "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623", + "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c", + "files": [ + { + "path": "SKILL.md", + "size": 31985, + "executable": false, + "classification": "text", + "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d", + "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557", + "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b", + "files": [ + { + "path": "SKILL.md", + "size": 32276, + "executable": false, + "classification": "text", + "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c", + "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec", + "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69", + "files": [ + { + "path": "SKILL.md", + "size": 32853, + "executable": false, + "classification": "text", + "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672", + "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c", + "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291", + "files": [ + { + "path": "SKILL.md", + "size": 33310, + "executable": false, + "classification": "text", + "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f", + "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7", + "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c", + "files": [ + { + "path": "SKILL.md", + "size": 11055, + "executable": false, + "classification": "text", + "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b", + "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1", + "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4", + "files": [ + { + "path": "SKILL.md", + "size": 13423, + "executable": false, + "classification": "text", + "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea", + "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9", + "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4", + "files": [ + { + "path": "SKILL.md", + "size": 13764, + "executable": false, + "classification": "text", + "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c", + "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c" + } + ] + }, + { + "releaseRevision": 24, + "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b", + "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4", + "files": [ + { + "path": "SKILL.md", + "size": 14298, + "executable": false, + "classification": "text", + "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f", + "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f" + } + ] + }, + { + "releaseRevision": 25, + "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd", + "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e", + "files": [ + { + "path": "SKILL.md", + "size": 14320, + "executable": false, + "classification": "text", + "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4", + "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4" + } + ] + }, + { + "releaseRevision": 26, + "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac", + "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093", + "files": [ + { + "path": "SKILL.md", + "size": 14636, + "executable": false, + "classification": "text", + "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce", + "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce" + } + ] + }, + { + "releaseRevision": 27, + "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3", + "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6", + "files": [ + { + "path": "SKILL.md", + "size": 14829, + "executable": false, + "classification": "text", + "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835", + "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835" + } + ] + }, + { + "releaseRevision": 28, + "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc", + "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c", + "files": [ + { + "path": "SKILL.md", + "size": 15055, + "executable": false, + "classification": "text", + "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995", + "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995" + } + ] + }, + { + "releaseRevision": 29, + "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5", + "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb", + "files": [ + { + "path": "SKILL.md", + "size": 17257, + "executable": false, + "classification": "text", + "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e", + "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e" + } + ] + }, + { + "releaseRevision": 30, + "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35", + "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185", + "files": [ + { + "path": "SKILL.md", + "size": 19660, + "executable": false, + "classification": "text", + "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368", + "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368" + } + ] + }, + { + "releaseRevision": 31, + "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901", + "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c", + "files": [ + { + "path": "SKILL.md", + "size": 20862, + "executable": false, + "classification": "text", + "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14", + "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14" + } + ] + } + ], + "orchestration": [ + { + "releaseRevision": 1, + "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e", + "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c", + "files": [ + { + "path": "SKILL.md", + "size": 12589, + "executable": false, + "classification": "text", + "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75", + "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d", + "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2", + "files": [ + { + "path": "SKILL.md", + "size": 12972, + "executable": false, + "classification": "text", + "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca", + "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 5, + "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab", + "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff", + "files": [ + { + "path": "SKILL.md", + "size": 13003, + "executable": false, + "classification": "text", + "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee", + "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee" + } + ] + }, + { + "releaseRevision": 6, + "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049", + "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0", + "files": [ + { + "path": "SKILL.md", + "size": 13167, + "executable": false, + "classification": "text", + "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059", + "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059" + } + ] + }, + { + "releaseRevision": 7, + "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427", + "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd", + "files": [ + { + "path": "SKILL.md", + "size": 12749, + "executable": false, + "classification": "text", + "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223", + "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223" + } + ] + }, + { + "releaseRevision": 8, + "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16", + "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a", + "files": [ + { + "path": "SKILL.md", + "size": 12795, + "executable": false, + "classification": "text", + "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1", + "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1" + } + ] + }, + { + "releaseRevision": 9, + "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02", + "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a", + "files": [ + { + "path": "SKILL.md", + "size": 15044, + "executable": false, + "classification": "text", + "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6", + "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6" + } + ] + }, + { + "releaseRevision": 10, + "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455", + "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161", + "files": [ + { + "path": "SKILL.md", + "size": 10338, + "executable": false, + "classification": "text", + "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce", + "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce" + } + ] + }, + { + "releaseRevision": 11, + "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd", + "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5", + "files": [ + { + "path": "SKILL.md", + "size": 10348, + "executable": false, + "classification": "text", + "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7", + "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7" + } + ] + }, + { + "releaseRevision": 12, + "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006", + "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae", + "files": [ + { + "path": "SKILL.md", + "size": 10973, + "executable": false, + "classification": "text", + "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4", + "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4" + } + ] + }, + { + "releaseRevision": 13, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 14, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 15, + "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8", + "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c", + "files": [ + { + "path": "SKILL.md", + "size": 11536, + "executable": false, + "classification": "text", + "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab", + "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab" + } + ] + }, + { + "releaseRevision": 16, + "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1", + "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f", + "files": [ + { + "path": "SKILL.md", + "size": 12177, + "executable": false, + "classification": "text", + "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a", + "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a" + } + ] + }, + { + "releaseRevision": 17, + "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169", + "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc", + "files": [ + { + "path": "SKILL.md", + "size": 13415, + "executable": false, + "classification": "text", + "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb", + "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb" + } + ] + }, + { + "releaseRevision": 18, + "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23", + "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700", + "files": [ + { + "path": "SKILL.md", + "size": 17357, + "executable": false, + "classification": "text", + "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a", + "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a" + } + ] + }, + { + "releaseRevision": 19, + "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f", + "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a", + "files": [ + { + "path": "SKILL.md", + "size": 17600, + "executable": false, + "classification": "text", + "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628", + "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628" + } + ] + }, + { + "releaseRevision": 20, + "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d", + "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716", + "files": [ + { + "path": "SKILL.md", + "size": 19701, + "executable": false, + "classification": "text", + "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a", + "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a" + } + ] + }, + { + "releaseRevision": 21, + "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7", + "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698", + "files": [ + { + "path": "SKILL.md", + "size": 21546, + "executable": false, + "classification": "text", + "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67", + "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67" + } + ] + }, + { + "releaseRevision": 22, + "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b", + "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917", + "files": [ + { + "path": "SKILL.md", + "size": 21701, + "executable": false, + "classification": "text", + "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10", + "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10" + } + ] + }, + { + "releaseRevision": 23, + "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b", + "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2", + "files": [ + { + "path": "SKILL.md", + "size": 22850, + "executable": false, + "classification": "text", + "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9", + "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9" + } + ] + } + ], + "mobile-fit-debug": [ + { + "releaseRevision": 1, + "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46", + "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a", + "files": [ + { + "path": "SKILL.md", + "size": 9414, + "executable": false, + "classification": "text", + "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab", + "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab" + } + ] + } + ], + "computer-use": [ + { + "releaseRevision": 1, + "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb", + "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0", + "files": [ + { + "path": "SKILL.md", + "size": 8514, + "executable": false, + "classification": "text", + "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc", + "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547", + "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0", + "files": [ + { + "path": "SKILL.md", + "size": 6368, + "executable": false, + "classification": "text", + "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c", + "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7", + "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2", + "files": [ + { + "path": "SKILL.md", + "size": 10454, + "executable": false, + "classification": "text", + "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601", + "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc", + "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b", + "files": [ + { + "path": "SKILL.md", + "size": 10625, + "executable": false, + "classification": "text", + "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b", + "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b" + } + ] + } + ], + "orca-emulator": [ + { + "releaseRevision": 1, + "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a", + "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417", + "files": [ + { + "path": "SKILL.md", + "size": 10715, + "executable": false, + "classification": "text", + "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045", + "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b", + "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd", + "files": [ + { + "path": "SKILL.md", + "size": 10717, + "executable": false, + "classification": "text", + "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712", + "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412", + "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f", + "files": [ + { + "path": "SKILL.md", + "size": 10853, + "executable": false, + "classification": "text", + "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834", + "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834" + } + ] + } + ], + "linear-tickets": [ + { + "releaseRevision": 1, + "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350", + "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882", + "files": [ + { + "path": "SKILL.md", + "size": 6271, + "executable": false, + "classification": "text", + "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1", + "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05", + "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0", + "files": [ + { + "path": "SKILL.md", + "size": 8985, + "executable": false, + "classification": "text", + "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2", + "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2" + } + ] + }, + { + "releaseRevision": 3, + "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15", + "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7", + "files": [ + { + "path": "SKILL.md", + "size": 9670, + "executable": false, + "classification": "text", + "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b", + "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b" + } + ] + }, + { + "releaseRevision": 4, + "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", + "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", + "files": [ + { + "path": "SKILL.md", + "size": 10596, + "executable": false, + "classification": "text", + "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5", + "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5" + } + ] + } + ], + "orca-linear": [ + { + "releaseRevision": 1, + "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d", + "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f", + "files": [ + { + "path": "SKILL.md", + "size": 9394, + "executable": false, + "classification": "text", + "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f", + "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", + "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", + "files": [ + { + "path": "SKILL.md", + "size": 10320, + "executable": false, + "classification": "text", + "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5", + "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5" + } + ] + } + ], + "orca-emulator-android": [ + { + "releaseRevision": 1, + "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4", + "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6", + "files": [ + { + "path": "SKILL.md", + "size": 8189, + "executable": false, + "classification": "text", + "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844", + "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844" + } + ] + } + ], + "orca-per-workspace-env": [ + { + "releaseRevision": 1, + "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714", + "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c", + "files": [ + { + "path": "SKILL.md", + "size": 37692, + "executable": false, + "classification": "text", + "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc", + "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc" + } + ] + }, + { + "releaseRevision": 2, + "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", + "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", + "files": [ + { + "path": "SKILL.md", + "size": 43769, + "executable": false, + "classification": "text", + "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7", + "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7" + } + ] + } + ] + } +} diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d..aaa56d4426a 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -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('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[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 } @@ -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' + }) + ) + }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd695790..c3b5e75e583 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -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 + +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 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 { + 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 => { @@ -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 => { + 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 => { + 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 => { + 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 => { + const context = getSkillManagementHostContext(args.target) + return previewExplicitSkillReplacement({ + ...context, + ledger: store.getSkillManagementLedger(), + installationId: args.installationId + }) + } + ) + + ipcMain.handle( + 'skills:replace', + async (_event, args: SkillManagementActionArgs): Promise => { + 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 => { + 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 => { + 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 + } + ) } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4ae746a1f28..528e804b0e8 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 } + } + ).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') diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 44d0827bc5b..1b3b9f891b8 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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] } diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index dcee058f8e2..ce1ce029385 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -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') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af426..e67c500d78f 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -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 + 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 + } +} diff --git a/src/main/skills/skill-adoption.test.ts b/src/main/skills/skill-adoption.test.ts new file mode 100644 index 00000000000..2c742123ec9 --- /dev/null +++ b/src/main/skills/skill-adoption.test.ts @@ -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 }) => 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) + }) +}) diff --git a/src/main/skills/skill-adoption.ts b/src/main/skills/skill-adoption.ts new file mode 100644 index 00000000000..3bc41fefe62 --- /dev/null +++ b/src/main/skills/skill-adoption.ts @@ -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 + commit: (destination: ManagedSkillDestination) => void | Promise +}): Promise { + 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 { + 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() + } +} diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts new file mode 100644 index 00000000000..d04880cd700 --- /dev/null +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -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 + releasedAppVersions: Record> + 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 { + 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>() + const releasedAppVersions: Record> = {} + 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() + 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') + } +} diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index c1bb1072b2e..d909c688582 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -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( diff --git a/src/main/skills/skill-explicit-replacement.test.ts b/src/main/skills/skill-explicit-replacement.test.ts new file mode 100644 index 00000000000..4cd322e5424 --- /dev/null +++ b/src/main/skills/skill-explicit-replacement.test.ts @@ -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' }) + }) +}) diff --git a/src/main/skills/skill-explicit-replacement.ts b/src/main/skills/skill-explicit-replacement.ts new file mode 100644 index 00000000000..3ac8cf511b8 --- /dev/null +++ b/src/main/skills/skill-explicit-replacement.ts @@ -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 { + 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 => { + 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 { + 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 +}): Promise { + 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' } +} diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts new file mode 100644 index 00000000000..aa9a10f6b52 --- /dev/null +++ b/src/main/skills/skill-installation-topology.ts @@ -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> +): 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 { + 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 { + 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 { + 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 { + 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 { + let logicalStat: Awaited> + 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> + 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 } +} diff --git a/src/main/skills/skill-managed-update.ts b/src/main/skills/skill-managed-update.ts new file mode 100644 index 00000000000..633d4a9908d --- /dev/null +++ b/src/main/skills/skill-managed-update.ts @@ -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 +}): Promise { + 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 + }) +} diff --git a/src/main/skills/skill-management-inaccessible-candidate.ts b/src/main/skills/skill-management-inaccessible-candidate.ts new file mode 100644 index 00000000000..0123a40f5fb --- /dev/null +++ b/src/main/skills/skill-management-inaccessible-candidate.ts @@ -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 + } + } +} diff --git a/src/main/skills/skill-management-inventory.test.ts b/src/main/skills/skill-management-inventory.test.ts new file mode 100644 index 00000000000..db85b863862 --- /dev/null +++ b/src/main/skills/skill-management-inventory.test.ts @@ -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 { + 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 { + 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 }[] + } + 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']) + }) + } + ) +}) diff --git a/src/main/skills/skill-management-inventory.ts b/src/main/skills/skill-management-inventory.ts new file mode 100644 index 00000000000..a4f754a7052 --- /dev/null +++ b/src/main/skills/skill-management-inventory.ts @@ -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 +type CandidateTopology = ( + root: SkillScanRoot, + unresolvedPath: string, + canonicalRootPath: string +) => Promise + +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 + actionsSupported: boolean + candidateLstat: CandidateLstat + classifyTopology: CandidateTopology +}): Promise { + 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 { + 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() + 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() + } +} diff --git a/src/main/skills/skill-management-ledger.test.ts b/src/main/skills/skill-management-ledger.test.ts new file mode 100644 index 00000000000..0b7d3df0585 --- /dev/null +++ b/src/main/skills/skill-management-ledger.test.ts @@ -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({}) + }) +}) diff --git a/src/main/skills/skill-management-ledger.ts b/src/main/skills/skill-management-ledger.ts new file mode 100644 index 00000000000..83370aced45 --- /dev/null +++ b/src/main/skills/skill-management-ledger.ts @@ -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 { + 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') +} diff --git a/src/main/skills/skill-mutation-authority.test.ts b/src/main/skills/skill-mutation-authority.test.ts new file mode 100644 index 00000000000..4039d3681e3 --- /dev/null +++ b/src/main/skills/skill-mutation-authority.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/main/skills/skill-mutation-authority.ts b/src/main/skills/skill-mutation-authority.ts new file mode 100644 index 00000000000..281bf04f689 --- /dev/null +++ b/src/main/skills/skill-mutation-authority.ts @@ -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) + ) +} diff --git a/src/main/skills/skill-orphan-transaction-sweep.ts b/src/main/skills/skill-orphan-transaction-sweep.ts new file mode 100644 index 00000000000..6cbc86f01c6 --- /dev/null +++ b/src/main/skills/skill-orphan-transaction-sweep.ts @@ -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 { + 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 { + 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) | 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?.() + } + } + } +} diff --git a/src/main/skills/skill-owned-temp-publication.ts b/src/main/skills/skill-owned-temp-publication.ts new file mode 100644 index 00000000000..23c811a3dd6 --- /dev/null +++ b/src/main/skills/skill-owned-temp-publication.ts @@ -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 + writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise + chmodTemp?: (handle: FileHandle, mode: number) => Promise + syncTemp?: (handle: FileHandle) => Promise +} + +export async function publishSkillFileThroughOwnedTemp(args: { + destinationRoot: string + sourcePath: string + expectedFile: SkillBundleFileIdentity + direction: 'forward' | 'rollback' + packagePhysicalIdentity: string + operationIndex: number + callbacks: PublicationCallbacks + runtime?: SkillOwnedTempRuntime +}): Promise { + 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) +} diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts new file mode 100644 index 00000000000..0df2be320ea --- /dev/null +++ b/src/main/skills/skill-package-identity.ts @@ -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 { + 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 { + const files: ObservedSkillFile[] = [] + const caseFoldedPaths = new Map() + let entryCount = 0 + let totalBytes = 0 + + async function visit(directory: string, depth: number): Promise { + 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) +} diff --git a/src/main/skills/skill-package-observation-limits.test.ts b/src/main/skills/skill-package-observation-limits.test.ts new file mode 100644 index 00000000000..3cd5930c642 --- /dev/null +++ b/src/main/skills/skill-package-observation-limits.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/main/skills/skill-package-publication.test.ts b/src/main/skills/skill-package-publication.test.ts new file mode 100644 index 00000000000..431479e3417 --- /dev/null +++ b/src/main/skills/skill-package-publication.test.ts @@ -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): Promise { + 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 { + 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 => { + marker = { ...marker, publication } + history.push(publication) + } + const assertBoundary = async ( + publication: SkillPublicationState, + expectedLiveState?: Awaited> + ): Promise => { + 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 => { + 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>): SkillOwnedTempRuntime => ({ + writeBytes: async (handle, bytes) => { + await handle.writeFile(bytes) + await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit') + } + }) + }, + { + boundary: 'chmod', + runtime: (fixture: Awaited>): SkillOwnedTempRuntime => ({ + chmodTemp: async (handle, mode) => { + await handle.chmod(mode) + await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit') + } + }) + }, + { + boundary: 'sync', + runtime: (fixture: Awaited>): 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 => { + 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 => { + marker = { ...marker, publication } + } + let edited = false + const assertBoundary = async (publication: SkillPublicationState): Promise => { + 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 => { + 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 => { + activeMarker = { ...activeMarker, publication } + } + const assertBoundary = async (publication: SkillPublicationState): Promise => { + 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') + } + } + ) +}) diff --git a/src/main/skills/skill-package-publication.ts b/src/main/skills/skill-package-publication.ts new file mode 100644 index 00000000000..3a118ed098d --- /dev/null +++ b/src/main/skills/skill-package-publication.ts @@ -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 + assertBoundary: ( + state: SkillPublicationState, + expectedLiveState?: SkillTransactionLiveState + ) => Promise +} + +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 { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/src/main/skills/skill-package-publish.test.ts b/src/main/skills/skill-package-publish.test.ts new file mode 100644 index 00000000000..3477dd5742d --- /dev/null +++ b/src/main/skills/skill-package-publish.test.ts @@ -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 { + 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 => { + 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() + } + ) +}) diff --git a/src/main/skills/skill-package-publish.ts b/src/main/skills/skill-package-publish.ts new file mode 100644 index 00000000000..ae9ebcff557 --- /dev/null +++ b/src/main/skills/skill-package-publish.ts @@ -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[1] + packagesRoot: string + commit: (destination: ManagedSkillDestination) => void | Promise + now?: number + beforeDurableCommitValidation?: () => void | Promise + beforeWorkspaceCreationValidation?: () => void | Promise + publicationRenameRuntime?: SkillRenameRuntime +}): Promise { + 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) + } + } +} diff --git a/src/main/skills/skill-package-rollback.ts b/src/main/skills/skill-package-rollback.ts new file mode 100644 index 00000000000..5ff0b54a7fc --- /dev/null +++ b/src/main/skills/skill-package-rollback.ts @@ -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 { + 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 { + 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) + } + } + } +} diff --git a/src/main/skills/skill-package-swap-authority.test.ts b/src/main/skills/skill-package-swap-authority.test.ts new file mode 100644 index 00000000000..0f4c3daf517 --- /dev/null +++ b/src/main/skills/skill-package-swap-authority.test.ts @@ -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): Promise { + 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 { + 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 => { + marker = { ...marker, publication } + history.push(publication) + } + const assertBoundary = async ( + publication: SkillPublicationState, + expectedLiveState?: Awaited> + ): Promise => { + 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' + }) + } + ) +}) diff --git a/src/main/skills/skill-package-transaction-context.ts b/src/main/skills/skill-package-transaction-context.ts new file mode 100644 index 00000000000..1024a67b0c4 --- /dev/null +++ b/src/main/skills/skill-package-transaction-context.ts @@ -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 + assertParentAuthority: () => Promise + journalStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise + assertStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise + journalBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise + assertBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise + marker: (phase: SkillTransactionMarker['phase']) => SkillTransactionMarker + writeMarker: (phase: SkillTransactionMarker['phase'], checkContent: boolean) => Promise + writeVerifiedMarker: (assertPublished: () => Promise) => Promise +} + +export async function createSkillTransactionContext(args: { + record: ManagedSkillDestination + current: SkillCurrentBundleEntry + releasedSnapshots: readonly SkillReleasedSnapshot[] + now: number + beforeWorkspaceValidation?: () => void | Promise +}): Promise { + const workspace = await resolveSkillTransactionWorkspace(args.record) + await args.beforeWorkspaceValidation?.() + const assertAuthority = async (checkContent: boolean): Promise => { + 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 => { + 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 => { + await writeSkillTransactionMarker(transactionRoot, marker('staging'), assertBuildAuthority) + } + const journal = async (next: SkillPublicationState): Promise => { + 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() + }) + } + } +} diff --git a/src/main/skills/skill-published-destination-authority.ts b/src/main/skills/skill-published-destination-authority.ts new file mode 100644 index 00000000000..9bf80e8d24b --- /dev/null +++ b/src/main/skills/skill-published-destination-authority.ts @@ -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[1] +): Promise { + 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 { + 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 + }) +} diff --git a/src/main/skills/skill-transaction-marker.test.ts b/src/main/skills/skill-transaction-marker.test.ts new file mode 100644 index 00000000000..ba416e954d2 --- /dev/null +++ b/src/main/skills/skill-transaction-marker.test.ts @@ -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 { + 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 { + 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() + }) +}) diff --git a/src/main/skills/skill-transaction-marker.ts b/src/main/skills/skill-transaction-marker.ts new file mode 100644 index 00000000000..99caa7bb16f --- /dev/null +++ b/src/main/skills/skill-transaction-marker.ts @@ -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 + 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 = async () => undefined +): Promise { + 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) + } + } +} diff --git a/src/main/skills/skill-transaction-owned-state.ts b/src/main/skills/skill-transaction-owned-state.ts new file mode 100644 index 00000000000..20b9ff78fbd --- /dev/null +++ b/src/main/skills/skill-transaction-owned-state.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return matchesOwnedPackage({ path: marker.destinationPath, physicalIdentity, snapshot }) +} + +async function assertInPlaceState(args: { + transactionRoot: string + marker: SkillTransactionMarker + allowAppliedOperation: boolean +}): Promise { + 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 { + 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 + allowAppliedOperation: boolean +}): Promise { + 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) +} diff --git a/src/main/skills/skill-transaction-package-build.test.ts b/src/main/skills/skill-transaction-package-build.test.ts new file mode 100644 index 00000000000..b37c94e0064 --- /dev/null +++ b/src/main/skills/skill-transaction-package-build.test.ts @@ -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') + }) +}) diff --git a/src/main/skills/skill-transaction-package-build.ts b/src/main/skills/skill-transaction-package-build.ts new file mode 100644 index 00000000000..84d8b0256b4 --- /dev/null +++ b/src/main/skills/skill-transaction-package-build.ts @@ -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 + createParent?: (path: string) => Promise + openFile?: (path: string, mode: number) => Promise + readSource?: (path: string) => Promise + writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise + chmodFile?: (handle: FileHandle, mode: number) => Promise + syncFile?: (handle: FileHandle) => Promise +} + +export function isSkillTransactionPackageBuildEvidence( + value: unknown +): value is SkillTransactionPackageBuildEvidence { + if (typeof value !== 'object' || value === null) { + return false + } + const evidence = value as Partial + 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 { + const directories = new Set() + 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 { + const allowed = expectedDirectories(paths) + const visit = async (directory: string, relativeDirectory: string): Promise => { + 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>, + 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 { + 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 + journal: (evidence: SkillTransactionPackageBuildEvidence) => Promise + assertBoundary: (evidence: SkillTransactionPackageBuildEvidence) => Promise + runtime?: SkillTransactionPackageBuildRuntime +}): Promise { + 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 +} diff --git a/src/main/skills/skill-transaction-paths.ts b/src/main/skills/skill-transaction-paths.ts new file mode 100644 index 00000000000..550cb2ebb6a --- /dev/null +++ b/src/main/skills/skill-transaction-paths.ts @@ -0,0 +1 @@ +export const RESERVED_SKILL_TRANSACTION_DIRECTORY = '.orca-skill-transactions' diff --git a/src/main/skills/skill-transaction-publication-state.test.ts b/src/main/skills/skill-transaction-publication-state.test.ts new file mode 100644 index 00000000000..51339c48e47 --- /dev/null +++ b/src/main/skills/skill-transaction-publication-state.test.ts @@ -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): Promise { + 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 { + 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 { + 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) + }) +}) diff --git a/src/main/skills/skill-transaction-publication-state.ts b/src/main/skills/skill-transaction-publication-state.ts new file mode 100644 index 00000000000..8e00186f54a --- /dev/null +++ b/src/main/skills/skill-transaction-publication-state.ts @@ -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)) + ) +} diff --git a/src/main/skills/skill-transaction-recovery.test.ts b/src/main/skills/skill-transaction-recovery.test.ts new file mode 100644 index 00000000000..00b4aadb2a1 --- /dev/null +++ b/src/main/skills/skill-transaction-recovery.test.ts @@ -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 { + 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['step'] +}): Promise { + 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 { + 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() + }) +}) diff --git a/src/main/skills/skill-transaction-recovery.ts b/src/main/skills/skill-transaction-recovery.ts new file mode 100644 index 00000000000..a3c4e4f0199 --- /dev/null +++ b/src/main/skills/skill-transaction-recovery.ts @@ -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 { + 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 = async () => undefined +): Promise<() => Promise> { + const create = async (): Promise => { + 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 }) +} diff --git a/src/main/skills/skill-transaction-rename.ts b/src/main/skills/skill-transaction-rename.ts new file mode 100644 index 00000000000..92580c2d21f --- /dev/null +++ b/src/main/skills/skill-transaction-rename.ts @@ -0,0 +1,35 @@ +import { rename } from 'node:fs/promises' + +export type SkillRenameRuntime = { + renameEntry?: typeof rename + wait?: (milliseconds: number) => Promise + windowsFallback?: boolean +} + +export async function retrySkillRename( + source: string, + destination: string, + beforeAttempt: () => Promise, + renameEntry: typeof rename = rename, + wait: (milliseconds: number) => Promise = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)) +): Promise { + 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) + } + } +} diff --git a/src/main/skills/skill-transaction-restore.ts b/src/main/skills/skill-transaction-restore.ts new file mode 100644 index 00000000000..3f4522f6199 --- /dev/null +++ b/src/main/skills/skill-transaction-restore.ts @@ -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 + currentDisposition: 'committed' | 'restore' +}): Promise { + 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 => { + 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') + } +} diff --git a/src/main/skills/skill-transaction-snapshot-validation.ts b/src/main/skills/skill-transaction-snapshot-validation.ts new file mode 100644 index 00000000000..7724575e432 --- /dev/null +++ b/src/main/skills/skill-transaction-snapshot-validation.ts @@ -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 + 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 + 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) + ) +} diff --git a/src/main/skills/skill-transaction-workspace.test.ts b/src/main/skills/skill-transaction-workspace.test.ts new file mode 100644 index 00000000000..60c52959d24 --- /dev/null +++ b/src/main/skills/skill-transaction-workspace.test.ts @@ -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)) + }) +}) diff --git a/src/main/skills/skill-transaction-workspace.ts b/src/main/skills/skill-transaction-workspace.ts new file mode 100644 index 00000000000..0291e0132ba --- /dev/null +++ b/src/main/skills/skill-transaction-workspace.ts @@ -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 { + const roots = await inspectWorkspaceRoots(record) + return { + ...roots, + reservedRoot: skillTransactionReservedRoot(roots) + } +} + +export async function assertSkillTransactionWorkspace( + record: ManagedSkillDestination, + expected: SkillTransactionWorkspace +): Promise { + 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') + } +} diff --git a/src/main/skills/skills-cli-lock-provenance.test.ts b/src/main/skills/skills-cli-lock-provenance.test.ts new file mode 100644 index 00000000000..50da459f4ac --- /dev/null +++ b/src/main/skills/skills-cli-lock-provenance.test.ts @@ -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 { + 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()) + }) +}) diff --git a/src/main/skills/skills-cli-lock-provenance.ts b/src/main/skills/skills-cli-lock-provenance.ts new file mode 100644 index 00000000000..5f026093e7a --- /dev/null +++ b/src/main/skills/skills-cli-lock-provenance.ts @@ -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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function officialOrcaSource(entry: Record): 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> { + 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] => isRecord(entry[1])) + .filter(([, entry]) => officialOrcaSource(entry)) + .map(([name]) => name) + ) + } catch { + return new Set() + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4babcd587bb..657c2abffbd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 + managementInventory: (target?: SkillDiscoveryTarget) => Promise + adopt: (args: SkillManagementActionArgs) => Promise + dismissAdoption: (args: SkillAdoptionDismissalArgs) => Promise + previewReplacement: (args: SkillManagementActionArgs) => Promise + replace: (args: SkillManagementActionArgs) => Promise + updateManaged: (args: SkillManagementActionArgs) => Promise + recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise } pet: { import: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c7af55f0af..711a428de8c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + managementInventory: (target?: SkillDiscoveryTarget): Promise => + ipcRenderer.invoke('skills:managementInventory', target), + adopt: (args: SkillManagementActionArgs): Promise => + ipcRenderer.invoke('skills:adopt', args), + dismissAdoption: (args: SkillAdoptionDismissalArgs): Promise => + ipcRenderer.invoke('skills:dismissAdoption', args), + previewReplacement: (args: SkillManagementActionArgs): Promise => + ipcRenderer.invoke('skills:previewReplacement', args), + replace: (args: SkillManagementActionArgs): Promise => + ipcRenderer.invoke('skills:replace', args), + updateManaged: (args: SkillManagementActionArgs): Promise => + ipcRenderer.invoke('skills:updateManaged', args), + recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise => + ipcRenderer.invoke('skills:recordOrcaInstall', args) }, pet: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9e111a6cb4d..32e9a0e3c0d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + {/* 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 diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx index f169591d5e1..5849f6289f9 100644 --- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx +++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx @@ -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({ )}

(null) const handleCopySkillCommand = async (): Promise => { 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) + }} /> ) diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx index b8b1cd58306..90e64572e4a 100644 --- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx +++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx @@ -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 = ( ({ + 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()) + + 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()) + 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() + }) +}) diff --git a/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx new file mode 100644 index 00000000000..b6ac3e6ae49 --- /dev/null +++ b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx @@ -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()) + const persistedKeys = useRef(new Set()) + 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 +} diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx index e15c796d4bf..249dc9d962d 100644 --- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx +++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx @@ -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(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) + }} /> ) } diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 9f7c8da1398..3ef6f379460 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -28,7 +28,7 @@ type OnboardingInlineCommandTerminalProps = { shellOverride?: string onOpened?: () => void onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => 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 })} diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts index c16632d874b..e7cc532e742 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts @@ -55,6 +55,10 @@ const FEATURE_SKILL_NAMES: Record = { 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 } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts index 451d0fd807d..9cde55e3aaf 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts @@ -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' } } diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx index 69d2360b7e7..f72dccde491 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx @@ -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 }) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b619..c7f5b6ae7e3 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -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> @@ -46,6 +49,8 @@ type AgentSkillSetupPanelProps = { actionHint?: ReactNode openingHint?: ReactNode footer?: ReactNode + managedSkillNames?: readonly string[] + managementTarget?: SkillDiscoveryTarget onRecheck: () => void | Promise } @@ -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 = (
- {!installed || showInstallWhenInstalled ? ( + {skillManagement.applicable ? ( + skillManagement.actionLabel ? ( + + ) : null + ) : !installed || showInstallWhenInstalled ? (
) : null} diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 99718de46fe..f7dca23ac63 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -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} /> + + diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 8f2dea14ee4..b52769a5d52 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -274,6 +274,7 @@ export function BrowserUseSetup({ )} > Promise>> onBeforeOpenTerminal?: () => void | Promise + managementTarget?: SkillDiscoveryTarget onRecheck: () => void | Promise } @@ -28,10 +31,13 @@ export function BrowserUseSkillStep({ preInstallNotice, getPrerequisiteStatus, onBeforeOpenTerminal, + managementTarget, onRecheck }: Props): React.JSX.Element { return ( ['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 ( + !open && onClose()}> + + + + {translate( + 'auto.components.settings.ManagedOrcaSkills.replaceTitle', + 'Replace {{value0}}?', + { value0: preview.skillName } + )} + + + {translate( + 'auto.components.settings.ManagedOrcaSkills.replaceDescription', + 'This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.' + )} + + +
+ {preview.files.map((file) => ( +
+
+ {file.path} + {file.change} +
+ {file.beforeText !== null || file.afterText !== null ? ( +
+
+                    {file.beforeText ?? '—'}
+                  
+
+                    {file.afterText ?? '—'}
+                  
+
+ ) : ( +

+ {translate( + 'auto.components.settings.ManagedOrcaSkills.binaryChange', + 'Binary file contents differ.' + )} +

+ )} +
+ ))} +
+ + + + + + +
+
+ ) +} + +export function ManagedOrcaSkillsSection(): React.JSX.Element { + const state = useManagedAgentSkills() + const [preview, setPreview] = useState(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 => { + try { + setPreview(await state.previewReplacement(installationId)) + } catch (error) { + reportFailure(error) + } + } + + const run = async (action: () => Promise, success: string): Promise => { + try { + await action() + toast.success(success) + return true + } catch (error) { + reportFailure(error) + return false + } + } + + return ( +
+ void state.refresh()} + > + + {translate('auto.components.settings.ManagedOrcaSkills.refresh', 'Refresh')} + + } + /> + {state.error ?

{state.error}

: null} + {state.inventory?.installations.length ? ( +
+ {state.inventory.installations.map((installation) => { + const busy = state.busyInstallationId === installation.id + const visibility = missingVisibilityCopy(installation) + const replaceable = + installation.status === 'modified' || installation.status === 'unknown' + return ( +
+
+
+ {installation.name} + + {managedSkillStatusCopy(installation.status)} + +
+

+ {diagnosticCopy(installation)} +

+ {installation.description ? ( +

{installation.description}

+ ) : null} +

+ {installation.unresolvedPath} +

+ {visibility ? ( +

{visibility}

+ ) : null} + {!installation.actionsSupported ? ( +

+ {translate( + 'auto.components.settings.ManagedOrcaSkills.hostPending', + 'Detection only on this host; management actions are not available yet.' + )} +

+ ) : null} +
+ {!installation.actionsSupported ? null : installation.status === 'known-current' || + installation.status === 'known-update-available' ? ( + + ) : installation.status === 'managed-update-available' || + installation.status === 'update-failed' ? ( + + ) : replaceable ? ( + + ) : null} +
+ ) + })} +
+ ) : state.loading ? ( +

+ {translate( + 'auto.components.settings.ManagedOrcaSkills.checking', + 'Checking installed skills…' + )} +

+ ) : ( +

+ {translate( + 'auto.components.settings.ManagedOrcaSkills.none', + 'No installed Orca skills found in global agent homes.' + )} +

+ )} + {preview ? ( + 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} +
+ ) +} diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx index 17946134bc1..4ceaca1f757 100644 --- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx +++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx @@ -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 {
({ 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 () => { diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx index 43e755dcbe2..5e63bc71c9d 100644 --- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -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({ )} ) : skill.installed ? ( - - {translate( - 'auto.components.settings.linear.agent.skill.install.cta.installed', - 'Installed' - )} + + {management.statusLabel ?? + translate( + 'auto.components.settings.linear.agent.skill.install.cta.installed', + 'Installed' + )} ) : ( @@ -136,44 +136,53 @@ export function LinearAgentSkillInstallCta({ {!skill.loading && ( <>

- {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.' + )}

-
- - {command} - - - - + ) : null + ) : ( +
+ + {command} + + + + + + + {translate( 'auto.components.settings.linear.agent.skill.install.cta.copyCommand', 'Copy command' )} - onClick={() => void copyCommand()} - > - - - - - {translate( - 'auto.components.settings.linear.agent.skill.install.cta.copyCommand', - 'Copy command' - )} - - -
+ +
+
+ )} {remote || agentRuntime.runtime === 'wsl' ? (

{getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)} diff --git a/src/renderer/src/components/settings/managed-skill-status-copy.ts b/src/renderer/src/components/settings/managed-skill-status-copy.ts new file mode 100644 index 00000000000..2343ea1102f --- /dev/null +++ b/src/renderer/src/components/settings/managed-skill-status-copy.ts @@ -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') + } +} diff --git a/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts b/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts new file mode 100644 index 00000000000..669cdfd9bac --- /dev/null +++ b/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/settings/skill-install-terminal-exit.ts b/src/renderer/src/components/settings/skill-install-terminal-exit.ts new file mode 100644 index 00000000000..ff080e4b223 --- /dev/null +++ b/src/renderer/src/components/settings/skill-install-terminal-exit.ts @@ -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 +} diff --git a/src/renderer/src/components/settings/use-agent-skill-management-actions.ts b/src/renderer/src/components/settings/use-agent-skill-management-actions.ts new file mode 100644 index 00000000000..ca808d590f9 --- /dev/null +++ b/src/renderer/src/components/settings/use-agent-skill-management-actions.ts @@ -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(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 => { + 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() + } + } +} diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx index 05f534f6953..f4fb516af18 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx @@ -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 @@ -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({

void + onPtyExit: (ptyId: string, code?: number | null) => void onCloseTab: () => void } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 8e151c6ab5c..911cf62f8aa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -47,7 +47,7 @@ export type PtyConnectionDeps = { restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject - onPtyExitRef: React.RefObject<(ptyId: string) => void> + onPtyExitRef: React.RefObject<(ptyId: string, code?: number | null) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8399d469d08..19e486b542c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2351,7 +2351,7 @@ export function connectPanePty( }) return claimKey } - const onExit = (ptyId: string): void => { + const onExit = (ptyId: string, code: number | null = null): void => { if (handledExitPtyId === ptyId) { return } @@ -2452,7 +2452,11 @@ export function connectPanePty( if (spawnedFreshPtyId === ptyId && !Number.isFinite(lastTerminalInputAt)) { return } - deps.onPtyExitRef.current(ptyId) + if (code === null) { + deps.onPtyExitRef.current(ptyId) + } else { + deps.onPtyExitRef.current(ptyId, code) + } return } if ( diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 7f0f4876eba..bb756c3039f 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -145,7 +145,7 @@ export type IpcPtyTransportOptions = { projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: TerminalOscColorQueryReplyColors telemetry?: EventProps<'agent_started'> - onPtyExit?: (ptyId: string) => void + onPtyExit?: (ptyId: string, code?: number | null) => void onTitleChange?: (title: string, rawTitle: string) => void onPtySpawn?: (ptyId: string) => void onBell?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index fd2663b5a33..4f8fe658f01 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -132,7 +132,7 @@ describe('createIpcPtyTransport', () => { onExit?.({ id: 'pty-new', code: 0 }) - expect(onPtyExit).toHaveBeenCalledWith('pty-new') + expect(onPtyExit).toHaveBeenCalledWith('pty-new', 0) expect(transport.getPtyId()).toBeNull() expect(transport.isConnected()).toBe(false) }) @@ -1433,7 +1433,7 @@ describe('createIpcPtyTransport', () => { // Exit handler should still work (exit handlers are kept alive) onExit?.({ id: 'pty-1', code: -1 }) - expect(onPtyExit).toHaveBeenCalledWith('pty-1') + expect(onPtyExit).toHaveBeenCalledWith('pty-1', -1) }) it('restores data handlers when an intentional shutdown fails before exit', async () => { @@ -1747,7 +1747,7 @@ describe('createIpcPtyTransport', () => { onExit?.({ id: 'pty-detached', code: 0 }) - expect(onPtyExit).toHaveBeenCalledWith('pty-detached') + expect(onPtyExit).toHaveBeenCalledWith('pty-detached', 0) expect(transport.getPtyId()).toBeNull() }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 60656f2907f..c92e6e834b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -663,7 +663,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra unregisterPtyHandlers(id) storedCallbacks.onExit?.(code) storedCallbacks.onDisconnect?.() - onPtyExit?.(id) + onPtyExit?.(id, code) } ptyExitHandlers.set(id, exitHandler) ownedExitHandlers.set(id, exitHandler) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 59d23ca43b3..85b075ef24a 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -394,7 +394,7 @@ describe('createRemoteRuntimePtyTransport', () => { // Why: no red xterm error — retire quietly and let the next session-tabs // snapshot drive respawn/removal. - await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1')) + await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1', null)) expect(transport.getPtyId()).toBeNull() expect(onError).not.toHaveBeenCalled() }) @@ -472,7 +472,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) expect(onError).not.toHaveBeenCalled() - expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale') + expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale', null) expect(transport.getPtyId()).toBeNull() }) @@ -520,7 +520,7 @@ describe('createRemoteRuntimePtyTransport', () => { result: { type: 'end', streamId: newStreamId } }) - expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-new') + expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-new', null) expect(transport.getPtyId()).toBeNull() expect(transport.isConnected()).toBe(false) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 78d78dfefcb..727b54e2683 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -410,7 +410,7 @@ export function createRemoteRuntimePtyTransport( remotePtyId = null closeMultiplexedStream() if (stalePtyId) { - onPtyExit?.(stalePtyId) + onPtyExit?.(stalePtyId, null) } } @@ -542,10 +542,11 @@ export function createRemoteRuntimePtyTransport( multiplexedStream = null multiplexedStreamHandle = null clearPendingViewportClaim() - storedCallbacks.onExit?.(0) storedCallbacks.onDisconnect?.() if (subscribedPtyId) { - onPtyExit?.(subscribedPtyId) + // Why: a remote stream ending proves disconnection, not the + // subprocess exit status required for install ownership. + onPtyExit?.(subscribedPtyId, null) } }, onError: (message) => { @@ -741,7 +742,7 @@ export function createRemoteRuntimePtyTransport( remotePtyId = null storedCallbacks.onDisconnect?.() if (id) { - onPtyExit?.(id) + onPtyExit?.(id, null) } }, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index ccb21fac182..d9a1a6f10df 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -249,7 +249,7 @@ type UseTerminalPaneLifecycleDeps = { replayingPanesRef: ReplayingPanesRef isActiveRef: React.RefObject isVisibleRef: React.RefObject - onPtyExitRef: React.RefObject<(ptyId: string) => void> + onPtyExitRef: React.RefObject<(ptyId: string, code?: number | null) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean diff --git a/src/renderer/src/hooks/useManagedAgentSkills.ts b/src/renderer/src/hooks/useManagedAgentSkills.ts new file mode 100644 index 00000000000..52511b5cb9c --- /dev/null +++ b/src/renderer/src/hooks/useManagedAgentSkills.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SkillDiscoveryTarget } from '../../../shared/skills' +import type { + SkillManagementInstallation, + SkillManagementInventory, + SkillReplacementPreview +} from '../../../shared/skill-management' +import { useMountedRef } from './useMountedRef' + +const INSTALLED_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +const cachedInventory = new Map() +const pendingInventory = new Map>() + +function targetKey(target: SkillDiscoveryTarget | undefined): string { + if (target?.projectRuntime) { + return target.projectRuntime.status === 'resolved' + ? target.projectRuntime.runtime.cacheKey + : target.projectRuntime.repair.cacheKey + } + return target?.runtime === 'wsl' ? `wsl:${target.wslDistro ?? ''}` : 'host' +} + +async function loadInventory( + target: SkillDiscoveryTarget | undefined, + force: boolean +): Promise { + const key = targetKey(target) + if (!force && cachedInventory.has(key)) { + return cachedInventory.get(key)! + } + const pending = pendingInventory.get(key) + if (pending) { + return pending + } + const request = window.api.skills + .managementInventory(target) + .then((inventory) => { + cachedInventory.set(key, inventory) + return inventory + }) + .finally(() => pendingInventory.delete(key)) + pendingInventory.set(key, request) + return request +} + +export type ManagedAgentSkillsState = { + inventory: SkillManagementInventory | null + loading: boolean + busyInstallationId: string | null + error: string | null + refresh: () => Promise + manage: (installationId: string) => Promise + update: (installationId: string) => Promise + dismiss: (installation: SkillManagementInstallation) => Promise + previewReplacement: (installationId: string) => Promise + replace: (installationId: string) => Promise +} + +export function useManagedAgentSkills(target?: SkillDiscoveryTarget): ManagedAgentSkillsState { + const key = targetKey(target) + const [inventory, setInventory] = useState( + cachedInventory.get(key) ?? null + ) + const [loading, setLoading] = useState(!cachedInventory.has(key)) + const [busyInstallationId, setBusyInstallationId] = useState(null) + const [error, setError] = useState(null) + const mountedRef = useMountedRef() + const currentKeyRef = useRef(key) + currentKeyRef.current = key + + const accept = useCallback( + (requestKey: string, next: SkillManagementInventory): void => { + cachedInventory.set(requestKey, next) + if (mountedRef.current && currentKeyRef.current === requestKey) { + setInventory(next) + setError(null) + } + }, + [mountedRef] + ) + + const refresh = useCallback(async (): Promise => { + const requestKey = key + setLoading(true) + try { + accept(requestKey, await loadInventory(target, true)) + } catch (cause) { + if (mountedRef.current && currentKeyRef.current === requestKey) { + setError(cause instanceof Error ? cause.message : 'Failed to inspect agent skills.') + } + } finally { + if (mountedRef.current && currentKeyRef.current === requestKey) { + setLoading(false) + } + } + }, [accept, key, mountedRef, target]) + + useEffect(() => { + const requestKey = key + setInventory(cachedInventory.get(requestKey) ?? null) + setLoading(true) + void loadInventory(target, false) + .then((next) => accept(requestKey, next)) + .catch((cause) => { + if (mountedRef.current && currentKeyRef.current === requestKey) { + setError(cause instanceof Error ? cause.message : 'Failed to inspect agent skills.') + } + }) + .finally(() => { + if (mountedRef.current && currentKeyRef.current === requestKey) { + setLoading(false) + } + }) + }, [accept, key, mountedRef, target]) + + useEffect(() => { + const invalidate = (): void => { + cachedInventory.delete(key) + void refresh() + } + window.addEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate) + window.addEventListener('focus', invalidate) + return () => { + window.removeEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate) + window.removeEventListener('focus', invalidate) + } + }, [key, refresh]) + + const runAction = useCallback( + async ( + installationId: string, + action: () => Promise + ): Promise => { + setBusyInstallationId(installationId) + try { + accept(key, await action()) + } catch (cause) { + if (mountedRef.current) { + setError(cause instanceof Error ? cause.message : 'Agent skill action failed.') + } + throw cause + } finally { + if (mountedRef.current) { + setBusyInstallationId(null) + } + } + }, + [accept, key, mountedRef] + ) + + return useMemo( + () => ({ + inventory, + loading, + busyInstallationId, + error, + refresh, + manage: (installationId: string) => + runAction(installationId, () => window.api.skills.adopt({ installationId, target })), + update: (installationId: string) => + runAction(installationId, () => + window.api.skills.updateManaged({ installationId, target }) + ), + dismiss: (installation: SkillManagementInstallation) => { + const physicalIdentity = installation.physicalIdentity + const snapshotDigest = installation.installedPackageDigest + if (!physicalIdentity || !snapshotDigest) { + return Promise.reject(new Error('skill-adoption-candidate-incomplete')) + } + return runAction(installation.id, () => + window.api.skills.dismissAdoption({ + candidate: { + hostId: installation.hostId, + physicalIdentity, + skillName: installation.name, + snapshotDigest + }, + target + }) + ) + }, + previewReplacement: (installationId: string) => + window.api.skills.previewReplacement({ installationId, target }), + replace: (installationId: string) => + runAction(installationId, () => window.api.skills.replace({ installationId, target })) + }), + [busyInstallationId, error, inventory, loading, refresh, runAction, target] + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c89567567d0..fee6ca8290b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4893,7 +4893,15 @@ "copyCommandAria": "Copy command", "runCommandDescription": "Press Enter to run the command.", "5f818f12ab": "Preparing...", - "4c05b9d7cb": "Preparing setup terminal." + "4c05b9d7cb": "Preparing setup terminal.", + "checkingManagement": "Checking…", + "review": "Review", + "manage": "Manage", + "manageUpdate": "Manage and update", + "update": "Update", + "managedCurrent": "Skill is managed and current.", + "retryUpdate": "Retry update", + "actionFailed": "Skill action failed." }, "AgentsPane": { "d83834f5e6": "Detecting installed agents…", @@ -9021,6 +9029,40 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "ManagedOrcaSkills": { + "unverified": "unverified", + "notVisible": "Managed but not visible in {{value0}}.", + "replaceTitle": "Replace {{value0}}?", + "replaceDescription": "This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.", + "binaryChange": "Binary file contents differ.", + "cancel": "Cancel", + "replace": "Replace skill", + "actionFailed": "Skill action failed.", + "title": "Orca agent skills", + "description": "Orca updates only exact official copies you explicitly choose to manage.", + "refresh": "Refresh", + "hostPending": "Detection only on this host; management actions are not available yet.", + "managedToast": "Skill is now managed and current.", + "manageUpdateAction": "Manage and update", + "manageAction": "Manage", + "updatedToast": "Skill updated.", + "updateAction": "Update", + "reviewAction": "Review", + "checking": "Checking installed skills…", + "none": "No installed Orca skills found in global agent homes.", + "replacedToast": "Skill replaced with the bundled version.", + "managedCurrent": "Managed", + "managedUpdate": "Update available", + "availableToManage": "Available to manage", + "manageAndUpdate": "Manage and update", + "newerKnown": "Newer release", + "modified": "Modified", + "unknown": "Unknown copy", + "external": "Externally managed", + "inaccessible": "Inaccessible", + "failed": "Update failed", + "retryAction": "Retry" } }, "right": { @@ -12930,6 +12972,13 @@ "searchLabels": "Filter labels…", "searchAssignee": "Filter assignee…", "back": "Back" + }, + "managedSkills": { + "adoptionNudge": { + "title": "{{value0}} installed Orca skills can be kept up to date", + "description": "Review exact official copies before Orca manages them.", + "review": "Review" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 11cf5339d6b..ef7e87b82fa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4893,7 +4893,15 @@ "copyCommandAria": "Copiar comando", "runCommandDescription": "Presiona Enter para ejecutar el comando.", "5f818f12ab": "Preparando...", - "4c05b9d7cb": "Preparando el terminal de configuración." + "4c05b9d7cb": "Preparando el terminal de configuración.", + "checkingManagement": "Comprobando…", + "review": "Revisar", + "manage": "Gestionar", + "manageUpdate": "Gestionar y actualizar", + "update": "Actualizar", + "managedCurrent": "El skill está gestionado y actualizado.", + "retryUpdate": "Reintentar actualización", + "actionFailed": "La acción del skill falló." }, "AgentsPane": { "d83834f5e6": "Detectando agents instalados…", @@ -9021,6 +9029,40 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "ManagedOrcaSkills": { + "unverified": "sin verificar", + "notVisible": "Gestionado, pero no visible en {{value0}}.", + "replaceTitle": "¿Reemplazar {{value0}}?", + "replaceDescription": "Esto descarta los cambios locales que se muestran abajo. Orca conserva una copia de reversión hasta verificar el skill incluido.", + "binaryChange": "El contenido del archivo binario es diferente.", + "cancel": "Cancelar", + "replace": "Reemplazar skill", + "actionFailed": "La acción del skill falló.", + "title": "Skills de agente de Orca", + "description": "Orca solo actualiza copias oficiales exactas que eliges gestionar explícitamente.", + "refresh": "Actualizar", + "hostPending": "Solo detección en este host; las acciones de gestión aún no están disponibles.", + "managedToast": "El skill ahora está gestionado y actualizado.", + "manageUpdateAction": "Gestionar y actualizar", + "manageAction": "Gestionar", + "updatedToast": "Skill actualizado.", + "updateAction": "Actualizar", + "reviewAction": "Revisar", + "checking": "Comprobando los skills instalados…", + "none": "No se encontraron skills de Orca instalados en los directorios globales de agentes.", + "replacedToast": "El skill se reemplazó con la versión incluida.", + "managedCurrent": "Gestionado", + "managedUpdate": "Actualización disponible", + "availableToManage": "Disponible para gestionar", + "manageAndUpdate": "Gestionar y actualizar", + "newerKnown": "Versión más reciente", + "modified": "Modificado", + "unknown": "Copia desconocida", + "external": "Gestionado externamente", + "inaccessible": "Inaccesible", + "failed": "Error de actualización", + "retryAction": "Reintentar" } }, "right": { @@ -12930,6 +12972,13 @@ "searchLabels": "Filtrar etiquetas…", "searchAssignee": "Filtrar asignatario…", "back": "Atrás" + }, + "managedSkills": { + "adoptionNudge": { + "title": "Se pueden mantener actualizados {{value0}} skills de Orca instalados", + "description": "Revisa las copias oficiales exactas antes de que Orca las gestione.", + "review": "Revisar" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8cc54d3ac1f..e426b6df26c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4878,7 +4878,15 @@ "copyCommandAria": "コマンドをコピー", "runCommandDescription": "Enter を押してコマンドを実行します。", "5f818f12ab": "準備中...", - "4c05b9d7cb": "セットアップ用ターミナルを準備しています。" + "4c05b9d7cb": "セットアップ用ターミナルを準備しています。", + "checkingManagement": "確認中…", + "review": "確認", + "manage": "管理", + "manageUpdate": "管理して更新", + "update": "更新", + "managedCurrent": "スキルは管理対象で、最新です。", + "retryUpdate": "更新を再試行", + "actionFailed": "スキル操作に失敗しました。" }, "AgentsPane": { "d83834f5e6": "インストールされている agents を検出しています…", @@ -9021,6 +9029,40 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "ManagedOrcaSkills": { + "unverified": "未検証", + "notVisible": "管理対象ですが、{{value0}} には表示されません。", + "replaceTitle": "{{value0}} を置き換えますか?", + "replaceDescription": "以下のローカル変更は破棄されます。Orca は同梱スキルの検証が完了するまでロールバック用コピーを保持します。", + "binaryChange": "バイナリファイルの内容が異なります。", + "cancel": "キャンセル", + "replace": "スキルを置き換え", + "actionFailed": "スキル操作に失敗しました。", + "title": "Orca エージェントスキル", + "description": "Orca は、明示的に管理対象にした完全一致の公式コピーのみ更新します。", + "refresh": "更新", + "hostPending": "このホストでは検出のみ可能です。管理操作はまだ利用できません。", + "managedToast": "スキルを管理対象にし、最新版にしました。", + "manageUpdateAction": "管理して更新", + "manageAction": "管理", + "updatedToast": "スキルを更新しました。", + "updateAction": "更新", + "reviewAction": "確認", + "checking": "インストール済みスキルを確認中…", + "none": "グローバルエージェントホームにインストール済みの Orca スキルはありません。", + "replacedToast": "スキルを同梱版に置き換えました。", + "managedCurrent": "管理対象", + "managedUpdate": "更新あり", + "availableToManage": "管理可能", + "manageAndUpdate": "管理して更新", + "newerKnown": "新しいリリース", + "modified": "変更あり", + "unknown": "不明なコピー", + "external": "外部で管理", + "inaccessible": "アクセス不可", + "failed": "更新失敗", + "retryAction": "再試行" } }, "right": { @@ -12930,6 +12972,13 @@ "searchLabels": "ラベルをフィルター…", "searchAssignee": "担当者をフィルター…", "back": "戻る" + }, + "managedSkills": { + "adoptionNudge": { + "title": "インストール済みの Orca スキル {{value0}} 件を最新に保てます", + "description": "Orca が管理する前に、完全一致する公式コピーを確認してください。", + "review": "確認" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e66b07cc5e0..c616c5ac106 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4878,7 +4878,15 @@ "copyCommandAria": "명령 복사", "runCommandDescription": "Enter를 눌러 명령을 실행하세요.", "5f818f12ab": "준비 중...", - "4c05b9d7cb": "설정 터미널을 준비하고 있습니다." + "4c05b9d7cb": "설정 터미널을 준비하고 있습니다.", + "checkingManagement": "확인 중…", + "review": "검토", + "manage": "관리", + "manageUpdate": "관리 및 업데이트", + "update": "업데이트", + "managedCurrent": "스킬이 관리되고 있으며 최신 상태입니다.", + "retryUpdate": "업데이트 재시도", + "actionFailed": "스킬 작업에 실패했습니다." }, "AgentsPane": { "d83834f5e6": "설치된 agents 감지 중…", @@ -9021,6 +9029,40 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "ManagedOrcaSkills": { + "unverified": "검증되지 않음", + "notVisible": "관리 중이지만 {{value0}}에 표시되지 않습니다.", + "replaceTitle": "{{value0}}을(를) 교체할까요?", + "replaceDescription": "아래 로컬 변경 사항이 삭제됩니다. Orca는 번들 스킬이 검증될 때까지 롤백 복사본을 보관합니다.", + "binaryChange": "바이너리 파일 내용이 다릅니다.", + "cancel": "취소", + "replace": "스킬 교체", + "actionFailed": "스킬 작업에 실패했습니다.", + "title": "Orca 에이전트 스킬", + "description": "Orca는 사용자가 명시적으로 관리하도록 선택한 정확한 공식 복사본만 업데이트합니다.", + "refresh": "새로 고침", + "hostPending": "이 호스트에서는 감지만 가능하며 관리 작업은 아직 사용할 수 없습니다.", + "managedToast": "이제 스킬이 관리되며 최신 상태입니다.", + "manageUpdateAction": "관리 및 업데이트", + "manageAction": "관리", + "updatedToast": "스킬이 업데이트되었습니다.", + "updateAction": "업데이트", + "reviewAction": "검토", + "checking": "설치된 스킬 확인 중…", + "none": "전역 에이전트 홈에 설치된 Orca 스킬이 없습니다.", + "replacedToast": "스킬을 번들 버전으로 교체했습니다.", + "managedCurrent": "관리됨", + "managedUpdate": "업데이트 가능", + "availableToManage": "관리 가능", + "manageAndUpdate": "관리 및 업데이트", + "newerKnown": "최신 릴리스", + "modified": "수정됨", + "unknown": "알 수 없는 복사본", + "external": "외부에서 관리됨", + "inaccessible": "접근 불가", + "failed": "업데이트 실패", + "retryAction": "재시도" } }, "right": { @@ -12930,6 +12972,13 @@ "searchLabels": "레이블 필터…", "searchAssignee": "담당자 필터…", "back": "뒤로" + }, + "managedSkills": { + "adoptionNudge": { + "title": "설치된 Orca 스킬 {{value0}}개를 최신 상태로 유지할 수 있습니다", + "description": "Orca가 관리하기 전에 정확한 공식 복사본을 검토하세요.", + "review": "검토" + } } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e0d28d35be6..d330fe0a51a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4878,7 +4878,15 @@ "copyCommandAria": "复制命令", "runCommandDescription": "按 Enter 运行命令。", "5f818f12ab": "正在准备...", - "4c05b9d7cb": "正在准备设置终端。" + "4c05b9d7cb": "正在准备设置终端。", + "checkingManagement": "正在检查…", + "review": "查看", + "manage": "管理", + "manageUpdate": "管理并更新", + "update": "更新", + "managedCurrent": "技能已纳入管理并且是最新版本。", + "retryUpdate": "重试更新", + "actionFailed": "技能操作失败。" }, "AgentsPane": { "d83834f5e6": "检测已安装的智能体...", @@ -9021,6 +9029,40 @@ "TerminalInteractionSection": { "567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.", "c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu." + }, + "ManagedOrcaSkills": { + "unverified": "未验证", + "notVisible": "已管理,但在 {{value0}} 中不可见。", + "replaceTitle": "替换 {{value0}}?", + "replaceDescription": "这会丢弃下面显示的本地更改。在验证捆绑技能之前,Orca 会保留回滚副本。", + "binaryChange": "二进制文件内容不同。", + "cancel": "取消", + "replace": "替换技能", + "actionFailed": "技能操作失败。", + "title": "Orca 代理技能", + "description": "Orca 只会更新你明确选择管理的完全一致的官方副本。", + "refresh": "刷新", + "hostPending": "此主机仅支持检测;管理操作尚不可用。", + "managedToast": "技能现已纳入管理并更新到最新版本。", + "manageUpdateAction": "管理并更新", + "manageAction": "管理", + "updatedToast": "技能已更新。", + "updateAction": "更新", + "reviewAction": "查看", + "checking": "正在检查已安装的技能…", + "none": "在全局代理主目录中未找到已安装的 Orca 技能。", + "replacedToast": "技能已替换为捆绑版本。", + "managedCurrent": "已管理", + "managedUpdate": "有可用更新", + "availableToManage": "可管理", + "manageAndUpdate": "管理并更新", + "newerKnown": "较新版本", + "modified": "已修改", + "unknown": "未知副本", + "external": "由外部管理", + "inaccessible": "无法访问", + "failed": "更新失败", + "retryAction": "重试" } }, "right": { @@ -12930,6 +12972,13 @@ "searchLabels": "筛选标签…", "searchAssignee": "筛选负责人…", "back": "返回" + }, + "managedSkills": { + "adoptionNudge": { + "title": "可将已安装的 {{value0}} 个 Orca 技能保持为最新版本", + "description": "在 Orca 管理之前,请检查完全一致的官方副本。", + "review": "查看" + } } }, "i18n": { diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts index f52e26c62e8..63936f46042 100644 --- a/src/renderer/src/lib/ai-vault-resume-target.ts +++ b/src/renderer/src/lib/ai-vault-resume-target.ts @@ -13,7 +13,7 @@ import { isWslUncPath } from '../../../shared/wsl-paths' import type { AppState } from '@/store/types' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' -export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' +export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'wsl' | 'unknown' type AiVaultResumeRepoOwner = Pick @@ -35,7 +35,7 @@ export function isSupportedAiVaultResumeRepo( } export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTargetStatus): boolean { - return status === 'local' || status === 'ssh' || status === 'runtime' + return status === 'local' || status === 'ssh' || status === 'runtime' || status === 'wsl' } export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4e4b5502051..961cdba113d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2578,7 +2578,25 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + managementInventory: () => + Promise.resolve({ + schemaVersion: 1, + hostId: LOCAL_EXECUTION_HOST_ID, + installations: [], + adoptionCandidateCount: 0, + scannedAt: Date.now() + }), + adopt: () => Promise.reject(new Error('Skill management is unavailable in the web client.')), + dismissAdoption: () => + Promise.reject(new Error('Skill management is unavailable in the web client.')), + previewReplacement: () => + Promise.reject(new Error('Skill management is unavailable in the web client.')), + replace: () => Promise.reject(new Error('Skill management is unavailable in the web client.')), + updateManaged: () => + Promise.reject(new Error('Skill management is unavailable in the web client.')), + recordOrcaInstall: () => + Promise.reject(new Error('Skill management is unavailable in the web client.')) } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 44a195d7d1e..bab0da51a05 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -456,7 +456,12 @@ export function getDefaultPersistedState(homedir: string): PersistedState { automations: [], automationRuns: [], onboarding: getDefaultOnboardingState(), - featureInteractionTelemetryBuckets: {} + featureInteractionTelemetryBuckets: {}, + skillManagementLedger: { + schemaVersion: 1, + destinations: {}, + dismissedAdoptionCandidates: [] + } } } diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts index 503962a1021..39f0dc6f41a 100644 --- a/src/shared/execution-host.test.ts +++ b/src/shared/execution-host.test.ts @@ -10,7 +10,8 @@ import { normalizeVisibleExecutionHostIds, parseExecutionHostId, toRuntimeExecutionHostId, - toSshExecutionHostId + toSshExecutionHostId, + toWslExecutionHostId } from './execution-host' describe('execution host identity', () => { @@ -32,6 +33,11 @@ describe('execution host identity', () => { id: 'runtime:prod%2Fserver', environmentId: 'prod/server' }) + expect(parseExecutionHostId(toWslExecutionHostId('Ubuntu 24.04'))).toEqual({ + kind: 'wsl', + id: 'wsl:Ubuntu%2024.04', + distro: 'Ubuntu 24.04' + }) }) it('labels the local host by platform and by navigator detection', () => { diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index a2b9ae69104..6a96062f5bb 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -3,8 +3,12 @@ import type { GlobalSettings, Repo } from './types' export const LOCAL_EXECUTION_HOST_ID = 'local' export const ALL_EXECUTION_HOSTS_SCOPE = 'all' -export type ExecutionHostKind = 'local' | 'ssh' | 'runtime' -export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}` +export type ExecutionHostKind = 'local' | 'ssh' | 'runtime' | 'wsl' +export type ExecutionHostId = + | typeof LOCAL_EXECUTION_HOST_ID + | `ssh:${string}` + | `runtime:${string}` + | `wsl:${string}` export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId @@ -12,6 +16,7 @@ export type ParsedExecutionHost = | { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID } | { kind: 'ssh'; id: `ssh:${string}`; targetId: string } | { kind: 'runtime'; id: `runtime:${string}`; environmentId: string } + | { kind: 'wsl'; id: `wsl:${string}`; distro: string } function getCurrentLocalPlatform(): NodeJS.Platform | null { const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } }) @@ -56,6 +61,10 @@ export function toRuntimeExecutionHostId(environmentId: string): `runtime:${stri return `runtime:${encodeURIComponent(environmentId)}` } +export function toWslExecutionHostId(distro: string): `wsl:${string}` { + return `wsl:${encodeURIComponent(distro)}` +} + // Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing // SSH/run-target surfaces. The renderer can't read the target.owner field, so it // recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId @@ -98,6 +107,18 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx return null } } + if (normalized.startsWith('wsl:')) { + const encoded = normalized.slice('wsl:'.length) + if (!encoded) { + return null + } + try { + const distro = decodeURIComponent(encoded) + return distro ? { kind: 'wsl', id: `wsl:${encoded}`, distro } : null + } catch { + return null + } + } return null } @@ -174,5 +195,7 @@ export function getExecutionHostLabel(id: ExecutionHostScope): string { return parsed.targetId case 'runtime': return parsed.environmentId + case 'wsl': + return `WSL: ${parsed.distro}` } } diff --git a/src/shared/skill-management.ts b/src/shared/skill-management.ts new file mode 100644 index 00000000000..db63fa703d9 --- /dev/null +++ b/src/shared/skill-management.ts @@ -0,0 +1,164 @@ +import type { ExecutionHostId } from './execution-host' +import type { SkillProvider } from './skills' +import type { SkillDiscoveryTarget } from './skills' + +export type SkillBundleFileIdentity = { + path: string + size: number + executable: boolean + classification: 'text' | 'binary' + exactSha256: string + textNormalizedSha256: string | null + identitySha256: string +} + +export type SkillReleasedSnapshot = { + releaseRevision: number + packageDigest: string + gitTreeSha: string + files: SkillBundleFileIdentity[] +} + +export type SkillCurrentBundleEntry = SkillReleasedSnapshot & { + name: string + sourcePath: string + appVersion: string +} + +export type SkillBundleManifest = { + schemaVersion: 1 + appVersion: string + skills: SkillCurrentBundleEntry[] +} + +export type SkillSnapshotRegistry = { + schemaVersion: 1 + skills: Record +} + +export type SkillReleaseMapping = { + schemaVersion: 1 + releases: { appVersion: string; skills: Record }[] +} + +export type SkillManagementStatus = + | 'managed-current' + | 'managed-update-available' + | 'known-current' + | 'known-update-available' + | 'newer-known' + | 'modified' + | 'unknown' + | 'externally-managed' + | 'inaccessible' + | 'update-failed' + +export type SkillInstallationTopology = + | 'canonical-copy' + | 'provider-alias' + | 'independent-copy' + | 'external-link' + | 'broken-link' + | 'read-only' + +export type ManagedSkillDestination = { + id: string + hostId: ExecutionHostId + homeIdentity: string + rootId: string + unresolvedPath: string + resolvedPath: string + physicalIdentity: string + entryType: 'directory' | 'symlink' | 'junction' + skillName: string + source: 'stablyai/orca' + sourcePath: string + sourceRef: string | null + installedReleaseRevision: number + installedPackageDigest: string + installedFiles: SkillBundleFileIdentity[] + lastWrittenPackageDigest: string | null + lastAttemptedBundleFingerprint: string | null + lastOutcome: 'adopted' | 'updated' | 'replaced' | 'failed' + lastErrorCategory: string | null + adoptedFrom: 'orca-install' | 'exact-snapshot' | 'explicit-replacement' + adoptedAt: number + updatedAt: number +} + +export type DismissedSkillAdoptionCandidate = { + hostId: ExecutionHostId + physicalIdentity: string + skillName: string + snapshotDigest: string + dismissedAt: number +} + +export type SkillManagementLedger = { + schemaVersion: 1 + destinations: Record + dismissedAdoptionCandidates: DismissedSkillAdoptionCandidate[] +} + +export type SkillManagementInstallation = { + id: string + hostId: ExecutionHostId + name: string + description: string | null + rootId: string + providers: SkillProvider[] + unresolvedPath: string + resolvedPath: string | null + physicalIdentity: string | null + topology: SkillInstallationTopology + status: SkillManagementStatus + eligible: boolean + adoptionPromptEligible: boolean + lockCorroborated: boolean + actionsSupported: boolean + managed: boolean + installedReleaseRevision: number | null + installedAppVersion: string | null + currentReleaseRevision: number + installedPackageDigest: string | null + currentPackageDigest: string + currentAppVersion: string + errorCategory: string | null +} + +export type SkillManagementInventory = { + schemaVersion: 1 + hostId: ExecutionHostId + installations: SkillManagementInstallation[] + adoptionCandidateCount: number + scannedAt: number +} + +export type SkillManagementActionArgs = { + installationId: string + target?: SkillDiscoveryTarget +} + +export type SkillAdoptionDismissalArgs = { + candidate: Omit + target?: SkillDiscoveryTarget +} + +export type OrcaSkillInstallResultArgs = { + skillNames: string[] + startedAt: number + target?: SkillDiscoveryTarget +} + +export type SkillReplacementFilePreview = { + path: string + change: 'added' | 'removed' | 'modified' + beforeText: string | null + afterText: string | null +} + +export type SkillReplacementPreview = { + installationId: string + skillName: string + files: SkillReplacementFilePreview[] +} diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 0b386624cbf..68d8a038454 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -1,4 +1,5 @@ import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' +import type { ExecutionHostId } from './execution-host' export type SkillProvider = 'codex' | 'claude' | 'agent-skills' @@ -36,6 +37,7 @@ export type SkillDiscoveryResult = { } export type SkillDiscoveryTarget = { + executionHostId?: ExecutionHostId runtime?: 'host' | 'wsl' wslDistro?: string | null /** Workspace path whose local .agents/.claude skill roots should be scanned. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index b88fef653dd..069cbd22f9d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import type { ExecutionHostId } from './execution-host' +import type { SkillManagementLedger } from './skill-management' import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types' import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types' import type { WorkspaceSource } from './workspace-source' @@ -251,7 +252,7 @@ export type Repo = { * Explicit execution owner for this repo. Runtime-host repos need this * because they otherwise look identical to local repos (`connectionId: null`). */ - executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null + executionHostId?: ExecutionHostId | null /** Per-repo override for issue-source resolution. `undefined` is treated * identically to `'auto'`; writers leave it undefined on creation so * existing persisted records stay forward-compatible. */ @@ -3223,7 +3224,7 @@ export type ActiveRightSidebarTab = Exclude export type RightSidebarExplorerView = 'files' | 'search' export type ProjectOrderBy = 'manual' | 'recent' -export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}` +export type WorkspaceHostScope = 'all' | ExecutionHostId export type VisibleWorkspaceHostIds = Exclude[] | null export type WorkspaceHostOrder = Exclude[] @@ -3604,6 +3605,8 @@ export type PersistedState = { onboarding: OnboardingState /** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */ featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState + /** Orca-owned write authority for exact physical skill destinations. */ + skillManagementLedger: SkillManagementLedger } // ─── Filesystem ───────────────────────────────────────────── diff --git a/tests/e2e/settings-skill-detection.spec.ts b/tests/e2e/settings-skill-detection.spec.ts index 155247c28d2..984ddf3f3ef 100644 --- a/tests/e2e/settings-skill-detection.spec.ts +++ b/tests/e2e/settings-skill-detection.spec.ts @@ -7,9 +7,82 @@ import type { SkillSourceKind } from '../../src/shared/skills' import { ORCHESTRATION_ENABLED_STORAGE_KEY } from '../../src/renderer/src/lib/orchestration-setup-state' +import type { + SkillManagementInstallation, + SkillManagementInventory +} from '../../src/shared/skill-management' type MockSkillDiscoveryGlobal = typeof globalThis & { __orcaSettingsSkillDiscoveryResult?: SkillDiscoveryResult + __orcaSettingsSkillManagementInventory?: SkillManagementInventory +} + +function managedInstallation( + name: string, + status: SkillManagementInstallation['status'] +): SkillManagementInstallation { + const managed = status.startsWith('managed-') || status === 'update-failed' + return { + id: name, + hostId: 'local', + name, + description: null, + rootId: 'home-agents', + providers: ['agent-skills', 'codex'], + unresolvedPath: `/Users/test/.agents/skills/${name}`, + resolvedPath: `/Users/test/.agents/skills/${name}`, + physicalIdentity: `1:${name}`, + topology: 'canonical-copy', + status, + eligible: status !== 'modified', + adoptionPromptEligible: status === 'known-current', + lockCorroborated: true, + actionsSupported: true, + managed, + installedReleaseRevision: 1, + installedAppVersion: '1.2.3', + currentReleaseRevision: 2, + installedPackageDigest: 'a'.repeat(64), + currentPackageDigest: 'b'.repeat(64), + currentAppVersion: '2.0.0', + errorCategory: status === 'update-failed' ? 'filesystem-ebusy' : null + } +} + +async function installMockSkillManagement( + app: ElectronApplication, + inventory: SkillManagementInventory +): Promise { + await app.evaluate((electron, initialInventory) => { + ;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillManagementInventory = + initialInventory + const current = () => + (globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillManagementInventory! + for (const channel of [ + 'skills:managementInventory', + 'skills:adopt', + 'skills:updateManaged', + 'skills:dismissAdoption' + ]) { + electron.ipcMain.removeHandler(channel) + electron.ipcMain.handle(channel, current) + } + electron.ipcMain.removeHandler('skills:previewReplacement') + electron.ipcMain.handle( + 'skills:previewReplacement', + (_event, args: { installationId: string }) => ({ + installationId: args.installationId, + skillName: args.installationId, + files: [ + { path: 'SKILL.md', change: 'modified', beforeText: 'local', afterText: 'official' } + ] + }) + ) + electron.ipcMain.removeHandler('skills:replace') + electron.ipcMain.handle('skills:replace', () => { + throw new Error('injected rollback') + }) + }, inventory) } function makeSkill(sourceKind: SkillSourceKind, directoryPath: string): DiscoveredSkill { @@ -117,9 +190,45 @@ test.describe('Settings skill detection', () => { ) await section.getByRole('button', { name: 'Re-check' }).click() - await expect(section.getByText('Installed', { exact: true })).toBeVisible() + await expect(section.getByText('Manage and update', { exact: true }).first()).toBeVisible() await expect( section.getByText('Enables agents to hand off context and coordinate work through Orca.') ).toBeVisible() }) + + test('shows managed adoption, update, conflict, retry, nudge, and retained failure review', async ({ + electronApp, + orcaPage + }) => { + const installations = [ + managedInstallation('orca-cli', 'known-current'), + managedInstallation('orchestration', 'managed-update-available'), + managedInstallation('computer-use', 'modified'), + managedInstallation('orca-linear', 'update-failed') + ] + await installMockSkillManagement(electronApp, { + schemaVersion: 1, + hostId: 'local', + installations, + adoptionCandidateCount: 1, + scannedAt: Date.now() + }) + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'agents', repoId: null }) + state.openSettingsPage() + window.dispatchEvent(new Event('orca:installed-agent-skills-changed')) + }) + + await expect(orcaPage.getByText('Orca agent skills', { exact: true })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Manage', exact: true })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Update', exact: true })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Retry', exact: true })).toBeVisible() + await expect(orcaPage.getByText('1 installed Orca skills can be kept up to date')).toBeVisible() + await orcaPage.locator('#agents').getByRole('button', { name: 'Review', exact: true }).click() + await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible() + await orcaPage.getByRole('button', { name: 'Replace skill' }).click() + await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible() + await expect(orcaPage.locator('#agents').getByText('injected rollback')).toBeVisible() + }) }) diff --git a/tests/e2e/settings-skill-management-production.spec.ts b/tests/e2e/settings-skill-management-production.spec.ts new file mode 100644 index 00000000000..22d40072699 --- /dev/null +++ b/tests/e2e/settings-skill-management-production.spec.ts @@ -0,0 +1,47 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +const isolatedHome = mkdtempSync(join(tmpdir(), 'orca-e2e-skill-home-')) +const installedRoot = join(isolatedHome, '.agents', 'skills', 'orca-cli') +mkdirSync(installedRoot, { recursive: true }) +cpSync(resolve('skills/orca-cli'), installedRoot, { recursive: true }) + +test.use({ + orcaAppExtraEnv: { + HOME: isolatedHome, + USERPROFILE: isolatedHome, + ORCA_E2E_SKILL_HOME_DIR: isolatedHome + } +}) + +test.afterAll(() => { + rmSync(isolatedHome, { recursive: true, force: true }) +}) + +test('real skill IPC inventories and adopts only inside isolated production roots', async ({ + orcaPage +}) => { + await waitForSessionReady(orcaPage) + const before = await orcaPage.evaluate(() => window.api.skills.managementInventory()) + const candidate = before.installations.find((entry) => entry.name === 'orca-cli') + expect(candidate).toMatchObject({ + status: 'known-current', + managed: false, + actionsSupported: true + }) + + const after = await orcaPage.evaluate( + (installationId) => window.api.skills.adopt({ installationId }), + candidate!.id + ) + expect(after.installations.find((entry) => entry.id === candidate!.id)).toMatchObject({ + status: 'managed-current', + managed: true + }) + expect(readFileSync(join(installedRoot, 'SKILL.md'), 'utf8')).toBe( + readFileSync(resolve('skills/orca-cli/SKILL.md'), 'utf8') + ) +})