agentic-runner 2.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
agentic_runner/cli.py ADDED
@@ -0,0 +1,416 @@
1
+ """The ``agentic-runner`` console entry point (ADR-0013 §4, PRD issue 23's name).
2
+
3
+ ``--version`` prints the Runner's own version and the **contracts** version, because the
4
+ contracts version is the compatibility floor the control plane checks before it mints a
5
+ Directive token (ADR-0013 §7). An operator debugging "why did this Runner stop getting
6
+ Directives" needs both numbers from one command.
7
+
8
+ ``register`` is first boot (PRD issue 41): it presents the Agent Token from the
9
+ environment, persists the durable identity the exchange hands back, and prints the
10
+ namespace and queue this process was assigned. It is idempotent by state file -- a
11
+ restart re-reads what is already there rather than registering a second Runner against
12
+ the Organisation's cap.
13
+
14
+ ``install | start | stop | status <org>`` and ``credential set`` are the workstation
15
+ Runner (PRD issue 47, ``workstation.py``): one login agent per Organisation, managed by
16
+ the user who hosts it, with no ``sudo`` anywhere.
17
+
18
+ The remaining subcommands are what a Directive calls back with, over its own attempt
19
+ socket (``callback.py``): ``annotate``, ``artifact upload``, ``verb`` and ``message send |
20
+ list`` (the Channel seam, PRD issue 52) and ``ask`` (a Question, issue 60). They read the
21
+ socket path and the bearer from the environment the Runner gave the Agent Runtime
22
+ subprocess, so inside a Directive they take no credentials and no endpoint. There is no
23
+ ``pipeline upload`` and no ``meta-data``: map ticket 26 §4 declines both.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import asyncio
30
+ import getpass
31
+ import json
32
+ import os
33
+ import sys
34
+ from collections.abc import Sequence
35
+ from pathlib import Path
36
+
37
+ from agentic_runner import __version__ as runner_version
38
+ from agentic_runner import service, workstation
39
+ from agentic_runner.callback import (
40
+ CALLBACK_SOCKET_ENV,
41
+ CALLBACK_TOKEN_ENV,
42
+ CallbackError,
43
+ call,
44
+ )
45
+ from agentic_runner.config import load as load_config
46
+ from agentic_runner.host_store import open_workstation_store
47
+ from agentic_runner.recipient_key_secret import ensure_recipient_key, in_cluster
48
+ from agentic_runner.registration import RunnerRegistrationError, can_separate_uids, load_state
49
+ from agentic_runner.sealed_box import RECIPIENT_KEY_FILENAME
50
+ from agentic_runner.service import (
51
+ AGENT_TOKEN_ENV,
52
+ CONTROL_PLANE_ENV,
53
+ ISOLATION_ENV,
54
+ RECIPIENT_KEY_ID_ENV,
55
+ RECIPIENT_PUBLIC_KEY_ENV,
56
+ TAGS_ENV,
57
+ parse_tags,
58
+ )
59
+ from agentic_runner_contracts import __version__ as contracts_version
60
+ from agentic_runner_contracts.runner_registration import (
61
+ HostAttestation,
62
+ InstallChannel,
63
+ SessionKind,
64
+ StoreKind,
65
+ )
66
+ from agentic_runner_contracts.swarm import ROLES
67
+
68
+ __all__ = [
69
+ "AGENT_TOKEN_ENV",
70
+ "CONTROL_PLANE_ENV",
71
+ "ISOLATION_ENV",
72
+ "RECIPIENT_KEY_ID_ENV",
73
+ "RECIPIENT_PUBLIC_KEY_ENV",
74
+ "STATE_DIR_ENV",
75
+ "TAGS_ENV",
76
+ "main",
77
+ "parse_tags",
78
+ "status_lines",
79
+ "version_line",
80
+ ]
81
+
82
+ # `AGENTIC_RUNNER_STATE_DIR`: the same env var `config.RunnerConfig.state_dir` reads, so
83
+ # the state directory has exactly one spelling (flag > env > file, map ticket 26 §2).
84
+ STATE_DIR_ENV = "AGENTIC_RUNNER_STATE_DIR"
85
+
86
+
87
+ def _state_dir() -> Path:
88
+ return load_config().state_dir
89
+
90
+
91
+ def status_lines() -> list[str]:
92
+ """What this installation is, including the fingerprint a funder compares (22 A4).
93
+
94
+ The console prints the same string beside every slot; a funder about to seal a value
95
+ reads it here, over the shoulder or over the phone, and checks the two match before
96
+ typing anything. That comparison is the only thing standing between a funder and
97
+ sealing to a key somebody else substituted.
98
+ """
99
+
100
+ state_dir = _state_dir()
101
+ state = load_state(state_dir)
102
+ # A read must not mint: `current()` writes a fresh key file on first call, which would
103
+ # leave a status probe on an unregistered box holding a key nobody registered.
104
+ configured = all(
105
+ os.environ.get(name, "").strip()
106
+ for name in (RECIPIENT_KEY_ID_ENV, RECIPIENT_PUBLIC_KEY_ENV)
107
+ )
108
+ fingerprint = (
109
+ service.recipient_key(state_dir)[1]
110
+ if configured or (state_dir / RECIPIENT_KEY_FILENAME).exists()
111
+ else "not generated yet (registration creates it)"
112
+ )
113
+ return [
114
+ version_line(),
115
+ f"recipient key {fingerprint}",
116
+ (
117
+ f"runner {state.runner_id} on {state.task_queue}"
118
+ if state is not None
119
+ else "runner not registered"
120
+ ),
121
+ ]
122
+
123
+
124
+ def version_line() -> str:
125
+ return f"agentic-runner {runner_version} (contracts {contracts_version})"
126
+
127
+
128
+ def main(argv: Sequence[str] | None = None) -> int:
129
+ parser = argparse.ArgumentParser(prog="agentic-runner", description=__doc__)
130
+ parser.add_argument(
131
+ "--version", action="store_true", help="print runner and contracts versions"
132
+ )
133
+ parser.add_argument("--config", default=None, help="path to the Runner config file")
134
+ parser.add_argument("--hooks-path", default=None, help="directory Runner Hooks are read from")
135
+ subcommands = parser.add_subparsers(dest="command")
136
+
137
+ subcommands.add_parser(
138
+ "register", help="exchange the Agent Token for this process's durable identity"
139
+ )
140
+
141
+ run = subcommands.add_parser("run", help="register if needed, then heartbeat and poll for work")
142
+ run.add_argument("--org", default=None, help="run an installed workstation Organisation")
143
+ run.add_argument("--root", default=None, type=Path, help=argparse.SUPPRESS)
144
+ run.add_argument("--login-agent", action="store_true", help=argparse.SUPPRESS)
145
+
146
+ install = subcommands.add_parser(
147
+ "install", help="register a workstation Runner for one Organisation (issue 47)"
148
+ )
149
+ install.add_argument("org")
150
+ install.add_argument(
151
+ "--control-plane", default=os.environ.get(CONTROL_PLANE_ENV), help="control plane URL"
152
+ )
153
+ install.add_argument(
154
+ "--temporal-address", required=True, help="host:port, e.g. temporal-grpc.<zone>:443"
155
+ )
156
+ install.add_argument(
157
+ "--temporal-plaintext",
158
+ action="store_true",
159
+ help="no TLS to Temporal -- a local development server only",
160
+ )
161
+ install.add_argument("--no-start", action="store_true", help="write the agent, do not start")
162
+ install.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
163
+ for service_verb in ("start", "stop"):
164
+ driven = subcommands.add_parser(
165
+ service_verb, help=f"{service_verb} one Organisation's login agent"
166
+ )
167
+ driven.add_argument("org")
168
+ driven.add_argument("--root", default=None, type=Path)
169
+
170
+ credential = subcommands.add_parser(
171
+ "credential", help="install a Credential Reference value into this host's store"
172
+ )
173
+ credential.add_argument("action", choices=("set",))
174
+ credential.add_argument("org")
175
+ credential.add_argument("reference")
176
+ credential.add_argument("--root", default=None, type=Path)
177
+
178
+ recipient = subcommands.add_parser(
179
+ "recipient-key", help="the installation's Recipient Key (PRD issue 48, 22 A4)"
180
+ )
181
+ recipient.add_argument("action", choices=("ensure-secret",))
182
+ recipient.add_argument(
183
+ "--secret", required=True, help="Kubernetes Secret holding the release's key"
184
+ )
185
+
186
+ status = subcommands.add_parser(
187
+ "status", help="print this installation's versions and Recipient Key fingerprint"
188
+ )
189
+ status.add_argument("org", nargs="?", help="a workstation Organisation (issue 47)")
190
+ status.add_argument("--root", default=None, type=Path)
191
+
192
+ annotate = subcommands.add_parser("annotate", help="attach an annotation to this Work Record")
193
+ annotate.add_argument("--context", default="default")
194
+ annotate.add_argument(
195
+ "--style", default="info", choices=("info", "success", "warning", "error")
196
+ )
197
+ annotate.add_argument("body", nargs="?", help="annotation body; omitted reads stdin")
198
+
199
+ artifact = subcommands.add_parser("artifact", help="offer a Workspace file as an artifact")
200
+ artifact.add_argument("action", choices=("upload",))
201
+ artifact.add_argument("path")
202
+ artifact.add_argument("--label", default="")
203
+
204
+ verb = subcommands.add_parser("verb", help="ask the Runner to evaluate one privileged verb")
205
+ verb.add_argument("name")
206
+ verb.add_argument("--resource", required=True)
207
+
208
+ message = subcommands.add_parser("message", help="post to, or read, a Channel (issue 52)")
209
+ message.add_argument("action", choices=("send", "list"))
210
+ message.add_argument("--channel", required=True, help="the Channel's id")
211
+ message.add_argument(
212
+ "--kind", default="note", choices=("note", "request", "result", "handoff", "verdict")
213
+ )
214
+ message.add_argument("--to", default=None, help="the addressee Agent's id (issue 53)")
215
+ message.add_argument(
216
+ "--role",
217
+ default=None,
218
+ choices=ROLES,
219
+ help="address a role instead of an Agent: a `request` wakes whichever Agent holds "
220
+ "it, a `handoff` transfers it to `--to`",
221
+ )
222
+ message.add_argument(
223
+ "--ref",
224
+ action="append",
225
+ default=[],
226
+ metavar="KIND=VALUE",
227
+ help="a reference by platform id: branch, pull_request, evidence_event, message, ...",
228
+ )
229
+ message.add_argument("body", nargs="?", help="markdown body for `send`; omitted reads stdin")
230
+
231
+ ask = subcommands.add_parser(
232
+ "ask", help="ask a human; the Work Record waits up to 24 h for the answer (issue 60)"
233
+ )
234
+ ask.add_argument("text", nargs="?", help="the question; omitted reads stdin")
235
+
236
+ subcommands.add_parser("config", help="print the resolved configuration (flag > env > file)")
237
+
238
+ arguments = parser.parse_args(argv)
239
+ if arguments.version:
240
+ print(version_line())
241
+ return 0
242
+ if arguments.command == "register":
243
+ try:
244
+ # A `contract_uid` Runner that cannot change uid fails closed here rather
245
+ # than silently sharing one uid between Contracts (17 A2).
246
+ _, outcome = asyncio.run(
247
+ service.register(state_dir=_state_dir(), can_separate_uids=can_separate_uids())
248
+ )
249
+ print(outcome)
250
+ except RunnerRegistrationError as error:
251
+ print(f"registration refused ({error.reason}): {error}")
252
+ return 1
253
+ return 0
254
+ if arguments.command == "run":
255
+ if arguments.org:
256
+ return workstation.run_process(_org_paths(arguments), login_agent=arguments.login_agent)
257
+ return asyncio.run(service.run(attestation=_image_attestation()))
258
+ if arguments.command in {"install", "start", "stop", "credential"}:
259
+ return _workstation(arguments)
260
+ if arguments.command == "recipient-key":
261
+ api, kube_ns = in_cluster()
262
+ pair = ensure_recipient_key(
263
+ api, secret_name=arguments.secret, kube_ns=kube_ns, state_dir=_state_dir()
264
+ )
265
+ print(
266
+ f"recipient key {pair.fingerprint} installed from Secret {kube_ns}/{arguments.secret}"
267
+ )
268
+ return 0
269
+ if arguments.command == "status":
270
+ lines = workstation.status_lines(_org_paths(arguments)) if arguments.org else status_lines()
271
+ for line in lines:
272
+ print(line)
273
+ return 0
274
+ if arguments.command == "config":
275
+ config = load_config(config_file=arguments.config, hooks_path=arguments.hooks_path)
276
+ print(config.model_dump_json(indent=2))
277
+ return 0
278
+ if arguments.command is None:
279
+ parser.print_help()
280
+ return 0
281
+ return _callback(arguments)
282
+
283
+
284
+ def _org_paths(arguments: argparse.Namespace) -> workstation.OrgPaths:
285
+ return workstation.OrgPaths(
286
+ root=arguments.root or workstation.default_root(),
287
+ org=workstation.validate_org(arguments.org),
288
+ )
289
+
290
+
291
+ def _workstation(arguments: argparse.Namespace) -> int:
292
+ paths = _org_paths(arguments)
293
+ try:
294
+ if arguments.command == "install":
295
+ return _install(arguments, paths)
296
+ if arguments.command == "start":
297
+ workstation.start(paths)
298
+ print(f"started {paths.org}")
299
+ return 0
300
+ if arguments.command == "stop":
301
+ workstation.stop(paths)
302
+ print(f"stopped {paths.org}; every other Organisation's Runner keeps polling")
303
+ return 0
304
+ # The value arrives on stdin so it is never in argv or shell history.
305
+ store = open_workstation_store(paths.org, fallback=paths.credentials)
306
+ store.put(arguments.reference, sys.stdin.read().strip())
307
+ print(f"{arguments.reference} stored in the {store.kind.value} store")
308
+ return 0
309
+ except (FileNotFoundError, RuntimeError, ValueError) as error:
310
+ print(error, file=sys.stderr)
311
+ return 1
312
+
313
+
314
+ def _image_attestation() -> HostAttestation:
315
+ """What a container Runner says about itself, where issue 47 gave the workstation its
316
+ own: the CLIs it found are what the registry serves and routing matches on."""
317
+
318
+ return workstation.collect_attestation(
319
+ channel=(
320
+ InstallChannel.HELM
321
+ if os.environ.get("KUBERNETES_SERVICE_HOST")
322
+ else InstallChannel.MANUAL
323
+ ),
324
+ session=SessionKind.CONTAINER,
325
+ store=StoreKind.FILE if load_config().credential_store is not None else StoreKind.NONE,
326
+ path=os.environ.get("PATH", ""),
327
+ )
328
+
329
+
330
+ def _install(arguments: argparse.Namespace, paths: workstation.OrgPaths) -> int:
331
+ if not arguments.control_plane:
332
+ print(f"--control-plane (or {CONTROL_PLANE_ENV}) is required", file=sys.stderr)
333
+ return 2
334
+ # Pasted, never a flag: a flag is in the process table for every uid on the box.
335
+ token = os.environ.get(AGENT_TOKEN_ENV, "").strip() or getpass.getpass(
336
+ "Agent Token (issued to you by the Organisation's Admin): "
337
+ )
338
+ settings = workstation.WorkstationSettings(
339
+ org=paths.org,
340
+ control_plane_url=arguments.control_plane,
341
+ temporal_address=arguments.temporal_address,
342
+ temporal_tls=not arguments.temporal_plaintext,
343
+ path=os.environ.get("PATH", ""),
344
+ install_channel=workstation.install_channel(),
345
+ )
346
+ try:
347
+ lines = asyncio.run(
348
+ workstation.install(
349
+ paths, settings, agent_token=token, start_service=not arguments.no_start
350
+ )
351
+ )
352
+ except (workstation.CliMissingError, RunnerRegistrationError, RuntimeError) as error:
353
+ print(f"install refused: {error}", file=sys.stderr)
354
+ return 1
355
+ for line in lines:
356
+ print(line)
357
+ return 0
358
+
359
+
360
+ def _callback(arguments: argparse.Namespace) -> int:
361
+ socket_path = os.environ.get(CALLBACK_SOCKET_ENV, "")
362
+ token = os.environ.get(CALLBACK_TOKEN_ENV, "")
363
+ if not socket_path or not token:
364
+ print(
365
+ f"{CALLBACK_SOCKET_ENV} and {CALLBACK_TOKEN_ENV} are set only inside a Directive",
366
+ file=sys.stderr,
367
+ )
368
+ return 2
369
+ path, payload = _request(arguments)
370
+ try:
371
+ answer = call(socket_path=socket_path, token=token, path=path, payload=payload)
372
+ except CallbackError as error:
373
+ print(error.detail, file=sys.stderr)
374
+ return 1
375
+ print(json.dumps(answer))
376
+ return 0
377
+
378
+
379
+ def _request(arguments: argparse.Namespace) -> tuple[str, dict[str, object]]:
380
+ if arguments.command == "annotate":
381
+ body = arguments.body if arguments.body is not None else sys.stdin.read()
382
+ return "/v0/annotate", {
383
+ "context": arguments.context,
384
+ "body": body,
385
+ "style": arguments.style,
386
+ }
387
+ if arguments.command == "artifact":
388
+ return "/v0/artifact", {"path": arguments.path, "label": arguments.label}
389
+ if arguments.command == "ask":
390
+ return "/v0/ask", {
391
+ "text": arguments.text if arguments.text is not None else sys.stdin.read()
392
+ }
393
+ if arguments.command == "message":
394
+ if arguments.action == "list":
395
+ return "/v0/message/list", {"channel_id": arguments.channel}
396
+ body = arguments.body if arguments.body is not None else sys.stdin.read()
397
+ return "/v0/message/send", {
398
+ "channel_id": arguments.channel,
399
+ "kind": arguments.kind,
400
+ "body": body,
401
+ "references": [_reference(item) for item in arguments.ref],
402
+ "recipient_agent_id": arguments.to,
403
+ "recipient_role": arguments.role,
404
+ }
405
+ return "/v0/verb", {"verb": arguments.name, "resource": arguments.resource}
406
+
407
+
408
+ def _reference(item: str) -> dict[str, str]:
409
+ kind, separator, value = item.partition("=")
410
+ if not separator or not kind or not value:
411
+ raise SystemExit(f"--ref expects KIND=VALUE, got {item!r}")
412
+ return {"kind": kind, "value": value}
413
+
414
+
415
+ if __name__ == "__main__": # pragma: no cover - exercised through the console script
416
+ raise SystemExit(main())
@@ -0,0 +1,105 @@
1
+ """The Runner's operator configuration, with the precedence rule written down.
2
+
3
+ **flag > env > config file**, for every setting (map ticket 26 §2). Buildkite never
4
+ documented its own order and it took reading the loader's source to establish it; that is
5
+ itself the finding, so ours is documented on day one, in the module that implements it,
6
+ and pinned by ``tests/unit/test_runner_config_precedence.py``.
7
+
8
+ Deliberately small. 26 §4 works out that the Runner's config surface collapses to the
9
+ paths and the log level: it has no plugins to switch off, no repository hooks, and no
10
+ operator-typed command — the Directive is dispatched, not scripted — and its endpoint,
11
+ namespace and task queues are delivered at bootstrap and never typed. ``WorkerSettings``
12
+ remains the pod's env-only surface for the Temporal/FastAPI wiring; this is the surface a
13
+ workstation Runner (PRD issue 47) and the Helm chart (issue 46) actually fill.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import tomllib
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+ from typing import Any, Final
23
+
24
+ from pydantic import BaseModel, ConfigDict
25
+
26
+ __all__ = ["CONFIG_FILE_ENV", "DEFAULT_CONFIG_FILE", "ENV_PREFIX", "RunnerConfig", "load"]
27
+
28
+ ENV_PREFIX: Final[str] = "AGENTIC_RUNNER_"
29
+ CONFIG_FILE_ENV: Final[str] = f"{ENV_PREFIX}CONFIG"
30
+ DEFAULT_CONFIG_FILE: Final[Path] = Path("/etc/agentic-runner/config.toml")
31
+
32
+
33
+ class RunnerConfig(BaseModel):
34
+ """What an operator may set on a Runner."""
35
+
36
+ model_config = ConfigDict(frozen=True, extra="forbid")
37
+
38
+ # Where Runner Hooks are installed: a ConfigMap mounted with `defaultMode: 0755` on
39
+ # Kubernetes (PRD issue 46), a directory baked into an org's layered image, or
40
+ # `<state_dir>/<org>/hooks/` on a workstation Runner (issue 47). Unset means no hooks.
41
+ hooks_path: Path | None = None
42
+ workspace_root: Path = Path("/var/lib/agentic-os/workspaces")
43
+ state_dir: Path = Path("/var/lib/agentic-os/state")
44
+ # Per-attempt callback sockets. Its own short root because `sun_path` is ~104 bytes
45
+ # and a Workspace path is already past that (`callback.MAX_SOCKET_PATH_BYTES`).
46
+ socket_dir: Path = Path("/run/agentic-runner")
47
+ # Where the host operator installed the Credential Reference values: a Secret mounted
48
+ # `0400` on Kubernetes (PRD issue 46), a directory on a workstation Runner (issue 47).
49
+ # One file per reference, named after it (`credentials.DirectoryCredentialStore`).
50
+ # Unset is fail-closed rather than permissive: a Contract that declares a manifest
51
+ # fails its Directives with Evidence naming the reference (22 A1), instead of running
52
+ # without the credential it was told to use.
53
+ credential_store: Path | None = None
54
+ log_level: str = "info"
55
+
56
+
57
+ def load(
58
+ *,
59
+ config_file: Path | None = None,
60
+ environ: Mapping[str, str] | None = None,
61
+ **flags: object,
62
+ ) -> RunnerConfig:
63
+ """Resolve the Runner's settings: flag > env > config file.
64
+
65
+ ``flags`` are the parsed command line — a ``None`` there means "not passed", so an
66
+ unset flag falls through to the env var and then to the file rather than overwriting
67
+ them with a default.
68
+ """
69
+
70
+ env = os.environ if environ is None else environ
71
+ path = config_file or _path_from_env(env) or DEFAULT_CONFIG_FILE
72
+ values: dict[str, Any] = _from_file(path)
73
+ values.update(_from_env(env))
74
+ values.update({name: value for name, value in flags.items() if value is not None})
75
+ return RunnerConfig(**values)
76
+
77
+
78
+ def _path_from_env(env: Mapping[str, str]) -> Path | None:
79
+ raw = env.get(CONFIG_FILE_ENV, "").strip()
80
+ return Path(raw) if raw else None
81
+
82
+
83
+ def _from_file(path: Path) -> dict[str, Any]:
84
+ """The config file's keys, ignoring any this release does not know.
85
+
86
+ Unknown keys are dropped rather than refused: a file is shared across a fleet an
87
+ operator upgrades one Runner at a time, and a key added for a newer Runner must not
88
+ stop an older one from starting. A *flag* or env var is the opposite — it was typed
89
+ for this process, so `extra="forbid"` on the model surfaces the typo.
90
+ """
91
+
92
+ if not path.is_file():
93
+ return {}
94
+ with path.open("rb") as handle:
95
+ loaded = tomllib.load(handle)
96
+ known = set(RunnerConfig.model_fields)
97
+ return {key: value for key, value in loaded.items() if key in known}
98
+
99
+
100
+ def _from_env(env: Mapping[str, str]) -> dict[str, Any]:
101
+ return {
102
+ field: env[f"{ENV_PREFIX}{field.upper()}"]
103
+ for field in RunnerConfig.model_fields
104
+ if env.get(f"{ENV_PREFIX}{field.upper()}", "").strip()
105
+ }