Files
orca/config/scripts/verify-skill-update-roundtrip.mjs
T
OrcaWinandOrca Worker 83056e5edb fix(skills): judge official skill updates by ownership, not snapshot matching (#27603)
* fix(skills): judge official skill updates by ownership, not snapshot matching

Orca decided whether an installed official skill was pristine by matching its
bytes against a shipped list of past snapshots, falling back to the skills CLI's
lock hash. Copies installed between releases, CRLF copies, and installs whose lock
recorded a sha256 hash all read "doesn't match the official version" and could not
be updated (#26841), and older content could be labelled newer and never offered.

Now an official skill is Orca's when the skills.sh global lock says it came from
stablyai/orca, and it is current only when its listed files equal the bundled
manifest (line endings and sidecars ignored); anything else is outdated. Install
and update are one pinned `skills add .../tree/v<appVersion>` command, used by the
in-app runner, `orca skills update`, and the Settings rails. The update dialog
preselects Orca-owned rows and lists same-named skills from elsewhere unchecked.

Removes the snapshot registry, release mapping, git-tree identity, lock trust and
convergence logic, and the release-cut provenance step.

* ci(skills): run the pinned-add round trip on a skills CLI that can fetch a commit

* fix(skills): let orca skills update target the shared folder on a bare host

Update became the same pinned add as install, so it inherited install's
refusal when no agent is detected. That broke the bare-host update the old
command guaranteed, and the built-CLI check (update --dry-run, no --agent) on
CI runners. The in-app runner already fell back to universal; both now share
one detectSkillsCliUpdateAgentKeys rule.

Also brings the CLI skills docs off `npx skills update`.

* refactor(skills): derive the update selection from the grouped rows

The selection hook took the live and the retained inventory, re-derived Orca
ownership from installations beside the grouping's own orcaOwned, and kept a
separate display count. Groups already carry both eligibility and ownership,
so the hook reads them once; the button's existing live-scan gate is the only
authorization check. The unselected line is inlined into the headline.

* refactor(skills): drop helpers the ownership design made redundant

- isOwnerManagedSkillScope only negated into the participation check once
  plugin caches stopped participating; fold it in.
- Eligibility re-checked resolvedPath/physicalIdentity, which observation
  already turns into 'inaccessible'.
- The manifest verifier probed access() before a readFile that throws anyway.
- orchestration-install-command.ts only forwarded one constant from the lib
  module its three importers already import.

* i18n(skills): stop naming npx skills update in four skipped-copy strings

The es/ja/ko/zh cantUpdateReason and tipDuplicate strings still named
`npx skills update`, which Orca no longer runs for its skills. Say 'the update
command' instead.

* refactor(skills): inline the single-use pinned source URL helper

orcaSkillsSourceUrl was exported but only called by the builder beside it.

* fix(skills): let an update choice lapse when the install source changes

The selection froze every checked name, Orca's preselection included, the
moment the user ticked anything. A re-check that then attributed a name to
another source kept it checked, replacing it without consent. Store only the
user's explicit choices, keyed by name and lock source, and default to
ownership otherwise.

* fix(skills): label the empty update action 'Update skills', not 'Update 0 skills'

The button stays whenever a row is updatable, so unticking every row (or a
dialog with only non-Orca copies outdated) doesn't reflow the footer. At zero
selected it stays disabled and reads 'Update skills' instead of a zero count.
The label lives beside the dialog's other count copy.

* fix(skills): let keyboard focus open the Not from Orca tooltip

The source of a non-Orca copy is only in the tooltip, and its trigger was a
plain span. Make it focusable like Orca's other tooltip-on-text triggers
(tabIndex 0, cursor-help, the row primitive's focus ring), as in the agreed
mock.

* test(skills): hold the round-trip add equal to the shared install builder

The CI round trip hardcoded its skills add argv, so it could drift from what
Orca runs. A .mjs script can't import the TS builder (extensionless imports),
so the argv moves into an exported roundTripAddArgs and a unit test asserts it
is the builder's unattended add with the tag swapped for the PR commit. The
script runs under the same main guard as the manifest generator.

* i18n(skills): translate the skill row's location count and duplicate reason

es, ko and zh still carried English for SkillUpdateRow.oneLocation and
manyLocations, and es and ko for SkillFreshnessRow.skippedReasonDuplicate.

* fix(skills): pin official skill installs to the build commit and skills CLI

`skills add .../tree/v<version>` only works for stable and RC builds: hourly,
daily and adhoc builds publish elsewhere, so stablyai/orca has no tag for their
version and both install and update failed there. Every build is made from a
commit that exists in stablyai/orca, so the build now stamps `git rev-parse HEAD`
and the one shared builder pins to `tree/<commit>`, which equals the bundled
skills by construction. Main, renderer, web and vitest get it as
ORCA_BUILD_COMMIT (replacing ORCA_APP_VERSION); the tsc-built CLI reads it from
the compiled out/package.json boundary. A missing or malformed commit fails the
build or the command with a clear error instead of building a bad URL.

The builder also pins the skills CLI (`npx skills@1.7.2`): 1.5.x cannot fetch a
commit, and a bare `npx skills` can reuse a stale cache or resolve to another
binary (#18687). The CI round-trip script reuses the same constant.

* ci(skills): run the round trip on the shared pinned skills CLI version

* docs(skills): document the commit-pinned skills add

* refactor(skills): keep the pinned skills CLI version in the install builder

skills-cli-version.ts existed only so the plain-Node round-trip script could
strip-load a .ts file, which needed --disable-warning in CI. The version now
lives beside the builder that uses it; the script keeps a literal, and the
existing round-trip test already asserts its argv, skills@<version> included,
equals the builder's.

* refactor(skills): validate the build commit once, where it is resolved

resolveBuildSourceCommit already refuses anything but a full hash, and both the
define and the CLI's stamped package.json come from it. The builder's second
check could only fire as a module-load throw in the renderer's command
constants. Also drops the resolver's unused cwd parameter.

* test: stamp a fixed build commit instead of running git in the vitest config

Tests only interpolate ORCA_BUILD_COMMIT, so they need not read HEAD (or fail
outside a git checkout) on every run.

* refactor(settings): match only skills add in the Windows npx preflight

Every official skill command is now the builder's skills@<version> add; no
rail emits skills update any more.

* fix(skills): fail an update that leaves a broken skill link behind

Eligibility already skips names with a broken link before the run, so one
afterwards is the run's own damage; report it instead of claiming success.

* ci(release): refuse to tag when the skill manifest is stale

The removed --release step implicitly checked the committed manifest against
skills/. Keep that guarantee with a verify-only run; it writes nothing.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-11 18:30:03 -07:00

181 lines
6.4 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
// Why: the exact rail Orca runs for both install and update, pinned to this PR's commit.
// A unit test holds it, and this pinned skills CLI, equal to the shared builder's argv.
const PINNED_SKILLS_CLI_VERSION = '1.7.2'
export function roundTripAddArgs(source, ref, skillName, cliVersion = PINNED_SKILLS_CLI_VERSION) {
return [
`skills@${cliVersion}`,
'add',
`https://github.com/${source}/tree/${ref}`,
'--skill',
skillName,
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
]
}
async function main() {
// Why: defaults to the version Orca pins; `--cli=latest` previews the next release.
const cliVersion = option('cli') ?? PINNED_SKILLS_CLI_VERSION
const autocrlf = option('autocrlf')
const placement = option('shape')
// Why: PR refs are untrusted workflow input. Keep them out of the generated shell
// command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
(autocrlf !== 'true' && autocrlf !== 'false') ||
(placement !== 'symlink' && placement !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source) ||
// Why: Orca pins to a full commit, never a branch or tag.
!/^[0-9a-f]{40}$/.test(ref)
) {
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs [--cli=<version>] --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<commit>'
)
}
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
function currentDigest(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill.packageDigest
}
async function packageDigestAt(directory) {
return packageDigest(await collectPackageFiles(await realpath(directory)))
}
// Why: the copies #26841 stranded: CRLF bytes from no known release, edited, with a
// sha256 lock hash. The pinned add must replace them all the same.
async function seedStalePlacement(name) {
const canonical = path.join(home, '.agents', 'skills', name)
await mkdir(canonical, { recursive: true })
const markdown = await readFile(path.join('skills', name, 'SKILL.md'), 'utf8')
await writeFile(
path.join(canonical, 'SKILL.md'),
`${markdown}\nA line no Orca release ever shipped.\n`.replace(/\n/g, '\r\n')
)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (placement === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
return { canonical, provider }
}
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const target = await seedStalePlacement(targetName)
const control = await seedStalePlacement(controlName)
const controlBefore = await packageDigestAt(control.canonical)
const lock = {
version: 3,
skills: Object.fromEntries(
[targetName, controlName].map((name) => [
name,
{
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
skillPath: `skills/${name}/SKILL.md`,
skillFolderHash: 'f'.repeat(64)
}
])
)
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
execSkills(roundTripAddArgs(source, ref, targetName, cliVersion))
const expected = currentDigest(targetName)
if ((await packageDigestAt(target.canonical)) !== expected) {
throw new Error(`${targetName} canonical placement did not become the pinned content`)
}
if ((await packageDigestAt(target.provider)) !== expected) {
throw new Error(`${targetName} claude placement did not become the pinned content`)
}
if (placement === 'symlink' && !(await lstat(target.provider)).isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
const recorded = JSON.parse(await readFile(lockPath, 'utf8')).skills?.[targetName]?.source
if (typeof recorded !== 'string' || recorded.toLowerCase() !== source.toLowerCase()) {
throw new Error(`${targetName} lock source is ${recorded}, not ${source}`)
}
if ((await packageDigestAt(control.canonical)) !== controlBefore) {
throw new Error('Targeted add changed the non-targeted control skill')
}
if (placement === 'symlink' && !(await lstat(control.provider)).isSymbolicLink()) {
throw new Error('Targeted add changed the non-targeted control topology')
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
await main()
}