mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* 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>
181 lines
6.4 KiB
JavaScript
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()
|
|
}
|