mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user