Fix packaged skills CLI runtime ownership (#11627)

* fix(cli): make packaged skills runtime self-contained

* fix(cli): address packaged skills review feedback

* ci(cli): smoke packaged skills on Windows

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Jinwoo Hong
2026-07-30 18:27:16 -07:00
committed by GitHub
co-authored by OrcaWin
parent 1004c16103
commit 8f7692aa12
26 changed files with 1033 additions and 435 deletions
+53 -1
View File
@@ -273,6 +273,55 @@ jobs:
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
package_windows:
name: package (windows)
runs-on: windows-2022
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-windows-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-windows-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build package inputs
run: pnpm run build:release
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
# Why: regression specs under tests/e2e/** used to merge green without ever
# running — e2e.yml only fired on schedule/release (#10518). Path-filter so
# ordinary PRs stay light; any E2E suite change still gets a full shard run.
@@ -331,6 +380,7 @@ jobs:
- shell_contracts
- test
- package
- package_windows
runs-on: ubuntu-latest
steps:
@@ -350,6 +400,7 @@ jobs:
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
TEST: ${{ needs.test.result }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
run: |
for result in \
"$STATIC_ANALYSIS" \
@@ -357,7 +408,8 @@ jobs:
"$GIT_COMPATIBILITY" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$PACKAGE"; do
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
exit 1
fi
+12 -6
View File
@@ -14,6 +14,7 @@ const {
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -143,16 +144,12 @@ module.exports = {
'out/main/claude/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex/**',
'out/main/codex-cli/command.js',
'out/main/copilot/**',
'out/main/cursor/**',
'out/main/droid/**',
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
@@ -183,7 +180,7 @@ module.exports = {
)
: join(context.appOutDir, 'resources')
if (!existsSync(resourcesDir)) {
return
throw new Error(`Missing packaged resources directory: ${resourcesDir}`)
}
if (context.electronPlatformName === 'darwin') {
const architectureByEnum = { 1: 'x64', 3: 'arm64' }
@@ -213,7 +210,16 @@ module.exports = {
// arm64=3, universal=4 (universal contains the host slice, so run it).
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
if (context.arch === hostArchEnum || context.arch === 4) {
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
executeCommands: canExecuteTargetArch
})
if (!canExecuteTargetArch) {
console.log(
`[verify-skills-cli-runtime] skipped command probes on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
if (canExecuteTargetArch) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
@@ -158,11 +158,7 @@ describe('electron-builder config', () => {
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex-cli/command.js',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js'
'out/main/claude-accounts/keychain.js'
])
)
})
@@ -486,6 +482,20 @@ describe('electron-builder config', () => {
}
})
it('fails when the packaged resources directory is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
await expect(
electronBuilderConfig.afterPack({
appOutDir: root,
electronPlatformName: 'win32'
})
).rejects.toThrow(/Missing packaged resources directory/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
@@ -509,6 +519,19 @@ describe('electron-builder config', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(
join(unpackedCliDir, 'index.js'),
[
'const args = process.argv.slice(2)',
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
'else console.log(JSON.stringify({ executed: false }))'
].join('\n'),
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
@@ -177,7 +177,8 @@ describe('PR workflow parallelism', () => {
'git_compatibility',
'shell_contracts',
'test',
'package'
'package',
'package_windows'
])
})
})
@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
describe('packaged skills CLI PR gates', () => {
it('builds and executes the Windows packaged CLI', () => {
const job = workflow.jobs.package_windows
const buildStep = job.steps.find((step) => step.name === 'Build package inputs')
const prepareStep = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
const smokeStep = job.steps.find((step) => step.name === 'Smoke packaged CLI')
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(smokeStep.run).toBe(
'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked'
)
const aggregateStep = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(aggregateStep.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
expect(aggregateStep.run).toContain('"$PACKAGE_WINDOWS"')
})
})
+36 -4
View File
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import assert from 'node:assert/strict'
const execFileAsync = promisify(execFile)
@@ -37,10 +38,41 @@ const copiedAppDir = join(tempRoot, basename(appDir))
try {
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
const cliPath = getPackagedCliPath(copiedAppDir)
await execFileAsync(cliPath, ['--help'], {
env: { ...process.env, NODE_PATH: '' }
})
console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`)
const env = { ...process.env, NODE_PATH: '' }
delete env.ORCA_CLI_CWD
const run = (args) =>
execFileAsync(cliPath, args, {
env,
killSignal: 'SIGKILL',
maxBuffer: 16 * 1024 * 1024,
timeout: 30_000
})
await run(['--help'])
const list = JSON.parse((await run(['skills', 'list', '--json'])).stdout)
assert(list.topics.some((topic) => topic.name === 'orca-cli'))
assert.match((await run(['skills', 'get', 'orca-cli'])).stdout, /name: orca-cli/)
assert.match((await run(['skills', 'get', 'computer-use'])).stdout, /name: computer-use/)
const install = JSON.parse(
(
await run([
'skills',
'install',
'--skill',
'orca-cli',
'--agent',
'codex',
'--dry-run',
'--json'
])
).stdout
)
const update = JSON.parse(
(await run(['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])).stdout
)
assert.equal(install.executed, false)
assert.equal(update.executed, false)
console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
@@ -0,0 +1,229 @@
const { existsSync, readFileSync, realpathSync } = require('node:fs')
const { builtinModules, createRequire, isBuiltin } = require('node:module')
const { dirname, isAbsolute, join, relative, resolve, sep } = require('node:path')
const { spawnSync } = require('node:child_process')
const ts = require('typescript-api')
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]))
const CLI_COMMAND_TIMEOUT_MS = 30_000
function artifactPath(outDir, file) {
return relative(outDir, file).split(sep).join('/')
}
function runtimeImportSpecifiers(source, file) {
const sourceFile = ts.createSourceFile(
file,
source,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.JS
)
const specifiers = []
function visit(node) {
if (ts.isCallExpression(node) && node.arguments.length > 0) {
const [argument] = node.arguments
const expression = node.expression
const isRequire = ts.isIdentifier(expression) && expression.text === 'require'
const isRequireResolve =
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
expression.expression.text === 'require' &&
expression.name.text === 'resolve'
const isDynamicImport = expression.kind === ts.SyntaxKind.ImportKeyword
if ((isRequire || isRequireResolve || isDynamicImport) && ts.isStringLiteralLike(argument)) {
specifiers.push(argument.text)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return specifiers
}
function isOutsideRoot(root, target) {
const pathFromRoot = relative(root, target)
return isAbsolute(pathFromRoot) || pathFromRoot === '..' || pathFromRoot.startsWith(`..${sep}`)
}
function isOptionalPackageImport(artifactRoot, importer, specifier) {
if (specifier.startsWith('.') || isAbsolute(specifier)) {
return false
}
const segments = specifier.split('/')
const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
let directory = realpathSync(dirname(importer))
while (!isOutsideRoot(artifactRoot, directory)) {
const packageJson = join(directory, 'package.json')
if (existsSync(packageJson)) {
try {
const manifest = JSON.parse(readFileSync(packageJson, 'utf8'))
return (
Object.hasOwn(manifest.optionalDependencies ?? {}, packageName) ||
manifest.peerDependenciesMeta?.[packageName]?.optional === true
)
} catch {
return false
}
}
if (directory === artifactRoot) {
break
}
directory = dirname(directory)
}
return false
}
function resolveRuntimeImport(outDir, artifactRoot, importer, specifier) {
if (BUILTINS.has(specifier) || isBuiltin(specifier)) {
return null
}
let resolved
try {
resolved = createRequire(importer).resolve(specifier)
} catch (error) {
if (isOptionalPackageImport(artifactRoot, importer, specifier)) {
return null
}
const detail = error instanceof Error ? error.message : String(error)
throw new Error(
`[verify-skills-cli-runtime] missing runtime import "${specifier}" from ` +
`${artifactPath(outDir, importer)}: ${detail}`
)
}
if (isOutsideRoot(artifactRoot, resolved)) {
throw new Error(
`[verify-skills-cli-runtime] import "${specifier}" from ` +
`${artifactPath(outDir, importer)} resolved outside ${artifactRoot}: ${resolved}`
)
}
return resolved
}
function collectRuntimeClosure(outDir, artifactRoot = dirname(outDir)) {
outDir = realpathSync(outDir)
artifactRoot = realpathSync(artifactRoot)
if (isOutsideRoot(artifactRoot, outDir)) {
throw new Error(`[verify-skills-cli-runtime] ${outDir} is outside ${artifactRoot}`)
}
const entry = resolve(outDir, 'cli', 'index.js')
if (!existsSync(entry)) {
throw new Error(`[verify-skills-cli-runtime] missing entry ${entry}`)
}
const pending = [entry]
const visited = new Set()
while (pending.length > 0) {
const file = pending.pop()
if (!file || visited.has(file)) {
continue
}
visited.add(file)
const source = readFileSync(file, 'utf8')
for (const specifier of runtimeImportSpecifiers(source, file)) {
const resolved = resolveRuntimeImport(outDir, artifactRoot, file, specifier)
if (resolved && !isOutsideRoot(artifactRoot, resolved) && /\.(?:c|m)?js$/.test(resolved)) {
pending.push(resolved)
}
}
}
return [...visited].sort()
}
function runCli(outDir, args, timeoutMs = CLI_COMMAND_TIMEOUT_MS) {
const entry = resolve(outDir, 'cli', 'index.js')
const env = { ...process.env, NODE_PATH: '' }
delete env.ORCA_CLI_CWD
const result = spawnSync(process.execPath, [entry, ...args], {
cwd: dirname(outDir),
encoding: 'utf8',
env,
killSignal: 'SIGKILL',
maxBuffer: 16 * 1024 * 1024,
timeout: timeoutMs
})
if (result.error || result.signal || result.status !== 0) {
const detail = [
result.error?.message,
result.signal ? `terminated by ${result.signal}` : null,
result.stdout,
result.stderr
]
.filter(Boolean)
.join('\n')
throw new Error(
`[verify-skills-cli-runtime] ${args.join(' ')} exited ${String(result.status)}\n${detail}`
)
}
return result.stdout
}
function parseJson(label, output) {
try {
return JSON.parse(output)
} catch {
throw new Error(`[verify-skills-cli-runtime] ${label} emitted invalid JSON:\n${output}`)
}
}
function verifySkillsCliRuntime(outDir, artifactRoot = dirname(outDir), options = {}) {
const absoluteOutDir = resolve(outDir)
const closure = collectRuntimeClosure(absoluteOutDir, resolve(artifactRoot))
if (options.executeCommands === false) {
return { closureFiles: closure.length, commands: 0 }
}
const list = parseJson('skills list', runCli(absoluteOutDir, ['skills', 'list', '--json']))
const topicNames = new Set(list.topics?.map((topic) => topic.name))
for (const topic of ['orca-cli', 'computer-use']) {
if (!topicNames.has(topic)) {
throw new Error(`[verify-skills-cli-runtime] skills list omitted ${topic}`)
}
const guide = runCli(absoluteOutDir, ['skills', 'get', topic])
if (!guide.includes(`name: ${topic}`)) {
throw new Error(`[verify-skills-cli-runtime] skills get ${topic} returned the wrong guide`)
}
}
const install = parseJson(
'skills install --dry-run',
runCli(absoluteOutDir, [
'skills',
'install',
'--skill',
'orca-cli',
'--agent',
'codex',
'--dry-run',
'--json'
])
)
const update = parseJson(
'skills update --dry-run',
runCli(absoluteOutDir, ['skills', 'update', '--skill', 'orca-cli', '--dry-run', '--json'])
)
if (install.executed !== false || update.executed !== false) {
throw new Error('[verify-skills-cli-runtime] a dry-run reported execution')
}
return { closureFiles: closure.length, commands: 5 }
}
if (require.main === module) {
try {
const result = verifySkillsCliRuntime(process.argv[2] ?? 'out')
console.log(
`[verify-skills-cli-runtime] ${result.closureFiles} closure files and ` +
`${result.commands} commands passed`
)
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
}
module.exports = { collectRuntimeClosure, runCli, verifySkillsCliRuntime }
@@ -0,0 +1,230 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
collectRuntimeClosure,
runCli,
verifySkillsCliRuntime
} = require('./verify-skills-cli-runtime.cjs')
async function writeSkillsCliFixture(outDir, handlerSource) {
const cliDir = join(outDir, 'cli')
const handlerDir = join(cliDir, 'handlers')
await mkdir(handlerDir, { recursive: true })
await writeFile(join(cliDir, 'index.js'), "require('./handlers/skills')\n", 'utf8')
await writeFile(join(handlerDir, 'skills.js'), handlerSource, 'utf8')
}
describe('skills CLI runtime closure', () => {
it('runs after Electron composes the final output', async () => {
const packageJson = JSON.parse(
await readFile(new URL('../../package.json', import.meta.url), 'utf8')
)
for (const scriptName of ['build:desktop', 'build:release']) {
const script = packageJson.scripts[scriptName]
expect(script.indexOf('build:electron-vite')).toBeLessThan(
script.indexOf('verify:built-skills-cli')
)
}
})
it('reports the missing final-artifact import and its owner', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, "require('../../main/codex-cli/command')\n")
expect(() => collectRuntimeClosure(root)).toThrow(
/missing runtime import "\.\.\/\.\.\/main\/codex-cli\/command" from cli\/handlers\/skills\.js/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('walks static and dynamic relative imports', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const sharedDir = join(root, 'shared')
await mkdir(sharedDir, { recursive: true })
await writeSkillsCliFixture(
root,
"require('../../shared/first.js'); import('../../shared/second.js')\n"
)
await writeFile(join(sharedDir, 'first.js'), '', 'utf8')
await writeFile(join(sharedDir, 'second.js'), '', 'utf8')
expect(
collectRuntimeClosure(root)
.map((file) => relative(realpathSync(root), file))
.sort()
).toEqual(['cli/handlers/skills.js', 'cli/index.js', 'shared/first.js', 'shared/second.js'])
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('ignores import-shaped text in comments and strings', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(
root,
[
"// require('../../missing-comment.js')",
'const message = "import(\'../../missing-string.js\')"',
"const template = `require.resolve('../../missing-template.js')`"
].join('\n')
)
expect(collectRuntimeClosure(root)).toHaveLength(2)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('can inspect a cross-arch artifact without executing it', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, '')
expect(verifySkillsCliRuntime(root, undefined, { executeCommands: false })).toEqual({
closureFiles: 2,
commands: 0
})
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('bounds command execution time', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
await writeSkillsCliFixture(root, 'setInterval(() => {}, 1_000)\n')
expect(() => runCli(root, [], 50)).toThrow(/ETIMEDOUT|terminated by SIGKILL/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects bare imports resolved outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const externalPackageDir = join(root, 'node_modules', 'external-package')
await mkdir(externalPackageDir, { recursive: true })
await writeSkillsCliFixture(outDir, "require('external-package')\n")
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/external-package.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects package dependencies resolved outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
const externalPackageDir = join(root, 'node_modules', 'ancestor-dependency')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await mkdir(externalPackageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(packageDir, 'index.js'), "require('ancestor-dependency')\n", 'utf8')
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/ancestor-dependency.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('allows absent dependencies declared optional by their package', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({
main: 'index.js',
peerDependencies: { 'optional-native': '*' },
peerDependenciesMeta: { 'optional-native': { optional: true } }
}),
'utf8'
)
await writeFile(
join(packageDir, 'index.js'),
"try { require('optional-native') } catch {}\n",
'utf8'
)
expect(collectRuntimeClosure(outDir, artifactRoot)).toHaveLength(3)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects optional dependencies resolved only outside the artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-cli-closure-'))
try {
const artifactRoot = join(root, 'artifact')
const outDir = join(artifactRoot, 'out')
const packageDir = join(artifactRoot, 'node_modules', 'inside-package')
const externalPackageDir = join(root, 'node_modules', 'optional-native')
await writeSkillsCliFixture(outDir, "require('inside-package')\n")
await mkdir(packageDir, { recursive: true })
await mkdir(externalPackageDir, { recursive: true })
await writeFile(
join(packageDir, 'package.json'),
JSON.stringify({
main: 'index.js',
optionalDependencies: { 'optional-native': '*' }
}),
'utf8'
)
await writeFile(join(packageDir, 'index.js'), "require('optional-native')\n", 'utf8')
await writeFile(
join(externalPackageDir, 'package.json'),
JSON.stringify({ main: 'index.js' }),
'utf8'
)
await writeFile(join(externalPackageDir, 'index.js'), '', 'utf8')
expect(() => collectRuntimeClosure(outDir, artifactRoot)).toThrow(
/optional-native.*resolved outside/s
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
-2
View File
@@ -12,8 +12,6 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/ipc/local-agent-install-dir-detection.ts",
"../src/main/ipc/tui-agent-detection-commands.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
+2 -10
View File
@@ -260,16 +260,8 @@ export const electronViteConfig: UserConfig = {
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
),
'ipc/local-agent-install-dir-detection': resolve(
'src/main/ipc/local-agent-install-dir-detection.ts'
),
'ipc/tui-agent-detection-commands': resolve(
'src/main/ipc/tui-agent-detection-commands.ts'
),
// Why: same rule — `orca account add` / `account list` import these.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'),
'codex-cli/command': resolve('src/main/codex-cli/command.ts'),
'win32-utils': resolve('src/main/win32-utils.ts')
// Why: account import mutates the user's macOS Keychain from the CLI.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
+4 -2
View File
@@ -58,19 +58,21 @@
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
"verify:built-skills-cli": "node config/scripts/verify-skills-cli-runtime.cjs out",
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
"test:repro:skills-cli-runtime": "pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs",
"build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs",
"build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs",
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer",
"build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"build": "pnpm run build:desktop && pnpm run build:native",
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer",
"build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:built-skills-cli && pnpm run build:web-from-renderer",
"postinstall": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node config/scripts/rebuild-native-deps.mjs",
"rebuild:node": "pnpm rebuild node-pty",
+2 -2
View File
@@ -41,7 +41,7 @@ vi.mock('../../main/claude-accounts/keychain', () => ({
readActiveClaudeKeychainCredentialsStrict: readKeychainMock,
writeActiveClaudeKeychainCredentials: writeKeychainMock
}))
vi.mock('../../main/codex-cli/command', () => ({
vi.mock('../../shared/node-cli-command-resolution', () => ({
getVersionManagerBinPaths: getVersionManagerBinPathsMock,
resolveCliCommand: resolveCliCommandMock
}))
@@ -49,7 +49,7 @@ vi.mock('../../main/codex-cli/command', () => ({
import { ACCOUNT_HANDLERS } from './account'
import type { HandlerContext } from '../dispatch'
import type { RuntimeClient } from '../runtime-client'
import { getCmdExePath } from '../../main/win32-utils'
import { getCmdExePath } from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
function successfulChild(): EventEmitter {
+5 -2
View File
@@ -12,8 +12,11 @@ import {
readActiveClaudeKeychainCredentialsStrict,
writeActiveClaudeKeychainCredentials
} from '../../main/claude-accounts/keychain'
import { getVersionManagerBinPaths, resolveCliCommand } from '../../main/codex-cli/command'
import { getSpawnArgsForWindows } from '../../main/win32-utils'
import {
getVersionManagerBinPaths,
resolveCliCommand
} from '../../shared/node-cli-command-resolution'
import { getSpawnArgsForWindows } from '../../shared/windows-batch-spawn'
import { ACCOUNT_IMPORT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
+7 -4
View File
@@ -3,14 +3,17 @@ import type { CommandHandler } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { delimiter, dirname } from 'node:path'
import { getRepeatedStringFlag } from '../flags'
import { resolveCliCommand } from '../../main/codex-cli/command'
import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection'
import { resolveCliCommand } from '../../shared/node-cli-command-resolution'
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from '../../main/ipc/tui-agent-detection-commands'
import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils'
} from '../../shared/tui-agent-detection-commands'
import {
getSpawnArgsForWindows,
UnsafeWindowsBatchArgumentsError
} from '../../shared/windows-batch-spawn'
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import {
buildAgentFeatureSkillInstallArgs,
+4 -4
View File
@@ -41,8 +41,8 @@ function findElectronViteMainEntries(): Set<string> {
describe('CLI imports of main-process modules', () => {
// Why: electron-vite cleans out/main and emits only its declared entries, so a
// `src/main/*` module the CLI imports but the config omits is deleted by the
// build that runs after `build:cli` — the command then dies at require time
// with "Cannot find module". Nothing else catches this before packaging.
// build that runs after `build:cli` — keep source-level feedback ahead of the
// final-artifact runtime verifier.
it('has an electron-vite entry for every main module the CLI imports', () => {
const entries = findElectronViteMainEntries()
const missing = findMainImports().filter(({ module }) => !entries.has(module))
@@ -52,7 +52,7 @@ describe('CLI imports of main-process modules', () => {
it('finds the imports it is meant to guard', () => {
// Why: a broken matcher would make the guard above vacuously pass.
expect(findMainImports().length).toBeGreaterThanOrEqual(4)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(4)
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
})
})
+3 -3
View File
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as CodexCliCommandModule from '../main/codex-cli/command'
import type * as CodexCliCommandModule from '../shared/node-cli-command-resolution'
const {
detectCommandsMock,
@@ -19,12 +19,12 @@ const {
// Why: agent detection probes the real machine, so pin it or every install
// assertion depends on what the test runner happens to have installed.
vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({
vi.mock('../shared/local-agent-install-dir-detection', () => ({
detectCommandsInInstallDirs: detectCommandsMock
}))
// Why: override only the npx lookup so the real Windows .cmd rail still runs.
vi.mock('../main/codex-cli/command', async (importOriginal) => ({
vi.mock('../shared/node-cli-command-resolution', async (importOriginal) => ({
...(await importOriginal<typeof CodexCliCommandModule>()),
resolveCliCommand: resolveCliCommandMock
}))
+1 -226
View File
@@ -1,226 +1 @@
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
type ResolveCommandOptions = {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
}
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
if (platform === 'win32') {
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
}
return [commandName]
}
function splitPath(pathEnv: string | null | undefined): string[] {
if (!pathEnv) {
return []
}
return pathEnv
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
function parseVersionSegment(raw: string): number[] {
return raw
.replace(/^v/i, '')
.split('.')
.map((segment) => Number.parseInt(segment, 10))
.map((segment) => (Number.isFinite(segment) ? segment : 0))
}
function compareVersionDesc(left: string, right: string): number {
const leftParts = parseVersionSegment(left)
const rightParts = parseVersionSegment(right)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
if (delta !== 0) {
return delta
}
}
return right.localeCompare(left)
}
function findFirstExecutable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): string | null {
for (const directory of directories) {
for (const executableName of executableNames) {
const candidate = join(directory, executableName)
if (isRunnableCommand(platform, candidate)) {
return candidate
}
}
}
return null
}
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
try {
const stats = statSync(candidate)
if (!stats.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
// Why: GUI fallback probing should skip placeholders/directories so spawn
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
accessSync(candidate, constants.X_OK)
return true
} catch {
return false
}
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
// directory, similar to asdf. Without this, users who installed node
// or CLI tools through mise can't be found by the fallback probe.
join(homePath, '.local', 'share', 'mise', 'shims')
]
if (platform === 'win32') {
// Why: Anthropic's native Windows installer places claude.exe here, and
// GUI-launched Orca may not inherit the user's PATH entry for it.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
} else {
directories.push(join(homePath, '.local', 'bin'))
// Why: pnpm uses platform-specific global bin directories that differ from
// npm's ~/.local/bin. macOS follows the ~/Library convention while Linux
// uses the XDG-compatible ~/.local/share path. Without these, users who
// installed via `pnpm add -g` can't be found by the fallback probe.
if (platform === 'darwin') {
directories.push(join(homePath, 'Library', 'pnpm'))
} else {
directories.push(join(homePath, '.local', 'share', 'pnpm'))
}
directories.push(join(homePath, '.yarn', 'bin'))
}
// Why: bun uses ~/.bun/bin on all platforms for globally installed packages.
directories.push(join(homePath, '.bun', 'bin'))
return directories
}
function getNvmVersionDirectories(homePath: string): string[] {
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
if (!existsSync(nvmVersionsDir)) {
return []
}
return readdirSync(nvmVersionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareVersionDesc)
.map((entry) => join(nvmVersionsDir, entry, 'bin'))
}
function getVersionManagerDirectories(
platform: NodeJS.Platform,
homePath: string,
executableNames: string[]
): string[] {
const directories = getBaseVersionManagerDirectories(platform, homePath)
// Why: GUI-launched Electron apps do not inherit shell init from nvm, so
// command resolution probes the newest installed Node versions explicitly.
const firstNvmMatch = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
if (firstNvmMatch) {
directories.unshift(dirname(firstNvmMatch))
}
return directories
}
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
const platform = options.platform ?? process.platform
const executableNames = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
if (pathCandidate) {
return pathCandidate
}
const homePath = options.homePath ?? homedir()
const versionManagerCandidate = findFirstExecutable(
platform,
getVersionManagerDirectories(platform, homePath, executableNames),
executableNames
)
return versionManagerCandidate ?? commandName
}
export function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
const homePath = options.homePath ?? homedir()
// Why: agent detection probes many CLIs at once; compute expensive install
// directories, especially nvm versions, once per detection pass.
const installDirectories = [
...getNvmVersionDirectories(homePath),
...getBaseVersionManagerDirectories(platform, homePath)
]
const resolved = new Map<string, string>()
for (const commandName of new Set(commandNames)) {
const executableNames = getExecutableNames(platform, commandName)
const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames)
const installCandidate =
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
resolved.set(commandName, installCandidate ?? commandName)
}
return resolved
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('claude', options)
}
// Why: GUI-launched Electron apps inherit a minimal PATH that excludes Node
// version manager directories. CLI tools like codex/claude are Node scripts
// with #!/usr/bin/env node shebangs — they need `node` in PATH to execute,
// not just to be *found*. This function returns the version manager bin paths
// so the caller can augment process.env.PATH at startup.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}
export * from '../../shared/node-cli-command-resolution'
@@ -1,18 +1 @@
import path from 'node:path'
import { resolveCliCommands } from '../codex-cli/command'
// Why: local agent detection may run before shell-PATH hydration, but the
// fallback must stay bounded because it runs on the main process.
export function detectCommandsInInstallDirs(commands: readonly string[]): Set<string> {
if (commands.length === 0) {
return new Set()
}
try {
const resolvedCommands = resolveCliCommands(commands)
return new Set(
commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command))
)
} catch {
return new Set()
}
}
export { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
+1 -1
View File
@@ -54,7 +54,7 @@ vi.mock('../startup/hydrate-shell-path', () => ({
mergePathSegments: mergePathSegmentsMock
}))
vi.mock('../codex-cli/command', () => ({
vi.mock('../../shared/node-cli-command-resolution', () => ({
resolveCliCommands: resolveCliCommandsMock
}))
+1 -77
View File
@@ -1,77 +1 @@
import type { TuiAgent } from '../../shared/types'
import {
getTuiAgentDetectCommands,
TUI_AGENT_CONFIG,
type TuiAgentConfig,
type TuiAgentDetectionRuntime
} from '../../shared/tui-agent-config'
export type TuiAgentDetectionCommand = {
id: TuiAgent
cmd: string
requiredCommands?: readonly string[]
unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[]
}
export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands()
function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] {
return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
getTuiAgentDetectCommands(config).map((cmd) =>
buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config)
)
)
}
function buildTuiAgentDetectionCommand(
id: TuiAgent,
cmd: string,
config: TuiAgentConfig
): TuiAgentDetectionCommand {
return {
id,
cmd,
...(config.detectRequiredCommands?.length
? { requiredCommands: config.detectRequiredCommands }
: {}),
...(config.detectUnsupportedRuntimes?.length
? { unsupportedRuntimes: config.detectUnsupportedRuntimes }
: {})
}
}
export function getTuiAgentDetectionProbeCommands(
commands: readonly TuiAgentDetectionCommand[],
runtime: TuiAgentDetectionRuntime
): string[] {
return [
...new Set(
commands
.filter((command) => !isDetectionUnsupportedInRuntime(command, runtime))
.flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])])
)
]
}
export function resolveDetectedTuiAgentIds(
commands: readonly TuiAgentDetectionCommand[],
foundCommands: ReadonlySet<string>,
runtime: TuiAgentDetectionRuntime
): TuiAgent[] {
const detected = commands
.filter(
(command) =>
!isDetectionUnsupportedInRuntime(command, runtime) &&
foundCommands.has(command.cmd) &&
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
)
.map(({ id }) => id)
return [...new Set(detected)]
}
export function isDetectionUnsupportedInRuntime(
command: TuiAgentDetectionCommand,
runtime: TuiAgentDetectionRuntime
): boolean {
return command.unsupportedRuntimes?.includes(runtime) === true
}
export * from '../../shared/tui-agent-detection-commands'
+18 -6
View File
@@ -119,17 +119,29 @@ describe('getSpawnArgsForWindows', () => {
it('rejects unsafe args for .cmd scripts on win32', () => {
withPlatform('win32', () => {
expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['hello & goodbye'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
for (const argument of ['hello & goodbye', 'close)', '(open']) {
expect(() => getSpawnArgsForWindows('C:\\tools\\agent.cmd', [argument])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
}
})
})
it('rejects unsafe command paths for .cmd scripts on win32', () => {
withPlatform('win32', () => {
expect(() => getSpawnArgsForWindows('C:\\bad&path\\agent.cmd', ['login'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
for (const command of ['C:\\bad&path\\agent.cmd', 'C:\\bad(path\\agent.cmd']) {
expect(() => getSpawnArgsForWindows(command, ['login'])).toThrow(
'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
)
}
})
})
it('allows punctuation that is not a cmd command operator', () => {
withPlatform('win32', () => {
expect(
getSpawnArgsForWindows('C:\\tools\\agent.cmd', ['package,name;version']).spawnArgs
).toEqual(['/d', '/c', 'C:\\tools\\agent.cmd', 'package,name;version'])
})
})
})
+8 -61
View File
@@ -2,6 +2,14 @@ import { execFile, execFileSync, type ExecFileOptionsWithStringEncoding } from '
import { delimiter, join, win32 } from 'node:path'
import { existsSync } from 'node:fs'
export {
getCmdExePath,
getSpawnArgsForWindows,
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
UnsafeWindowsBatchArgumentsError
} from '../shared/windows-batch-spawn'
function execFileWithoutBlocking(
command: string,
args: string[],
@@ -38,20 +46,6 @@ export function getRegExePath(env: NodeJS.ProcessEnv = process.env): string {
return win32.join(root, 'System32', 'reg.exe')
}
/**
* Full path to cmd.exe, respecting the ComSpec convention used elsewhere in
* the codebase (hooks.ts, repo.ts, ssh-connection-utils.ts).
* Falls back to SystemRoot-based path if ComSpec is unset.
*/
export function getCmdExePath(): string {
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
}
/** Whether a resolved command path points to a Windows batch script (.cmd/.bat). */
export function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
export function resolveWindowsCommand(
command: string,
env: NodeJS.ProcessEnv = process.env
@@ -79,19 +73,6 @@ export function resolveWindowsCommand(
return command
}
export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
export class UnsafeWindowsBatchArgumentsError extends Error {
constructor() {
super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
this.name = 'UnsafeWindowsBatchArgumentsError'
}
}
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return /[&|<>^"%!\r\n]/.test(value)
}
/** Check whether an error is a Windows permission error (EACCES or EPERM). */
export function isPermissionError(error: unknown): boolean {
return (
@@ -235,37 +216,3 @@ export async function grantDirAclAsync(dirPath: string): Promise<void> {
}
)
}
/**
* Resolve spawn parameters for a command that may be a Windows batch script.
*
* Why: Node's spawn() cannot execute .cmd/.bat files directly without
* shell:true, but shell:true with an args array triggers DEP0190 because
* args are concatenated, not escaped. Routing through cmd.exe /c explicitly
* avoids the deprecation warning while passing args correctly.
*
* Why /d: disables per-machine/user AutoRun registry commands so a background
* spawn cannot inherit surprising side effects from the user's shell config.
*
* SAFETY: when the .cmd/.bat branch is taken, cmd.exe re-parses the command
* line. Args with cmd metacharacters are rejected instead of escaped because
* the agent prompt may contain arbitrary staged diff text.
*/
export function getSpawnArgsForWindows(
command: string,
args: string[]
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
if (hasUnsafeWindowsBatchSyntax(value)) {
throw new UnsafeWindowsBatchArgumentsError()
}
}
// Why: when Node passes a pre-quoted command line as one argv entry,
// cmd.exe sees literal escaped quotes on Windows and refuses to run .cmd
// shims. Separate argv entries let Node quote spaces without breaking cmd.
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }
}
@@ -0,0 +1,17 @@
import path from 'node:path'
import { resolveCliCommands } from './node-cli-command-resolution'
// Why: detection may precede shell-PATH hydration, but the fallback stays bounded.
export function detectCommandsInInstallDirs(commands: readonly string[]): Set<string> {
if (commands.length === 0) {
return new Set()
}
try {
const resolvedCommands = resolveCliCommands(commands)
return new Set(
commands.filter((command) => path.isAbsolute(resolvedCommands.get(command) ?? command))
)
} catch {
return new Set()
}
}
+218
View File
@@ -0,0 +1,218 @@
import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
type ResolveCommandOptions = {
pathEnv?: string | null
platform?: NodeJS.Platform
homePath?: string
}
function getExecutableNames(platform: NodeJS.Platform, commandName: string): string[] {
if (platform === 'win32') {
return [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.bat`, commandName]
}
return [commandName]
}
function splitPath(pathEnv: string | null | undefined): string[] {
if (!pathEnv) {
return []
}
return pathEnv
.split(delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
}
function parseVersionSegment(raw: string): number[] {
return raw
.replace(/^v/i, '')
.split('.')
.map((segment) => Number.parseInt(segment, 10))
.map((segment) => (Number.isFinite(segment) ? segment : 0))
}
function compareVersionDesc(left: string, right: string): number {
const leftParts = parseVersionSegment(left)
const rightParts = parseVersionSegment(right)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const delta = (rightParts[index] ?? 0) - (leftParts[index] ?? 0)
if (delta !== 0) {
return delta
}
}
return right.localeCompare(left)
}
function findFirstExecutable(
platform: NodeJS.Platform,
directories: string[],
executableNames: string[]
): string | null {
for (const directory of directories) {
for (const executableName of executableNames) {
const candidate = join(directory, executableName)
if (isRunnableCommand(platform, candidate)) {
return candidate
}
}
}
return null
}
function isRunnableCommand(platform: NodeJS.Platform, candidate: string): boolean {
try {
const stats = statSync(candidate)
if (!stats.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
// Why: GUI fallback probing should skip placeholders/directories so spawn
// can continue to a runnable CLI instead of failing later with EACCES/EISDIR.
accessSync(candidate, constants.X_OK)
return true
} catch {
return false
}
}
function getBaseVersionManagerDirectories(platform: NodeJS.Platform, homePath: string): string[] {
const directories = [
join(homePath, '.volta', 'bin'),
join(homePath, '.asdf', 'shims'),
join(homePath, '.fnm', 'aliases', 'default', 'bin'),
// Why: mise (formerly rtx) exposes managed tool binaries via a shims
// directory, similar to asdf.
join(homePath, '.local', 'share', 'mise', 'shims')
]
if (platform === 'win32') {
// Why: Anthropic's native Windows installer places claude.exe here, and
// GUI-launched Orca may not inherit the user's PATH entry for it.
directories.push(join(homePath, '.local', 'bin'))
directories.push(join(homePath, 'AppData', 'Roaming', 'npm'))
directories.push(join(homePath, 'AppData', 'Local', 'pnpm'))
directories.push(join(homePath, 'AppData', 'Local', 'Yarn', 'bin'))
} else {
directories.push(join(homePath, '.local', 'bin'))
// Why: pnpm uses platform-specific global bin directories that differ from
// npm's ~/.local/bin.
if (platform === 'darwin') {
directories.push(join(homePath, 'Library', 'pnpm'))
} else {
directories.push(join(homePath, '.local', 'share', 'pnpm'))
}
directories.push(join(homePath, '.yarn', 'bin'))
}
directories.push(join(homePath, '.bun', 'bin'))
return directories
}
function getNvmVersionDirectories(homePath: string): string[] {
const nvmVersionsDir = join(homePath, '.nvm', 'versions', 'node')
if (!existsSync(nvmVersionsDir)) {
return []
}
return readdirSync(nvmVersionsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareVersionDesc)
.map((entry) => join(nvmVersionsDir, entry, 'bin'))
}
function getVersionManagerDirectories(
platform: NodeJS.Platform,
homePath: string,
executableNames: string[]
): string[] {
const directories = getBaseVersionManagerDirectories(platform, homePath)
const firstNvmMatch = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
if (firstNvmMatch) {
directories.unshift(dirname(firstNvmMatch))
}
return directories
}
export function resolveCliCommand(
commandName: string,
options: ResolveCommandOptions = {}
): string {
const platform = options.platform ?? process.platform
const executableNames = getExecutableNames(platform, commandName)
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathCandidate = findFirstExecutable(platform, splitPath(pathEnv), executableNames)
if (pathCandidate) {
return pathCandidate
}
const homePath = options.homePath ?? homedir()
const nvmCandidate = findFirstExecutable(
platform,
getNvmVersionDirectories(homePath),
executableNames
)
const versionManagerCandidate =
nvmCandidate ??
findFirstExecutable(
platform,
getBaseVersionManagerDirectories(platform, homePath),
executableNames
)
return versionManagerCandidate ?? commandName
}
export function resolveCliCommands(
commandNames: readonly string[],
options: ResolveCommandOptions = {}
): Map<string, string> {
const platform = options.platform ?? process.platform
const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? null
const pathDirectories = splitPath(pathEnv)
const homePath = options.homePath ?? homedir()
const installDirectories = [
...getNvmVersionDirectories(homePath),
...getBaseVersionManagerDirectories(platform, homePath)
]
const resolved = new Map<string, string>()
for (const commandName of new Set(commandNames)) {
const executableNames = getExecutableNames(platform, commandName)
const pathCandidate = findFirstExecutable(platform, pathDirectories, executableNames)
const installCandidate =
pathCandidate ?? findFirstExecutable(platform, installDirectories, executableNames)
resolved.set(commandName, installCandidate ?? commandName)
}
return resolved
}
export function resolveCodexCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('codex', options)
}
export function resolveClaudeCommand(options: ResolveCommandOptions = {}): string {
return resolveCliCommand('claude', options)
}
// Why: Node-script CLIs need their version-manager sibling `node` on PATH.
export function getVersionManagerBinPaths(options: ResolveCommandOptions = {}): string[] {
const platform = options.platform ?? process.platform
const homePath = options.homePath ?? homedir()
const nodeNames = getExecutableNames(platform, 'node')
return getVersionManagerDirectories(platform, homePath, nodeNames)
}
@@ -0,0 +1,77 @@
import type { TuiAgent } from './types'
import {
getTuiAgentDetectCommands,
TUI_AGENT_CONFIG,
type TuiAgentConfig,
type TuiAgentDetectionRuntime
} from './tui-agent-config'
export type TuiAgentDetectionCommand = {
id: TuiAgent
cmd: string
requiredCommands?: readonly string[]
unsupportedRuntimes?: readonly TuiAgentDetectionRuntime[]
}
export const KNOWN_TUI_AGENT_DETECTION_COMMANDS = buildTuiAgentDetectionCommands()
function buildTuiAgentDetectionCommands(): TuiAgentDetectionCommand[] {
return Object.entries(TUI_AGENT_CONFIG).flatMap(([id, config]) =>
getTuiAgentDetectCommands(config).map((cmd) =>
buildTuiAgentDetectionCommand(id as TuiAgent, cmd, config)
)
)
}
function buildTuiAgentDetectionCommand(
id: TuiAgent,
cmd: string,
config: TuiAgentConfig
): TuiAgentDetectionCommand {
return {
id,
cmd,
...(config.detectRequiredCommands?.length
? { requiredCommands: config.detectRequiredCommands }
: {}),
...(config.detectUnsupportedRuntimes?.length
? { unsupportedRuntimes: config.detectUnsupportedRuntimes }
: {})
}
}
export function getTuiAgentDetectionProbeCommands(
commands: readonly TuiAgentDetectionCommand[],
runtime: TuiAgentDetectionRuntime
): string[] {
return [
...new Set(
commands
.filter((command) => !isDetectionUnsupportedInRuntime(command, runtime))
.flatMap((command) => [command.cmd, ...(command.requiredCommands ?? [])])
)
]
}
export function resolveDetectedTuiAgentIds(
commands: readonly TuiAgentDetectionCommand[],
foundCommands: ReadonlySet<string>,
runtime: TuiAgentDetectionRuntime
): TuiAgent[] {
const detected = commands
.filter(
(command) =>
!isDetectionUnsupportedInRuntime(command, runtime) &&
foundCommands.has(command.cmd) &&
(command.requiredCommands ?? []).every((required) => foundCommands.has(required))
)
.map(({ id }) => id)
return [...new Set(detected)]
}
export function isDetectionUnsupportedInRuntime(
command: TuiAgentDetectionCommand,
runtime: TuiAgentDetectionRuntime
): boolean {
return command.unsupportedRuntimes?.includes(runtime) === true
}
+43
View File
@@ -0,0 +1,43 @@
import { win32 } from 'node:path'
/** Full path to cmd.exe for GUI and service-launched processes. */
export function getCmdExePath(): string {
return (
process.env.ComSpec ||
win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
)
}
export function isWindowsBatchScript(commandPath: string): boolean {
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
}
export const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
export class UnsafeWindowsBatchArgumentsError extends Error {
constructor() {
super(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
this.name = 'UnsafeWindowsBatchArgumentsError'
}
}
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return /[&|<>^"%!()\r\n]/.test(value)
}
export function getSpawnArgsForWindows(
command: string,
args: string[]
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
if (hasUnsafeWindowsBatchSyntax(value)) {
throw new UnsafeWindowsBatchArgumentsError()
}
}
// Why: separate argv entries let Node quote spaces without breaking cmd.
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }
}