Skip to content

Commit 8494ee9

Browse files
committed
fix(browser): make socket discovery node_repl-safe
1 parent ed88e64 commit 8494ee9

6 files changed

Lines changed: 203 additions & 36 deletions

File tree

.github/workflows/upstream-build-app.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ on:
1414
- scripts/ci/upstream-dmg-*.js
1515
- scripts/validate-upstream-dmg.js
1616
- scripts/lib/candidate-install.sh
17+
- scripts/lib/bundled-plugins.sh
18+
- scripts/lib/browser-client-node-repl-runtime.test.js
19+
- scripts/lib/patch-browser-client-iab-socket-scope.js
1720
- scripts/lib/patch-validation.js
1821
- scripts/lib/upstream-dmg-acceptance.js
1922
- scripts/lib/upstream-dmg-release-profile.js
2023
- scripts/lib/linux-features.js
24+
- computer-use-linux/src/bin/codex-chrome-extension-host.rs
2125
- linux-features/**
2226
- .github/workflows/upstream-build-app.yml
2327
push:
@@ -33,10 +37,14 @@ on:
3337
- scripts/ci/upstream-dmg-*.js
3438
- scripts/validate-upstream-dmg.js
3539
- scripts/lib/candidate-install.sh
40+
- scripts/lib/bundled-plugins.sh
41+
- scripts/lib/browser-client-node-repl-runtime.test.js
42+
- scripts/lib/patch-browser-client-iab-socket-scope.js
3643
- scripts/lib/patch-validation.js
3744
- scripts/lib/upstream-dmg-acceptance.js
3845
- scripts/lib/upstream-dmg-release-profile.js
3946
- scripts/lib/linux-features.js
47+
- computer-use-linux/src/bin/codex-chrome-extension-host.rs
4048
- linux-features/**
4149
- .github/workflows/upstream-build-app.yml
4250
workflow_dispatch:
@@ -164,6 +172,13 @@ jobs:
164172
REBUILD_REPORT_DIR: ${{ github.workspace }}/upstream-acceptance
165173
run: make build-app DMG=/tmp/codex-upstream-ci/Codex.dmg
166174

175+
- name: Import staged Browser clients through node_repl
176+
if: steps.acceptance-build.outcome == 'success'
177+
env:
178+
CODEX_NODE_REPL_PATH: ${{ github.workspace }}/codex-app/resources/node_repl
179+
CODEX_STAGED_BUNDLED_PLUGINS_ROOT: ${{ github.workspace }}/codex-app/resources/plugins/openai-bundled/plugins
180+
run: node --test scripts/lib/browser-client-node-repl-runtime.test.js
181+
167182
- name: Upload upstream DMG metadata artifact
168183
# The transactional installer records a decision before returning a
169184
# rejected status, so diagnostics remain available on failed runs.

computer-use-linux/src/bin/codex-chrome-extension-host.rs

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77
io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom, Write},
88
net::Shutdown,
99
os::unix::{
10+
ffi::OsStrExt,
1011
fs::{MetadataExt, PermissionsExt},
1112
io::AsRawFd,
1213
net::{UnixListener, UnixStream},
@@ -27,6 +28,7 @@ const HOST_NAME: &str = "com.openai.codexextension";
2728
const SOCKET_DIR_ENV: &str = "CODEX_BROWSER_USE_SOCKET_DIR";
2829
const SESSIONS_DIR_ENV: &str = "CODEX_BROWSER_USE_SESSIONS_DIR";
2930
const SOCKET_DIR_NAME: &str = "codex-browser-use";
31+
const MAX_UNIX_SOCKET_PATH_BYTES: usize = 107;
3032
const ROLLOUT_POLL_INTERVAL: Duration = Duration::from_millis(500);
3133
const OBSERVED_TURN_TTL: Duration = Duration::from_secs(6 * 60 * 60);
3234
const ROLLOUT_SEARCH_MAX_DEPTH: usize = 5;
@@ -330,9 +332,10 @@ impl RolloutTracker {
330332
}
331333

332334
fn main() -> Result<()> {
333-
let socket_dir = socket_dir();
335+
let effective_uid = unsafe { libc::geteuid() };
336+
let socket_dir = socket_dir(effective_uid);
337+
let socket_path = socket_path(&socket_dir)?;
334338
prepare_socket_dir(&socket_dir)?;
335-
let socket_path = socket_path(&socket_dir);
336339
remove_socket_if_present(&socket_path)?;
337340

338341
let listener = UnixListener::bind(&socket_path)
@@ -364,21 +367,16 @@ fn main() -> Result<()> {
364367
result
365368
}
366369

367-
fn socket_dir() -> PathBuf {
370+
fn socket_dir(effective_uid: u32) -> PathBuf {
368371
if let Some(path) = env::var_os(SOCKET_DIR_ENV).filter(|value| !value.is_empty()) {
369372
return PathBuf::from(path);
370373
}
371374

372-
let runtime_dir = env::var_os("XDG_RUNTIME_DIR");
373-
default_socket_dir(runtime_dir.as_deref(), unsafe { libc::geteuid() })
375+
default_socket_dir(effective_uid)
374376
}
375377

376-
fn default_socket_dir(runtime_dir: Option<&std::ffi::OsStr>, effective_uid: u32) -> PathBuf {
377-
runtime_dir
378-
.filter(|value| !value.is_empty())
379-
.map(PathBuf::from)
380-
.map(|path| path.join(SOCKET_DIR_NAME))
381-
.unwrap_or_else(|| PathBuf::from(format!("/tmp/{SOCKET_DIR_NAME}-{effective_uid}")))
378+
fn default_socket_dir(effective_uid: u32) -> PathBuf {
379+
PathBuf::from(format!("/tmp/{SOCKET_DIR_NAME}-{effective_uid}"))
382380
}
383381

384382
fn sessions_root() -> Option<PathBuf> {
@@ -408,12 +406,23 @@ fn is_extension_id(value: &str) -> bool {
408406
value.len() == 32 && value.bytes().all(|byte| matches!(byte, b'a'..=b'p'))
409407
}
410408

411-
fn socket_path(socket_dir: &Path) -> PathBuf {
409+
fn socket_path(socket_dir: &Path) -> Result<PathBuf> {
412410
let nonce = SystemTime::now()
413411
.duration_since(UNIX_EPOCH)
414412
.map(|duration| duration.as_nanos())
415413
.unwrap_or_default();
416-
socket_dir.join(format!("extension-{}-{nonce}.sock", process::id()))
414+
socket_path_for(socket_dir, process::id(), nonce)
415+
}
416+
417+
fn socket_path_for(socket_dir: &Path, process_id: u32, nonce: u128) -> Result<PathBuf> {
418+
let path = socket_dir.join(format!("extension-{process_id}-{nonce}.sock"));
419+
if path.as_os_str().as_bytes().len() > MAX_UNIX_SOCKET_PATH_BYTES {
420+
bail!(
421+
"unix socket path exceeds the {MAX_UNIX_SOCKET_PATH_BYTES}-byte Linux limit: {}",
422+
path.display()
423+
);
424+
}
425+
Ok(path)
417426
}
418427

419428
fn prepare_socket_dir(path: &Path) -> Result<()> {
@@ -1039,19 +1048,24 @@ mod tests {
10391048
use super::*;
10401049

10411050
#[test]
1042-
fn socket_directory_prefers_the_per_user_runtime_directory() {
1051+
fn socket_directory_default_is_scoped_by_uid() {
10431052
assert_eq!(
1044-
default_socket_dir(Some(std::ffi::OsStr::new("/run/user/1000")), 1000),
1045-
PathBuf::from("/run/user/1000/codex-browser-use")
1053+
default_socket_dir(1000),
1054+
PathBuf::from("/tmp/codex-browser-use-1000")
10461055
);
10471056
}
10481057

10491058
#[test]
1050-
fn socket_directory_fallback_is_scoped_by_uid() {
1059+
fn complete_socket_path_is_guarded_against_the_linux_limit() {
1060+
let short = socket_path_for(Path::new("/tmp/codex-browser-use-1000"), 42, 7).unwrap();
10511061
assert_eq!(
1052-
default_socket_dir(None, 1000),
1053-
PathBuf::from("/tmp/codex-browser-use-1000")
1062+
short,
1063+
PathBuf::from("/tmp/codex-browser-use-1000/extension-42-7.sock")
10541064
);
1065+
1066+
let long_dir = PathBuf::from("/tmp").join("x".repeat(MAX_UNIX_SOCKET_PATH_BYTES));
1067+
let error = socket_path_for(&long_dir, 42, 7).unwrap_err();
1068+
assert!(error.to_string().contains("unix socket path exceeds"));
10551069
}
10561070

10571071
#[test]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env node
2+
"use strict";
3+
4+
const assert = require("node:assert/strict");
5+
const { spawn } = require("node:child_process");
6+
const fs = require("node:fs");
7+
const path = require("node:path");
8+
const readline = require("node:readline");
9+
const test = require("node:test");
10+
const { pathToFileURL } = require("node:url");
11+
12+
const runtimePath = process.env.CODEX_NODE_REPL_PATH;
13+
const pluginsRoot = process.env.CODEX_STAGED_BUNDLED_PLUGINS_ROOT;
14+
15+
function runNodeReplImport(runtime, clients) {
16+
return new Promise((resolve, reject) => {
17+
const child = spawn(runtime, [], {
18+
env: {
19+
...process.env,
20+
CODEX_BROWSER_USE_SOCKET_DIR: "/tmp/codex-browser-use-runtime-test",
21+
},
22+
stdio: ["pipe", "pipe", "pipe"],
23+
});
24+
const stdout = readline.createInterface({ input: child.stdout });
25+
let stderr = "";
26+
let settled = false;
27+
28+
const finish = (error, value) => {
29+
if (settled) return;
30+
settled = true;
31+
clearTimeout(timer);
32+
stdout.close();
33+
child.kill("SIGTERM");
34+
if (error) reject(error);
35+
else resolve(value);
36+
};
37+
38+
const send = (message) => child.stdin.write(`${JSON.stringify(message)}\n`);
39+
const code = `${clients
40+
.map((client) => `await import(${JSON.stringify(pathToFileURL(client).href)});`)
41+
.join("")}nodeRepl.write("imports-ok")`;
42+
const timer = setTimeout(
43+
() => finish(new Error(`node_repl import timed out: ${stderr}`)),
44+
20_000,
45+
);
46+
47+
child.stderr.setEncoding("utf8");
48+
child.stderr.on("data", (chunk) => {
49+
stderr += chunk;
50+
});
51+
child.on("error", (error) => finish(error));
52+
child.on("exit", (codeValue, signal) => {
53+
if (!settled) {
54+
finish(
55+
new Error(
56+
`node_repl exited before the import response (code=${codeValue}, signal=${signal}): ${stderr}`,
57+
),
58+
);
59+
}
60+
});
61+
stdout.on("line", (line) => {
62+
let message;
63+
try {
64+
message = JSON.parse(line);
65+
} catch {
66+
return;
67+
}
68+
69+
if (message.id === 1) {
70+
send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
71+
send({
72+
jsonrpc: "2.0",
73+
id: 2,
74+
method: "tools/call",
75+
params: {
76+
name: "js",
77+
arguments: {
78+
code,
79+
timeout_ms: 10_000,
80+
title: "Import staged Browser clients",
81+
},
82+
},
83+
});
84+
}
85+
86+
if (message.id === 2) {
87+
const text = message.result?.content
88+
?.filter((item) => item.type === "text")
89+
.map((item) => item.text)
90+
.join("");
91+
if (message.result?.isError) {
92+
finish(new Error(`node_repl import failed: ${text || stderr}`));
93+
} else {
94+
finish(null, text);
95+
}
96+
}
97+
});
98+
99+
send({
100+
jsonrpc: "2.0",
101+
id: 1,
102+
method: "initialize",
103+
params: {
104+
protocolVersion: "2025-06-18",
105+
capabilities: {},
106+
clientInfo: { name: "codex-browser-client-runtime-test", version: "1" },
107+
},
108+
});
109+
});
110+
}
111+
112+
test(
113+
"staged Browser and Chrome clients import through the real node_repl runtime",
114+
{ skip: !runtimePath || !pluginsRoot },
115+
async () => {
116+
const clients = ["browser", "chrome"].map((plugin) =>
117+
path.join(pluginsRoot, plugin, "scripts", "browser-client.mjs"),
118+
);
119+
assert.ok(fs.existsSync(runtimePath), `node_repl runtime not found: ${runtimePath}`);
120+
for (const client of clients) {
121+
assert.ok(fs.existsSync(client), `staged Browser client not found: ${client}`);
122+
}
123+
124+
assert.equal(await runNodeReplImport(runtimePath, clients), "imports-ok");
125+
},
126+
);

scripts/lib/patch-browser-client-iab-socket-scope.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,16 @@ if (!source.includes(socketDirMarker)) {
2121
const socketDirectoryMatches = [...source.matchAll(socketDirectoryPattern)];
2222
if (socketDirectoryMatches.length === 1) {
2323
const [target, resolver, platform, windowsSocket] = socketDirectoryMatches[0];
24+
const userInfoImport =
25+
'import{userInfo as codexLinuxBrowserUseUserInfo}from"node:os";';
2426
const helper =
25-
`function codexLinuxBrowserUseSocketDir(){let e=process.env.CODEX_BROWSER_USE_SOCKET_DIR;` +
26-
`if(typeof e==="string"&&e.length>0)return e;let r=process.env.XDG_RUNTIME_DIR;` +
27-
`if(typeof r==="string"&&r.length>0)return r.replace(/\\\/+$/,"")+"/codex-browser-use";` +
28-
`let t=typeof process.getuid==="function"?process.getuid():null;` +
27+
`function codexLinuxBrowserUseSocketDir(){let e=globalThis.nodeRepl?.env?.CODEX_BROWSER_USE_SOCKET_DIR;` +
28+
`if(typeof e==="string"&&e.length>0)return e;let t=codexLinuxBrowserUseUserInfo().uid;` +
2929
`if(Number.isInteger(t)&&t>=0)return \`/tmp/codex-browser-use-\${t}\`;` +
3030
`throw Error("Browser Use cannot resolve a per-user Linux socket directory")}${socketDirMarker}`;
3131
const replacement =
3232
`${resolver}=${platform}=>${platform}==="win32"?${windowsSocket}:codexLinuxBrowserUseSocketDir()`;
33-
source = helper + source.replace(target, replacement);
33+
source = userInfoImport + helper + source.replace(target, replacement);
3434
} else if (source.includes("/tmp/codex-browser-use")) {
3535
process.stderr.write(
3636
`WARN: Expected one Browser Use socket-directory resolver, found ${socketDirectoryMatches.length}; leaving its path unchanged\n`,

scripts/lib/patch-browser-client-iab-socket-scope.test.js

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ const fs = require("node:fs");
77
const os = require("node:os");
88
const path = require("node:path");
99
const test = require("node:test");
10-
const vm = require("node:vm");
1110
const { pathToFileURL } = require("node:url");
1211

1312
const patcher = path.join(__dirname, "patch-browser-client-iab-socket-scope.js");
1413

15-
test("Linux socket discovery uses the override, runtime directory, then UID fallback", () => {
14+
test("Linux socket discovery uses the bridge override then a deterministic UID path", async () => {
1615
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "codex-user-socket-dir-"));
1716
const clientPath = path.join(workspace, "browser-client.mjs");
1817
const fixture =
@@ -26,29 +25,38 @@ test("Linux socket discovery uses the override, runtime directory, then UID fall
2625
assert.equal(firstPatch.status, 0, firstPatch.stderr);
2726
const patched = fs.readFileSync(clientPath, "utf8");
2827
assert.match(patched, /codexLinuxPerUserBrowserSocketDir/);
29-
30-
const resolve = (env, uid = 1000) => {
31-
const context = { process: { env, getuid: () => uid } };
32-
context.globalThis = context;
33-
vm.runInNewContext(patched, context);
34-
return context.result;
28+
assert.doesNotMatch(patched, /\bprocess\./);
29+
30+
let importIndex = 0;
31+
const resolve = async (env) => {
32+
globalThis.nodeRepl = { env };
33+
delete globalThis.result;
34+
await import(`${pathToFileURL(clientPath).href}?socket-case=${importIndex++}`);
35+
return globalThis.result;
3536
};
3637
assert.equal(
37-
resolve({ CODEX_BROWSER_USE_SOCKET_DIR: "/custom/browser-sockets" }),
38+
await resolve({ CODEX_BROWSER_USE_SOCKET_DIR: "/custom/browser-sockets" }),
3839
"/custom/browser-sockets",
3940
);
41+
const expectedDefault = `/tmp/codex-browser-use-${os.userInfo().uid}`;
42+
assert.equal(
43+
await resolve({ XDG_RUNTIME_DIR: "/run/user/1000/" }),
44+
expectedDefault,
45+
);
4046
assert.equal(
41-
resolve({ XDG_RUNTIME_DIR: "/run/user/1000/" }),
42-
"/run/user/1000/codex-browser-use",
47+
await resolve({ XDG_RUNTIME_DIR: `/run/user/1000/${"x".repeat(200)}` }),
48+
expectedDefault,
4349
);
44-
assert.equal(resolve({}, 1001), "/tmp/codex-browser-use-1001");
50+
assert.equal(await resolve({}), expectedDefault);
4551

4652
const secondPatch = spawnSync(process.execPath, [patcher, clientPath, "--socket-dir-only"], {
4753
encoding: "utf8",
4854
});
4955
assert.equal(secondPatch.status, 0, secondPatch.stderr);
5056
assert.equal(fs.readFileSync(clientPath, "utf8"), patched);
5157
} finally {
58+
delete globalThis.nodeRepl;
59+
delete globalThis.result;
5260
fs.rmSync(workspace, { recursive: true, force: true });
5361
}
5462
});

tests/scripts_smoke.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7297,6 +7297,8 @@ test_browser_plugin_renamed_upstream_staging() {
72977297
assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxFileUrlPolicy"
72987298
assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxIabSocketScope"
72997299
assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxPerUserBrowserSocketDir"
7300+
assert_contains "$browser_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseUserInfo"
7301+
assert_not_contains "$browser_dir/scripts/browser-client.mjs" "process.env.CODEX_BROWSER_USE_SOCKET_DIR"
73007302
assert_not_contains "$browser_dir/scripts/browser-client.mjs" '"/tmp/codex-browser-use"'
73017303
assert_contains "$browser_dir/scripts/browser-client.mjs" 'protocol==="file:"'
73027304
assert_not_contains "$browser_dir/scripts/browser-client.mjs" 'protocol==="data:"'
@@ -8142,6 +8144,8 @@ test_chrome_plugin_staging() {
81428144
assert_not_contains "$chrome_dir/scripts/browser-client.mjs" 'await import("node:net")'
81438145
assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxSiteStatusAllowlistFallback"
81448146
assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxPerUserBrowserSocketDir"
8147+
assert_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxBrowserUseUserInfo"
8148+
assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "process.env.CODEX_BROWSER_USE_SOCKET_DIR"
81458149
assert_not_contains "$chrome_dir/scripts/browser-client.mjs" '"/tmp/codex-browser-use"'
81468150
assert_not_contains "$chrome_dir/scripts/browser-client.mjs" "codexLinuxIabSocketScope"
81478151
assert_contains "$chrome_dir/skills/control-chrome/SKILL.md" "agent.browsers.list()"

0 commit comments

Comments
 (0)