mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +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>
220 lines
7.7 KiB
JavaScript
220 lines
7.7 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
|
|
// Why: schema 3 dropped release revisions and git tree identity along with the snapshot
|
|
// registry. Freshness compares installed copies against these bytes only.
|
|
const CURRENT_MANIFEST_SCHEMA_VERSION = 3
|
|
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')
|
|
|
|
function sha256(bytes) {
|
|
return createHash('sha256').update(bytes).digest('hex')
|
|
}
|
|
|
|
function compareCodeUnits(left, right) {
|
|
return left === right ? 0 : left < right ? -1 : 1
|
|
}
|
|
|
|
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 normalizedTextOrNull(bytes) {
|
|
if (bytes.includes(0)) {
|
|
return null
|
|
}
|
|
try {
|
|
return normalizeText(bytes)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const classifyFile = (bytes) => (normalizedTextOrNull(bytes) === null ? 'binary' : 'text')
|
|
|
|
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 normalized = normalizedTextOrNull(bytes)
|
|
const exactSha256 = sha256(bytes)
|
|
const textNormalizedSha256 = normalized === null ? null : sha256(normalized)
|
|
return {
|
|
path: manifestPath,
|
|
size: bytes.length,
|
|
executable,
|
|
classification: normalized === null ? 'binary' : 'text',
|
|
exactSha256,
|
|
textNormalizedSha256,
|
|
identitySha256: normalized !== null && !executable ? textNormalizedSha256 : exactSha256
|
|
}
|
|
}
|
|
|
|
// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts.
|
|
// The scanner ignores these because the OS writes them into a live install; the generator
|
|
// ignores them so a stray one in a working tree cannot be committed into the manifest as
|
|
// content no user could ever match. Skipped rather than rejected: the file is not the
|
|
// developer's doing, so failing the build over it would be hostile.
|
|
const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini'])
|
|
|
|
function isOsMetadataSkillEntryName(name) {
|
|
const folded = name.toLocaleLowerCase('en-US')
|
|
return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._')
|
|
}
|
|
|
|
async function collectPackageFiles(packageRoot) {
|
|
const files = []
|
|
const caseFoldedPaths = new Map()
|
|
|
|
async function visit(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true })
|
|
// Why: build-time Node and packaged Electron may ship different ICU data;
|
|
// package identity order must use the same locale-independent comparison.
|
|
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(directory, entry.name)
|
|
const fileStat = await lstat(absolutePath)
|
|
// Only a plain file is OS-authored, so the type decides and not the name alone: a
|
|
// directory or link wearing the name would otherwise drop its subtree out of the
|
|
// manifest and skip the guards below. Decided before the case-fold map so two
|
|
// spellings of one sidecar cannot collide.
|
|
if (isOsMetadataSkillEntryName(entry.name) && fileStat.isFile()) {
|
|
continue
|
|
}
|
|
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)
|
|
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}`)
|
|
}
|
|
// Why: Windows observation cannot see execute bits, so an executable file in
|
|
// a shipped skill would misclassify every pristine Windows install as outdated.
|
|
if ((fileStat.mode & 0o111) !== 0) {
|
|
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
|
|
}
|
|
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
|
|
}
|
|
}
|
|
|
|
await visit(packageRoot)
|
|
return sortManifestFiles(files)
|
|
}
|
|
|
|
// Why: the package digest hashes files in array order, so the generator and runtime
|
|
// observation must emit one canonical order: the sorted depth-first filesystem walk.
|
|
function compareManifestPaths(left, right) {
|
|
const leftParts = left.split('/')
|
|
const rightParts = right.split('/')
|
|
const shared = Math.min(leftParts.length, rightParts.length)
|
|
for (let index = 0; index < shared; index += 1) {
|
|
const order = compareCodeUnits(leftParts[index], rightParts[index])
|
|
if (order !== 0) {
|
|
return order
|
|
}
|
|
}
|
|
return leftParts.length - rightParts.length
|
|
}
|
|
|
|
function sortManifestFiles(files) {
|
|
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
|
|
}
|
|
|
|
function packageDigest(files) {
|
|
return sha256(
|
|
Buffer.from(
|
|
JSON.stringify(
|
|
files.map((file) => ({
|
|
path: file.path,
|
|
executable: file.executable,
|
|
classification: file.classification,
|
|
identitySha256: file.identitySha256
|
|
}))
|
|
),
|
|
'utf8'
|
|
)
|
|
)
|
|
}
|
|
|
|
async function buildCurrentManifest() {
|
|
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name)
|
|
.sort(compareCodeUnits)
|
|
const skills = []
|
|
for (const name of skillDirectories) {
|
|
const files = await collectPackageFiles(path.join(SKILLS_ROOT, name))
|
|
if (!files.some((file) => file.path === 'SKILL.md')) {
|
|
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
|
|
}
|
|
skills.push({ name, sourcePath: `skills/${name}`, packageDigest: packageDigest(files), files })
|
|
}
|
|
return { schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, skills }
|
|
}
|
|
|
|
function serialized(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`
|
|
}
|
|
|
|
async function verifyCurrentManifest(manifest) {
|
|
const committedText = await readFile(CURRENT_MANIFEST_PATH, 'utf8').catch(() => null)
|
|
if (committedText !== serialized(manifest)) {
|
|
throw new Error(
|
|
`Generated skill manifest is stale:\n${path.relative(REPO_ROOT, CURRENT_MANIFEST_PATH)}\n` +
|
|
'Run pnpm generate:skill-bundle-manifest.'
|
|
)
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const manifest = await buildCurrentManifest()
|
|
if (process.argv.slice(2).includes('--write')) {
|
|
await mkdir(OUTPUT_ROOT, { recursive: true })
|
|
await writeFile(CURRENT_MANIFEST_PATH, serialized(manifest))
|
|
return
|
|
}
|
|
await verifyCurrentManifest(manifest)
|
|
}
|
|
|
|
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 {
|
|
buildCurrentManifest,
|
|
classifyFile,
|
|
collectPackageFiles,
|
|
describeFile,
|
|
normalizeText,
|
|
packageDigest,
|
|
sortManifestFiles
|
|
}
|