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
+3 -2
View File
@@ -45,7 +45,8 @@ jobs:
with:
bun-version-file: .bun-version
# The verify script only uses node builtins + scripts/externalsValidation
# (relative import) — no bun install needed, keeping the matrix cheap.
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Verify published package installs clean
run: bun run scripts/verify-clean-install.ts --published
+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)
}
}
+2 -1
View File
@@ -112,7 +112,7 @@ import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAge
import type { LogOption } from './types/logs.js';
import type { Message as MessageType } from './types/message.js';
import { assertMinVersion } from './utils/autoUpdater.js';
import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome } from './utils/claudeInChrome/setup.js';
import { setupClaudeInChrome, shouldAutoEnableClaudeInChrome, shouldEnableClaudeInChrome, waitForClaudeInChromeSetup } from './utils/claudeInChrome/setup.js';
import { mergeClaudeInChromeStartupConfig, resolveClaudeInChromeStartupMode } from './utils/claudeInChrome/startup.js';
import { getContextWindowForModel } from './utils/context.js';
import { loadConversationForResume } from './utils/conversationRecovery.js';
@@ -2499,6 +2499,7 @@ async function run(): Promise<CommanderCommand> {
await processSessionStartHooks('startup', {
forceSyncExecution: true
});
await waitForClaudeInChromeSetup();
gracefulShutdownSync(0);
return;
}
+253
View File
@@ -0,0 +1,253 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
statSync,
symlinkSync,
utimesSync,
writeFileSync,
} from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { resolveCurrentCliEntrypoint } from '../cliEntrypoint.js'
import {
createWrapperScript,
renderWrapperScript,
resolveClaudeInChromeLaunches,
} from './launch.js'
const scratchDirs: string[] = []
afterEach(async () => {
await Promise.all(
scratchDirs.splice(0).map(path => rm(path, { recursive: true, force: true })),
)
})
describe('resolveClaudeInChromeLaunches', () => {
test('keeps native executable mode on flag-only child arguments', () => {
const launches = resolveClaudeInChromeLaunches({
isNativeBuild: true,
execPath: '/opt/OpenClaude/openclaude',
})
expect(launches.nativeHost).toEqual({
command: '/opt/OpenClaude/openclaude',
args: ['--chrome-native-host'],
})
expect(launches.mcpServer).toEqual({
command: '/opt/OpenClaude/openclaude',
args: ['--claude-in-chrome-mcp'],
})
})
test('shares the resolved CLI entrypoint across both npm-mode launches', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude npm launch '))
scratchDirs.push(scratch)
const entrypoint = join(scratch, 'package dir', 'bin', 'openclaude')
mkdirSync(join(scratch, 'package dir', 'bin'), { recursive: true })
writeFileSync(entrypoint, '#!/usr/bin/env node\n')
const launches = resolveClaudeInChromeLaunches({
isNativeBuild: false,
execPath: '/usr/bin/node',
cliEntrypoint: entrypoint,
})
expect(launches.nativeHost).toEqual({
command: '/usr/bin/node',
args: [entrypoint, '--chrome-native-host'],
requiredEntrypoint: entrypoint,
})
expect(launches.mcpServer).toEqual({
command: '/usr/bin/node',
args: [entrypoint, '--claude-in-chrome-mcp'],
requiredEntrypoint: entrypoint,
})
})
})
describe('resolveCurrentCliEntrypoint', () => {
test('normalizes relative invocations without resolving symlinks', () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude cli entrypoint '))
scratchDirs.push(scratch)
const packageDir = join(scratch, 'package dir')
const launcher = join(packageDir, 'bin', 'openclaude')
const invokedLink = join(scratch, 'prefix with spaces', 'bin', 'openclaude')
mkdirSync(join(packageDir, 'bin'), { recursive: true })
mkdirSync(join(scratch, 'prefix with spaces', 'bin'), { recursive: true })
writeFileSync(launcher, '#!/usr/bin/env node\n')
expect(
resolveCurrentCliEntrypoint({
argv1: join('package dir', 'bin', 'openclaude'),
cwd: scratch,
}),
).toBe(launcher)
if (process.platform === 'win32') return
symlinkSync(launcher, invokedLink)
expect(resolveCurrentCliEntrypoint({ argv1: invokedLink })).toBe(invokedLink)
})
test('rejects a missing target without leaking its absolute path', () => {
const missing = join(tmpdir(), 'private user path', 'missing-cli.mjs')
let caught: Error | undefined
try {
resolveCurrentCliEntrypoint({ argv1: missing })
} catch (error) {
caught = error as Error
}
expect(caught?.message).toContain(
'Unable to resolve the current OpenClaude CLI entrypoint',
)
expect(caught?.message).not.toContain(missing)
expect(caught?.message).not.toContain(basename(missing))
})
test('does not read the current directory for an absolute entrypoint', () => {
const entrypoint = join(tmpdir(), 'installed openclaude', 'bin', 'openclaude')
expect(
resolveCurrentCliEntrypoint({
argv1: entrypoint,
getCwd: () => {
throw new Error('cwd was removed')
},
pathExists: path => path === entrypoint,
}),
).toBe(entrypoint)
})
})
describe('Claude-in-Chrome native host wrapper', () => {
test('quotes POSIX executable and script paths containing spaces', () => {
const script = renderWrapperScript(
{
command: '/runtime path/node',
args: ['/package path/dist/cli.mjs', '--chrome-native-host'],
},
'linux',
)
expect(script).toContain(
"exec '/runtime path/node' '/package path/dist/cli.mjs' '--chrome-native-host'",
)
if (process.platform !== 'win32') {
expect(spawnSync('sh', ['-n'], { input: script }).status).toBe(0)
}
})
test('renders a valid Windows batch command', () => {
const script = renderWrapperScript(
{
command: 'C:\\Program Files\\nodejs\\node.exe',
args: [
'C:\\Open Claude\\dist\\cli.mjs',
'--chrome-native-host',
],
},
'windows',
)
expect(script).toBe(`@echo off
setlocal DisableDelayedExpansion
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
"C:\\Program Files\\nodejs\\node.exe" "C:\\Open Claude\\dist\\cli.mjs" "--chrome-native-host"
`)
})
test('rewrites a stale cli.js wrapper', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude stale wrapper '))
scratchDirs.push(scratch)
const chromeDir = join(scratch, 'chrome')
const wrapperPath = join(chromeDir, 'chrome-native-host')
const entrypoint = join(scratch, 'package', 'bin', 'openclaude')
mkdirSync(chromeDir, { recursive: true })
mkdirSync(join(scratch, 'package', 'bin'), { recursive: true })
writeFileSync(entrypoint, '#!/usr/bin/env node\n')
writeFileSync(
wrapperPath,
'#!/bin/sh\nexec "/usr/bin/node" "/package/dist/cli.js" --chrome-native-host\n',
)
await createWrapperScript(
{
command: '/usr/bin/node',
args: [entrypoint, '--chrome-native-host'],
requiredEntrypoint: entrypoint,
},
{ platform: 'linux', chromeDir },
)
const rewritten = readFileSync(wrapperPath, 'utf8')
expect(rewritten).not.toContain('cli.js')
expect(rewritten).toContain(`'${entrypoint}'`)
if (process.platform !== 'win32') {
expect(statSync(wrapperPath).mode & 0o777).toBe(0o755)
}
})
test('does not rewrite an already-correct wrapper', async () => {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude correct wrapper '))
scratchDirs.push(scratch)
const chromeDir = join(scratch, 'chrome')
const entrypoint = join(scratch, 'package', 'bin', 'openclaude')
mkdirSync(join(scratch, 'package', 'bin'), { recursive: true })
writeFileSync(entrypoint, '#!/usr/bin/env node\n')
const launch = {
command: '/usr/bin/node',
args: [entrypoint, '--chrome-native-host'],
requiredEntrypoint: entrypoint,
}
const wrapperPath = await createWrapperScript(launch, {
platform: 'linux',
chromeDir,
})
const oldTime = new Date('2020-01-02T03:04:05.000Z')
utimesSync(wrapperPath, oldTime, oldTime)
await createWrapperScript(launch, { platform: 'linux', chromeDir })
expect(statSync(wrapperPath).mtime.getTime()).toBe(oldTime.getTime())
})
test('restores executable mode without rewriting matching POSIX content', async () => {
if (process.platform === 'win32') return
const scratch = mkdtempSync(join(tmpdir(), 'openclaude wrapper mode '))
scratchDirs.push(scratch)
const chromeDir = join(scratch, 'chrome')
const entrypoint = join(scratch, 'package', 'bin', 'openclaude')
mkdirSync(join(scratch, 'package', 'bin'), { recursive: true })
writeFileSync(entrypoint, '#!/usr/bin/env node\n')
const launch = {
command: '/usr/bin/node',
args: [entrypoint, '--chrome-native-host'],
requiredEntrypoint: entrypoint,
}
const wrapperPath = await createWrapperScript(launch, {
platform: 'linux',
chromeDir,
})
const oldTime = new Date('2020-01-02T03:04:05.000Z')
chmodSync(wrapperPath, 0o644)
utimesSync(wrapperPath, oldTime, oldTime)
const content = readFileSync(wrapperPath, 'utf8')
await createWrapperScript(launch, { platform: 'linux', chromeDir })
expect(readFileSync(wrapperPath, 'utf8')).toBe(content)
expect(statSync(wrapperPath).mtime.getTime()).toBe(oldTime.getTime())
expect(statSync(wrapperPath).mode & 0o777).toBe(0o755)
})
})
+138
View File
@@ -0,0 +1,138 @@
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { resolveCurrentCliEntrypoint } from '../cliEntrypoint.js'
import { logForDebugging } from '../debug.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { getPlatform, type Platform } from '../platform.js'
type ProcessLaunch = {
command: string
args: string[]
requiredEntrypoint?: string
}
export function resolveClaudeInChromeLaunches({
isNativeBuild,
execPath = process.execPath,
cliEntrypoint,
}: {
isNativeBuild: boolean
execPath?: string
cliEntrypoint?: string
}): {
nativeHost: ProcessLaunch
mcpServer: ProcessLaunch
} {
if (isNativeBuild) {
return {
nativeHost: {
command: execPath,
args: ['--chrome-native-host'],
},
mcpServer: {
command: execPath,
args: ['--claude-in-chrome-mcp'],
},
}
}
const entrypoint = resolveCurrentCliEntrypoint({ argv1: cliEntrypoint })
return {
nativeHost: {
command: execPath,
args: [entrypoint, '--chrome-native-host'],
requiredEntrypoint: entrypoint,
},
mcpServer: {
command: execPath,
args: [entrypoint, '--claude-in-chrome-mcp'],
requiredEntrypoint: entrypoint,
},
}
}
/**
* Create the argument-free wrapper path required by native-messaging manifests.
*/
export async function createWrapperScript(
launch: ProcessLaunch,
{
platform = getPlatform(),
chromeDir = join(getClaudeConfigHomeDir(), 'chrome'),
}: {
platform?: Platform
chromeDir?: string
} = {},
): Promise<string> {
if (launch.requiredEntrypoint && !existsSync(launch.requiredEntrypoint)) {
throw new Error(
'Unable to create the Claude-in-Chrome native host wrapper because the OpenClaude CLI entrypoint is unavailable.',
)
}
const wrapperPath =
platform === 'windows'
? join(chromeDir, 'chrome-native-host.bat')
: join(chromeDir, 'chrome-native-host')
const scriptContent = renderWrapperScript(launch, platform)
const existingContent = await readFile(wrapperPath, 'utf-8').catch(() => null)
if (existingContent === scriptContent) {
if (platform !== 'windows') {
const existingMode = (await stat(wrapperPath)).mode & 0o777
if (existingMode !== 0o755) {
await chmod(wrapperPath, 0o755)
}
}
return wrapperPath
}
await mkdir(chromeDir, { recursive: true })
await writeFile(wrapperPath, scriptContent)
if (platform !== 'windows') {
await chmod(wrapperPath, 0o755)
}
logForDebugging(
`[Claude in Chrome] Created Chrome native host wrapper script: ${wrapperPath}`,
)
return wrapperPath
}
function quotePosixArgument(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
function quoteWindowsBatchArgument(value: string): string {
if (/[\0\r\n"]/.test(value)) {
throw new Error(
'Unable to create the Claude-in-Chrome native host wrapper from an invalid command argument.',
)
}
return `"${value.replaceAll('%', '%%')}"`
}
export function renderWrapperScript(
launch: Pick<ProcessLaunch, 'command' | 'args'>,
platform: Platform,
): string {
const quote =
platform === 'windows' ? quoteWindowsBatchArgument : quotePosixArgument
const command = [launch.command, ...launch.args].map(quote).join(' ')
return platform === 'windows'
? `@echo off
setlocal DisableDelayedExpansion
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
${command}
`
: `#!/bin/sh
# Chrome native host wrapper script
# Generated by Claude Code - do not edit manually
exec ${command}
`
}
+40 -109
View File
@@ -1,8 +1,7 @@
import { BROWSER_TOOLS } from '@ant/claude-for-chrome-mcp'
import { chmod, mkdir, readFile, writeFile } from 'fs/promises'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { fileURLToPath } from 'url'
import {
getSessionDangerousPermissionMode,
getIsInteractive,
@@ -13,11 +12,7 @@ import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import { isInBundledMode } from '../bundledMode.js'
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
import { logForDebugging } from '../debug.js'
import {
getClaudeConfigHomeDir,
isEnvDefinedFalsy,
isEnvTruthy,
} from '../envUtils.js'
import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
import { execFileNoThrowWithCwd } from '../execFileNoThrow.js'
import { getPlatform } from '../platform.js'
import { jsonStringify } from '../slowOperations.js'
@@ -28,6 +23,10 @@ import {
getAllWindowsRegistryKeys,
openInChrome,
} from './common.js'
import {
createWrapperScript,
resolveClaudeInChromeLaunches,
} from './launch.js'
import { getChromeSystemPrompt } from './prompt.js'
import { isChromeExtensionInstalledPortable } from './setupPortable.js'
@@ -68,6 +67,7 @@ export function shouldEnableClaudeInChrome(chromeFlag?: boolean): boolean {
}
let shouldAutoEnable: boolean | undefined = undefined
let pendingClaudeInChromeSetup: Promise<void> | null = null
export function shouldAutoEnableClaudeInChrome(): boolean {
if (shouldAutoEnable !== undefined) {
@@ -82,6 +82,10 @@ export function shouldAutoEnableClaudeInChrome(): boolean {
return shouldAutoEnable
}
export function waitForClaudeInChromeSetup(): Promise<void> {
return pendingClaudeInChromeSetup ?? Promise.resolve()
}
/**
* Setup Claude in Chrome MCP server and tools
*
@@ -93,6 +97,7 @@ export function setupClaudeInChrome(): {
systemPrompt: string
} {
const isNativeBuild = isInBundledMode()
const launches = resolveClaudeInChromeLaunches({ isNativeBuild })
const allowedTools = BROWSER_TOOLS.map(
tool => `mcp__claude-in-chrome__${tool.name}`,
)
@@ -103,69 +108,42 @@ export function setupClaudeInChrome(): {
}
const hasEnv = Object.keys(env).length > 0
if (isNativeBuild) {
// Create a wrapper script that calls the same binary with --chrome-native-host. This
// is needed because the native host manifest "path" field cannot contain arguments.
const execCommand = `"${process.execPath}" --chrome-native-host`
// Run asynchronously without blocking; best-effort so swallow errors
void createWrapperScript(execCommand)
.then(manifestBinaryPath =>
installChromeNativeHostManifest(manifestBinaryPath),
// The manifest path cannot contain arguments, so both runtime modes need a
// wrapper. Launch selection above keeps the native mode flag-only and gives
// non-native mode the validated current CLI entrypoint.
pendingClaudeInChromeSetup = createWrapperScript(launches.nativeHost)
.then(manifestBinaryPath => {
logForDebugging(
`[Claude in Chrome] Setup launch configuration: ${jsonStringify(launches)}`,
)
.catch(e =>
logForDebugging(
`[Claude in Chrome] Failed to install native host: ${e}`,
{ level: 'error' },
),
)
return {
mcpConfig: {
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
type: 'stdio' as const,
command: process.execPath,
args: ['--claude-in-chrome-mcp'],
scope: 'dynamic' as const,
...(hasEnv && { env }),
},
},
allowedTools,
systemPrompt: getChromeSystemPrompt(),
}
} else {
const __filename = fileURLToPath(import.meta.url)
const __dirname = join(__filename, '..')
const cliPath = join(__dirname, 'cli.js')
void createWrapperScript(
`"${process.execPath}" "${cliPath}" --chrome-native-host`,
if (
isEnvTruthy(
process.env.OPENCLAUDE_SKIP_CHROME_NATIVE_HOST_REGISTRATION,
)
) {
return
}
return installChromeNativeHostManifest(manifestBinaryPath)
})
.catch(e =>
logForDebugging(
`[Claude in Chrome] Failed to install native host: ${e}`,
{ level: 'error' },
),
)
.then(manifestBinaryPath =>
installChromeNativeHostManifest(manifestBinaryPath),
)
.catch(e =>
logForDebugging(
`[Claude in Chrome] Failed to install native host: ${e}`,
{ level: 'error' },
),
)
const mcpConfig = {
return {
mcpConfig: {
[CLAUDE_IN_CHROME_MCP_SERVER_NAME]: {
type: 'stdio' as const,
command: process.execPath,
args: [`${cliPath}`, '--claude-in-chrome-mcp'],
command: launches.mcpServer.command,
args: launches.mcpServer.args,
scope: 'dynamic' as const,
...(hasEnv && { env }),
},
}
return {
mcpConfig,
allowedTools,
systemPrompt: getChromeSystemPrompt(),
}
},
allowedTools,
systemPrompt: getChromeSystemPrompt(),
}
}
@@ -297,53 +275,6 @@ function registerWindowsNativeHosts(manifestPath: string): void {
}
}
/**
* Create a wrapper script in ~/.claude/chrome/ that invokes the given command. This is
* necessary because Chrome's native host manifest "path" field cannot contain arguments.
*
* @param command - The full command to execute (e.g., "/path/to/claude --chrome-native-host")
* @returns The path to the wrapper script
*/
async function createWrapperScript(command: string): Promise<string> {
const platform = getPlatform()
const chromeDir = join(getClaudeConfigHomeDir(), 'chrome')
const wrapperPath =
platform === 'windows'
? join(chromeDir, 'chrome-native-host.bat')
: join(chromeDir, 'chrome-native-host')
const scriptContent =
platform === 'windows'
? `@echo off
REM Chrome native host wrapper script
REM Generated by Claude Code - do not edit manually
${command}
`
: `#!/bin/sh
# Chrome native host wrapper script
# Generated by Claude Code - do not edit manually
exec ${command}
`
// Check if content matches to avoid unnecessary writes
const existingContent = await readFile(wrapperPath, 'utf-8').catch(() => null)
if (existingContent === scriptContent) {
return wrapperPath
}
await mkdir(chromeDir, { recursive: true })
await writeFile(wrapperPath, scriptContent)
if (platform !== 'windows') {
await chmod(wrapperPath, 0o755)
}
logForDebugging(
`[Claude in Chrome] Created Chrome native host wrapper script: ${wrapperPath}`,
)
return wrapperPath
}
/**
* Get cached value of whether Chrome extension is installed. Returns
* from disk cache immediately, updates cache in background.
+127
View File
@@ -1,4 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { spawnSync } from 'node:child_process'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import {
CLAUDE_IN_CHROME_SKILL_HINT,
@@ -9,6 +15,117 @@ import {
resolveClaudeInChromeStartupMode,
} from './startup.js'
async function runSetupFixture(mode: 'existing' | 'missing'): Promise<void> {
const scratch = mkdtempSync(join(tmpdir(), 'openclaude chrome setup '))
const fixturePath = join(scratch, 'setup.fixture.test.ts')
const configDir = join(scratch, 'config dir')
const cliEntrypoint = join(scratch, 'package dir', 'bin', 'openclaude')
try {
if (mode === 'existing') {
await Bun.write(cliEntrypoint, '#!/usr/bin/env node\n')
}
await Bun.write(
fixturePath,
`import { expect, mock, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
mock.module('@ant/claude-for-chrome-mcp', () => ({ BROWSER_TOOLS: [] }))
const envUtilsUrl = process.env.TEST_ENV_UTILS_URL
const setupUrl = process.env.TEST_SETUP_MODULE_URL
if (!envUtilsUrl || !setupUrl) throw new Error('Missing fixture module URL')
process.argv.push('--debug-to-stderr')
const { setClaudeConfigHomeDirForTesting } = await import(envUtilsUrl)
const { setupClaudeInChrome, waitForClaudeInChromeSetup } = await import(setupUrl)
test('isolated Claude-in-Chrome setup', async () => {
const mode = process.env.TEST_SETUP_MODE
const configDir = process.env.TEST_CONFIG_DIR!
const cliEntrypoint = process.env.TEST_CLI_ENTRYPOINT!
process.argv[1] = cliEntrypoint
setClaudeConfigHomeDirForTesting(configDir)
if (mode === 'missing') {
expect(() => setupClaudeInChrome()).toThrow(
'Unable to resolve the current OpenClaude CLI entrypoint',
)
const wrapperName = process.platform === 'win32'
? 'chrome-native-host.bat'
: 'chrome-native-host'
expect(existsSync(join(configDir, 'chrome', wrapperName))).toBe(false)
return
}
const setup = setupClaudeInChrome()
const chromeServer = setup.mcpConfig['claude-in-chrome']
expect(chromeServer?.type).toBe('stdio')
if (chromeServer?.type !== 'stdio') {
throw new Error('Expected the Claude-in-Chrome stdio server')
}
const resolvedTarget = chromeServer.args[0]
expect(resolvedTarget).toBe(cliEntrypoint)
expect(existsSync(resolvedTarget!)).toBe(true)
const wrapperName = process.platform === 'win32'
? 'chrome-native-host.bat'
: 'chrome-native-host'
const wrapperPath = join(configDir, 'chrome', wrapperName)
await waitForClaudeInChromeSetup()
expect(existsSync(wrapperPath)).toBe(true)
const wrapper = await Bun.file(wrapperPath).text()
expect(wrapper).toContain(resolvedTarget!)
expect(wrapper).toContain('--chrome-native-host')
})
`,
)
const result = spawnSync(process.execPath, ['test', fixturePath], {
encoding: 'utf8',
timeout: 60_000,
env: {
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: scratch,
USERPROFILE: scratch,
APPDATA: join(scratch, 'AppData', 'Roaming'),
LOCALAPPDATA: join(scratch, 'AppData', 'Local'),
OPENCLAUDE_SKIP_CHROME_NATIVE_HOST_REGISTRATION: '1',
TEST_CLI_ENTRYPOINT: cliEntrypoint,
TEST_CONFIG_DIR: configDir,
TEST_ENV_UTILS_URL: pathToFileURL(
join(import.meta.dir, '..', 'envUtils.ts'),
).href,
TEST_SETUP_MODE: mode,
TEST_SETUP_MODULE_URL: pathToFileURL(join(import.meta.dir, 'setup.ts'))
.href,
},
})
if (result.status !== 0) {
throw new Error(
`Isolated setup fixture failed (status=${result.status ?? 'none'}, signal=${result.signal ?? 'none'}, error=${result.error?.message ?? 'none'}):\n${result.stdout ?? ''}\n${result.stderr ?? ''}`,
)
}
if (mode === 'existing') {
const marker = '[Claude in Chrome] Setup launch configuration: '
const receiptLine = (result.stderr ?? '')
.split(/\r?\n/)
.find(line => line.includes(marker))
expect(receiptLine).toBeDefined()
const receipt = JSON.parse(
receiptLine!.slice(receiptLine!.indexOf(marker) + marker.length),
)
expect(receipt.nativeHost.args[0]).toBe(cliEntrypoint)
expect(receipt.mcpServer.args[0]).toBe(cliEntrypoint)
}
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
const existingMcpConfig: Record<string, ScopedMcpServerConfig> = {
existing: {
type: 'stdio',
@@ -131,3 +248,13 @@ describe('mergeClaudeInChromeStartupConfig', () => {
)
})
})
describe('setupClaudeInChrome', () => {
test('uses an existing current CLI entrypoint for npm-style child launches', async () => {
await runSetupFixture('existing')
})
test('does not write a wrapper when the current CLI entrypoint is missing', async () => {
await runSetupFixture('missing')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { existsSync } from 'node:fs'
import { isAbsolute, resolve } from 'node:path'
const MISSING_CLI_ENTRYPOINT_MESSAGE =
'Unable to resolve the current OpenClaude CLI entrypoint. Start OpenClaude through its installed launcher and try again.'
export function resolveCurrentCliEntrypoint({
argv1 = process.argv[1],
cwd,
getCwd = process.cwd,
pathExists = existsSync,
}: {
argv1?: string
cwd?: string
getCwd?: () => string
pathExists?: (path: string) => boolean
} = {}): string {
if (!argv1) {
throw new Error(MISSING_CLI_ENTRYPOINT_MESSAGE)
}
const entrypoint = isAbsolute(argv1)
? argv1
: resolve(cwd ?? getCwd(), argv1)
if (!pathExists(entrypoint)) {
throw new Error(MISSING_CLI_ENTRYPOINT_MESSAGE)
}
return entrypoint
}