From e546c8e7ebbe051999a7362241290aeafa00f7d7 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:21:08 +0000 Subject: [PATCH 1/6] fix(launcher): verify PATH ancestry and reject temporary app bundles Preserve bounded runtime ownership and failure behavior, with focused regression coverage and the owning architecture contract. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/src/cli_command_posix.rs | 68 ++++++++++- src/lib/bun-path-runtime.d.mts | 11 +- src/lib/bun-path-runtime.mjs | 64 ++++++++++- structure/desktop-terminal-command.md | 2 + tests/cli/ocx-launcher-runtime.test.ts | 125 +++++++++++++++++++-- 5 files changed, 252 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs index 471fcbb9f4e..a080e045c14 100644 --- a/desktop/src-tauri/src/cli_command_posix.rs +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -94,6 +94,24 @@ pub fn render_shim(cli: &Path, owner: &str) -> Result { pub fn render_helper(bin: &Path) -> Result { Ok(HELPER.replace("@BIN@", "e(bin)?)) } +fn is_temporary_bundle_path(path: &Path, temp_dir: &Path) -> bool { + [ + Path::new("/tmp"), + Path::new("/private/tmp"), + Path::new("/var/tmp"), + Path::new("/private/var/tmp"), + Path::new("/var/folders"), + Path::new("/private/var/folders"), + ] + .into_iter() + .chain(std::iter::once(temp_dir)) + .any(|root| path.starts_with(root)) + || path.starts_with("/Volumes") +} +fn has_app_translocation_marker(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str().to_str() == Some("AppTranslocation")) +} pub fn block(shell: &str, root: &Path) -> Result { let body = if shell == "fish" { format!( @@ -148,10 +166,9 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { if debug { return Err("development-launch".into()); } - let raw = text(exe)?; - if raw.starts_with("/Volumes/") - || raw.starts_with("/private/var/folders/") && raw.contains("/AppTranslocation/") - || raw.contains("/tmp/.mount_") + text(exe)?; + if has_app_translocation_marker(exe) + || is_temporary_bundle_path(exe, &std::env::temp_dir()) || std::env::var_os("APPIMAGE").is_some() || std::env::var_os("APPDIR").is_some() { @@ -159,7 +176,8 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { } let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; let s = text(&real)?; - if s.starts_with("/Volumes/") || s.contains("/AppTranslocation/") || s.contains("/tmp/.mount_") + if has_app_translocation_marker(&real) + || is_temporary_bundle_path(&real, &std::env::temp_dir()) { return Err("temporary-bundle".into()); } @@ -206,6 +224,46 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { version: version.into(), }) } + +#[cfg(test)] +mod temporary_bundle_path_tests { + use super::{has_app_translocation_marker, is_temporary_bundle_path}; + use std::path::Path; + + #[test] + fn temporary_bundle_roots_are_component_aware() { + let temp_dir = Path::new("/var/folders/test/app"); + for path in [ + "/tmp/App.app/Contents/MacOS/app", + "/private/tmp/App.app/Contents/MacOS/app", + "/var/tmp/App.app/Contents/MacOS/app", + "/private/var/tmp/App.app/Contents/MacOS/app", + "/var/folders/xx/App.app/Contents/MacOS/app", + "/private/var/folders/xx/App.app/Contents/MacOS/app", + "/Volumes/Installer/App.app/Contents/MacOS/app", + ] { + assert!(is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + } + for path in [ + "/Applications/OpenCodex.app/Contents/MacOS/app", + "/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app", + "/tmp-old/OpenCodex.app/Contents/MacOS/app", + "/private/var/folder-old/OpenCodex.app/Contents/MacOS/app", + ] { + assert!(!is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + } + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + assert!(has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation/OpenCodex.app/Contents/MacOS/app" + ))); + assert!(!has_app_translocation_marker(Path::new( + "/Volumes/Installer/AppTranslocation-old/OpenCodex.app/Contents/MacOS/app" + ))); + } +} // Return exact block range, including its line terminator. Reject malformed or changed markers. fn range(bytes: &[u8], expected: &str) -> Result>> { let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; diff --git a/src/lib/bun-path-runtime.d.mts b/src/lib/bun-path-runtime.d.mts index 88d3aa94d75..bb0bdd31108 100644 --- a/src/lib/bun-path-runtime.d.mts +++ b/src/lib/bun-path-runtime.d.mts @@ -5,7 +5,16 @@ export interface DesktopCliIo { export interface PathBunIo extends DesktopCliIo { now?: () => number; realpath?: (path: string) => string; - stat?: (path: string) => { isFile(): boolean; mode: number }; + stat?: (path: string) => { isFile(): boolean; mode: number; uid?: number }; + lstat?: (path: string) => { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; + mode: number; + uid?: number; + }; + readlink?: (path: string) => string; + geteuid?: () => number | undefined; isRealBunBinary?: (path: string) => boolean; spawnSync?: (path: string, args: string[], options: { env: NodeJS.ProcessEnv; diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 729e86901cd..54b70b7f262 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { accessSync, constants, realpathSync, statSync } from "node:fs"; +import { accessSync, constants, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { posix, win32 } from "node:path"; import { performance } from "node:perf_hooks"; @@ -7,6 +7,7 @@ import { isRealBunBinary } from "./bun-binary-validator.mjs"; const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const MAX_VERSION_BYTES = 4096; +const MAX_SYMLINK_EXPANSIONS = 40; const IDENTITY_PROBE = "process.stdout.write(String(typeof Bun==='object'&&Bun.version))"; function stableVersion(value) { @@ -17,6 +18,61 @@ function stableVersion(value) { return numbers.every(Number.isSafeInteger) ? numbers : null; } +function trustedPosixCandidate(candidate, canonical, io) { + const lstat = io.lstat ?? lstatSync; + const readlink = io.readlink ?? readlinkSync; + const uid = io.geteuid?.() ?? process.geteuid?.(); + if (!Number.isSafeInteger(uid) || uid < 0) return false; + + const walk = path => { + let pending = path.slice(1).split("/"); + let current = "/"; + let expansions = 0; + while (pending.length) { + const [component, ...remaining] = pending; + pending = remaining; + if (!component || component === ".") continue; + if (component === "..") { + current = posix.dirname(current); + continue; + } + const entry = posix.join(current, component); + const info = lstat(entry); + if (!Number.isSafeInteger(info.uid) || (info.uid !== 0 && info.uid !== uid)) return null; + if (info.isSymbolicLink()) { + if (++expansions > MAX_SYMLINK_EXPANSIONS) return null; + const target = readlink(entry); + pending = target.startsWith("/") + ? [...target.slice(1).split("/"), ...pending] + : [ + ...(current === "/" ? [] : current.slice(1).split("/")), + ...target.split("/"), + ...pending, + ]; + current = "/"; + continue; + } + if (remaining.length || pending.length) { + if (!info.isDirectory()) return null; + const stickyRootAncestor = info.uid === 0 && (info.mode & 0o1000) !== 0; + if ((info.mode & 0o022) !== 0 && !stickyRootAncestor) return null; + } else if (!info.isFile() || (info.mode & 0o022) !== 0) { + return null; + } + current = entry; + } + return current; + }; + + const resolved = walk(candidate); + if (!resolved || resolved !== canonical) return false; + const parent = lstat(posix.dirname(canonical)); + return parent.isDirectory() + && Number.isSafeInteger(parent.uid) + && (parent.uid === 0 || parent.uid === uid) + && (parent.mode & 0o022) === 0; +} + /** Discover the first absolute PATH Bun passing version-policy and identity checks. */ export function findPathBun({ env = process.env, platform = process.platform, pinnedVersion, @@ -54,14 +110,16 @@ export function findPathBun({ if (now() >= end) break; if (!directory || !absolute(directory)) continue; try { - const path = realpath(paths.join(directory, platform === "win32" ? "bun.exe" : "bun")); + const candidatePath = paths.join(directory, platform === "win32" ? "bun.exe" : "bun"); + const path = realpath(candidatePath); if (!absolute(path)) continue; + if (platform !== "win32" && !trustedPosixCandidate(candidatePath, path, io)) continue; const identity = platform === "win32" ? path.toLowerCase() : path; if (seen.has(identity)) continue; seen.add(identity); const file = stat(path); if (!file.isFile()) continue; - if (platform !== "win32" && ((file.mode & 0o022) || (stat(paths.dirname(path)).mode & 0o022))) continue; + if (platform !== "win32" && (file.mode & 0o022)) continue; access(path, constants.X_OK); if (!validate(path)) continue; const version = probe(path, ["--version"]); diff --git a/structure/desktop-terminal-command.md b/structure/desktop-terminal-command.md index b9d0e753253..0f27c44296c 100644 --- a/structure/desktop-terminal-command.md +++ b/structure/desktop-terminal-command.md @@ -24,6 +24,8 @@ and final managed zsh/bash/fish blocks. Reconcile separates desired rc targets f ownership; remove walks the recorded list and preserves modified blocks and unsafe files. An rc file with an ACL is never rewritten (`rc-acl-present`). The shim captures exported Anthropic slot names with an argv-bound random proof before dotenv; failure keeps stripping. +Persistent bundle registration rejects raw and canonical locations under system and OS temporary +directories, while ordinary `/Applications` and user Applications bundles remain eligible. `desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only the owned entry and broadcasts Environment; a top-level `notifyPending` debt survives crashes and diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index a4b72ace331..1930640c177 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { bundledBunPath } from "../../src/lib/bun-runtime"; import { findDesktopCli, findPathBun, type PathBunIo } from "../../src/lib/bun-path-runtime.mjs"; @@ -389,6 +389,15 @@ describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { }, EFFECTIVE_RUNTIME_TEST_TIMEOUT_MS); }); +function pathFixtureRoot(prefix: string): string { + // A trusted-executable fixture cannot live beneath a shared temp ancestor whose + // owner is neither root nor this user (common in user-namespace sandboxes). + // Keep the launcher package isolated from this checkout's installed Bun. + const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); + mkdirSync(base, { recursive: true, mode: 0o700 }); + return mkdtempSync(join(base, prefix)); +} + function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n") { const calls: Array<{ path: string; args: string[]; timeout: number; shell: boolean; maxBuffer: number }> = []; let clock = 0; @@ -396,6 +405,15 @@ function pathProbeFixture(platform: NodeJS.Platform = "linux", stdout = "1.4.0\n now: () => clock, realpath: path => path, stat: () => ({ isFile: () => true, mode: 0o755 }), + lstat: path => ({ + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun") && !path.endsWith("/alias"), + isSymbolicLink: () => path.endsWith("/alias"), + mode: path.endsWith("/bun") ? 0o755 : path.endsWith("/alias") ? 0o777 : 0o755, + uid: 0, + }), + readlink: () => "/first", + geteuid: () => 1000, access: () => {}, isRealBunBinary: () => true, spawnSync: (path, args, options) => { @@ -501,8 +519,9 @@ describe("failure-only Desktop CLI pointer", () => { describe("real PATH Bun validation", () => { test.each(["file-group", "file-world", "parent-group", "parent-world"])("rejects POSIX %s write access before spawning", kind => { const fixture = pathProbeFixture(); - fixture.io.stat = path => ({ - isFile: () => true, + const trustedLstat = fixture.io.lstat!; + fixture.io.lstat = path => ({ + ...trustedLstat(path), mode: 0o755 | ((kind.startsWith("file") === path.endsWith("/bun")) ? (kind.endsWith("group") ? 0o020 : 0o002) : 0), }); @@ -510,6 +529,33 @@ describe("real PATH Bun validation", () => { expect(fixture.calls).toHaveLength(0); }); + test("rejects a writable original PATH symlink directory before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/untrusted/bun" : path; + fixture.io.readlink = () => "/untrusted"; + fixture.io.lstat = path => path === "/alias" + ? { isFile: () => false, isDirectory: () => false, isSymbolicLink: () => true, mode: 0o777, uid: 1000 } + : path === "/untrusted" + ? { isFile: () => false, isDirectory: () => true, isSymbolicLink: () => false, mode: 0o777, uid: 1000 } + : { + isFile: () => path.endsWith("/bun"), + isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + mode: 0o755, + uid: 0, + }; + expect(fixture.find({ PATH: "/alias" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + + test("accepts a trusted original PATH symlink directory", () => { + const fixture = pathProbeFixture(); + fixture.io.realpath = path => path === "/alias/bun" ? "/trusted/bun" : path; + fixture.io.readlink = () => "/trusted"; + expect(fixture.find({ PATH: "/alias" })?.path).toBe("/trusted/bun"); + expect(fixture.calls).toHaveLength(2); + }); + test("Windows does not interpret POSIX write mode bits", () => { const fixture = pathProbeFixture("win32"); fixture.io.stat = () => ({ isFile: () => true, mode: 0o777 }); @@ -576,7 +622,7 @@ describe("real PATH Bun validation", () => { }); test.skipIf(process.platform === "win32")("real padded version-only script fails the identity check", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-identity-")); + const root = pathFixtureRoot("ocx-path-identity-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nif [ "$1" = "--version" ]; then printf "1.4.0\\n"; else exit 1; fi\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); @@ -586,7 +632,7 @@ describe("real PATH Bun validation", () => { }); test.skipIf(process.platform === "win32")("real writable file and resolved parent are rejected", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-mode-")); + const root = pathFixtureRoot("ocx-path-mode-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nprintf "1.4.0\\n"\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); @@ -599,8 +645,69 @@ describe("real PATH Bun validation", () => { } finally { removeTree(root); } }); + test.skipIf(process.platform === "win32")("writable original PATH directory is rejected before spawning its symlinked Bun", () => { + const root = pathFixtureRoot("ocx-path-symlink-dir-"); + try { + const trusted = join(root, "trusted"); + const writable = join(root, "writable"); + mkdirSync(trusted, { mode: 0o700 }); + mkdirSync(writable, { mode: 0o700 }); + const binary = join(trusted, "bun"); + const link = join(writable, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + chmodSync(writable, 0o777); + symlinkSync(binary, link); + let spawns = 0; + const selected = findPathBun({ + env: { PATH: writable }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + geteuid: () => process.geteuid?.(), + spawnSync: () => { spawns++; return { status: 0, stdout: "1.4.2" }; }, + }, + }); + expect(selected).toBeNull(); + expect(spawns).toBe(0); + } finally { removeTree(root); } + }); + + test.skipIf(process.platform === "win32")("trusted Bun symlink is accepted", () => { + const root = pathFixtureRoot("ocx-path-trusted-symlink-"); + try { + const directory = join(root, "bin"); + mkdirSync(directory, { mode: 0o700 }); + const binary = join(root, "bun-real"); + const link = join(directory, "bun"); + writeFileSync(binary, "fixture"); + chmodSync(binary, 0o755); + symlinkSync(binary, link); + const selected = findPathBun({ + env: { PATH: directory }, + pinnedVersion: "1.4.2", + io: { + lstat: lstatSync, + readlink: readlinkSync, + realpath: realpathSync.native, + stat: statSync, + geteuid: () => process.geteuid?.(), + access: () => {}, + isRealBunBinary: () => true, + spawnSync: (_path, args) => ({ + status: 0, + stdout: args[0] === "--version" ? "1.4.2" : "1.4.2", + }), + }, + }); + expect(selected).toEqual({ path: realpathSync.native(binary), version: "1.4.2" }); + } finally { removeTree(root); } + }); + test("real PATH validation rejects the small stub and accepts the complete executable", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-bun-")); + const root = pathFixtureRoot("ocx-path-bun-"); try { const first = join(root, "first"), last = join(root, "last"); mkdirSync(first); mkdirSync(last); @@ -617,7 +724,7 @@ describe("real PATH Bun validation", () => { }); function launcherFallbackFixture(mode: "missing" | "bundled" | "repair" | "broken" | "override" | "none" | "inspection" | "rejected") { - const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-path-launcher-"))); + const root = realpathSync.native(pathFixtureRoot("ocx-path-launcher-")); try { const node = spawnSync("node", ["-p", "process.execPath"], { encoding: "utf8", windowsHide: true }).stdout.trim(); const version = spawnSync(process.execPath, ["--version"], { encoding: "utf8", windowsHide: true }).stdout.trim(); @@ -690,7 +797,7 @@ describe.skipIf(!nodeAvailable)("PATH fallback negative activation", () => { expect(result.stderr).not.toContain("using PATH Bun"); }); test.skipIf(process.platform === "win32")("real version probe returns null within the deadline", () => { - const root = mkdtempSync(join(tmpdir(), "ocx-path-timeout-")); + const root = pathFixtureRoot("ocx-path-timeout-"); try { const path = join(root, "bun"); writeFileSync(path, '#!/bin/sh\nexec /bin/sleep 5\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); From 38aec2ef6b1726749057b38a712722beb3478c62 Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:03:29 +0000 Subject: [PATCH 2/6] fix(launcher): normalize validated pathname spelling before canonical comparison Recheck realpath after validating the original ancestry so case-variant macOS spelling remains usable. Keep peer ownership and mode checks and document the complete PATH policy. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/lib/bun-path-runtime.mjs | 2 +- structure/runtime.md | 2 +- tests/cli/ocx-launcher-runtime.test.ts | 21 ++++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 54b70b7f262..2b8a3220bb4 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -65,7 +65,7 @@ function trustedPosixCandidate(candidate, canonical, io) { }; const resolved = walk(candidate); - if (!resolved || resolved !== canonical) return false; + if (!resolved || (io.realpath ?? realpathSync.native)(resolved) !== canonical) return false; const parent = lstat(posix.dirname(canonical)); return parent.isDirectory() && Number.isSafeInteger(parent.uid) diff --git a/structure/runtime.md b/structure/runtime.md index f87e4a5fc0d..1400bcbb921 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -99,7 +99,7 @@ derives the four inputs from the proof-bound launcher snapshot: the configured c the first codex on the captured PATH, an OpenCodex wrapper resolving to its renamed npm backing, the npm prefix layout, and the Node/npm toolchain beside the resolved node.exe. Configured values containing a path separator must be drive-absolute; otherwise derivation -refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare +refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare POSIX PATH validation walks the original spelling and symlink targets, requires each entry to be owned by root or the effective user, rejects writable ancestors except root-owned sticky directories, and rejects writable final parents, and normalizes validated spelling through realpath before matching the canonical candidate. command names and the unset default continue to resolve only through the captured PATH. Discovery probes candidates through the same local-volume, reparse-refusing held-handle reader as final observation, so captured PATH entries cannot trigger network filesystem I/O. It never reads ambient state. Four explicit absolute paths remain accepted as an all-or-none override. diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 1930640c177..684f1f71aff 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -517,6 +517,25 @@ describe("failure-only Desktop CLI pointer", () => { }); describe("real PATH Bun validation", () => { + test("normalizes case-variant macOS PATH spelling after validating its ancestry", () => { + const fixture = pathProbeFixture("darwin"); + fixture.io.realpath = path => path.replace("/OPT/fixture", "/opt/fixture"); + expect(fixture.find({ PATH: "/OPT/fixture/bin" })).toEqual({ path: "/opt/fixture/bin/bun", version: "1.4.0" }); + expect(fixture.calls).toHaveLength(2); + }); + + test("a foreign-owned entry below a root-owned sticky ancestor is refused before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.lstat = path => ({ + isFile: () => path.endsWith("/bun"), isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + uid: path === "/tmp/foreign" ? 2000 : 0, + mode: path === "/tmp" ? 0o1777 : 0o755, + }); + expect(fixture.find({ PATH: "/tmp/foreign/bin" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + test.each(["file-group", "file-world", "parent-group", "parent-world"])("rejects POSIX %s write access before spawning", kind => { const fixture = pathProbeFixture(); const trustedLstat = fixture.io.lstat!; @@ -608,7 +627,7 @@ describe("real PATH Bun validation", () => { const visited: string[] = []; fixture.io.realpath = path => { visited.push(path); return path; }; expect(fixture.find({ PATH: ":.:relative:~/bin:/trusted" })?.path).toBe("/trusted/bun"); - expect(visited).toEqual(["/trusted/bun"]); + expect(visited).toEqual(["/trusted/bun", "/trusted/bun"]); }); test("Windows accepts UNC bun.exe and deduplicates case aliases", () => { From 96f835d4966963352520dd85de50a90bfa797b0d Mon Sep 17 00:00:00 2001 From: Epinephrine <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:30:13 +0000 Subject: [PATCH 3/6] docs(runtime): keep PATH validation in the launcher contract Describe ancestry and case normalization beside the npm launcher and restore the separate Windows installation-observation paragraph. --- structure/runtime.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/runtime.md b/structure/runtime.md index 1400bcbb921..47f62a69f86 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -99,7 +99,7 @@ derives the four inputs from the proof-bound launcher snapshot: the configured c the first codex on the captured PATH, an OpenCodex wrapper resolving to its renamed npm backing, the npm prefix layout, and the Node/npm toolchain beside the resolved node.exe. Configured values containing a path separator must be drive-absolute; otherwise derivation -refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare POSIX PATH validation walks the original spelling and symlink targets, requires each entry to be owned by root or the effective user, rejects writable ancestors except root-owned sticky directories, and rejects writable final parents, and normalizes validated spelling through realpath before matching the canonical candidate. +refuses with `candidate_unavailable` instead of substituting a different PATH candidate. Bare command names and the unset default continue to resolve only through the captured PATH. Discovery probes candidates through the same local-volume, reparse-refusing held-handle reader as final observation, so captured PATH entries cannot trigger network filesystem I/O. It never reads ambient state. Four explicit absolute paths remain accepted as an all-or-none override. @@ -120,7 +120,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX rejection of group/world-writable resolved files or parent directories, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused except in root-owned sticky ancestors), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | From e5935795c9b69cf3dcf9d70b493381ef41210924 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:08:49 +0900 Subject: [PATCH 4/6] fix(launcher): refuse shared-temp PATH ancestors and verify TMPDIR transience Drop the root-owned sticky-ancestor exemption from Bun PATH validation: a shared writable ancestor lets another user replace a child entry between the ancestry walk and the exec probe. Gate the environment temp root in temporary-bundle classification on canonical residence inside a recognized temporary root or a shared sticky mode, so a custom TMPDIR no longer misclassifies an installed bundle. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/src/cli_command_posix.rs | 92 ++++++++++++++++++---- src/lib/bun-path-runtime.mjs | 7 +- structure/desktop-terminal-command.md | 4 +- structure/runtime.md | 2 +- tests/cli/ocx-launcher-runtime.test.ts | 12 +++ 5 files changed, 97 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs index a080e045c14..f693afb0123 100644 --- a/desktop/src-tauri/src/cli_command_posix.rs +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -94,18 +94,45 @@ pub fn render_shim(cli: &Path, owner: &str) -> Result { pub fn render_helper(bin: &Path) -> Result { Ok(HELPER.replace("@BIN@", "e(bin)?)) } +const TEMPORARY_ROOTS: [&str; 6] = [ + "/tmp", + "/private/tmp", + "/var/tmp", + "/private/var/tmp", + "/var/folders", + "/private/var/folders", +]; +/// A caller-selected TMPDIR only proves transience when it resolves inside a recognized +/// temporary root or is itself a shared sticky directory. An arbitrary override such as +/// TMPDIR=$HOME can name an ordinary ancestor of a permanent install. +fn environment_temp_root(temp_dir: &Path) -> Option { + let real = fs::canonicalize(temp_dir).ok()?; + if TEMPORARY_ROOTS + .iter() + .map(Path::new) + .any(|root| real.starts_with(root)) + { + return Some(real); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if fs::metadata(&real) + .map(|m| m.permissions().mode() & 0o1777 == 0o1777) + .unwrap_or(false) + { + return Some(real); + } + } + None +} fn is_temporary_bundle_path(path: &Path, temp_dir: &Path) -> bool { - [ - Path::new("/tmp"), - Path::new("/private/tmp"), - Path::new("/var/tmp"), - Path::new("/private/var/tmp"), - Path::new("/var/folders"), - Path::new("/private/var/folders"), - ] - .into_iter() - .chain(std::iter::once(temp_dir)) - .any(|root| path.starts_with(root)) + let env_root = environment_temp_root(temp_dir); + TEMPORARY_ROOTS + .iter() + .map(Path::new) + .chain(env_root.as_deref()) + .any(|root| path.starts_with(root)) || path.starts_with("/Volumes") } fn has_app_translocation_marker(path: &Path) -> bool { @@ -176,8 +203,7 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { } let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; let s = text(&real)?; - if has_app_translocation_marker(&real) - || is_temporary_bundle_path(&real, &std::env::temp_dir()) + if has_app_translocation_marker(&real) || is_temporary_bundle_path(&real, &std::env::temp_dir()) { return Err("temporary-bundle".into()); } @@ -242,7 +268,10 @@ mod temporary_bundle_path_tests { "/private/var/folders/xx/App.app/Contents/MacOS/app", "/Volumes/Installer/App.app/Contents/MacOS/app", ] { - assert!(is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + assert!( + is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); } for path in [ "/Applications/OpenCodex.app/Contents/MacOS/app", @@ -250,7 +279,10 @@ mod temporary_bundle_path_tests { "/tmp-old/OpenCodex.app/Contents/MacOS/app", "/private/var/folder-old/OpenCodex.app/Contents/MacOS/app", ] { - assert!(!is_temporary_bundle_path(Path::new(path), temp_dir), "{path}"); + assert!( + !is_temporary_bundle_path(Path::new(path), temp_dir), + "{path}" + ); } assert!(is_temporary_bundle_path( Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), @@ -263,6 +295,36 @@ mod temporary_bundle_path_tests { "/Volumes/Installer/AppTranslocation-old/OpenCodex.app/Contents/MacOS/app" ))); } + + #[test] + fn environment_temp_dir_must_prove_transience() { + // An ordinary ancestor named by a custom TMPDIR must not misclassify a stable + // install; "/" resolves but is not a shared sticky directory, and a missing + // path cannot be verified as temporary at all. + for temp_dir in ["/", "/definitely/absent/ocx-tmpdir"] { + let temp_dir = Path::new(temp_dir); + assert!( + !is_temporary_bundle_path( + Path::new("/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + assert!( + !is_temporary_bundle_path( + Path::new("/Users/alice/Applications/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + ), + "{temp_dir:?}" + ); + } + // The recognized temporary roots still prove transience on their own. + let temp_dir = Path::new("/var/folders/test/app"); + assert!(is_temporary_bundle_path( + Path::new("/var/folders/test/app/OpenCodex.app/Contents/MacOS/app"), + temp_dir, + )); + } } // Return exact block range, including its line terminator. Reject malformed or changed markers. fn range(bytes: &[u8], expected: &str) -> Result>> { diff --git a/src/lib/bun-path-runtime.mjs b/src/lib/bun-path-runtime.mjs index 2b8a3220bb4..20bbf7cd140 100644 --- a/src/lib/bun-path-runtime.mjs +++ b/src/lib/bun-path-runtime.mjs @@ -53,9 +53,10 @@ function trustedPosixCandidate(candidate, canonical, io) { continue; } if (remaining.length || pending.length) { - if (!info.isDirectory()) return null; - const stickyRootAncestor = info.uid === 0 && (info.mode & 0o1000) !== 0; - if ((info.mode & 0o022) !== 0 && !stickyRootAncestor) return null; + // No writable-ancestor exemption: a shared sticky directory passes its own + // ownership check but lets another user replace a child entry between the + // ancestry walk and the exec probe, substituting an unvalidated binary. + if (!info.isDirectory() || (info.mode & 0o022) !== 0) return null; } else if (!info.isFile() || (info.mode & 0o022) !== 0) { return null; } diff --git a/structure/desktop-terminal-command.md b/structure/desktop-terminal-command.md index 0f27c44296c..9d1b961c5ee 100644 --- a/structure/desktop-terminal-command.md +++ b/structure/desktop-terminal-command.md @@ -25,7 +25,9 @@ ownership; remove walks the recorded list and preserves modified blocks and unsa An rc file with an ACL is never rewritten (`rc-acl-present`). The shim captures exported Anthropic slot names with an argv-bound random proof before dotenv; failure keeps stripping. Persistent bundle registration rejects raw and canonical locations under system and OS temporary -directories, while ordinary `/Applications` and user Applications bundles remain eligible. +directories. A caller-selected TMPDIR counts only when it resolves inside a recognized temporary +root or a shared sticky directory, so ordinary `/Applications` and user Applications bundles +remain eligible even beneath a custom temporary-directory override. `desktop/src-tauri/src/cli_command_windows.rs` preserves raw HKCU Path text/type, changes only the owned entry and broadcasts Environment; a top-level `notifyPending` debt survives crashes and diff --git a/structure/runtime.md b/structure/runtime.md index 47f62a69f86..c1275b5fd1c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -120,7 +120,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | Path | Responsibility | | --- | --- | -| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused except in root-owned sticky ancestors), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | +| `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Before project dotenv: explicit Bun override, bundle, permitted installer recovery, then `src/lib/bun-path-runtime.mjs` validates absolute PATH Bun candidates with canonical regular/executable and size checks, POSIX validation of every original and symlink-expanded ancestor (root or effective-user ownership; group/world write permission is refused in every ancestor, including shared sticky directories), plus non-writable resolved files and final parent directories and a canonical realpath recheck that permits case-variant spelling, and bounded version-policy and identity checks (`--version` must match `-e`'s `Bun.version`; stable version has the dependency pin's major and minor ≥ pinned minor); PATH selection stamps `process` with its canonical path and prints a version-only stderr notice. Failure names an executable macOS Desktop CLI when present without delegating. The proof-bound Anthropic snapshot, signal/exit propagation and zero-install updater inspection stay intact. `src/cli/version-skew-notice.ts`, called from `src/cli/root.ts`, makes one identity-checked 200 ms advisory probe for start/stop/restart/update/service, excluding JSON/help/internal; Node-managed update bypasses this notice. Before package repair or Bun resolution, `src/lib/desktop-cli-record.mjs` validates the bounded settled Desktop command record before JSON (POSIX private ownership/modes, no symlinks, matching no-follow nonblocking descriptor and macOS ACL checks; failures are `record-unsafe`) and `src/lib/desktop-cli-handoff.mjs` delegates eligible POSIX commands to its validated target with a regenerated argv proof, inherited IO and mirrored exit/signal. Windows always retains the package path and selects Desktop through user `Path` order. Update, removal, internal inspection and `OCX_NO_DESKTOP_HANDOFF=1` retain the package path; only target ENOENT confirmed by a post-spawn `lstat` resumes it after selection. Command selection is separate from runtime/service authority; see [command selection](cli-management.md#ocx-command-selection). | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version; a newer CLI may use the guarded standalone update path described below, because an in-place replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. Hidden `__update-badge` prints the read-only package badge JSON without refresh or shim repair for the npm tray. The full top-level reference is rendered by `src/cli/help.ts`; this table names the groups, not every verb. After help/version and unknown-root early exits, recognized commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `status`, `doctor`, and `codex-shim status` skip shim auto-repair; `system codex-cli-update` and [command-local messaging](local-messaging.md#command-local-cli) suppress it for their whole namespaces, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). `src/cli/init.ts` validates an explicit setup port as a decimal integer from 1 through 65535 before publishing the initial configuration. Only an empty answer selects 10100; malformed input is reported and the port question is asked again, while EOF or SIGINT still cancels without saving. | diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index 684f1f71aff..cbac8b56fa4 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -536,6 +536,18 @@ describe("real PATH Bun validation", () => { expect(fixture.calls).toHaveLength(0); }); + test("a user-owned entry below a shared sticky ancestor is refused before spawning", () => { + const fixture = pathProbeFixture(); + fixture.io.lstat = path => ({ + isFile: () => path.endsWith("/bun"), isDirectory: () => !path.endsWith("/bun"), + isSymbolicLink: () => false, + uid: path === "/tmp" ? 0 : 1000, + mode: path === "/tmp" ? 0o1777 : 0o755, + }); + expect(fixture.find({ PATH: "/tmp/own/bin" })).toBeNull(); + expect(fixture.calls).toHaveLength(0); + }); + test.each(["file-group", "file-world", "parent-group", "parent-world"])("rejects POSIX %s write access before spawning", kind => { const fixture = pathProbeFixture(); const trustedLstat = fixture.io.lstat!; From 9bc1be8d3d459f2b6dc3534116654854f1aafdd0 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:32:34 +0900 Subject: [PATCH 5/6] test(launcher): isolate Windows PATH fixtures from inherited paths Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/cli/ocx-launcher-runtime.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/cli/ocx-launcher-runtime.test.ts b/tests/cli/ocx-launcher-runtime.test.ts index cbac8b56fa4..12834ec2c92 100644 --- a/tests/cli/ocx-launcher-runtime.test.ts +++ b/tests/cli/ocx-launcher-runtime.test.ts @@ -390,6 +390,7 @@ describe.skipIf(!runnable)("ocx package launcher effective Bun runtime", () => { }); function pathFixtureRoot(prefix: string): string { + if (process.platform === "win32") return mkdtempSync(join(tmpdir(), prefix)); // A trusted-executable fixture cannot live beneath a shared temp ancestor whose // owner is neither root nor this user (common in user-namespace sandboxes). // Keep the launcher package isolated from this checkout's installed Bun. @@ -747,7 +748,11 @@ describe("real PATH Bun validation", () => { if (process.platform === "win32") copyFileSync(process.execPath, join(last, name)); else writeFileSync(join(last, name), '#!/bin/sh\ncase "$1" in --version|-e) printf "1.4.0\\n";; *) exit 1;; esac\n#' + "x".repeat(REAL_BUN_MIN_BYTES)); chmodSync(join(first, name), 0o755); chmodSync(join(last, name), 0o755); - const selected = findPathBun({ env: { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }, pinnedVersion: "1.4.2", deadlineMs: 2000 }); + const env = { ...process.env, PATH: [first, last].join(process.platform === "win32" ? ";" : ":") }; + // Windows keeps the inherited variable under its stored case (e.g. `Path`); + // the resolver's case-insensitive lookup would reach it before this override. + for (const key of Object.keys(env)) if (key !== "PATH" && key.toLowerCase() === "path") delete env[key]; + const selected = findPathBun({ env, pinnedVersion: "1.4.2", deadlineMs: 2000 }); expect(selected?.path).toBe(realpathSync.native(join(last, name))); expect(selected?.version).toMatch(/^1\.(?:[4-9]|[1-9]\d+)\./); } finally { removeTree(root); } From 48c6928e75697ab2d10888bc69ae6b3f6370e463 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:39:34 +0900 Subject: [PATCH 6/6] test(launcher): put the trusted PATH fixture outside shared temp on POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findPathBun now rejects a candidate beneath any group/other-writable ancestor, and /tmp is 1777 — so the PATH-dir fixture in the provenance test was refused by the new trust check before the probe ever ran. Keep the PATH fixture under the checkout sibling .tmp base like ocx-launcher-runtime.test.ts already does; the HOME/dotenv half stays under tmpdir. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/ci-workflows/bun-runtime.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/ci-workflows/bun-runtime.test.ts b/tests/ci-workflows/bun-runtime.test.ts index 533178a7069..7b74ec4cff7 100644 --- a/tests/ci-workflows/bun-runtime.test.ts +++ b/tests/ci-workflows/bun-runtime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, afterAll, afterEach } from "bun:test"; import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, isRealBunBinary, bundledBunPath, durableBunPath, durableBunRuntime, reportedBunRuntimeSource, withProcessRuntimeProvenance } from "../../src/lib/bun-runtime"; import { findPathBun } from "../../src/lib/bun-path-runtime.mjs"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -25,8 +25,26 @@ afterEach(() => { }); afterAll(() => { removeTreeWithRetry(tmp); + for (const root of trustedPathRoots) removeTreeWithRetry(root); }); +// findPathBun rejects a PATH candidate beneath any group/other-writable ancestor, +// which includes the shared temp root on POSIX ("/tmp" is 1777). A fixture that is +// supposed to exercise a *trusted* PATH dir therefore cannot live under tmpdir(). +const trustedPathRoots: string[] = []; +function trustedPathRoot(prefix: string): string { + if (process.platform === "win32") { + const root = mkdtempSync(join(tmpdir(), prefix)); + trustedPathRoots.push(root); + return root; + } + const base = join(dirname(repoPath()), ".tmp", "path-bun-fixtures"); + mkdirSync(base, { recursive: true, mode: 0o700 }); + const root = mkdtempSync(join(base, prefix)); + trustedPathRoots.push(root); + return root; +} + describe("isRealBunBinary (size gate vs placeholder stub)", () => { it("rejects the ~450-byte ASCII placeholder stub", () => { const stub = join(tmp, "bun-stub.exe"); @@ -55,7 +73,10 @@ describe("isRealBunBinary (size gate vs placeholder stub)", () => { describe("bundledBunPath / durableBunPath", () => { it("PATH-selected process provenance survives durable selection and project dotenv", () => { const root = join(tmp, "path-dotenv"); - const pathDir = join(root, "path"); + mkdirSync(root, { recursive: true }); + // The PATH fixture needs trusted ancestors (see trustedPathRoot); everything + // else in this case (HOME, dotenv, consumer script) stays under tmp. + const pathDir = join(trustedPathRoot("path-dotenv-"), "path"); mkdirSync(pathDir, { recursive: true }); const bun = join(pathDir, process.platform === "win32" ? "bun.exe" : "bun"); copyFileSync(process.execPath, bun);