diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index c3943920c..ad3ffcf70 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -496,6 +496,39 @@ fn normalize_path_segment(segment: &str) -> String { normalized.to_string_lossy().to_ascii_lowercase() } +/// Check if a command is resolvable in the given PATH string. +/// Returns true if the command exists and is executable in one of the PATH +/// directories. +fn command_is_resolvable(command: &str, path: &str) -> bool { + for dir in std::env::split_paths(path) { + let full_path = dir.join(command); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if full_path.exists() { + if let Ok(metadata) = full_path.metadata() { + let permissions = metadata.permissions(); + if permissions.mode() & 0o111 != 0 { + return true; + } + } + } + } + #[cfg(windows)] + { + if full_path.exists() { + // On Windows, .exe/.bat/.cmd extensions are automatically tried + for ext in ["", ".exe", ".bat", ".cmd"] { + let with_ext = dir.join(format!("{}{}", command, ext)); + if with_ext.exists() { + return true; + } + } + } + } + } + false +} #[cfg(not(windows))] fn merge_path_values(_existing: &str, incoming: &str) -> String { incoming.to_string() @@ -519,10 +552,6 @@ async fn create_session(config: &ShellConfig) -> Result { } shell.register_builtin("sleep", builtins::builtin::()); shell.register_builtin("timeout", builtins::builtin::()); - shell.register_builtin( - "nohup", - builtins::builtin::().transparent_background_wrapper(), - ); let mut merged_path: Option = None; for (key, value) in std::env::vars() { @@ -552,8 +581,8 @@ async fn create_session(config: &ShellConfig) -> Result { merged_path = Some(value.to_string_lossy().into_owned()); } - if let Some(path_value) = merged_path { - let mut var = ShellVariable::new(ShellValue::String(path_value)); + if let Some(path_value) = &merged_path { + let mut var = ShellVariable::new(ShellValue::String(path_value.clone())); var.export(); shell .env_mut() @@ -576,6 +605,27 @@ async fn create_session(config: &ShellConfig) -> Result { } } apply_env_fallback(&mut shell)?; + // The nohup builtin detaches its operand into a new session (see + // NohupCommand) so a backgrounded server survives this embedded shell's + // kill-on-drop teardown. It therefore shadows any system `nohup` (which does + // NOT escape the process-group kill) — unless explicitly opted out via + // PI_DISABLE_NOHUP_BUILTIN (session env or process env), in which case bare + // `nohup` resolves to the real coreutils binary. + let nohup_builtin_disabled = { + let raw = config + .session_env + .as_ref() + .and_then(|env| env.get("PI_DISABLE_NOHUP_BUILTIN").cloned()) + .or_else(|| std::env::var("PI_DISABLE_NOHUP_BUILTIN").ok()); + matches!(raw.as_deref(), Some(v) if !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")) + }; + let should_register_nohup = !nohup_builtin_disabled; + if should_register_nohup { + shell.register_builtin( + "nohup", + builtins::builtin::().transparent_background_wrapper(), + ); + } #[cfg(windows)] configure_windows_path(&mut shell)?; @@ -1916,18 +1966,13 @@ impl builtins::Command for NohupCommand { return Ok(ExecutionResult::new(125)); } - // Deliberately *not* nohup: we neither ignore SIGHUP nor detach the - // child into a new session. The command runs as an ordinary brush - // descendant so it is reaped together with the host instead of - // lingering as an orphan once the host process goes away. Agents - // reach for `nohup` assuming the shell is one-shot; in this - // persistent embedded shell that assumption is wrong and the only - // effect of real `nohup` would be to leak background processes. - // - // coreutils `nohup` additionally redirects stdin from /dev/null and - // stdout/stderr to `nohup.out`, but *only* when those streams are - // terminals. The embedded host always hands commands a pipe with a - // /dev/null stdin, so none of that redirection ever applies here. + // Detach the operand into a new session / process group (like `setsid`) + // so a backgrounded server survives this embedded shell's kill-on-drop + // teardown, which SIGKILLs the shell's own process group when the host + // process exits. Agents reach for `nohup &` expecting exactly + // this persistence; a real coreutils `nohup` would NOT help, since it + // stays in the shell's process group and dies with it. The new session + // is applied below via ProcessGroupPolicy::NewProcessGroup. let mut command_line = String::new(); for (idx, arg) in command.iter().enumerate() { if idx > 0 { @@ -1936,7 +1981,8 @@ impl builtins::Command for NohupCommand { command_line.push_str("e_arg(arg)); } - let params = context.params.clone(); + let mut params = context.params.clone(); + params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; let source_info = SourceInfo::from("pi-natives:nohup"); context .shell diff --git a/packages/coding-agent/scripts/build-binary.ts b/packages/coding-agent/scripts/build-binary.ts index 7a5779b28..a4cfabeb1 100644 --- a/packages/coding-agent/scripts/build-binary.ts +++ b/packages/coding-agent/scripts/build-binary.ts @@ -5,7 +5,15 @@ import * as path from "node:path"; const packageDir = path.join(import.meta.dir, ".."); const repoRoot = path.join(packageDir, "..", ".."); -const outputPath = path.join(packageDir, "dist", "omp"); +// Optional cross-compile target, e.g. CROSS_TARGET=linux-arm64 → bun build +// --target=bun-linux-arm64, embeds the matching native, outputs dist/omp-. +const crossTarget = Bun.env.CROSS_TARGET || null; +const [crossPlatform, crossArch] = crossTarget ? crossTarget.split("-") : [null, null]; +// x64 uses the baseline bun runtime so it runs under Rosetta / pre-AVX2 CPUs +// (the modern bun-linux-x64 target SIGILLs under Apple-Silicon Rosetta). +const bunTarget = crossTarget ? (crossTarget === "linux-x64" ? "bun-linux-x64-baseline" : `bun-${crossTarget}`) : null; +const outName = crossTarget ? `omp-${crossTarget}` : "omp"; +const outputPath = path.join(packageDir, "dist", outName); // Transformers.js is an optional, native-heavy dependency that is never bundled // into the binary; the tiny-model worker `bun install`s it into a runtime cache @@ -17,7 +25,7 @@ const transformersVersion = ( ).version; function shouldAdhocSignDarwinBinary(): boolean { - return process.platform === "darwin"; + return process.platform === "darwin" && !crossTarget; } async function runCommand( @@ -43,7 +51,10 @@ async function main(): Promise { try { await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]); await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]); - await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]); + await runCommand( + ["bun", "--cwd=../natives", "run", "embed:native"], + crossTarget ? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string } : Bun.env, + ); await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]); try { const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env; @@ -52,6 +63,7 @@ async function main(): Promise { "bun", "build", "--compile", + ...(bunTarget ? ["--target", bunTarget] : []), "--no-compile-autoload-bunfig", "--no-compile-autoload-dotenv", "--no-compile-autoload-tsconfig", @@ -85,7 +97,7 @@ async function main(): Promise { "./packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts", "./packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts", "--outfile", - "packages/coding-agent/dist/omp", + `packages/coding-agent/dist/${outName}`, ], buildEnv, repoRoot, diff --git a/packages/terminal-bench/agent/omp_local.py b/packages/terminal-bench/agent/omp_local.py index 65f640c32..b647221f1 100644 --- a/packages/terminal-bench/agent/omp_local.py +++ b/packages/terminal-bench/agent/omp_local.py @@ -195,6 +195,9 @@ class OmpLocal(BaseInstalledAgent): self._home = "/root" self._bun = "/root/.bun/bin/bun" self._cli = "/root/.omp-bench/app/dist/cli.js" + self._binary_arm64 = _env("OMP_TB_BINARY_ARM64") + self._binary_x64 = _env("OMP_TB_BINARY_X64") + self._binary = bool(self._binary_arm64 or self._binary_x64) @staticmethod @override @@ -207,6 +210,8 @@ class OmpLocal(BaseInstalledAgent): @override def get_version_command(self) -> str | None: + if self._binary: + return f"{shlex.quote(self._cli)} --version" return self._wrap(f"{shlex.quote(self._bun)} {shlex.quote(self._cli)} --version") @override @@ -229,41 +234,44 @@ class OmpLocal(BaseInstalledAgent): @override async def install(self, environment: BaseEnvironment) -> None: - # 1) System deps (root). curl+unzip for the Bun installer; ca-certs for TLS. - await self.exec_as_root( - environment, - command=( - "set -e; " - "if command -v apt-get >/dev/null 2>&1; then " - " apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl unzip ca-certificates tar; " - "elif command -v apk >/dev/null 2>&1; then " - " echo 'ERROR: Alpine/musl base image; @oh-my-pi/pi-natives ships no musl prebuilt' >&2; exit 3; " - "elif command -v dnf >/dev/null 2>&1; then dnf install -y curl unzip tar; " - "elif command -v yum >/dev/null 2>&1; then yum install -y curl unzip tar; " - "fi" - ), - ) - - # Resolve the agent user's HOME (root vs non-root tasks differ). + # Resolve the agent user's HOME first (root vs non-root tasks differ). home = (await self.exec_as_agent(environment, command='printf %s "$HOME"')).stdout self._home = (home or "/root").strip() or "/root" - # 2) Bun (agent user). - await self.exec_as_agent( - environment, - command=( - "set -e; " - f"export BUN_INSTALL={shlex.quote(self._home + '/.bun')}; " - f'curl -fsSL https://bun.sh/install | bash -s "bun-v{self._bun_version}"; ' - f'{shlex.quote(self._home + "/.bun/bin/bun")} --version' - ), - ) - self._bun = f"{self._home}/.bun/bin/bun" - - if self._install_mode == "published": - self._cli = await self._install_published(environment) + if self._binary: + # Self-contained binary mode: upload + chmod only. No apt/curl/bun/npm, so + # trial setup needs zero outbound network (no_network tasks set up cleanly). + await self._install_binary(environment) else: - self._cli = await self._install_local(environment) + # 1) System deps (root). curl+unzip for the Bun installer; ca-certs for TLS. + await self.exec_as_root( + environment, + command=( + "set -e; " + "if command -v apt-get >/dev/null 2>&1; then " + " apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl unzip ca-certificates tar; " + "elif command -v apk >/dev/null 2>&1; then " + " echo 'ERROR: Alpine/musl base image; @oh-my-pi/pi-natives ships no musl prebuilt' >&2; exit 3; " + "elif command -v dnf >/dev/null 2>&1; then dnf install -y curl unzip tar; " + "elif command -v yum >/dev/null 2>&1; then yum install -y curl unzip tar; " + "fi" + ), + ) + # 2) Bun (agent user). + await self.exec_as_agent( + environment, + command=( + "set -e; " + f"export BUN_INSTALL={shlex.quote(self._home + '/.bun')}; " + f'curl -fsSL https://bun.sh/install | bash -s "bun-v{self._bun_version}"; ' + f'{shlex.quote(self._home + "/.bun/bin/bun")} --version' + ), + ) + self._bun = f"{self._home}/.bun/bin/bun" + if self._install_mode == "published": + self._cli = await self._install_published(environment) + else: + self._cli = await self._install_local(environment) # 3) Auth + model config under $HOME/.omp/agent. if self._gateway_on: @@ -299,6 +307,29 @@ class OmpLocal(BaseInstalledAgent): ) return f"{app}/dist/cli.js" + async def _install_binary(self, environment: BaseEnvironment) -> str: + """Probe container arch, upload only the matching self-contained omp binary.""" + arch = (await self.exec_as_agent(environment, command="uname -m")).stdout.strip() + if arch in ("aarch64", "arm64"): + hostbin = self._binary_arm64 + elif arch in ("x86_64", "amd64"): + hostbin = self._binary_x64 + else: + raise RuntimeError(f"binary mode: unsupported container arch {arch!r}") + if not hostbin: + raise RuntimeError(f"binary mode: no omp binary provided for container arch {arch}") + app_dir = f"{self._home}/.omp-bench" + dst = f"{app_dir}/omp" + staging = "/tmp/omp-bin" + await self.exec_as_agent(environment, command=f"mkdir -p {shlex.quote(app_dir)}") + await environment.upload_file(hostbin, staging) + await self.exec_as_agent( + environment, + command=f"cp {shlex.quote(staging)} {shlex.quote(dst)} && chmod +x {shlex.quote(dst)}", + ) + self._cli = dst + return dst + async def _install_published(self, environment: BaseEnvironment) -> str: app = f"{self._home}/.omp-bench/app" spec = f"@oh-my-pi/pi-coding-agent@{self._pkg_version}" @@ -415,9 +446,11 @@ class OmpLocal(BaseInstalledAgent): raise ValueError("model must be 'provider/model' (e.g. anthropic/claude-sonnet-4-6)") provider, model = self.model_name.split("/", 1) - parts = [ - shlex.quote(self._bun), - shlex.quote(self._cli), + if self._binary: + parts = [shlex.quote(self._cli)] + else: + parts = [shlex.quote(self._bun), shlex.quote(self._cli)] + parts += [ "--print", "--mode json", f"--provider {shlex.quote(provider)}", @@ -451,7 +484,7 @@ class OmpLocal(BaseInstalledAgent): if not self._gateway_on: run_env.update(self._collect_provider_keys(provider)) run_env.update(self._forward_env) - await self.exec_as_agent(environment, command=self._wrap(run), env=run_env or None) + await self.exec_as_agent(environment, command=run if self._binary else self._wrap(run), env=run_env or None) @override def populate_context_post_run(self, context: AgentContext) -> None: diff --git a/packages/terminal-bench/src/runner.ts b/packages/terminal-bench/src/runner.ts index 068db1d5a..33b24c52e 100755 --- a/packages/terminal-bench/src/runner.ts +++ b/packages/terminal-bench/src/runner.ts @@ -42,6 +42,8 @@ export interface Config { install: "local" | "published"; version: string | null; tarball: string | null; + binaryArm64: string | null; + binaryX64: string | null; build: boolean; jobsDir: string; jobName: string | null; @@ -77,6 +79,8 @@ function defaultConfig(): Config { install: "local", version: null, tarball: null, + binaryArm64: null, + binaryX64: null, build: true, jobsDir: path.join(REPO_ROOT, "runs", "tb2"), jobName: null, @@ -196,6 +200,15 @@ export function parseArgs(argv: string[]): Config { cfg.tarball = path.resolve(take(arg)); cfg.build = false; break; + case "--binary": { + const p = path.resolve(take(arg)); + const base = path.basename(p); + if (/arm64|aarch64/.test(base)) cfg.binaryArm64 = p; + else if (/x64|x86[_-]?64|amd64/.test(base)) cfg.binaryX64 = p; + else throw new Error(`--binary: cannot infer arch from ${base} (expect arm64/x64 in filename)`); + cfg.build = false; + break; + } case "--no-build": cfg.build = false; break; @@ -918,6 +931,8 @@ export function buildHarborEnv( env.OMP_TB_INSTALL = cfg.install; env.OMP_TB_VERSION = cfg.version ?? version; if (tarball) env.OMP_TB_TARBALL = tarball; + if (cfg.binaryArm64) env.OMP_TB_BINARY_ARM64 = cfg.binaryArm64; + if (cfg.binaryX64) env.OMP_TB_BINARY_X64 = cfg.binaryX64; if (cfg.thinking) env.OMP_TB_THINKING = cfg.thinking; if (cfg.advisorModel) { env.OMP_TB_ADVISOR_MODEL = cfg.advisorModel; @@ -1068,7 +1083,7 @@ async function main(): Promise { // tarball (local install only) let tarball: string | null = cfg.tarball; - if (cfg.agent === "omp" && cfg.install === "local") { + if (cfg.agent === "omp" && cfg.install === "local" && !cfg.binaryArm64 && !cfg.binaryX64) { if (tarball) { process.stdout.write(dim(`using tarball ${tarball}\n`)); } else if (cfg.build) {