fix: nohup

This commit is contained in:
can1357
2026-06-23 05:34:17 +02:00
parent c311c30cad
commit c6fcc155a2
4 changed files with 165 additions and 59 deletions
+65 -19
View File
@@ -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<ShellSessionCore> {
}
shell.register_builtin("sleep", builtins::builtin::<SleepCommand, _>());
shell.register_builtin("timeout", builtins::builtin::<TimeoutCommand, _>());
shell.register_builtin(
"nohup",
builtins::builtin::<NohupCommand, _>().transparent_background_wrapper(),
);
let mut merged_path: Option<String> = None;
for (key, value) in std::env::vars() {
@@ -552,8 +581,8 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
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<ShellSessionCore> {
}
}
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::<NohupCommand, _>().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 <server> &` 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(&quote_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
+16 -4
View File
@@ -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-<target>.
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<void> {
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<void> {
"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<void> {
"./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,
+68 -35
View File
@@ -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:
+16 -1
View File
@@ -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<void> {
// 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) {