fix(chrome): launch the packaged CLI entrypoint in npm installs (#2146)

* fix(chrome): launch the packaged CLI entrypoint

* fix(chrome): harden install verification

* fix(chrome): verify installed setup targets

* fix(chrome): harden setup receipt verification

* test(chrome): require the packaged heap-limit helper

A packed tarball that has every other required entry but not
package/bin/heap-limit.mjs must fail clean-install verification.
This commit is contained in:
Bogdan
2026-09-22 11:28:44 +08:00
committed by GitHub
parent 55e9b24d91
commit bfb44ffa81
9 changed files with 1436 additions and 149 deletions
+480 -2
View File
@@ -1,6 +1,25 @@
import { describe, expect, test } from 'bun:test'
import { afterEach, describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolvePreviousPublishedVersion } from './verify-clean-install.js'
import {
checkInstalledChromeEntrypoint,
getInstalledChromeSetupProblems,
getTarballPayloadProblems,
parseInstalledChromeSetupLaunches,
resolvePreviousPublishedVersion,
runCommand,
} from './verify-clean-install.js'
const scratchDirs: string[] = []
afterEach(async () => {
await Promise.all(
scratchDirs.splice(0).map(path => rm(path, { recursive: true, force: true })),
)
})
// The retry/skip/infra branches decide whether the upgrade-install scenario
// runs, is skipped, or aborts as an infra failure — regression-covered here
@@ -74,3 +93,462 @@ describe('resolvePreviousPublishedVersion', () => {
expect(value).toBeNull()
})
})
describe('clean-install verifier seams', () => {
test('keeps Windows command and space-containing arguments as separate argv values', () => {
const calls: Array<{ command: string; args: string[]; options: object }> = []
const command = 'C:\\install prefix\\openclaude.cmd'
const args = [
'install',
'--prefix=C:\\install prefix',
'--cache=C:\\npm cache',
]
const result = runCommand(
command,
args,
{ env: {}, timeout: 1_000 },
(receivedCommand, receivedArgs, options) => {
calls.push({
command: receivedCommand,
args: receivedArgs,
options,
})
return { exitCode: 0, stdout: 'ok\n', stderr: '' }
},
)
expect(calls).toEqual([
{
command,
args,
options: {
env: {},
timeout: 1_000,
reject: false,
stripFinalNewline: false,
},
},
])
expect(result).toEqual({ status: 0, stdout: 'ok\n', stderr: '' })
})
test('reports missing required and present forbidden tar entries independently', () => {
expect(getTarballPayloadProblems(new Set(['package/dist/cli.js']))).toEqual([
expect.stringContaining('tarball is missing declared payload'),
'tarball contains obsolete CLI payload: package/dist/cli.js',
])
})
test('requires the packaged heap-limit launcher helper', () => {
const required = [
'package/package.json',
'package/bin/openclaude',
'package/bin/node-compile-cache.mjs',
'package/bin/heap-limit.mjs',
'package/dist/cli.mjs',
'package/dist/sdk.mjs',
'package/src/entrypoints/sdk.d.ts',
]
const withoutHelper = required.filter(entry => entry !== 'package/bin/heap-limit.mjs')
expect(getTarballPayloadProblems(new Set(required))).toEqual([])
expect(getTarballPayloadProblems(new Set(withoutHelper))).toEqual([
'tarball is missing declared payload: package/bin/heap-limit.mjs',
])
})
test('continues past a malformed setup receipt to a later valid receipt', () => {
const launches = {
nativeHost: {
command: process.execPath,
args: ['/installed/openclaude', '--chrome-native-host'],
},
mcpServer: {
command: process.execPath,
args: ['/installed/openclaude', '--claude-in-chrome-mcp'],
},
}
const output = [
'[DEBUG] [Claude in Chrome] Setup launch configuration: {truncated',
`[DEBUG] [Claude in Chrome] Setup launch configuration: ${JSON.stringify(launches)}`,
].join('\n')
expect(parseInstalledChromeSetupLaunches(output)).toEqual(launches)
expect(
parseInstalledChromeSetupLaunches(
'[DEBUG] [Claude in Chrome] Setup launch configuration: {truncated',
),
).toBeNull()
})
test('records missing installed Chrome artifacts without throwing', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude verifier artifacts '))
scratchDirs.push(scratch)
const packageRoot = join(
scratch,
...(process.platform === 'win32'
? ['node_modules']
: ['lib', 'node_modules']),
'@gitlawb',
'openclaude',
)
const manifestPath = join(packageRoot, 'package.json')
const launcherPath = join(packageRoot, 'bin', 'openclaude')
const globalLauncherPath =
process.platform === 'win32'
? join(scratch, 'openclaude.cmd')
: join(scratch, 'bin', 'openclaude')
const home = join(scratch, 'home')
const failures: string[] = []
const passes: string[] = []
const reporter = {
fail: (problem: string) => failures.push(problem),
pass: (what: string) => passes.push(what),
}
checkInstalledChromeEntrypoint('test', scratch, home, reporter)
expect(failures.splice(0)).toEqual([
`installed manifest missing at ${manifestPath}`,
])
mkdirSync(packageRoot, { recursive: true })
writeFileSync(
manifestPath,
JSON.stringify({ bin: { openclaude: 'bin/openclaude' } }),
)
checkInstalledChromeEntrypoint('test', scratch, home, reporter)
expect(failures.splice(0)).toEqual([
`installed OpenClaude launcher missing at ${launcherPath}`,
])
mkdirSync(join(packageRoot, 'bin'), { recursive: true })
writeFileSync(launcherPath, '#!/usr/bin/env node\n')
checkInstalledChromeEntrypoint('test', scratch, home, reporter)
expect(failures.splice(0)).toEqual([
`installed OpenClaude global launcher missing at ${globalLauncherPath}`,
])
mkdirSync(join(scratch, 'bin'), { recursive: true })
writeFileSync(globalLauncherPath, '#!/usr/bin/env node\n')
checkInstalledChromeEntrypoint('test', scratch, home, reporter)
expect(failures.splice(0)).toEqual([
`installed CLI bundle missing at ${join(packageRoot, 'dist', 'cli.mjs')}`,
])
expect(passes).toEqual([])
})
test('checks the installed setup receipt and wrapper through the full verifier path', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude verifier setup '))
scratchDirs.push(scratch)
const packageRoot = join(
scratch,
...(process.platform === 'win32'
? ['node_modules']
: ['lib', 'node_modules']),
'@gitlawb',
'openclaude',
)
const packageLauncher = join(packageRoot, 'bin', 'openclaude')
const globalLauncher =
process.platform === 'win32'
? join(scratch, 'openclaude.cmd')
: join(scratch, 'bin', 'openclaude')
const home = join(scratch, 'home')
mkdirSync(join(packageRoot, 'bin'), { recursive: true })
mkdirSync(join(packageRoot, 'dist'), { recursive: true })
mkdirSync(join(scratch, 'bin'), { recursive: true })
writeFileSync(
join(packageRoot, 'package.json'),
JSON.stringify({ bin: { openclaude: 'bin/openclaude' } }),
)
writeFileSync(packageLauncher, '#!/usr/bin/env node\n')
writeFileSync(globalLauncher, '#!/usr/bin/env node\n')
writeFileSync(join(packageRoot, 'dist', 'cli.mjs'), '// fixture bundle\n')
const verifyTarget = (target: string) => {
const failures: string[] = []
const passes: string[] = []
checkInstalledChromeEntrypoint(
'test',
scratch,
home,
{
fail: problem => failures.push(problem),
pass: what => passes.push(what),
},
(command, args, options) => {
expect(command).toBe(globalLauncher)
expect(args).toEqual([
'--chrome',
'--init-only',
'--debug-to-stderr',
])
expect(options.env.APPDATA).toBe(join(home, 'AppData', 'Roaming'))
expect(options.env.LOCALAPPDATA).toBe(join(home, 'AppData', 'Local'))
expect(options.env.CLAUDE_CODE_DEBUG_LOG_LEVEL).toBe('debug')
expect(
options.env.OPENCLAUDE_SKIP_CHROME_NATIVE_HOST_REGISTRATION,
).toBe('1')
const configDir = options.env.OPENCLAUDE_CONFIG_DIR!
const wrapperName =
process.platform === 'win32'
? 'chrome-native-host.bat'
: 'chrome-native-host'
const wrapperPath = join(configDir, 'chrome', wrapperName)
mkdirSync(join(configDir, 'chrome'), { recursive: true })
writeFileSync(
wrapperPath,
process.platform === 'win32'
? `@echo off
setlocal DisableDelayedExpansion
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
"${process.execPath.replaceAll('%', '%%')}" "${target.replaceAll('%', '%%')}" "--chrome-native-host"
`
: `#!/bin/sh
# Chrome native host wrapper script
# Generated by Claude Code - do not edit manually
exec '${process.execPath.replaceAll("'", `'"'"'`)}' '${target.replaceAll("'", `'"'"'`)}' '--chrome-native-host'
`,
)
return {
status: 0,
stdout: '',
stderr: `[DEBUG] [Claude in Chrome] Setup launch configuration: ${JSON.stringify(
{
nativeHost: {
command: process.execPath,
args: [target, '--chrome-native-host'],
requiredEntrypoint: target,
},
mcpServer: {
command: process.execPath,
args: [target, '--claude-in-chrome-mcp'],
requiredEntrypoint: target,
},
},
)}`,
}
},
)
return { failures, passes }
}
expect(verifyTarget(globalLauncher)).toEqual({
failures: [],
passes: [
'installed bundle setup generates Chrome targets for the global launcher',
],
})
const obsoleteTarget = join(packageRoot, 'dist', 'cli.js')
const obsolete = verifyTarget(obsoleteTarget)
expect(obsolete.failures).toContain(
`Chrome native-host setup target does not exist: ${obsoleteTarget}`,
)
expect(obsolete.failures).toContain(
`Chrome MCP setup target does not exist: ${obsoleteTarget}`,
)
expect(obsolete.passes).toEqual([])
})
test('accepts an artifact-local setup receipt for the installed launcher', () => {
const installedLauncher = join("/install O'Brien", 'bin', 'openclaude')
const launches = parseInstalledChromeSetupLaunches(
`2026-08-22T00:00:00.000Z [DEBUG] [Claude in Chrome] Setup launch configuration: ${JSON.stringify(
{
nativeHost: {
command: process.execPath,
args: [installedLauncher, '--chrome-native-host'],
requiredEntrypoint: installedLauncher,
},
mcpServer: {
command: process.execPath,
args: [installedLauncher, '--claude-in-chrome-mcp'],
requiredEntrypoint: installedLauncher,
},
},
)}`,
)
expect(launches).not.toBeNull()
expect(
getInstalledChromeSetupProblems({
installedLaunchers: [installedLauncher],
launches: launches!,
wrapperContent: `#!/bin/sh
# Chrome native host wrapper script
# Generated by Claude Code - do not edit manually
exec '${process.execPath}' '${installedLauncher.replaceAll("'", `'"'"'`)}' '--chrome-native-host'
`,
pathExists: path => path === installedLauncher,
platform: 'posix',
}),
).toEqual([])
})
test('rejects a wrapper target that only prefixes the installed launcher', () => {
const installedLauncher = '/install prefix/bin/openclaude'
const launches = {
nativeHost: {
command: '/usr/bin/node',
args: [installedLauncher, '--chrome-native-host'],
requiredEntrypoint: installedLauncher,
},
mcpServer: {
command: '/usr/bin/node',
args: [installedLauncher, '--claude-in-chrome-mcp'],
requiredEntrypoint: installedLauncher,
},
}
expect(
getInstalledChromeSetupProblems({
installedLaunchers: [installedLauncher],
launches,
wrapperContent: `exec '/usr/bin/node' '${installedLauncher}-old' '--chrome-native-host'`,
pathExists: path => path === installedLauncher,
platform: 'posix',
}),
).toContain(
'persisted Chrome native-host wrapper does not target the installed package launcher',
)
})
test('normalizes Windows shim targets and percent escaping', () => {
const packageLauncher =
'C:\\100% prefix\\lib\\node_modules\\@gitlawb\\openclaude\\bin\\openclaude'
const shimTarget =
'C:\\100% prefix\\bin\\..\\lib\\node_modules\\@gitlawb\\openclaude\\bin\\openclaude'
const command = 'C:\\Program Files\\nodejs\\node.exe'
const launches = {
nativeHost: {
command,
args: [shimTarget, '--chrome-native-host'],
requiredEntrypoint: shimTarget,
},
mcpServer: {
command,
args: [packageLauncher, '--claude-in-chrome-mcp'],
requiredEntrypoint: packageLauncher,
},
}
expect(
getInstalledChromeSetupProblems({
installedLaunchers: [packageLauncher],
launches,
wrapperContent: `@echo off
setlocal DisableDelayedExpansion
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
"${command}" "${shimTarget.replaceAll('%', '%%')}" "--chrome-native-host"
`,
pathExists: () => true,
platform: 'windows',
}),
).toEqual([])
})
test('reports invalid Windows wrapper arguments without throwing', () => {
const installedLauncher = 'C:\\install prefix\\bin\\openclaude'
const launches = {
nativeHost: {
command: 'C:\\invalid"runtime\\node.exe',
args: [installedLauncher, '--chrome-native-host'],
requiredEntrypoint: installedLauncher,
},
mcpServer: {
command: 'C:\\Program Files\\nodejs\\node.exe',
args: [installedLauncher, '--claude-in-chrome-mcp'],
requiredEntrypoint: installedLauncher,
},
}
expect(
getInstalledChromeSetupProblems({
installedLaunchers: [installedLauncher],
launches,
wrapperContent: '',
pathExists: () => true,
platform: 'windows',
}),
).toContain(
'persisted Chrome native-host wrapper does not target the installed package launcher',
)
})
test('rejects the released dist/cli.js setup targets', () => {
const packageRoot = join(
'/install prefix',
'lib',
'node_modules',
'@gitlawb',
'openclaude',
)
const installedLauncher = join('/install prefix', 'bin', 'openclaude')
const obsoleteTarget = join(packageRoot, 'dist', 'cli.js')
const problems = getInstalledChromeSetupProblems({
installedLaunchers: [installedLauncher],
launches: {
nativeHost: {
command: process.execPath,
args: [obsoleteTarget, '--chrome-native-host'],
requiredEntrypoint: obsoleteTarget,
},
mcpServer: {
command: process.execPath,
args: [obsoleteTarget, '--claude-in-chrome-mcp'],
requiredEntrypoint: obsoleteTarget,
},
},
wrapperContent: `"${obsoleteTarget}" "--chrome-native-host"`,
pathExists: path => path === installedLauncher,
})
expect(problems).toContain(
'Chrome native-host setup target does not exist: ' + obsoleteTarget,
)
expect(problems).toContain(
'Chrome MCP setup target does not exist: ' + obsoleteTarget,
)
expect(problems).toContain(
'persisted Chrome native-host wrapper does not target the installed package launcher',
)
})
test('rejects wrapper content that differs from the canonical renderer', () => {
const installedLauncher = '/install prefix/bin/openclaude'
const launches = {
nativeHost: {
command: '/usr/bin/node',
args: [installedLauncher, '--chrome-native-host'],
requiredEntrypoint: installedLauncher,
},
mcpServer: {
command: '/usr/bin/node',
args: [installedLauncher, '--claude-in-chrome-mcp'],
requiredEntrypoint: installedLauncher,
},
}
const wrapperContent = `#!/bin/sh
# Chrome native host wrapper script
# stale generated header
exec '/usr/bin/node' '${installedLauncher}' '--chrome-native-host'
`
expect(
getInstalledChromeSetupProblems({
installedLaunchers: [installedLauncher],
launches,
wrapperContent,
pathExists: path => path === installedLauncher,
platform: 'posix',
}),
).toContain(
'persisted Chrome native-host wrapper does not target the installed package launcher',
)
})
})
+363 -35
View File
@@ -34,11 +34,13 @@
* Note: package.json `overrides` do NOT travel to consumers; this script
* intentionally reproduces the user's resolution, not the repo's.
*/
import { execFileSync, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, posix, win32 } from 'node:path'
import { execaSync } from 'execa'
import { renderWrapperScript } from '../src/utils/claudeInChrome/launch.js'
import {
validateInstallHygieneFields,
validateRuntimeDependencyContract,
@@ -73,7 +75,46 @@ const INFRA_FAILURE_PATTERNS = [
]
type Failure = { scenario: string; problem: string }
type VerificationReporter = {
fail: (problem: string) => void
pass: (what: string) => void
}
type ChromeSetupLaunch = {
command?: unknown
args?: unknown
requiredEntrypoint?: unknown
}
export type ChromeSetupLaunches = {
nativeHost?: ChromeSetupLaunch
mcpServer?: ChromeSetupLaunch
}
type InstalledChromeSetupRunner = (
command: string,
args: string[],
options: {
cwd?: string
env: NodeJS.ProcessEnv
timeout: number
},
) => { status: number; stdout: string; stderr: string }
type SyncCommandRunner = (
command: string,
args: string[],
options: {
cwd?: string
env: NodeJS.ProcessEnv
reject: false
stripFinalNewline: false
timeout: number
},
) => {
exitCode?: number
stdout?: unknown
stderr?: unknown
}
const failures: Failure[] = []
const CHROME_SETUP_LOG_PREFIX =
'[Claude in Chrome] Setup launch configuration: '
function fail(scenario: string, problem: string): void {
failures.push({ scenario, problem })
console.error(` ❌ [${scenario}] ${problem}`)
@@ -97,21 +138,36 @@ function npmEnv(home: string): NodeJS.ProcessEnv {
}
}
export function runCommand(
command: string,
args: string[],
options: {
cwd?: string
env: NodeJS.ProcessEnv
timeout: number
},
runner: SyncCommandRunner = execaSync as SyncCommandRunner,
): { status: number; stdout: string; stderr: string } {
const result = runner(command, args, {
...options,
reject: false,
stripFinalNewline: false,
})
return {
status: result.exitCode ?? -1,
stdout: String(result.stdout ?? ''),
stderr: String(result.stderr ?? ''),
}
}
function runNpm(
args: string[],
home: string,
): { status: number; stdout: string; stderr: string } {
const result = spawnSync('npm', args, {
encoding: 'utf8',
return runCommand('npm', args, {
env: npmEnv(home),
shell: IS_WINDOWS, // npm is npm.cmd on Windows
timeout: 10 * 60 * 1000,
})
return {
status: result.status ?? -1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
function installFlags(prefix: string, cache: string): string[] {
@@ -238,6 +294,257 @@ function checkInstalledContract(scenario: string, prefix: string): void {
}
}
export function parseInstalledChromeSetupLaunches(
debugOutput: string,
): ChromeSetupLaunches | null {
for (const line of debugOutput.split(/\r?\n/)) {
const markerIndex = line.indexOf(CHROME_SETUP_LOG_PREFIX)
if (markerIndex === -1) continue
try {
const value = JSON.parse(
line.slice(markerIndex + CHROME_SETUP_LOG_PREFIX.length),
)
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as ChromeSetupLaunches
}
} catch {
continue
}
}
return null
}
type ChromeWrapperPlatform = 'posix' | 'windows'
function pathsMatch(
left: string,
right: string,
platform: ChromeWrapperPlatform,
): boolean {
if (platform === 'windows') {
return win32.resolve(left).toLowerCase() === win32.resolve(right).toLowerCase()
}
return posix.resolve(left) === posix.resolve(right)
}
function getExpectedWrapperContent(
launch: ChromeSetupLaunch | undefined,
platform: ChromeWrapperPlatform,
): string | null {
if (
typeof launch?.command !== 'string' ||
!Array.isArray(launch.args) ||
launch.args.some(arg => typeof arg !== 'string')
) {
return null
}
try {
return renderWrapperScript(
{
command: launch.command,
args: launch.args as string[],
},
platform === 'windows' ? 'windows' : 'linux',
)
} catch {
return null
}
}
export function getInstalledChromeSetupProblems({
installedLaunchers,
launches,
wrapperContent,
pathExists = existsSync,
platform = IS_WINDOWS ? 'windows' : 'posix',
}: {
installedLaunchers: readonly string[]
launches: ChromeSetupLaunches
wrapperContent: string
pathExists?: (path: string) => boolean
platform?: ChromeWrapperPlatform
}): string[] {
const problems: string[] = []
const checkLaunch = (
label: string,
launch: ChromeSetupLaunch | undefined,
flag: string,
): string | undefined => {
if (
typeof launch?.command !== 'string' ||
launch.command.length === 0 ||
!Array.isArray(launch.args) ||
launch.args.length !== 2 ||
launch.args.some(arg => typeof arg !== 'string')
) {
problems.push(`${label} setup did not emit a valid process launch`)
return undefined
}
const [target, actualFlag] = launch.args as string[]
if (
!target ||
!installedLaunchers.some(candidate => pathsMatch(candidate, target, platform)) ||
actualFlag !== flag
) {
problems.push(
`${label} setup does not target an installed package launcher with ${flag}`,
)
}
if (target && !pathExists(target)) {
problems.push(`${label} setup target does not exist: ${target}`)
}
if (
typeof launch.requiredEntrypoint !== 'string' ||
!pathsMatch(launch.requiredEntrypoint, target, platform)
) {
problems.push(
`${label} setup does not retain its launcher target as the required entrypoint`,
)
}
return target
}
const nativeHostTarget = checkLaunch(
'Chrome native-host',
launches.nativeHost,
'--chrome-native-host',
)
const mcpTarget = checkLaunch(
'Chrome MCP',
launches.mcpServer,
'--claude-in-chrome-mcp',
)
if (
nativeHostTarget === undefined ||
mcpTarget === undefined ||
!pathsMatch(nativeHostTarget, mcpTarget, platform)
) {
problems.push(
'Chrome native-host and MCP setup do not share one installed package launcher',
)
}
const expectedWrapperContent = getExpectedWrapperContent(
launches.nativeHost,
platform,
)
if (
expectedWrapperContent === null ||
wrapperContent !== expectedWrapperContent
) {
problems.push(
'persisted Chrome native-host wrapper does not target the installed package launcher',
)
}
return problems
}
export function checkInstalledChromeEntrypoint(
scenario: string,
prefix: string,
home: string,
reporter: VerificationReporter = {
fail: problem => fail(scenario, problem),
pass: what => pass(scenario, what),
},
runSetup: InstalledChromeSetupRunner = runCommand,
): void {
const packageRoot = join(globalRoot(prefix), ...PACKAGE_NAME.split('/'))
const manifestPath = join(packageRoot, 'package.json')
if (!existsSync(manifestPath)) {
reporter.fail(`installed manifest missing at ${manifestPath}`)
return
}
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8'))
const binEntry = pkg.bin?.openclaude
if (typeof binEntry !== 'string') {
reporter.fail('installed package has no openclaude launcher metadata')
return
}
const packageLauncher = join(packageRoot, binEntry)
if (!existsSync(packageLauncher)) {
reporter.fail(`installed OpenClaude launcher missing at ${packageLauncher}`)
return
}
const globalLauncher = binPath(prefix)
if (!existsSync(globalLauncher)) {
reporter.fail(`installed OpenClaude global launcher missing at ${globalLauncher}`)
return
}
const bundlePath = join(packageRoot, 'dist', 'cli.mjs')
if (!existsSync(bundlePath)) {
reporter.fail(`installed CLI bundle missing at ${bundlePath}`)
return
}
const setupConfigDir = join(home, 'chrome setup config')
mkdirSync(setupConfigDir, { recursive: true })
writeFileSync(
join(setupConfigDir, 'settings.json'),
`${JSON.stringify({ subscriptionType: 'pro' })}\n`,
{ mode: 0o600 },
)
const setup = runSetup(
binPath(prefix),
['--chrome', '--init-only', '--debug-to-stderr'],
{
cwd: home,
env: {
...npmEnv(home),
APPDATA: join(home, 'AppData', 'Roaming'),
LOCALAPPDATA: join(home, 'AppData', 'Local'),
CLAUDE_CODE_DEBUG_LOG_LEVEL: 'debug',
OPENCLAUDE_CONFIG_DIR: setupConfigDir,
OPENCLAUDE_SKIP_CHROME_NATIVE_HOST_REGISTRATION: '1',
},
timeout: 2 * 60 * 1000,
},
)
if (setup.status !== 0) {
reporter.fail(
`installed CLI Chrome setup exited ${setup.status}: ${setup.stderr.slice(0, 500)}`,
)
return
}
const launches = parseInstalledChromeSetupLaunches(setup.stderr)
if (launches === null) {
reporter.fail(
'installed CLI Chrome setup emitted no valid launch configuration receipt',
)
return
}
const wrapperName = IS_WINDOWS
? 'chrome-native-host.bat'
: 'chrome-native-host'
const wrapperPath = join(setupConfigDir, 'chrome', wrapperName)
if (!existsSync(wrapperPath)) {
reporter.fail(`installed CLI Chrome setup created no wrapper at ${wrapperPath}`)
return
}
const problems = getInstalledChromeSetupProblems({
installedLaunchers: [...new Set([globalLauncher, packageLauncher])],
launches,
wrapperContent: readFileSync(wrapperPath, 'utf8'),
})
for (const problem of problems) reporter.fail(problem)
if (problems.length === 0) {
reporter.pass(
'installed bundle setup generates Chrome targets for the global launcher',
)
}
}
function binPath(prefix: string): string {
return IS_WINDOWS ? join(prefix, 'openclaude.cmd') : join(prefix, 'bin', 'openclaude')
}
@@ -247,18 +554,11 @@ function runBin(
home: string,
args: string[],
): { status: number; stdout: string; stderr: string } {
const result = spawnSync(binPath(prefix), args, {
encoding: 'utf8',
return runCommand(binPath(prefix), args, {
env: npmEnv(home),
cwd: home,
shell: IS_WINDOWS,
timeout: 2 * 60 * 1000,
})
return {
status: result.status ?? -1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
}
}
function checkBinBoots(scenario: string, prefix: string, home: string, expectedVersion: string | null): void {
@@ -291,25 +591,46 @@ function checkBinBoots(scenario: string, prefix: string, home: string, expectedV
} else {
pass(scenario, '--help loads the full bundle with silent stderr')
}
}
const REQUIRED_TARBALL_ENTRIES = [
'package/package.json',
'package/bin/openclaude',
'package/bin/node-compile-cache.mjs',
'package/bin/heap-limit.mjs',
'package/dist/cli.mjs',
'package/dist/sdk.mjs',
'package/src/entrypoints/sdk.d.ts',
] as const
const FORBIDDEN_TARBALL_ENTRIES = ['package/dist/cli.js'] as const
export function getTarballPayloadProblems(
entries: ReadonlySet<string>,
): string[] {
const problems: string[] = []
const missing = REQUIRED_TARBALL_ENTRIES.filter(entry => !entries.has(entry))
const presentForbidden = FORBIDDEN_TARBALL_ENTRIES.filter(entry =>
entries.has(entry),
)
if (missing.length > 0) {
problems.push(`tarball is missing declared payload: ${missing.join(', ')}`)
}
if (presentForbidden.length > 0) {
problems.push(
`tarball contains obsolete CLI payload: ${presentForbidden.join(', ')}`,
)
}
return problems
}
function checkTarballContents(tarballPath: string): void {
const scenario = 'tarball'
const required = [
'package/package.json',
'package/bin/openclaude',
'package/bin/node-compile-cache.mjs',
'package/bin/heap-limit.mjs',
'package/dist/cli.mjs',
'package/dist/sdk.mjs',
'package/src/entrypoints/sdk.d.ts',
]
const listing = execFileSync('tar', ['-tzf', tarballPath], { encoding: 'utf8' })
const entries = new Set(listing.split(/\r?\n/).map(l => l.trim()))
const missing = required.filter(entry => !entries.has(entry))
if (missing.length > 0) {
fail(scenario, `tarball is missing declared payload: ${missing.join(', ')}`)
} else {
const payloadProblems = getTarballPayloadProblems(entries)
for (const problem of payloadProblems) fail(scenario, problem)
if (payloadProblems.length === 0) {
pass(scenario, `tarball carries the full declared payload (${entries.size - 1} files)`)
}
const size = statSync(tarballPath).size
@@ -321,9 +642,10 @@ function checkTarballContents(tarballPath: string): void {
}
function makeSandbox(work: string, name: string): { prefix: string; cache: string; home: string } {
const prefix = join(work, name, 'prefix')
const cache = join(work, name, 'cache')
const home = join(work, name, 'home')
const scenarioRoot = join(work, `${name} install scenario`)
const prefix = join(scenarioRoot, 'install prefix')
const cache = join(scenarioRoot, 'npm cache')
const home = join(scenarioRoot, 'home dir')
for (const dir of [prefix, cache, home]) mkdirSync(dir, { recursive: true })
return { prefix, cache, home }
}
@@ -417,6 +739,9 @@ function runScenarios(
// Contract comparison only makes sense for the artifact built from THIS
// tree; a published artifact predates contract bumps (version skew).
if (checkContract) checkInstalledContract(scenario, prefix)
if (checkContract) {
checkInstalledChromeEntrypoint(scenario, prefix, home)
}
checkBinBoots(scenario, prefix, home, expectedVersion)
}
}
@@ -440,6 +765,9 @@ function runScenarios(
if (output !== null) {
checkOutputWhitelist(scenario, output)
checkNoInstallScripts(scenario, prefix)
if (checkContract) {
checkInstalledChromeEntrypoint(scenario, prefix, home)
}
checkBinBoots(scenario, prefix, home, expectedVersion)
}
}