flashnode 0.3.3__tar.gz → 0.3.4__tar.gz

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 (66) hide show
  1. {flashnode-0.3.3 → flashnode-0.3.4}/PKG-INFO +2 -2
  2. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/cli.py +21 -2
  3. flashnode-0.3.4/flashnode/executor/trusted_runner.py +83 -0
  4. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/capabilities.py +4 -1
  5. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/PKG-INFO +2 -2
  6. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/SOURCES.txt +3 -0
  7. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/requires.txt +1 -1
  8. {flashnode-0.3.3 → flashnode-0.3.4}/pyproject.toml +15 -2
  9. flashnode-0.3.4/tests/test_cli_trusted.py +74 -0
  10. flashnode-0.3.4/tests/test_trusted_runner.py +92 -0
  11. {flashnode-0.3.3 → flashnode-0.3.4}/LICENSE +0 -0
  12. {flashnode-0.3.3 → flashnode-0.3.4}/README.md +0 -0
  13. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/__init__.py +0 -0
  14. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/__init__.py +0 -0
  15. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/daemon.py +0 -0
  16. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/agent/kube.py +0 -0
  17. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/artifacts/__init__.py +0 -0
  18. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/benchmark/__init__.py +0 -0
  19. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/config/__init__.py +0 -0
  20. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/config/local_data.py +0 -0
  21. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/doctor.py +0 -0
  22. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/__init__.py +0 -0
  23. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/archives.py +0 -0
  24. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/argv_runner.py +0 -0
  25. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/client.py +0 -0
  26. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/docker_runner.py +0 -0
  27. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/evidence.py +0 -0
  28. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/hardening.py +0 -0
  29. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/images.py +0 -0
  30. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/loop.py +0 -0
  31. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/executor/runner.py +0 -0
  32. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/__init__.py +0 -0
  33. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/credentials.py +0 -0
  34. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/enrol.py +0 -0
  35. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/identity/store.py +0 -0
  36. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/__init__.py +0 -0
  37. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/inventory/gpu.py +0 -0
  38. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/status.py +0 -0
  39. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode/telemetry/__init__.py +0 -0
  40. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/dependency_links.txt +0 -0
  41. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/entry_points.txt +0 -0
  42. {flashnode-0.3.3 → flashnode-0.3.4}/flashnode.egg-info/top_level.txt +0 -0
  43. {flashnode-0.3.3 → flashnode-0.3.4}/setup.cfg +0 -0
  44. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_agent.py +0 -0
  45. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_allowlist_drift.py +0 -0
  46. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_archives.py +0 -0
  47. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_argv_runner.py +0 -0
  48. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_capabilities.py +0 -0
  49. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_checkpoint_relay.py +0 -0
  50. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_credentials.py +0 -0
  51. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_docker_runner.py +0 -0
  52. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_doctor.py +0 -0
  53. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_enrol.py +0 -0
  54. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_evidence.py +0 -0
  55. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_executor.py +0 -0
  56. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_gpu_probe.py +0 -0
  57. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_hardening.py +0 -0
  58. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_hardening_platform.py +0 -0
  59. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_identity.py +0 -0
  60. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_images_allowlist.py +0 -0
  61. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_interfaces.py +0 -0
  62. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_local_data.py +0 -0
  63. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_loop_counters.py +0 -0
  64. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_status.py +0 -0
  65. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_work_gate.py +0 -0
  66. {flashnode-0.3.3 → flashnode-0.3.4}/tests/test_work_status.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: flashruntime<0.5,>=0.4.2
10
+ Requires-Dist: flashruntime<0.5,>=0.4.3
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -174,9 +174,10 @@ def _work(args: list[str]) -> int:
174
174
  )
175
175
  parser.add_argument(
176
176
  "--runner",
177
- choices=["subprocess", "docker", "argv"],
177
+ choices=["subprocess", "docker", "argv", "trusted"],
178
178
  default=os.environ.get("FLASHNODE_RUNNER", "subprocess"),
179
- help="task execution tier (docker/argv need the docker CLI on PATH)",
179
+ help="task execution tier (docker/argv need the docker CLI on PATH; "
180
+ "trusted runs pool jobs unsandboxed — opt-in only)",
180
181
  )
181
182
  parser.add_argument("--max-tasks", type=int, default=None)
182
183
  parser.add_argument("--poll-seconds", type=float, default=1.0)
@@ -260,6 +261,19 @@ def _work(args: list[str]) -> int:
260
261
  os.environ.get("FLASHNODE_MAX_OUTPUT_BYTES", str(2 * 1024**3))
261
262
  ),
262
263
  )
264
+ elif opts.runner == "trusted":
265
+ # Deliberately OUTSIDE the ("docker", "argv") branch above: this
266
+ # tier has no container and no docker CLI dependency, so the docker
267
+ # doctor gate has nothing to check — same as subprocess. The only
268
+ # gate here is the operator typing --runner trusted at all.
269
+ from flashnode.executor.trusted_runner import TrustedArgvRunner
270
+
271
+ print(
272
+ "trusted runner: pool jobs from your team run UNSANDBOXED on this\n"
273
+ "machine — no container, no network isolation. Only continue if\n"
274
+ "every member of your pool is someone you trust to run code here."
275
+ )
276
+ runner = TrustedArgvRunner()
263
277
 
264
278
  workdir_base = os.environ.get("FLASHNODE_WORKDIR") or None
265
279
 
@@ -280,6 +294,11 @@ def _work(args: list[str]) -> int:
280
294
  # tasks burn every attempt against ArgvDockerRunner's payload
281
295
  # rejection before the job ever fails for real.
282
296
  module_capable=(opts.runner != "argv"),
297
+ # Set true ONLY by the explicit --runner trusted opt-in, never
298
+ # inferred — the scheduler's placement gate treats this field as
299
+ # fail-closed (flashruntime.scheduler), so a node that merely has a
300
+ # shell available must not advertise it.
301
+ unsandboxed_argv_capable=(opts.runner == "trusted"),
283
302
  )
284
303
  client.register(registration)
285
304
  from flashnode.doctor import NON_BLOCKING_STATUSES, run_checks
@@ -0,0 +1,83 @@
1
+ """Trusted-pool argv execution: no container, no sandbox, by explicit
2
+ operator opt-in only.
3
+
4
+ This runner exists for hosts that CANNOT run Docker — Colab notebooks and
5
+ provider pods are themselves containers — inside a team pool whose members
6
+ chose to trust each other. It is not a security boundary and never claims
7
+ to be: the placement contract (pool + allowFallback + the operator's
8
+ --runner trusted opt-in) is what keeps strangers' code away from it.
9
+
10
+ Same interface as SubprocessRunner/ArgvDockerRunner:
11
+ run(payload, workdir, inputs) -> outdir.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from flashnode.executor.runner import TaskExecutionError, task_env
20
+
21
+ _CONTAINER_WORKDIR = "/work"
22
+
23
+
24
+ class TrustedArgvRunner:
25
+ def __init__(self, timeout_seconds: float = 600.0):
26
+ self.timeout_seconds = timeout_seconds
27
+ # Evidence attributes, same contract and same reset rule as
28
+ # SubprocessRunner: a stale value is a measurement of a DIFFERENT
29
+ # run wearing this one's name.
30
+ self.last_exit_code: int | None = None
31
+ #: Always "" — no image bytes executed; echoing the payload's image
32
+ #: reference would claim a container ran when none did.
33
+ self.last_image_digest: str = ""
34
+
35
+ def run(self, payload: dict, workdir: Path, inputs: dict[str, Path]) -> Path:
36
+ self.last_exit_code = None
37
+ workdir = Path(workdir)
38
+ argv = payload.get("argv")
39
+ if not isinstance(argv, list) or not argv or not all(
40
+ isinstance(a, str) for a in argv
41
+ ):
42
+ raise TaskExecutionError(
43
+ "trusted runner requires a payload with a string-list 'argv'"
44
+ )
45
+ # Rewrite /work-prefixed TOKENS onto the real workdir. Token-wise,
46
+ # never substring: an argument that merely contains "/work" belongs
47
+ # to the submitter. The compiled argv uses /work because the docker
48
+ # runners bind the workdir there; this runner has no container, so
49
+ # /work is a naming convention to honour, not a mount to make.
50
+ rewritten = [
51
+ str(workdir) + a[len(_CONTAINER_WORKDIR):]
52
+ if a == _CONTAINER_WORKDIR or a.startswith(_CONTAINER_WORKDIR + "/")
53
+ else a
54
+ for a in argv
55
+ ]
56
+ outdir = workdir / "out"
57
+ outdir.mkdir(parents=True, exist_ok=True)
58
+ try:
59
+ proc = subprocess.run(
60
+ rewritten,
61
+ cwd=workdir,
62
+ env=task_env(),
63
+ timeout=self.timeout_seconds,
64
+ capture_output=True,
65
+ )
66
+ except subprocess.TimeoutExpired as exc:
67
+ raise TaskExecutionError(
68
+ f"task exceeded {self.timeout_seconds}s"
69
+ ) from exc
70
+ except OSError as exc:
71
+ raise TaskExecutionError(f"could not start task: {exc}") from exc
72
+ self.last_exit_code = proc.returncode
73
+ if proc.returncode != 0:
74
+ tail = proc.stderr.decode(errors="replace")[-2000:]
75
+ raise TaskExecutionError(
76
+ f"task exited {proc.returncode}: {tail}"
77
+ )
78
+ if not (outdir / "metrics.json").is_file():
79
+ # Same rule as both sibling runners: an exit-0 workload that
80
+ # wrote no metrics is a task failure HERE, attributably — not a
81
+ # mysterious commit rejection three hops later.
82
+ raise TaskExecutionError("task produced no metrics.json — nothing to commit")
83
+ return outdir
@@ -62,7 +62,8 @@ def _parse_k8s_memory(value: str) -> int:
62
62
  def discover(node_id: str, kubernetes_node: str,
63
63
  node_meta: dict | None = None,
64
64
  argv_capable: bool = False,
65
- module_capable: bool = True) -> NodeRegistration:
65
+ module_capable: bool = True,
66
+ unsandboxed_argv_capable: bool = False) -> NodeRegistration:
66
67
  """Build the registration payload. `node_meta` is the Kubernetes Node
67
68
  object (status/metadata) when the agent has API access; None degrades to
68
69
  host-level probes only."""
@@ -127,6 +128,8 @@ def discover(node_id: str, kubernetes_node: str,
127
128
  # coordinator's module gate is fail-open (unlike argv_capable), so
128
129
  # the default here matches every caller that doesn't pass it.
129
130
  module_capable=module_capable,
131
+ # Set by the agent only for `--runner trusted` — never inferred.
132
+ unsandboxed_argv_capable=unsandboxed_argv_capable,
130
133
  # The LABELS of the datasets this host owner lends to tasks
131
134
  # (FLASHNODE_LOCAL_DATA) — never the paths. The coordinator needs the
132
135
  # names to place a job that requires `patients` on a machine that has
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashnode
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/Zolli-Labs/flashnode
7
7
  Requires-Python: >=3.10
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
- Requires-Dist: flashruntime<0.5,>=0.4.2
10
+ Requires-Dist: flashruntime<0.5,>=0.4.3
11
11
  Requires-Dist: psutil>=5.9
12
12
  Requires-Dist: websockets>=12
13
13
  Requires-Dist: cryptography>=42
@@ -28,6 +28,7 @@ flashnode/executor/hardening.py
28
28
  flashnode/executor/images.py
29
29
  flashnode/executor/loop.py
30
30
  flashnode/executor/runner.py
31
+ flashnode/executor/trusted_runner.py
31
32
  flashnode/identity/__init__.py
32
33
  flashnode/identity/credentials.py
33
34
  flashnode/identity/enrol.py
@@ -42,6 +43,7 @@ tests/test_archives.py
42
43
  tests/test_argv_runner.py
43
44
  tests/test_capabilities.py
44
45
  tests/test_checkpoint_relay.py
46
+ tests/test_cli_trusted.py
45
47
  tests/test_credentials.py
46
48
  tests/test_docker_runner.py
47
49
  tests/test_doctor.py
@@ -57,5 +59,6 @@ tests/test_interfaces.py
57
59
  tests/test_local_data.py
58
60
  tests/test_loop_counters.py
59
61
  tests/test_status.py
62
+ tests/test_trusted_runner.py
60
63
  tests/test_work_gate.py
61
64
  tests/test_work_status.py
@@ -1,4 +1,4 @@
1
- flashruntime<0.5,>=0.4.2
1
+ flashruntime<0.5,>=0.4.3
2
2
  psutil>=5.9
3
3
  websockets>=12
4
4
  cryptography>=42
@@ -15,7 +15,12 @@ name = "flashnode"
15
15
  # flashruntime 0.4.2's ExecutionEvidence — see the floor below. Bumped even
16
16
  # though nothing breaks: an agent whose wire behaviour differs must not share
17
17
  # a version string with one that predates it.
18
- version = "0.3.3"
18
+ # 0.3.4 adds `--runner trusted` (TrustedArgvRunner): unsandboxed argv
19
+ # execution, opt-in only, for hosts that cannot run Docker (Colab, provider
20
+ # pods) inside a team pool whose members trust each other. `discover()` now
21
+ # passes `unsandboxed_argv_capable` to NodeRegistration — see the floor
22
+ # below for why that moves the pin in this commit, not just at tag time.
23
+ version = "0.3.4"
19
24
  description = "Open host agent for the FlashML fragmented-compute network: join, benchmark, execute sandboxed ML tasks, earn contribution credits."
20
25
  readme = "README.md"
21
26
  license = { text = "Apache-2.0" }
@@ -49,7 +54,15 @@ dependencies = [
49
54
  # not exist in the published 0.4.1 wheel. The agent raises ImportError and
50
55
  # never registers at all. The release workflow's `resolvable` job runs
51
56
  # `flashnode --help` against PyPI alone precisely to catch this.
52
- "flashruntime>=0.4.2,<0.5",
57
+ # Raised again to >=0.4.3 for `--runner trusted`: `discover()` now passes
58
+ # `unsandboxed_argv_capable` to NodeRegistration, a field 0.4.2's model
59
+ # does not declare. Unlike the two floors above, this is not an
60
+ # import-time ImportError — it is pydantic refusing an unknown kwarg at
61
+ # CALL time, inside `flashnode work`, on every runner tier, not just
62
+ # trusted. Same import-time-hard-failure shape as 0.3.2/0.3.3 in effect
63
+ # (the agent never registers), so the floor moves in this commit, not at
64
+ # tag time.
65
+ "flashruntime>=0.4.3,<0.5",
53
66
  "psutil>=5.9",
54
67
  "websockets>=12",
55
68
  "cryptography>=42",
@@ -0,0 +1,74 @@
1
+ """--runner trusted: the opt-in wiring. The flag is the ONLY path to
2
+ unsandboxed_argv_capable=True — discover() must default it False for every
3
+ other caller (security fields fail closed)."""
4
+
5
+ from flashnode.inventory.capabilities import discover
6
+
7
+
8
+ def test_discover_defaults_unsandboxed_argv_capable_false():
9
+ reg = discover("n-1", kubernetes_node="")
10
+ assert reg.unsandboxed_argv_capable is False
11
+
12
+
13
+ def test_discover_can_opt_in():
14
+ reg = discover("n-1", kubernetes_node="", unsandboxed_argv_capable=True)
15
+ assert reg.unsandboxed_argv_capable is True
16
+ assert reg.argv_capable is False # trusted is NOT the containerised contract
17
+
18
+
19
+ def test_trusted_runner_selected_for_runner_trusted(monkeypatch, tmp_path, capsys):
20
+ """--runner trusted must:
21
+ - build a TrustedArgvRunner (passed to ExecutorLoop)
22
+ - register with unsandboxed_argv_capable=True, argv_capable=False
23
+ - NEVER invoke the docker doctor gate (trusted is outside the
24
+ ("docker", "argv") branch, same as subprocess)
25
+ - print a plain-words warning that pool jobs run unsandboxed
26
+
27
+ Mirrors test_agent.py::test_work_cli_sets_module_capable_from_runner_choice.
28
+ """
29
+ import flashnode.inventory.capabilities as capabilities_mod
30
+ from flashnode.agent import cli
31
+ from flashnode.executor import CoordinatorClient, ExecutorLoop
32
+ from flashnode.executor.trusted_runner import TrustedArgvRunner
33
+
34
+ monkeypatch.setenv("FLASHNODE_STATE_DIR", str(tmp_path))
35
+
36
+ # The docker doctor gate must not run for --runner trusted. Rather than
37
+ # counting calls, make one explode: any invocation fails the test loudly
38
+ # and precisely where it happened.
39
+ def _boom(**kw):
40
+ raise AssertionError("doctor.run_checks must not run for --runner trusted")
41
+
42
+ monkeypatch.setattr("flashnode.doctor.run_checks", _boom)
43
+
44
+ captured = {}
45
+ real_discover = capabilities_mod.discover
46
+
47
+ def spy_discover(*args, **kwargs):
48
+ reg = real_discover(*args, **kwargs)
49
+ captured["registration"] = reg
50
+ return reg
51
+
52
+ monkeypatch.setattr(capabilities_mod, "discover", spy_discover)
53
+ monkeypatch.setattr(CoordinatorClient, "register", lambda self, reg: None)
54
+
55
+ real_init = ExecutorLoop.__init__
56
+
57
+ def spy_init(self, *args, **kwargs):
58
+ captured["runner"] = kwargs.get("runner")
59
+ return real_init(self, *args, **kwargs)
60
+
61
+ monkeypatch.setattr(ExecutorLoop, "__init__", spy_init)
62
+ monkeypatch.setattr(ExecutorLoop, "run", lambda self, max_tasks=None: 0)
63
+
64
+ rc = cli.main(["work", "--runner", "trusted", "--coordinator", "http://localhost:1"])
65
+
66
+ assert rc == 0
67
+ assert isinstance(captured["runner"], TrustedArgvRunner)
68
+
69
+ reg = captured["registration"]
70
+ assert reg.unsandboxed_argv_capable is True
71
+ assert reg.argv_capable is False
72
+
73
+ out = capsys.readouterr().out
74
+ assert "unsandboxed" in out.lower()
@@ -0,0 +1,92 @@
1
+ """TrustedArgvRunner: pool argv payloads, no container, /work rewritten.
2
+
3
+ The compiled argv names CONTAINER paths (`python /work/inputs/code/train.py`
4
+ — the docker runners bind the workdir at /work). Here there is no container,
5
+ so every argv token starting with /work is rewritten onto the real workdir.
6
+ Rewriting tokens, not substrings: an argument that merely CONTAINS "/work"
7
+ is the submitter's business.
8
+ """
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from flashnode.executor.runner import TaskExecutionError
16
+ from flashnode.executor.trusted_runner import TrustedArgvRunner
17
+
18
+
19
+ def _payload(argv):
20
+ return {"argv": argv, "isolation": {"tier": "sandboxed", "allowFallback": True},
21
+ "pool": "p-1"}
22
+
23
+
24
+ def test_runs_the_argv_with_work_prefix_rewritten(tmp_path):
25
+ workdir = tmp_path
26
+ code = workdir / "inputs" / "code"
27
+ code.mkdir(parents=True)
28
+ (code / "train.py").write_text(
29
+ "import json, pathlib, sys\n"
30
+ "out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)\n"
31
+ "(out / 'metrics.json').write_text(json.dumps({'ok': True}))\n"
32
+ )
33
+ runner = TrustedArgvRunner()
34
+ outdir = runner.run(
35
+ _payload(["python", "/work/inputs/code/train.py", "/work/out"]),
36
+ workdir, {"code": code},
37
+ )
38
+ assert json.loads((outdir / "metrics.json").read_text()) == {"ok": True}
39
+ assert runner.last_exit_code == 0
40
+
41
+
42
+ def test_refuses_a_payload_without_argv(tmp_path):
43
+ with pytest.raises(TaskExecutionError):
44
+ TrustedArgvRunner().run({"module": "flashml_workloads.sklearn_trial"},
45
+ tmp_path, {})
46
+
47
+
48
+ def test_refuses_a_non_list_argv(tmp_path):
49
+ with pytest.raises(TaskExecutionError):
50
+ TrustedArgvRunner().run(_payload("python /work/x.py"), tmp_path, {})
51
+
52
+
53
+ def test_nonzero_exit_raises_and_records_the_code(tmp_path):
54
+ (tmp_path / "inputs").mkdir()
55
+ runner = TrustedArgvRunner()
56
+ with pytest.raises(TaskExecutionError):
57
+ runner.run(_payload(["python", "-c", "raise SystemExit(3)"]), tmp_path, {})
58
+ assert runner.last_exit_code == 3
59
+
60
+
61
+ def test_environment_is_scrubbed(tmp_path, monkeypatch):
62
+ """The task must not inherit the agent's secrets — same whitelist as
63
+ SubprocessRunner (task_env). The out path travels as an argv token so
64
+ the /work rewrite applies to it."""
65
+ monkeypatch.setenv("FLASHNODE_MACHINE_TOKEN", "fmk_secret")
66
+ probe = tmp_path / "probe.py"
67
+ probe.write_text(
68
+ "import json, os, pathlib, sys\n"
69
+ "out = pathlib.Path(sys.argv[1]); out.mkdir(parents=True, exist_ok=True)\n"
70
+ "(out / 'env.txt').write_text(str('FLASHNODE_MACHINE_TOKEN' in os.environ))\n"
71
+ "(out / 'metrics.json').write_text(json.dumps({'env_scrubbed': True}))\n"
72
+ )
73
+ runner = TrustedArgvRunner()
74
+ runner.run(_payload(["python", str(probe), "/work/out"]), tmp_path, {})
75
+ assert (tmp_path / "out" / "env.txt").read_text() == "False"
76
+
77
+
78
+ def test_exit_zero_without_metrics_json_raises(tmp_path):
79
+ """Mirrors SubprocessRunner/ArgvDockerRunner: metrics.json is
80
+ load-bearing; exit 0 without it is a task error, not a success."""
81
+ probe = tmp_path / "probe.py"
82
+ probe.write_text("print('did nothing')\n")
83
+ runner = TrustedArgvRunner()
84
+ with pytest.raises(TaskExecutionError, match="metrics.json"):
85
+ runner.run(_payload(["python", str(probe)]), tmp_path, {})
86
+ assert runner.last_exit_code == 0
87
+
88
+
89
+ def test_image_digest_is_always_empty():
90
+ """No container ran; claiming an image digest would be fabricated
91
+ evidence (the SubprocessRunner rule)."""
92
+ assert TrustedArgvRunner().last_image_digest == ""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes