Skip to content
130 changes: 125 additions & 5 deletions desktop/src-tauri/src/cli_command_posix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,51 @@ pub fn render_shim(cli: &Path, owner: &str) -> Result<String> {
pub fn render_helper(bin: &Path) -> Result<String> {
Ok(HELPER.replace("@BIN@", &quote(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<PathBuf> {
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 {
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 {
path.components()
.any(|component| component.as_os_str().to_str() == Some("AppTranslocation"))
}
pub fn block(shell: &str, root: &Path) -> Result<String> {
let body = if shell == "fish" {
format!(
Expand Down Expand Up @@ -148,18 +193,17 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result<Bundle> {
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()
{
return Err("temporary-bundle".into());
}
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());
}
Expand Down Expand Up @@ -206,6 +250,82 @@ pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result<Bundle> {
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"
)));
}

#[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<Option<std::ops::Range<usize>>> {
let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?;
Expand Down
11 changes: 10 additions & 1 deletion src/lib/bun-path-runtime.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
65 changes: 62 additions & 3 deletions src/lib/bun-path-runtime.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";
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) {
Expand All @@ -17,6 +18,62 @@ 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) {
// 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;
}
current = entry;
}
return current;
};

const resolved = walk(candidate);
if (!resolved || (io.realpath ?? realpathSync.native)(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,
Expand Down Expand Up @@ -54,14 +111,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"]);
Expand Down
4 changes: 4 additions & 0 deletions structure/desktop-terminal-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ 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. 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
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. |
Expand Down
Loading
Loading