diff --git a/.github/workflows/install-hygiene.yml b/.github/workflows/install-hygiene.yml index 0cb5ae0695..124a6a2f2f 100644 --- a/.github/workflows/install-hygiene.yml +++ b/.github/workflows/install-hygiene.yml @@ -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 diff --git a/scripts/verify-clean-install.test.ts b/scripts/verify-clean-install.test.ts index b29477a3fb..dfe1c1cf9a 100644 --- a/scripts/verify-clean-install.test.ts +++ b/scripts/verify-clean-install.test.ts @@ -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,444 @@ 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('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', + ) + }) +}) diff --git a/scripts/verify-clean-install.ts b/scripts/verify-clean-install.ts index 8c9b1d5f72..14864732ae 100644 --- a/scripts/verify-clean-install.ts +++ b/scripts/verify-clean-install.ts @@ -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,24 +591,45 @@ 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/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[] { + 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/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 @@ -320,9 +641,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 } } @@ -416,6 +738,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) } } @@ -439,6 +764,9 @@ function runScenarios( if (output !== null) { checkOutputWhitelist(scenario, output) checkNoInstallScripts(scenario, prefix) + if (checkContract) { + checkInstalledChromeEntrypoint(scenario, prefix, home) + } checkBinBoots(scenario, prefix, home, expectedVersion) } } diff --git a/src/main.tsx b/src/main.tsx index 6b563fd00b..e2c428d96d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -111,7 +111,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'; @@ -2488,6 +2488,7 @@ async function run(): Promise { await processSessionStartHooks('startup', { forceSyncExecution: true }); + await waitForClaudeInChromeSetup(); gracefulShutdownSync(0); return; } diff --git a/src/utils/claudeInChrome/launch.test.ts b/src/utils/claudeInChrome/launch.test.ts new file mode 100644 index 0000000000..9290e34e06 --- /dev/null +++ b/src/utils/claudeInChrome/launch.test.ts @@ -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) + }) +}) diff --git a/src/utils/claudeInChrome/launch.ts b/src/utils/claudeInChrome/launch.ts new file mode 100644 index 0000000000..d34017d1cd --- /dev/null +++ b/src/utils/claudeInChrome/launch.ts @@ -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 { + 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, + 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} +` +} diff --git a/src/utils/claudeInChrome/setup.ts b/src/utils/claudeInChrome/setup.ts index d9eb0bafd7..0bf513d18c 100644 --- a/src/utils/claudeInChrome/setup.ts +++ b/src/utils/claudeInChrome/setup.ts @@ -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 | null = null export function shouldAutoEnableClaudeInChrome(): boolean { if (shouldAutoEnable !== undefined) { @@ -82,6 +82,10 @@ export function shouldAutoEnableClaudeInChrome(): boolean { return shouldAutoEnable } +export function waitForClaudeInChromeSetup(): Promise { + 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), - ) - .catch(e => - logForDebugging( - `[Claude in Chrome] Failed to install native host: ${e}`, - { level: 'error' }, - ), + // 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)}`, ) - - 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 { - 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. diff --git a/src/utils/claudeInChrome/startup.test.ts b/src/utils/claudeInChrome/startup.test.ts index 198f9d7f1a..7814ab9fc3 100644 --- a/src/utils/claudeInChrome/startup.test.ts +++ b/src/utils/claudeInChrome/startup.test.ts @@ -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 { + 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 = { 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') + }) +}) diff --git a/src/utils/cliEntrypoint.ts b/src/utils/cliEntrypoint.ts new file mode 100644 index 0000000000..1fb5686dd9 --- /dev/null +++ b/src/utils/cliEntrypoint.ts @@ -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 +}