From a6becb9c5826631a655e1f3d4850bb150cce3777 Mon Sep 17 00:00:00 2001 From: Harenome Ranaivoarivony-Razanajato Date: Thu, 5 Nov 2026 19:31:11 +0100 Subject: [PATCH] Give the relay's container its key as a file The docker backend passed the provider's key to each unit's relay as an environment variable, so docker inspect, and anything that dumps container configurations, showed it. The key is now written owner-only in a folder of its own under relay-keys/ in the user's cache, outside the unit's record. Colima and Lima share the user's home with their engine by default, but not the system's temporary folder, so that folder is not used. The file is bind-mounted alone and read-only into the relay's container and passed to the relay with --key-file. The relay no longer reads NULLIUS_RELAY_UPSTREAM_KEY. Nullius removes the folder once the relay listens, and on stop, which a failed start also calls. A job's sweep removes the folders its relays left. Each unit's start and each sweep also remove any key folder older than 10 minutes, which no start takes: a killed run leaves such a folder. --- .claude/rules/running.md | 5 +- app/nullius/execution.py | 6 +- app/nullius/standard/container.py | 93 ++++++++++++++++++++++------ app/nullius/standard/relay.py | 23 ++++--- app/tests/test_standard_container.py | 74 ++++++++++++++++++++-- app/tests/test_standard_relay.py | 31 +++++++--- docs/builder.md | 2 +- docs/settings/model-providers.md | 2 +- docs/standard.md | 2 +- 9 files changed, 186 insertions(+), 52 deletions(-) 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 |