diff --git a/.claude/rules/running.md b/.claude/rules/running.md index 4880f77..6bb6d71 100644 --- a/.claude/rules/running.md +++ b/.claude/rules/running.md @@ -16,8 +16,9 @@ through the execution backend (`docs/builder.md`). are included; configuration and credential stores are excluded. Several machines run one shard each. - **Keys**: a run gets only its provider's key and endpoint, as environment - (over stdin to a host), never written; logs are redacted. The standard's - agents and upstream scripts get only the relay's word, never a key. + (over stdin to a host, and the key to a docker relay as a file); logs are + redacted. The standard's agents and upstream scripts get only the relay's + word, never a key. - What a recipe needs of each machine is asked before Run and again as it starts (`nullius/prerequisites.py`). `runs//run.json` lets a restarted Nullius pick a run up again. diff --git a/app/nullius/execution.py b/app/nullius/execution.py index 90b08ad..c3faa21 100644 --- a/app/nullius/execution.py +++ b/app/nullius/execution.py @@ -78,9 +78,9 @@ run without that endpoint fails before it starts. They are read on this machine and handed to the run as environment (API_TOKEN, OPENAI_API_KEY, LLM_API_KEY; API_URL, OPENAI_BASE_URL, -LLM_BASE_URL) - over stdin to the launcher, never in argv, never written -anywhere; a stdin cut short starts nothing and fails the run at that part -(``_launch``), and one that holds a line break is never sent +LLM_BASE_URL) - over stdin to the launcher, never in argv (the key to a +docker relay as a file); a stdin cut short starts nothing and fails the +run at that part (``_launch``), and one that holds a line break is never sent (``_line_break``) - and they are the only credentials it has: its commands get this process's environment less every secret in Settings, whichever provider it belongs to (``withheld``); a host gets nothing of it but stdin. diff --git a/app/nullius/standard/container.py b/app/nullius/standard/container.py index f4bfe26..4210924 100644 --- a/app/nullius/standard/container.py +++ b/app/nullius/standard/container.py @@ -32,12 +32,13 @@ bridge *and* on that network as ``relay``; the agent's container on the internal network alone. Its model endpoint is ``http://relay:8080//v1`` and nothing else answers it. The real key reaches the relay's container -only, as ``-e NULLIUS_RELAY_UPSTREAM_KEY`` with the value in the docker -program's own environment - never on an argument list, which ``ps`` and -``docker inspect`` show. The relay publishes its port on 127.0.0.1 so the -driver, which stays on this machine (it owns the answers), reaches the -judge's route and a capability benchmark's reader. Its record folder is mounted -read-write, so the host reads each call as the local relay's are read. +only, as an owner-only file under :func:`key_root`, mounted read-only and +removed from this machine once the relay listens: never on an argument list +(``ps``) or in the container's environment (``docker inspect``). The relay +publishes its port on 127.0.0.1 so the driver, which stays on this machine +(it owns the answers), reaches the judge's route and a capability +benchmark's reader. Its record folder is mounted read-write, so the host +reads each call as the local relay's are read. **A licence server is one more door.** A unit whose environment needs a licence of kind ``server`` (``host:port``) gets a forwarder beside its relay @@ -102,6 +103,7 @@ import urllib.request from typing import Dict, Iterable, List, Optional, Tuple +from nullius import paths from nullius.standard import definitions, install DOCKER = "docker" @@ -114,6 +116,8 @@ EGRESS_IN = "/opt/nullius/egress.py" # the check's proxy's one file, in its container EGRESS_PORT = 3128 EGRESS_ALIAS = "egress" +RELAY_KEY_IN = "/opt/nullius/relay-key" # the key's one file, in the relay's container +KEY_STALE = 600 # older than any start can take: a killed run left it IMAGE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" NODE_VERSION = "22.23.3" @@ -465,20 +469,23 @@ def install_argv(image: str, where: str, cmd: List[str]) -> List[str]: "-e", f"HOME={where}/.npm-home", "-e", f"npm_config_cache={where}/.npm-cache", image] + list(cmd) -def relay_argv(image: str, *, name: str, job: str, app: str, record: str, base: str, model: Optional[str], - routes: Dict[str, str], prices: Optional[dict] = None, side_models=(), +def relay_argv(image: str, *, name: str, job: str, app: str, record: str, key_file: str, base: str, + model: Optional[str], routes: Dict[str, str], prices: Optional[dict] = None, side_models=(), key_header: str = "authorization", hosts: Optional[Dict[str, str]] = None) -> List[str]: """``docker run`` for a unit's relay: on the default bridge (its way to the - provider), its port published on 127.0.0.1 only. The key is named, never - given: ``-e NULLIUS_RELAY_UPSTREAM_KEY``. ``hosts``: names it resolves + provider), its port published on 127.0.0.1 only. The key is never given: + ``key_file``, its file, is mounted read-only at ``RELAY_KEY_IN`` with + ``--mount``, which fails when the engine cannot see it (``-v`` would mount + an empty folder). ``hosts``: names it resolves (``--add-host``), the machine's own for an endpoint on its loopback.""" a = [DOCKER, "run", "-d", "--name", name, "--label", f"{LABEL}={job}", "--user", _user(), "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--init", *[w for n, ip in sorted((hosts or {}).items()) for w in ("--add-host", f"{n}:{ip}")], - "-p", f"127.0.0.1::{RELAY_PORT}", "-v", f"{app}:{app}:ro", "-v", f"{record}:{record}:rw", "-w", app, - "-e", "PYTHONPATH=" + app, "-e", "PYTHONDONTWRITEBYTECODE=1", "-e", "NULLIUS_RELAY_UPSTREAM_KEY", + "-p", f"127.0.0.1::{RELAY_PORT}", "-v", f"{app}:{app}:ro", "-v", f"{record}:{record}:rw", + "--mount", f"type=bind,source={key_file},target={RELAY_KEY_IN},readonly", "-w", app, + "-e", "PYTHONPATH=" + app, "-e", "PYTHONDONTWRITEBYTECODE=1", image, "python3", "-m", "nullius.standard.relay", "serve", "--listen", f"0.0.0.0:{RELAY_PORT}", - "--base", base, "--record", record, "--public", f"http://{RELAY_ALIAS}:{RELAY_PORT}"] + "--base", base, "--record", record, "--key-file", RELAY_KEY_IN, "--public", f"http://{RELAY_ALIAS}:{RELAY_PORT}"] if model: a += ["--model", model] for tag, m in sorted(routes.items()): @@ -657,6 +664,7 @@ def __init__(self, job: str, tag: str, record_dir: str, *, image: str, app: str, self.host: Optional[str] = None self.started: List[str] = [] # containers to remove self.network = False + self.key_dir: Optional[str] = None # the key file's folder, until the relay listens os.makedirs(record_dir, exist_ok=True) def start(self) -> "Unit": @@ -664,12 +672,14 @@ def start(self) -> "Unit": _docker(["network", "create", "--internal", "--label", f"{LABEL}={self.job}", self.names["network"]]) self.network = True argv = relay_argv(self.image, name=self.names["relay"], job=self.job, app=self.app, record=self.dir, - base=self.base, model=self.model, routes=self.routes, prices=self.prices, - side_models=self.side_models, key_header=self.key_header, hosts=self.hosts) + key_file=self._key_file(), base=self.base, model=self.model, routes=self.routes, + prices=self.prices, side_models=self.side_models, key_header=self.key_header, + hosts=self.hosts) self.started.append(self.names["relay"]) - _docker(argv[1:], env=client_env({"NULLIUS_RELAY_UPSTREAM_KEY": self.key})) + _docker(argv[1:]) _docker(["network", "connect", "--alias", RELAY_ALIAS, self.names["network"], self.names["relay"]]) self._wait(self.names["relay"], "the relay's container") + self._forget_key() port = _docker(["port", self.names["relay"], f"{RELAY_PORT}/tcp"]).stdout.strip().splitlines()[0] self.host = "http://" + port.replace("0.0.0.0", "127.0.0.1") self.reach = {} @@ -682,6 +692,24 @@ def start(self) -> "Unit": raise return self + def _key_file(self) -> str: + """Write the key to an owner-only file in a new folder under + :func:`key_root`, and return its path. Not the record folder: it is kept.""" + root = key_root() + os.makedirs(root, exist_ok=True) + os.chmod(root, 0o700) + forget_stale_keys() + self.key_dir = tempfile.mkdtemp(prefix=key_prefix(self.job), dir=root) + path = os.path.join(self.key_dir, "key") + with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as f: + f.write(self.key) + return path + + def _forget_key(self): + if self.key_dir: + shutil.rmtree(self.key_dir, ignore_errors=True) + self.key_dir = None + def _forward(self, i: int, address: str) -> str: """A licence server's forwarder, started and on the unit's network under the server's name: the address the agent reaches it by. It joins @@ -746,8 +774,9 @@ def wrap(self, cmd: List[str], env: dict, workdir: str, mounted: List[Tuple[str, return argv, client_env({k: v for k, v in inside.items() if k != "PATH"}) def stop(self): - """Remove the unit's containers and network; the relay's log is kept - beside its record first.""" + """Remove the unit's containers and network, and the key's file if the + relay has not read it; the relay's log is kept beside its record first.""" + self._forget_key() if self.names["relay"] in self.started: try: r = _docker(["logs", self.names["relay"]], check=False, timeout=30) @@ -1369,8 +1398,34 @@ def stop(self): self.own_net = False +def key_root() -> str: + """The relays' key folders, in the user's cache: Colima and Lima share the + home with their engine, not the temporary folder.""" + return os.path.join(paths.cache_dir(), "relay-keys") + + +def forget_stale_keys(): + """Remove every job's key folders older than ``KEY_STALE``.""" + old = time.time() - KEY_STALE + for d in glob.glob(os.path.join(glob.escape(key_root()), "*")): + try: + if os.lstat(d).st_mtime < old: + shutil.rmtree(d, ignore_errors=True) + except OSError: + pass + + +def key_prefix(job: str) -> str: + """A job's key folder prefix: a hash, so no job's prefix starts another's.""" + return f"nullius-relay-key-{hashlib.sha256(job.encode()).hexdigest()[:12]}-" + + def sweep(job: str): - """Remove every container and network a job's units left, by its label.""" + """Remove every container and network a job's units left, by its label, + its relays' key folders, and stale key folders.""" + for d in glob.glob(os.path.join(glob.escape(key_root()), glob.escape(key_prefix(job)) + "*")): + shutil.rmtree(d, ignore_errors=True) + forget_stale_keys() try: ids = _docker(["ps", "-aq", "--filter", f"label={LABEL}={job}"], check=False).stdout.split() if ids: diff --git a/app/nullius/standard/relay.py b/app/nullius/standard/relay.py index a525a99..dafd9f0 100644 --- a/app/nullius/standard/relay.py +++ b/app/nullius/standard/relay.py @@ -570,19 +570,16 @@ def failure(calls: List[dict]) -> Optional[tuple]: # -- the relay as a process of its own: a container backend's sidecar ---------- -KEY_VAR = "NULLIUS_RELAY_UPSTREAM_KEY" - - def serve(argv=None) -> int: """``python3 -m nullius.standard.relay serve --listen HOST:PORT --base URL - --record DIR [--model M] [--route TAG=MODEL ...] [--side-model M ...] [--key-header NAME] + --record DIR --key-file PATH [--model M] [--route TAG=MODEL ...] [--side-model M ...] [--key-header NAME] [--public URL] [--prices JSON]``. ``--base`` is the provider's endpoint, ``--route`` a route sent as another model (a benchmark's judge), ``--public`` the address ``url`` hands out. - The real key is read from ``NULLIUS_RELAY_UPSTREAM_KEY`` and nowhere else: - an argument is visible to anyone who lists processes or inspects a - container. One JSON line, ``{"listening": "http://host:port"}``, says it + The real key is read from ``--key-file`` and nowhere else: an argument or + an environment variable is visible to anyone who lists processes or + inspects a container. One JSON line, ``{"listening": "http://host:port"}``, says it is ready; SIGTERM (or SIGINT) stops it.""" import argparse import signal @@ -590,6 +587,7 @@ def serve(argv=None) -> int: ap.add_argument("--listen", default="127.0.0.1:0", help="HOST:PORT") ap.add_argument("--base", required=True, help="the provider's endpoint") ap.add_argument("--record", required=True, help="the folder each route's calls are recorded in") + ap.add_argument("--key-file", required=True, help="the file that holds the provider's key") ap.add_argument("--model", help="every call sent as this model") ap.add_argument("--route", action="append", default=[], help="TAG=MODEL") ap.add_argument("--side-model", action="append", default=[], help="a model the agent may call as asked") @@ -597,9 +595,14 @@ def serve(argv=None) -> int: ap.add_argument("--public", help="the address url() hands out, when not the listening one") ap.add_argument("--prices", help="a JSON object: model -> USD per million tokens, for a cost estimate") a = ap.parse_args(argv) - key = os.environ.get(KEY_VAR) + try: + with open(a.key_file) as f: + key = f.read().strip() + except OSError as e: + print(json.dumps({"error": f"cannot read --key-file: {e}"}), flush=True) + return 2 if not key: - print(json.dumps({"error": f"{KEY_VAR} is not set"}), flush=True) + print(json.dumps({"error": f"--key-file {a.key_file} contains no key"}), flush=True) return 2 host, _, port = a.listen.rpartition(":") r = Relay(a.base, key, a.record, prices=json.loads(a.prices) if a.prices else None, model=a.model, @@ -625,5 +628,5 @@ def serve(argv=None) -> int: if __name__ == "__main__": import sys if sys.argv[1:2] != ["serve"]: - sys.exit("python3 -m nullius.standard.relay serve --listen HOST:PORT --base URL --record DIR [--model M]") + sys.exit("python3 -m nullius.standard.relay serve --listen HOST:PORT --base URL --record DIR --key-file PATH [--model M]") sys.exit(serve(sys.argv[2:])) diff --git a/app/tests/test_standard_container.py b/app/tests/test_standard_container.py index 93d4521..c967492 100644 --- a/app/tests/test_standard_container.py +++ b/app/tests/test_standard_container.py @@ -7,8 +7,10 @@ import json import os import shutil +import stat import subprocess import tempfile +import time import unittest import urllib.error import urllib.request @@ -42,6 +44,7 @@ class ArgvTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.tmp, True) + self.enterContext(mock.patch.dict(os.environ, {"NULLIUS_HOME": os.path.join(self.tmp, "profile")})) def test_mounts_each_folder_once_at_its_own_path(self): ws, home, app = (os.path.join(self.tmp, n) for n in ("ws", "home", "app")) @@ -91,11 +94,12 @@ def test_the_agents_container_is_on_the_units_network_with_its_variables_by_name self.assertEqual(a[-2:], ["img", "codex-acp"]) self.assertNotIn("-p", a) # nothing published - def test_the_relays_container_names_the_key_and_never_holds_it(self): - a = container.relay_argv("img", name="nullius-j-u0000-relay", job="j", app="/app", record="/rec", + def test_the_relays_container_is_given_the_keys_file_never_the_key(self): + a = container.relay_argv("img", name="nullius-j-u0000-relay", job="j", app="/app", record="/rec", key_file="/k/key", base="https://api.example/v1", model="m", routes={"u0000-judge": "the-judge"}) - self.assertIn("NULLIUS_RELAY_UPSTREAM_KEY", a) - self.assertFalse(any(w.startswith("NULLIUS_RELAY_UPSTREAM_KEY=") for w in a)) + self.assertEqual(a[a.index("--mount") + 1], "type=bind,source=/k/key,target=/opt/nullius/relay-key,readonly") + self.assertEqual(a[a.index("--key-file") + 1], container.RELAY_KEY_IN) + self.assertFalse(any("KEY" in a[i + 1] for i, w in enumerate(a) if w == "-e")) self.assertIn("127.0.0.1::8080", a) # the driver's way in, this machine only self.assertIn("/app:/app:ro", a) self.assertIn("/rec:/rec:rw", a) @@ -106,10 +110,10 @@ def test_the_relays_container_names_the_key_and_never_holds_it(self): def test_an_endpoint_on_this_machines_loopback_is_reached_through_the_bridge(self): self.assertTrue(all(map(container.on_loopback, ["http://localhost:11434/v1", "http://127.0.0.1:8000/v1", "http://[::1]:8000/v1"]))) self.assertFalse(any(map(container.on_loopback, ["https://openrouter.ai/api/v1", "http://172.17.0.1:8000/v1", ""]))) - a = container.relay_argv("img", name="r", job="j", app="/app", record="/rec", base="http://host.docker.internal:4000/v1", + a = container.relay_argv("img", name="r", job="j", app="/app", record="/rec", key_file="/k", base="http://host.docker.internal:4000/v1", model="m", routes={}, hosts={"host.docker.internal": "172.17.0.1"}) self.assertEqual(a[a.index("--add-host") + 1], "host.docker.internal:172.17.0.1") - self.assertNotIn("--add-host", container.relay_argv("img", name="r", job="j", app="/app", record="/rec", base="b", model="m", routes={})) + self.assertNotIn("--add-host", container.relay_argv("img", name="r", job="j", app="/app", record="/rec", key_file="/k", base="b", model="m", routes={})) with mock.patch.object(container, "bridge_gateway", return_value=""): # Docker Desktop: the bridge is elsewhere door = container.Loopback("http://user@localhost:11434/v1?x=1").start() self.assertEqual((door.base, door.hosts), ("http://user@host.docker.internal:11434/v1?x=1", {"host.docker.internal": "host-gateway"})) @@ -153,6 +157,61 @@ def test_a_licence_forwarder_listens_before_it_takes_the_servers_name(self): name = u.names["agent"] + "-lic1" self.assertEqual(calls, [["run", "-d"], ["wait", name], ["network", "connect"]]) + def start(self, fail_on=None): + """A unit started against a stand-in docker: each call's arguments and + environment, and the key's file as it was when the relay was run.""" + u = container.Unit("job", "u0000", os.path.join(self.tmp, "rec"), image="img", app="/app", base="b", key="sk-real") + calls, seen = [], {} + + def docker(args, env=None, **_): + calls.append((args, env or {})) + if args[0] == "run": + src = args[args.index("--mount") + 1].split(",")[1].removeprefix("source=") + with open(src) as f: + seen.update(path=src, key=f.read(), mode=stat.S_IMODE(os.stat(src).st_mode), + dir_mode=stat.S_IMODE(os.stat(os.path.dirname(src)).st_mode)) + if args[:2] == fail_on: + raise container.Unavailable("docker failed") + return subprocess.CompletedProcess(args, 0, "0.0.0.0:32768\n" if args[0] == "port" else "", "") + self.enterContext(mock.patch.object(container, "_docker", docker)) # for the test's stop too + self.enterContext(mock.patch.object(container, "wait_listening")) + if fail_on: + with self.assertRaises(container.Unavailable): + u.start() + else: + u.start() + return u, calls, seen + + def test_the_key_reaches_the_relay_as_an_owner_only_file_removed_once_read(self): + u, calls, seen = self.start() + self.assertEqual((seen["key"], seen["mode"], seen["dir_mode"]), ("sk-real", 0o600, 0o700)) + self.assertEqual(os.path.dirname(os.path.dirname(seen["path"])), container.key_root()) # never the record's folder + self.assertEqual(stat.S_IMODE(os.stat(container.key_root()).st_mode), 0o700) + self.assertFalse(os.path.exists(os.path.dirname(seen["path"]))) # gone once the relay listens + self.assertNotIn("sk-real", json.dumps(calls)) # no argument, no variable + u.stop() + + def test_a_failed_start_removes_the_keys_file(self): + u, calls, seen = self.start(fail_on=["network", "connect"]) # after the relay's run + self.assertEqual(calls[-1][0][:2], ["network", "rm"]) + self.assertFalse(os.path.exists(os.path.dirname(seen["path"]))) + self.assertIsNone(u.key_dir) + + def test_a_sweep_removes_the_jobs_key_folders_and_no_other_jobs(self): + os.makedirs(container.key_root()) + mine, other = (tempfile.mkdtemp(prefix=container.key_prefix(j), dir=container.key_root()) for j in ("job", "job-2")) + with mock.patch.object(container, "_docker", side_effect=container.Unavailable("no docker")): + container.sweep("job") + self.assertEqual((os.path.exists(mine), os.path.exists(other)), (False, True)) + + def test_a_start_removes_stale_key_folders_of_any_job(self): + os.makedirs(container.key_root()) + old, fresh = (tempfile.mkdtemp(prefix=container.key_prefix(j), dir=container.key_root()) for j in ("dead", "live")) + t = time.time() - container.KEY_STALE - 1 + os.utime(old, (t, t)) + self.start()[0].stop() + self.assertEqual((os.path.exists(old), os.path.exists(fresh)), (False, True)) + def test_a_long_job_name_gives_a_short_unique_slug(self): a, b = container.slug("x" * 200), container.slug("x" * 199 + "y") self.assertLessEqual(len(a), 80) @@ -183,6 +242,9 @@ def test_the_relay_is_the_only_way_out_and_nothing_is_left(self): base="http://127.0.0.1:9/v1", key="sk-test-not-real") # an endpoint no one serves try: u.start() + seen = subprocess.run(["docker", "inspect", u.names["relay"]], stdout=subprocess.PIPE, text=True).stdout + self.assertIn(container.RELAY_KEY_IN, seen) + self.assertNotIn("sk-test-not-real", seen) # the key is in no configuration with self.assertRaises(urllib.error.HTTPError) as e: # the host reaches it urllib.request.urlopen(urllib.request.Request(u.url("u0000") + "/chat/completions", data=b"{}"), timeout=30) self.assertEqual(e.exception.code, 502) diff --git a/app/tests/test_standard_relay.py b/app/tests/test_standard_relay.py index a01ca91..eb7a1ac 100644 --- a/app/tests/test_standard_relay.py +++ b/app/tests/test_standard_relay.py @@ -5,6 +5,7 @@ import json import os +import shutil import sys import tempfile import threading @@ -192,23 +193,26 @@ def tearDownClass(cls): def serve(self, key, *extra): import subprocess + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d, True) + with open(os.path.join(d, "key"), "w") as f: + f.write(key + "\n") env = {"PATH": os.environ.get("PATH", ""), "PYTHONPATH": self.app} - if key: - env[relay.KEY_VAR] = key return subprocess.Popen([sys.executable, "-m", "nullius.standard.relay", "serve", "--listen", "127.0.0.1:0", - "--base", f"http://127.0.0.1:{self.up.server_address[1]}/v1", "--record", tempfile.mkdtemp(), - "--model", "the-recipes", *extra], env=env, stdout=subprocess.PIPE, text=True) + "--base", f"http://127.0.0.1:{self.up.server_address[1]}/v1", "--record", d, + "--key-file", os.path.join(d, "key"), "--model", "the-recipes", *extra], + env=env, stdout=subprocess.PIPE, text=True) - def test_serve_relays_with_the_key_from_its_environment_and_stops_on_sigterm(self): + def test_serve_relays_with_the_key_from_its_file_and_stops_on_sigterm(self): import signal - p = self.serve("sk-from-env", "--route", "u1-judge=the-judge") + p = self.serve("sk-from-file", "--route", "u1-judge=the-judge") try: where = json.loads(p.stdout.readline())["listening"] for tag, model in (("u1", "the-recipes"), ("u1-judge", "the-judge")): req = urllib.request.Request(f"{where}/{tag}/v1/chat/completions", data=b'{"model":"asked"}', headers={"Content-Type": "application/json"}) urllib.request.urlopen(req).read() - self.assertEqual(Upstream.seen[-1][1], "Bearer sk-from-env") + self.assertEqual(Upstream.seen[-1][1], "Bearer sk-from-file") self.assertEqual(Upstream.seen[-1][2]["model"], model) p.send_signal(signal.SIGTERM) self.assertEqual(p.wait(timeout=10), 0) @@ -217,9 +221,18 @@ def test_serve_relays_with_the_key_from_its_environment_and_stops_on_sigterm(sel p.kill() p.stdout.close() - def test_serve_without_the_key_in_its_environment_refuses(self): + def test_serve_without_its_key_file_refuses(self): + import subprocess + p = subprocess.Popen([sys.executable, "-m", "nullius.standard.relay", "serve", "--base", "http://127.0.0.1:9/v1", + "--record", tempfile.mkdtemp(), "--key-file", "/nonexistent/key"], + env={"PATH": os.environ.get("PATH", ""), "PYTHONPATH": self.app}, stdout=subprocess.PIPE, text=True) + self.assertIn("cannot read --key-file", p.stdout.readline()) + self.assertEqual(p.wait(timeout=10), 2) + p.stdout.close() + + def test_serve_with_an_empty_key_file_refuses(self): p = self.serve("") - self.assertIn(relay.KEY_VAR, p.stdout.readline()) + self.assertIn("contains no key", p.stdout.readline()) self.assertEqual(p.wait(timeout=10), 2) p.stdout.close() diff --git a/docs/builder.md b/docs/builder.md index e4efa5f..c227b43 100644 --- a/docs/builder.md +++ b/docs/builder.md @@ -88,7 +88,7 @@ a declared model list keeps those published choices. | --- | --- | | Order | `nop`, `oracle`, then the agents under evaluation | | Each Run | A new job (recipe name + timestamp); never resumes | -| Key and endpoint | Passed as environment (over stdin to a host), never written; logs redacted. Other secrets withheld | +| Key and endpoint | Passed as environment (over stdin to a host), the key to a docker relay as a file; logs redacted. Other secrets withheld | | A host | Gets a content-addressed snapshot of the running backend and effective catalogue into `~/.nullius/app/`, including source edits and user entries but no configuration stores; a Harbor recipe gets its pin's wheels in `~/.nullius/harbor//wheels`, and `uv` if missing | | Proofs | Local runs save to the user catalogue. A host uses a writable copy in its run's `catalogue/`, isolated from the host's own entries; that copy and its proofs mirror back alongside `jobs/`, without changing the active user catalogue | | Docker | Where a recipe needs it, `docker ps` must answer, or the run stops before it starts | diff --git a/docs/settings/model-providers.md b/docs/settings/model-providers.md index ee1a136..242cb3c 100644 --- a/docs/settings/model-providers.md +++ b/docs/settings/model-providers.md @@ -37,7 +37,7 @@ your existing `settings.json` ([location and precedence](README.md#save)). | | | | --- | --- | -| A run | Its provider's key and endpoint, as environment, never written | +| A run | Its provider's key and endpoint, as environment (the key to a docker relay as a file) | | A recipe, a log | Nothing; logs are redacted | | A standard run on Anthropic | The relay alone | diff --git a/docs/standard.md b/docs/standard.md index 2a46f71..d625ac8 100644 --- a/docs/standard.md +++ b/docs/standard.md @@ -150,7 +150,7 @@ The docker backend (`--backend docker`) is the isolation, and needs no such yes. | --- | --- | | One base image | `nullius/base:`: Ubuntu 24.04, git, python3, curl, CA certificates, tmux and Node 22. Environments are layers on it ([Per-task environments](#per-task-environments)), or each task's own image ([Own images](#own-images)) | | The agent's container | Runs as you (`--user`), no capabilities, only on the unit's internal network: no route out. Read-write: the unit's workspace and home, the MCP call log folder. Read-only, at its path here: the agent's install and definition folder, its MCP servers' files. Never `app/` (refused), the benchmark's folder or upstream | -| The relay is a sidecar | On that network as `relay`, and on the default bridge: the agent's one way out, `http://relay:8080//v1`; its port on 127.0.0.1 for the driver. The key is in the relay's container only, passed by name (`-e NULLIUS_RELAY_UPSTREAM_KEY`), never in the agent's environment, files or mounts; `docker inspect` can read it there | +| The relay is a sidecar | On that network as `relay`, and on the default bridge: the agent's one way out, `http://relay:8080//v1`; its port on 127.0.0.1 for the driver. The key reaches the relay's container only, as an owner-only file in the user's cache (`relay-keys/`), mounted read-only and removed from this machine once the relay listens. It stays readable in that container until it stops. `docker inspect` does not show it, and it is never in the agent's environment, files or mounts | | A model server on this machine | An endpoint on its loopback (`http://localhost:11434/v1`) is reached by the relays as `host.docker.internal`, through a forwarder the run keeps on the default bridge's gateway alone; the agents' internal networks do not reach it | | Cleanup | Everything a unit starts is labelled `nullius.job=` and removed when it ends, after an error too; the run sweeps the label |