Skip to content

Commit 0a7d0fc

Browse files
authored
Merge pull request #10 from Agentiix/fix/runtime-env-injection-contract
Define runtime env injection contract
2 parents 0ffdbae + ee84700 commit 0a7d0fc

9 files changed

Lines changed: 406 additions & 27 deletions

File tree

agentix/nix/Dockerfile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,23 @@ WORKDIR /build
3030
# nix build (toolchain + runtime), uv venv + uv sync, closure discovery.
3131
RUN sh ./bundle-build.sh
3232

33-
ENV PATH="/nix/runtime/bin:/nix/runtime/venv/bin:${PATH}" \
33+
ENV PATH="/nix/runtime/venv/bin:/nix/runtime/bin:${PATH}" \
34+
LD_LIBRARY_PATH="/nix/runtime/lib" \
35+
LIBRARY_PATH="/nix/runtime/lib" \
36+
CPATH="/nix/runtime/include" \
37+
C_INCLUDE_PATH="/nix/runtime/include" \
38+
CPLUS_INCLUDE_PATH="/nix/runtime/include" \
39+
PKG_CONFIG_PATH="/nix/runtime/lib/pkgconfig:/nix/runtime/share/pkgconfig" \
40+
CMAKE_PREFIX_PATH="/nix/runtime" \
41+
AGENTIX_ADDED_PATH="/nix/runtime/venv/bin:/nix/runtime/bin" \
42+
AGENTIX_ADDED_LD_LIBRARY_PATH="/nix/runtime/lib" \
43+
AGENTIX_ADDED_LIBRARY_PATH="/nix/runtime/lib" \
44+
AGENTIX_ADDED_CPATH="/nix/runtime/include" \
45+
AGENTIX_ADDED_C_INCLUDE_PATH="/nix/runtime/include" \
46+
AGENTIX_ADDED_CPLUS_INCLUDE_PATH="/nix/runtime/include" \
47+
AGENTIX_ADDED_PKG_CONFIG_PATH="/nix/runtime/lib/pkgconfig:/nix/runtime/share/pkgconfig" \
48+
AGENTIX_ADDED_CMAKE_PREFIX_PATH="/nix/runtime" \
3449
AGENTIX_BIND_PORT=8000
3550
EXPOSE 8000/tcp
51+
VOLUME ["/nix"]
3652
ENTRYPOINT ["/nix/runtime/venv/bin/agentix-server"]

agentix/runtime/env.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Runtime environment helpers.
2+
3+
Agentix may prepend bundle-owned paths while booting the runtime or its
4+
worker. User-facing subprocesses should be able to run without those
5+
bundle paths leaking into normal command lookup.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
from collections.abc import Mapping
12+
13+
AGENTIX_ADDED_PATH = "AGENTIX_ADDED_PATH"
14+
AGENTIX_ADDED_LD_LIBRARY_PATH = "AGENTIX_ADDED_LD_LIBRARY_PATH"
15+
16+
_TRACKING_PREFIX = "AGENTIX_ADDED_"
17+
18+
19+
def _split_path(value: str) -> list[str]:
20+
return value.split(os.pathsep) if value else []
21+
22+
23+
def _remove_path_entries(value: str, entries: str) -> str:
24+
remove = set(_split_path(entries))
25+
if not remove:
26+
return value
27+
return os.pathsep.join(entry for entry in _split_path(value) if entry not in remove)
28+
29+
30+
def _target_var(tracking_var: str) -> str | None:
31+
if not tracking_var.startswith(_TRACKING_PREFIX):
32+
return None
33+
name = tracking_var.removeprefix(_TRACKING_PREFIX)
34+
return name or None
35+
36+
37+
def get_env_without_agentix(
38+
extra: Mapping[str, str] | None = None,
39+
*,
40+
base: Mapping[str, str] | None = None,
41+
) -> dict[str, str]:
42+
"""Return an environment for user subprocesses without Agentix-added paths.
43+
44+
The helper only subtracts entries that Agentix explicitly recorded in
45+
`AGENTIX_ADDED_*`. It intentionally does not remove arbitrary `/nix`
46+
paths, because a task image may itself be Nix-based.
47+
"""
48+
49+
env = dict(os.environ if base is None else base)
50+
51+
tracking_vars = [name for name in env if name.startswith(_TRACKING_PREFIX)]
52+
for tracking_var in tracking_vars:
53+
target = _target_var(tracking_var)
54+
if target is None:
55+
continue
56+
value = _remove_path_entries(env.get(target, ""), env.get(tracking_var, ""))
57+
if value:
58+
env[target] = value
59+
else:
60+
env.pop(target, None)
61+
62+
for name in tracking_vars:
63+
env.pop(name, None)
64+
65+
if extra:
66+
env.update(extra)
67+
return env
68+
69+
70+
__all__ = [
71+
"AGENTIX_ADDED_LD_LIBRARY_PATH",
72+
"AGENTIX_ADDED_PATH",
73+
"get_env_without_agentix",
74+
]

agentix/runtime/server/worker/client.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pathlib import Path
1919
from typing import Any, Protocol
2020

21+
from agentix.runtime.env import AGENTIX_ADDED_PATH
2122
from agentix.runtime.server.worker.invoker import CallableInvoker
2223
from agentix.runtime.shared.framing import read_frame, write_frame
2324
from agentix.runtime.shared.models import RemoteError, RemoteRequest, RemoteResponse
@@ -44,15 +45,28 @@
4445
# (`subprocess.run("claude", ...)`, `c.remote(cc.run, ...)`, ...) must
4546
# be able to find those binaries by bare name.
4647
_RUNTIME_BIN_PATH = "/nix/runtime/bin"
48+
_RUNTIME_LIB_PATH = "/nix/runtime/lib"
49+
_RUNTIME_INCLUDE_PATH = "/nix/runtime/include"
4750
_STRIPPED_ENV = {
48-
"LD_LIBRARY_PATH",
4951
"LD_PRELOAD",
5052
"PYTHONPATH",
5153
"PYTHONHOME",
5254
"LOCALE_ARCHIVE",
5355
"SSL_CERT_FILE",
5456
}
5557
_STRIPPED_ENV_PREFIXES = ("NIX_", "FONTCONFIG_")
58+
_RUNTIME_PATH_ADDITIONS = {
59+
"LD_LIBRARY_PATH": (_RUNTIME_LIB_PATH,),
60+
"LIBRARY_PATH": (_RUNTIME_LIB_PATH,),
61+
"CPATH": (_RUNTIME_INCLUDE_PATH,),
62+
"C_INCLUDE_PATH": (_RUNTIME_INCLUDE_PATH,),
63+
"CPLUS_INCLUDE_PATH": (_RUNTIME_INCLUDE_PATH,),
64+
"PKG_CONFIG_PATH": (
65+
"/nix/runtime/lib/pkgconfig",
66+
"/nix/runtime/share/pkgconfig",
67+
),
68+
"CMAKE_PREFIX_PATH": ("/nix/runtime",),
69+
}
5670

5771

5872
def _join_path_entries(entries: Iterable[str]) -> str:
@@ -66,6 +80,17 @@ def _join_path_entries(entries: Iterable[str]) -> str:
6680
return os.pathsep.join(parts)
6781

6882

83+
def _tracking_var(name: str) -> str:
84+
return f"AGENTIX_ADDED_{name}"
85+
86+
87+
def _prepend_recorded_path_entries(env: dict[str, str], name: str, entries: Iterable[str]) -> None:
88+
added = _join_path_entries(entries)
89+
env[name] = _join_path_entries([*added.split(os.pathsep), *env.get(name, "").split(os.pathsep)])
90+
tracking_name = _tracking_var(name)
91+
env[tracking_name] = _join_path_entries([*env.get(tracking_name, "").split(os.pathsep), *added.split(os.pathsep)])
92+
93+
6994
def _clean_worker_env(runtime_bin_dir: Path | None) -> dict[str, str]:
7095
env = {
7196
key: value
@@ -82,6 +107,16 @@ def _clean_worker_env(runtime_bin_dir: Path | None) -> dict[str, str]:
82107
parts.append(_RUNTIME_BIN_PATH)
83108
parts.extend(env.get("PATH", "").split(os.pathsep))
84109
env["PATH"] = _join_path_entries(parts)
110+
111+
added_path = []
112+
added_path.extend(env.get(AGENTIX_ADDED_PATH, "").split(os.pathsep))
113+
if runtime_bin_dir is not None:
114+
added_path.append(str(runtime_bin_dir))
115+
added_path.append(_RUNTIME_BIN_PATH)
116+
env[AGENTIX_ADDED_PATH] = _join_path_entries(added_path)
117+
118+
for name, entries in _RUNTIME_PATH_ADDITIONS.items():
119+
_prepend_recorded_path_entries(env, name, entries)
85120
return env
86121

87122

plugins/deployment-docker/agentix/deployment/docker.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
docker run [--platform <platform>] -d --name <sid> --network host \\
2727
-e AGENTIX_BIND_PORT=<port> \\
2828
--volumes-from <carrier>:ro \\
29-
--entrypoint /nix/runtime/bin/agentix-server \\
30-
<image>
29+
--entrypoint /bin/sh \\
30+
<image> -c '<inject runtime env; exec agentix-server>'
3131
3232
`agentix-server` binds to the port from `AGENTIX_BIND_PORT`. We pick
3333
a free host port, pass it through, and health-check `/health` on it.
@@ -47,7 +47,36 @@
4747

4848
logger = logging.getLogger("agentix.deployment.docker")
4949

50-
_RUNTIME_ENTRYPOINT = "/nix/runtime/bin/agentix-server"
50+
_RUNTIME_ENTRYPOINT = "/bin/sh"
51+
_RUNTIME_BOOTSTRAP = r"""
52+
set -eu
53+
agentix_prepend_path() {
54+
name="$1"
55+
added="$2"
56+
tracking="AGENTIX_ADDED_${name}"
57+
eval "current=\${$name-}"
58+
eval "tracked=\${$tracking-}"
59+
if [ -n "$current" ]; then
60+
export "$name=$added:$current"
61+
else
62+
export "$name=$added"
63+
fi
64+
if [ -n "$tracked" ]; then
65+
export "$tracking=$tracked:$added"
66+
else
67+
export "$tracking=$added"
68+
fi
69+
}
70+
agentix_prepend_path PATH "/nix/runtime/venv/bin:/nix/runtime/bin"
71+
agentix_prepend_path LD_LIBRARY_PATH "/nix/runtime/lib"
72+
agentix_prepend_path LIBRARY_PATH "/nix/runtime/lib"
73+
agentix_prepend_path CPATH "/nix/runtime/include"
74+
agentix_prepend_path C_INCLUDE_PATH "/nix/runtime/include"
75+
agentix_prepend_path CPLUS_INCLUDE_PATH "/nix/runtime/include"
76+
agentix_prepend_path PKG_CONFIG_PATH "/nix/runtime/lib/pkgconfig:/nix/runtime/share/pkgconfig"
77+
agentix_prepend_path CMAKE_PREFIX_PATH "/nix/runtime"
78+
exec /nix/runtime/venv/bin/agentix-server
79+
""".strip()
5180

5281

5382
async def _docker(*args: str, check: bool = True) -> tuple[int, bytes, bytes]:
@@ -120,6 +149,7 @@ async def create(self, config: SandboxConfig) -> Sandbox:
120149
"--volumes-from", f"{carrier}:ro",
121150
"--entrypoint", _RUNTIME_ENTRYPOINT,
122151
config.image,
152+
"-c", _RUNTIME_BOOTSTRAP,
123153
)
124154

125155
self._ports[sandbox_id] = port

plugins/runtime-basic/agentix/bash/__init__.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,42 +27,32 @@
2727

2828
import asyncio
2929
import os
30+
import shutil
3031
from collections.abc import AsyncIterator
3132
from dataclasses import dataclass
3233
from typing import Annotated, Literal
3334

3435
from pydantic import Field
3536

36-
# Env vars stripped before forking a user-space subprocess. The runtime
37-
# is a Nix-built binary; os.environ is pre-loaded with Nix runtime paths
38-
# (LD_LIBRARY_PATH pointing at Nix-store libs, NIX_*, PYTHONPATH,
39-
# FONTCONFIG_*). Leaking those into a host-image subprocess causes glibc
40-
# ABI mismatches and silent library override bugs.
41-
_RUNTIME_ONLY_ENV = {
42-
"LD_LIBRARY_PATH",
43-
"LD_PRELOAD",
44-
"PYTHONPATH",
45-
"PYTHONHOME",
46-
"LOCALE_ARCHIVE",
47-
"FONTCONFIG_FILE",
48-
"FONTCONFIG_PATH",
49-
"SSL_CERT_FILE",
50-
"NIX_SSL_CERT_FILE",
51-
}
37+
_BUNDLE_BASH = "/nix/runtime/bin/bash"
5238

5339

5440
def _clean_env(extra: dict[str, str] | None) -> dict[str, str]:
55-
"""Build a subprocess env: scrubbed base + caller overrides."""
56-
env = {
57-
k: v
58-
for k, v in os.environ.items()
59-
if k not in _RUNTIME_ONLY_ENV and not k.startswith("NIX_")
60-
}
41+
"""Build a subprocess env: inherited runtime env + caller overrides."""
42+
env = dict(os.environ)
6143
if extra:
6244
env.update(extra)
6345
return env
6446

6547

48+
def _shell_executable(executable: str | None, env: dict[str, str]) -> str:
49+
if executable:
50+
return shutil.which(executable, path=env.get("PATH")) or executable
51+
if os.access(_BUNDLE_BASH, os.X_OK):
52+
return _BUNDLE_BASH
53+
return shutil.which("bash", path=env.get("PATH")) or "/bin/bash"
54+
55+
6656
async def _read_capped(stream: asyncio.StreamReader, limit: int) -> str:
6757
"""Drain a subprocess stream, retaining at most `limit` bytes.
6858
@@ -155,6 +145,7 @@ async def run(
155145
env: dict[str, str] | None = None,
156146
timeout: float | None = None,
157147
max_output: int = 10 * 1024 * 1024,
148+
executable: str | None = None,
158149
) -> BashResult:
159150
"""Run a shell command in the sandbox and return its captured output."""
160151
sub_env = _clean_env(env)
@@ -164,6 +155,7 @@ async def run(
164155
stderr=asyncio.subprocess.PIPE,
165156
cwd=cwd,
166157
env=sub_env,
158+
executable=_shell_executable(executable, sub_env),
167159
)
168160
assert proc.stdout is not None and proc.stderr is not None
169161
stdout_task = asyncio.create_task(_read_capped(proc.stdout, max_output))
@@ -193,6 +185,7 @@ async def run_stream(
193185
cwd: str | None = None,
194186
env: dict[str, str] | None = None,
195187
timeout: float | None = None,
188+
executable: str | None = None,
196189
) -> AsyncIterator[BashEvent]:
197190
"""Run a shell command, yielding events as the subprocess emits them.
198191
@@ -206,6 +199,7 @@ async def run_stream(
206199
stderr=asyncio.subprocess.PIPE,
207200
cwd=cwd,
208201
env=sub_env,
202+
executable=_shell_executable(executable, sub_env),
209203
)
210204

211205
async def _pump(stream, tag, queue):

plugins/runtime-basic/tests/test_primitives.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import os
45
import shlex
56
import sys
7+
from pathlib import Path
68

79
import agentix.bash as bash
810
import agentix.files as files
@@ -46,3 +48,69 @@ async def test_bash_run_drains_stderr_after_output_cap():
4648
assert result.exit_code == 0
4749
assert result.stdout.strip() == "done"
4850
assert "[truncated at 1024 bytes]" in result.stderr
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_bash_run_honors_bash_env(tmp_path: Path):
55+
bash_env = tmp_path / "bash_env"
56+
bash_env.write_text("export FROM_BASH_ENV=loaded\n")
57+
58+
result = await bash.run(
59+
"printf '%s' \"$FROM_BASH_ENV\"",
60+
env={"BASH_ENV": str(bash_env)},
61+
)
62+
63+
assert result.exit_code == 0
64+
assert result.stdout == "loaded"
65+
66+
67+
@pytest.mark.asyncio
68+
@pytest.mark.parametrize("executable", ["bash", "zsh", "fish"])
69+
async def test_bash_run_can_use_explicit_executable_names(tmp_path: Path, executable: str):
70+
fakebin = tmp_path / "bin"
71+
fakebin.mkdir()
72+
shell_path = fakebin / executable
73+
shell_path.write_text(
74+
"#!/bin/sh\n"
75+
"if [ \"$1\" != \"-c\" ]; then exit 64; fi\n"
76+
"shift\n"
77+
"export AGENTIX_TEST_SHELL=\"$(basename \"$0\")\"\n"
78+
"exec /bin/sh -c \"$1\"\n"
79+
)
80+
shell_path.chmod(0o755)
81+
82+
result = await bash.run(
83+
"printf '%s' \"$AGENTIX_TEST_SHELL\"",
84+
env={"PATH": os.pathsep.join([str(fakebin), os.environ.get("PATH", "")])},
85+
executable=executable,
86+
)
87+
88+
assert result.exit_code == 0
89+
assert result.stdout == executable
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_bash_run_stream_can_use_explicit_executable(tmp_path: Path):
94+
fakebin = tmp_path / "bin"
95+
fakebin.mkdir()
96+
shell_path = fakebin / "zsh"
97+
shell_path.write_text(
98+
"#!/bin/sh\n"
99+
"if [ \"$1\" != \"-c\" ]; then exit 64; fi\n"
100+
"shift\n"
101+
"export AGENTIX_TEST_SHELL=\"$(basename \"$0\")\"\n"
102+
"exec /bin/sh -c \"$1\"\n"
103+
)
104+
shell_path.chmod(0o755)
105+
106+
events = [
107+
event
108+
async for event in bash.run_stream(
109+
"printf '%s' \"$AGENTIX_TEST_SHELL\"",
110+
env={"PATH": os.pathsep.join([str(fakebin), os.environ.get("PATH", "")])},
111+
executable="zsh",
112+
)
113+
]
114+
115+
assert [event.data for event in events if isinstance(event, bash.BashStdout)] == ["zsh"]
116+
assert [event.exit_code for event in events if isinstance(event, bash.BashExit)] == [0]

0 commit comments

Comments
 (0)