fix: nohup
This commit is contained in:
@@ -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("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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user