Skip to content

Commit 32c08eb

Browse files
committed
fix: launch ACP adapters without host node
1 parent ffbd8db commit 32c08eb

8 files changed

Lines changed: 228 additions & 36 deletions

File tree

packages/browseros-agent/apps/server/src/agent/provider-factory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ async function createAcpLanguageModel(
156156
for (const builtIn of ['claude', 'codex'] as const) {
157157
const launcher = resolveAcpSpawnCommand({
158158
agentType: builtIn,
159+
browserosDir: getBrowserosDir(),
159160
resourcesDir: config.resourcesDir,
160161
})
161162
if (launcher?.source === 'bundled-bun') {

packages/browseros-agent/apps/server/src/api/services/acpx-probe/probeAgent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { type AgentProbeResult, probeAgent as runProbe } from 'acp-probe'
88
import { resolveAcpSpawnCommand } from '../../../lib/agents/host-acp/launcher'
9+
import { getBrowserosDir } from '../../../lib/browseros-dir'
910
import { logger } from '../../../lib/logger'
1011

1112
export interface ServerAcpxProbeInput {
@@ -21,6 +22,7 @@ export interface ServerAcpxProbeInput {
2122
* HttpServerConfig.
2223
*/
2324
resourcesDir?: string | null
25+
browserosDir?: string | null
2426
platform?: NodeJS.Platform
2527
}
2628

@@ -89,6 +91,7 @@ export async function probeAcpAgent(
8991
if (!command && agentId) {
9092
const launcher = resolveAcpSpawnCommand({
9193
agentType: agentId,
94+
browserosDir: input.browserosDir ?? getBrowserosDir(),
9295
resourcesDir: input.resourcesDir,
9396
platform: input.platform,
9497
})

packages/browseros-agent/apps/server/src/lib/agents/acpx/runtime.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
type AgentSessionId,
2828
MAIN_AGENT_SESSION_ID,
2929
} from '../agent-types'
30-
import { resolveBundledBun } from '../host-acp/bundled-bun'
30+
import {
31+
resolveBundledBun,
32+
withBundledBunAcpAdapterEnv,
33+
} from '../host-acp/bundled-bun'
3134
import { withBundledNativeBinaryPath } from '../host-acp/bundled-native-binary'
3235
import {
3336
DANGEROUS_ALLOW_MODE_CANDIDATES,
@@ -753,8 +756,12 @@ function createBrowserosAgentRegistry(input: {
753756
})
754757
return wrapCommandWithEnv(
755758
launch.command,
756-
launch.addBundledBunAdapterEnv
757-
? withBundledBunAcpAdapterEnv(commandEnv, input.browserosDir)
759+
launch.bundledBunPath
760+
? withBundledBunAcpAdapterEnv({
761+
bunPath: launch.bundledBunPath,
762+
browserosDir: input.browserosDir,
763+
env: commandEnv,
764+
})
758765
: commandEnv,
759766
)
760767
}
@@ -773,31 +780,20 @@ function createBrowserosAgentRegistry(input: {
773780
function resolveBrowserosHostAcpAdapterCommand(input: {
774781
adapter: 'claude' | 'codex'
775782
resourcesDir: string | null
776-
}): { command: string; addBundledBunAdapterEnv: boolean } {
783+
}): { command: string; bundledBunPath: string | null } {
777784
const bun = resolveBundledBun({ resourcesDir: input.resourcesDir })
778785
if (bun) {
779786
const config = HOST_ACP_ADAPTER_CONFIG[input.adapter]
780787
return {
781788
command: `${shellQuote(bun)} x --bun --silent --package ${shellQuote(config.acpPackageSpec)} ${shellQuote(config.acpBin)}`,
782-
addBundledBunAdapterEnv: true,
789+
bundledBunPath: bun,
783790
}
784791
}
785792

786793
const config = HOST_ACP_ADAPTER_CONFIG[input.adapter]
787794
return {
788795
command: config.acpCommand,
789-
addBundledBunAdapterEnv: false,
790-
}
791-
}
792-
793-
/** Adds the minimum env needed for BrowserOS-managed bundled Bun package installs. */
794-
function withBundledBunAcpAdapterEnv(
795-
env: Record<string, string>,
796-
browserosDir: string,
797-
): Record<string, string> {
798-
return {
799-
...env,
800-
BUN_INSTALL_CACHE_DIR: join(browserosDir, 'cache', 'bun-install'),
796+
bundledBunPath: null,
801797
}
802798
}
803799

packages/browseros-agent/apps/server/src/lib/agents/host-acp/bundled-bun.ts

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,15 @@
44
* SPDX-License-Identifier: AGPL-3.0-or-later
55
*/
66

7-
import { accessSync, constants, statSync } from 'node:fs'
8-
import { join } from 'node:path'
7+
import {
8+
accessSync,
9+
chmodSync,
10+
constants,
11+
mkdirSync,
12+
statSync,
13+
writeFileSync,
14+
} from 'node:fs'
15+
import { dirname, join } from 'node:path'
916

1017
const BUNDLED_BUN_RELATIVE_PATH = join('bin', 'third_party', 'bun')
1118
const WINDOWS_BUNDLED_BUN_RELATIVE_PATH = join('bin', 'third_party', 'bun.exe')
@@ -33,6 +40,42 @@ export function resolveBundledBun(input: {
3340
}
3441
}
3542

43+
/**
44+
* Builds the environment used to launch Node-shebang ACP adapter
45+
* packages through BrowserOS's bundled Bun. Several adapter packages
46+
* publish `#!/usr/bin/env node` entrypoints; GUI-launched apps often
47+
* lack the user's Homebrew/nvm Node on PATH, so provide a private
48+
* `node` shim that execs bundled Bun.
49+
*/
50+
export function withBundledBunAcpAdapterEnv(input: {
51+
bunPath: string
52+
browserosDir?: string | null
53+
env?: Record<string, string | undefined>
54+
platform?: NodeJS.Platform
55+
}): Record<string, string> {
56+
const platform = input.platform ?? process.platform
57+
const sourceEnv = input.env ?? process.env
58+
const env = input.env ? stringEnv(input.env) : {}
59+
const pathKey = pathEnvKey(sourceEnv, platform)
60+
const delimiter = platform === 'win32' ? ';' : ':'
61+
const pathEntries = [
62+
ensureBundledNodeShim({
63+
bunPath: input.bunPath,
64+
browserosDir: input.browserosDir,
65+
platform,
66+
}),
67+
dirname(input.bunPath),
68+
...(sourceEnv[pathKey] ?? '').split(delimiter),
69+
].filter((entry): entry is string => Boolean(entry))
70+
71+
env[pathKey] = dedupe(pathEntries).join(delimiter)
72+
const browserosDir = input.browserosDir?.trim()
73+
if (browserosDir) {
74+
env.BUN_INSTALL_CACHE_DIR = join(browserosDir, 'cache', 'bun-install')
75+
}
76+
return env
77+
}
78+
3679
function bundledBunRelativePath(platform: NodeJS.Platform): string | null {
3780
if (platform === 'darwin' || platform === 'linux') {
3881
return BUNDLED_BUN_RELATIVE_PATH
@@ -42,3 +85,61 @@ function bundledBunRelativePath(platform: NodeJS.Platform): string | null {
4285
}
4386
return null
4487
}
88+
89+
function ensureBundledNodeShim(input: {
90+
bunPath: string
91+
browserosDir?: string | null
92+
platform: NodeJS.Platform
93+
}): string | null {
94+
if (input.platform === 'win32') return null
95+
const browserosDir = input.browserosDir?.trim()
96+
if (!browserosDir) return null
97+
98+
const shimDir = join(browserosDir, 'cache', 'acp-node-shim')
99+
const shimPath = join(shimDir, 'node')
100+
try {
101+
mkdirSync(shimDir, { recursive: true })
102+
writeFileSync(
103+
shimPath,
104+
`#!/bin/sh\nexec ${shellQuote(input.bunPath)} "$@"\n`,
105+
'utf8',
106+
)
107+
chmodSync(shimPath, 0o755)
108+
return shimDir
109+
} catch {
110+
return null
111+
}
112+
}
113+
114+
function stringEnv(
115+
env: Record<string, string | undefined>,
116+
): Record<string, string> {
117+
const out: Record<string, string> = {}
118+
for (const [key, value] of Object.entries(env)) {
119+
if (value !== undefined) out[key] = value
120+
}
121+
return out
122+
}
123+
124+
function pathEnvKey(
125+
env: Record<string, string | undefined>,
126+
platform: NodeJS.Platform,
127+
): string {
128+
if (platform !== 'win32') return 'PATH'
129+
return Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'Path'
130+
}
131+
132+
function dedupe(values: string[]): string[] {
133+
const seen = new Set<string>()
134+
const out: string[] = []
135+
for (const value of values) {
136+
if (seen.has(value)) continue
137+
seen.add(value)
138+
out.push(value)
139+
}
140+
return out
141+
}
142+
143+
function shellQuote(value: string): string {
144+
return `'${value.replace(/'/g, "'\\''")}'`
145+
}

packages/browseros-agent/apps/server/src/lib/agents/host-acp/launcher.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* outside darwin / linux / win32).
1212
*/
1313

14-
import { resolveBundledBun } from './bundled-bun'
14+
import { resolveBundledBun, withBundledBunAcpAdapterEnv } from './bundled-bun'
1515
import {
1616
HOST_ACP_ADAPTER_CONFIG,
1717
type HostAcpAdapter,
@@ -27,6 +27,8 @@ export interface AcpLauncherResolution {
2727

2828
export interface ResolveAcpSpawnCommandInput {
2929
agentType: string
30+
browserosDir?: string | null
31+
env?: NodeJS.ProcessEnv
3032
resourcesDir?: string | null
3133
platform?: NodeJS.Platform
3234
/** Injected for tests; production callers leave it unset. */
@@ -55,7 +57,15 @@ export function resolveAcpSpawnCommand(
5557
})
5658
if (bunPath) {
5759
return {
58-
command: `${quoteAcpCommandToken(bunPath)} x ${config.acpPackageSpec}`,
60+
command: wrapCommandWithEnv(
61+
`${quoteAcpCommandToken(bunPath)} x --bun --silent --package ${quoteAcpCommandToken(config.acpPackageSpec)} ${quoteAcpCommandToken(config.acpBin)}`,
62+
withBundledBunAcpAdapterEnv({
63+
bunPath,
64+
browserosDir: input.browserosDir,
65+
env: input.env,
66+
platform: input.platform,
67+
}),
68+
),
5969
source: 'bundled-bun',
6070
}
6171
}
@@ -66,3 +76,14 @@ export function resolveAcpSpawnCommand(
6676
function quoteAcpCommandToken(value: string): string {
6777
return `'${value.replace(/'/g, "'\\''")}'`
6878
}
79+
80+
function wrapCommandWithEnv(
81+
command: string,
82+
env: Record<string, string>,
83+
): string {
84+
const prefix = Object.entries(env)
85+
.sort(([left], [right]) => left.localeCompare(right))
86+
.map(([key, value]) => `${key}=${quoteAcpCommandToken(value)}`)
87+
.join(' ')
88+
return prefix ? `env ${prefix} ${command}` : command
89+
}

packages/browseros-agent/apps/server/tests/lib/agents/acpx-runtime.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,8 @@ Use the BrowserOS MCP server for all browser tasks, including browsing the web,
11951195
`'${bunPath}' x --bun --silent --package '@zed-industries/codex-acp@^0.12.0' 'codex-acp'`,
11961196
)
11971197
expect(codexCommand).toContain('BUN_INSTALL_CACHE_DIR=')
1198-
expect(codexCommand).toContain(`PATH='${dirname(bunPath)}'`)
1198+
expect(codexCommand).toContain('cache/acp-node-shim')
1199+
expect(codexCommand).toContain(dirname(bunPath))
11991200
expect(codexCommand).not.toContain('npx -y')
12001201
},
12011202
)

packages/browseros-agent/apps/server/tests/lib/agents/bundled-bun.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@
44
*/
55

66
import { afterEach, describe, expect, it } from 'bun:test'
7-
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
7+
import {
8+
chmod,
9+
mkdir,
10+
mkdtemp,
11+
readFile,
12+
rm,
13+
stat,
14+
writeFile,
15+
} from 'node:fs/promises'
816
import { tmpdir } from 'node:os'
917
import { dirname, join } from 'node:path'
10-
import { resolveBundledBun } from '../../../src/lib/agents/host-acp/bundled-bun'
18+
import {
19+
resolveBundledBun,
20+
withBundledBunAcpAdapterEnv,
21+
} from '../../../src/lib/agents/host-acp/bundled-bun'
1122

1223
describe('bundled Bun helpers', () => {
1324
const tempDirs: string[] = []
@@ -84,4 +95,54 @@ describe('bundled Bun helpers', () => {
8495

8596
expect(resolveBundledBun({ resourcesDir, platform: 'freebsd' })).toBeNull()
8697
})
98+
99+
it('creates a private node shim for Unix ACP adapter launches', async () => {
100+
const resourcesDir = await mkdtemp(join(tmpdir(), 'browseros-bun-'))
101+
const browserosDir = await mkdtemp(join(tmpdir(), 'browseros-dir-'))
102+
tempDirs.push(resourcesDir, browserosDir)
103+
const bunPath = join(resourcesDir, 'bin', 'third_party', 'bun')
104+
await mkdir(dirname(bunPath), { recursive: true })
105+
await writeFile(bunPath, '#!/bin/sh\n')
106+
await chmod(bunPath, 0o755)
107+
108+
const env = withBundledBunAcpAdapterEnv({
109+
bunPath,
110+
browserosDir,
111+
env: { PATH: '/usr/bin' },
112+
platform: 'darwin',
113+
})
114+
115+
const shimPath = join(browserosDir, 'cache', 'acp-node-shim', 'node')
116+
expect(env.PATH.split(':').slice(0, 3)).toEqual([
117+
dirname(shimPath),
118+
dirname(bunPath),
119+
'/usr/bin',
120+
])
121+
expect(env.BUN_INSTALL_CACHE_DIR).toBe(
122+
join(browserosDir, 'cache', 'bun-install'),
123+
)
124+
expect(await readFile(shimPath, 'utf8')).toContain(bunPath)
125+
expect((await stat(shimPath)).mode & 0o111).not.toBe(0)
126+
})
127+
128+
it('prepends the bundled Bun directory even without a writable BrowserOS dir', async () => {
129+
const resourcesDir = await mkdtemp(join(tmpdir(), 'browseros-bun-'))
130+
tempDirs.push(resourcesDir)
131+
const bunPath = join(resourcesDir, 'bin', 'third_party', 'bun')
132+
await mkdir(dirname(bunPath), { recursive: true })
133+
await writeFile(bunPath, '#!/bin/sh\n')
134+
await chmod(bunPath, 0o755)
135+
136+
const env = withBundledBunAcpAdapterEnv({
137+
bunPath,
138+
env: { PATH: '/usr/bin' },
139+
platform: 'linux',
140+
})
141+
142+
expect(env.PATH.split(':').slice(0, 2)).toEqual([
143+
dirname(bunPath),
144+
'/usr/bin',
145+
])
146+
expect(env.BUN_INSTALL_CACHE_DIR).toBeUndefined()
147+
})
87148
})

0 commit comments

Comments
 (0)