mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user