harbor-glovebox 0.1.0__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.
@@ -0,0 +1,192 @@
1
+ .worktrees
2
+ .claude/worktrees/
3
+ .agents/
4
+ .codex/
5
+
6
+ # vm agent-entrypoint boot trace — a runtime log appended into the workspace by
7
+ # gb_boot_trace; a diagnostic artifact, never committed.
8
+ .gb-agent-boot-trace
9
+
10
+ # Dependencies
11
+ # Tool-managed dirs use the BARE name (no trailing slash). A `foo/` pattern
12
+ # matches only a *directory*, so a symlink named `foo` — e.g. one linked into a
13
+ # git worktree to run tests — slips past it and `git add -A` stages it (how a
14
+ # node_modules symlink twice reached CI this repo's history). The bare name
15
+ # matches the dir AND a same-named symlink/file, so tool output can't be
16
+ # committed by accident. Only applied to names that are never a legitimately
17
+ # tracked file (dependency/venv/cache/report dirs); generic names like dist/ or
18
+ # build/ keep the trailing slash so they don't over-ignore a real file.
19
+ node_modules
20
+ .pnpm-store
21
+
22
+ # Build outputs
23
+ dist/
24
+ build/
25
+ out/
26
+
27
+ # Environment files
28
+ .env
29
+ .env.local
30
+ .env.*.local
31
+
32
+ # IDE
33
+ .idea/
34
+ .vscode/
35
+ *.swp
36
+ *.swo
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Logs
43
+ *.log
44
+ npm-debug.log*
45
+ pnpm-debug.log*
46
+
47
+ # Coverage
48
+ coverage/
49
+ coverage.json
50
+ .c8-output
51
+ .coverage
52
+ .coverage.*
53
+ .py-coverage.lock
54
+ htmlcov
55
+
56
+ # Mutation testing (Stryker): sandbox, HTML report, and the incremental cache
57
+ .stryker-tmp
58
+ reports/
59
+
60
+ # Generated LaunchAgent (rendered from the .template by setup.bash at install time)
61
+ launchagents/*.generated.plist
62
+
63
+ # Python
64
+ __pycache__
65
+ *.pyc
66
+ .venv
67
+ .uv
68
+ .pytest_cache
69
+
70
+ # `uv run` inside inspect-glovebox/, glovebox-monitor/ or glovebox-driver/ writes a lock
71
+ # beside that pyproject.toml. Each wheel is a library, so it pins nothing; committing one
72
+ # adds a `uv.lock -merge` path that scripts/lib-regen-rules.mjs owns no rule for, which
73
+ # reds the JS suite.
74
+ /inspect-glovebox/uv.lock
75
+ /glovebox-monitor/uv.lock
76
+ /glovebox-driver/uv.lock
77
+ # pytest scratch (repo-relative --basetemp): throwaway fixture files, including
78
+ # fake executables the suite writes (keyprobe, docker stubs). Ignored so they never
79
+ # surface as uncommitted changes or get captured onto a session-end review branch.
80
+ .pytest-tmp
81
+
82
+ # Fuzzer crash reproducers (jazzer.js / atheris drop these in the repo root, which
83
+ # is their cwd). Anchored with a leading slash: an unanchored `crash-*` matches at
84
+ # ANY depth, so it silently swallowed bin/checks/vm/crash-resilience-int.bash — a
85
+ # `git add` of a legitimately-named source file that reports success and stages
86
+ # nothing.
87
+ /crash-*
88
+ /timeout-*
89
+ /oom-*
90
+ /leak-*
91
+
92
+ # History working files — each lives on the perf-history or ci-timings branch and is
93
+ # seeded into the checkout by persist-perf-history.sh read or record-setup-timing.sh;
94
+ # never committed to main or a feature branch. A glob, not a list: a tool that walks
95
+ # "tracked plus untracked-not-ignored" takes a seeded file for a repository file, and
96
+ # a per-metric list leaves the next metric's file out of the pattern.
97
+ .github/*-history.json
98
+ .github/*-history.jsonl
99
+
100
+ # pytest --basetemp droppings (used where /tmp is noexec)
101
+ .pytest-exec-tmp
102
+
103
+ # Hypothesis property-testing example database (regenerated locally; never committed)
104
+ .hypothesis/
105
+
106
+ # CI timing maps that balance the shard fan-outs. They live in R2 (uploaded only by
107
+ # main runs, fetched best-effort by CI — see .github/ci-durations.json) and are never
108
+ # committed; ignored so a CI fetch or a local `uv run pytest --store-durations` /
109
+ # vm-live run can't accidentally stage them.
110
+ tests/.gb-test-durations.json
111
+ tests/.gb-test-uninstrumented-durations.json
112
+ tests/.gb-kcov-durations.json
113
+ tests/.gb-drvfs-durations.json
114
+ tests/.gb-macos-durations.json
115
+ # The plan job's published shard assignment, one per leg (keyed by that leg's
116
+ # duration-map name): derived from the fetched map, shipped to the shards in the
117
+ # same artifact, never committed.
118
+ tests/.gb-*-shard-assignment.json
119
+ tests/.gb-select-maps.json
120
+ .github/vm-live/durations.json
121
+ .github/kata-live/durations.json
122
+
123
+ # The pinned scanner binary that .github/scripts/python-deps-vuln-scan.sh downloads
124
+ # into the repo root (~56 MB). Running that script locally to reproduce a red
125
+ # osv-scanner check is the documented response to one, so the artifact lands in
126
+ # every contributor's tree; ignored so it can't be staged by a `git add -A`.
127
+ /osv-scanner
128
+
129
+ # esbuild output — every artifact scripts/build-hook-bundles.mjs or
130
+ # scripts/build-vm-dispatcher.mjs writes, wherever its home. Built once per tree by
131
+ # `pnpm install`'s postinstall, by setup.bash at install time, in the nix buildPhase and
132
+ # in the image's own bundler stage, so the reviewable surface is the sources plus
133
+ # pnpm-lock.yaml. Suffix, not a directory or a member list: a bundle whose home the
134
+ # pattern misses leaves every tree that builds it permanently dirty.
135
+ *.bundle.mjs
136
+
137
+ # Sharded mutation configs are deterministic build output. The planner and
138
+ # `pnpm install` render them from config/mutation-sessions.json.
139
+ /tools/mutation/*of*.toml
140
+
141
+ # The pinned kcov binary that tests/install-kcov-local.sh builds into the repo
142
+ # root — the same path .github/actions/install-kcov caches to, so tests/run-kcov.sh
143
+ # finds a local build with no PATH edit. Reproducing a red "Bash coverage (kcov)"
144
+ # check locally is the documented response to one, so the artifact lands in every
145
+ # contributor's tree; ignored so it can't be staged by a `git add -A`.
146
+ /kcov-bin/
147
+ .claude/.hookpin-*/
148
+
149
+ # synced-deps.test.mjs's untracked-file probe, mkdtemp'd under a synced path on
150
+ # purpose. Ignored so a concurrent `git add -A` elsewhere in this checkout can't
151
+ # stage it mid-test: staged, it would pass trackedUnder()'s check and leave an
152
+ # AD entry in the shared index once the test deletes it from disk.
153
+ .claude/consumer-probe-*/
154
+
155
+ # `ct settings pull` writes this per-checkout pin cache into the repo root. The pin this
156
+ # harness measures lives in evals/control_tower/ct-settings-pin.txt; a committed copy here
157
+ # would be a second one that drifts.
158
+ .settings.local.yml
159
+
160
+ # `gh` writes its per-machine state here when it runs with the repo root as the
161
+ # XDG state home — a device identifier, which no checkout should carry.
162
+ /.local/
163
+
164
+ # Generated doc pages, published to the CDN instead of committed
165
+ # (scripts/render-doc-site.py, .github/workflows/docs-publish.yaml). A committed
166
+ # page is re-derived by every branch that touches its generator's inputs, which
167
+ # made these the busiest files in the history and conflicted branches on bytes
168
+ # nobody typed. Their generators still run, and still refuse a bad input; only
169
+ # the output stays out of the tree.
170
+ /docs/architecture-callgraph.md
171
+ /docs/ci-map.generated.md
172
+ /docs/inspect-evals-coverage.generated.md
173
+ /docs/tla/configs.md
174
+ /docs/tla/reachable-subsets.md
175
+ /docs/tla/diagrams/
176
+ /docs/tla/modules/
177
+ # TLC's own scratch metadir, written here by a run given no -metadir.
178
+ /docs/tla/states/
179
+
180
+ # The eval time series live on the `metrics-history` branch, which `main` never
181
+ # merges (.github/scripts/eval-series.sh). A tracker or a local run materializes its
182
+ # own working copy here; committing one would put the series back on the merge path.
183
+ metrics/monitor-eval.jsonl
184
+ metrics/sabotage-eval.jsonl
185
+ metrics/breakout-ctf.jsonl
186
+
187
+ # `nix build` links its output here.
188
+ /result
189
+ /result-*
190
+
191
+ # go build output of the guest network daemon
192
+ bin/lib/kata/netd/netd
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.5
2
+ Name: harbor-glovebox
3
+ Version: 0.1.0
4
+ Summary: Run a Harbor trial in a hypervisor-isolated, egress-filtered glovebox microVM
5
+ Project-URL: Homepage, https://github.com/AlexanderMattTurner/agent-glovebox
6
+ Project-URL: Source, https://github.com/AlexanderMattTurner/agent-glovebox
7
+ Author: AlexanderMattTurner
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai-control,evals,harbor,microvm,sandbox,terminal-bench
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: glovebox-driver>=0.6.0
16
+ Requires-Dist: harbor<0.24,>=0.23
17
+ Description-Content-Type: text/markdown
18
+
19
+ # harbor-glovebox
20
+
21
+ Run a [Harbor](https://github.com/harbor-framework/harbor) trial inside a glovebox microVM instead of a shared-kernel container. Each trial boots one hardware-isolated VM behind glovebox's outgoing-traffic filter, and the filter's records land beside the trial's other artifacts.
22
+
23
+ ## Status
24
+
25
+ The `harbor-trial` live check drives one trial's environment calls on a real microVM in CI, then runs `harbor run` with Harbor's oracle agent over a one-task dataset through this environment. No run over a published dataset with a model-backed agent has used it yet.
26
+
27
+ ## Use
28
+
29
+ Install the released package:
30
+
31
+ ```bash
32
+ pip install harbor-glovebox
33
+ ```
34
+
35
+ To run the unreleased code on `main` instead, install all three packages from the repository at one revision. That needs `git` on the machine. Set `R` to the same tag or full commit for all three, since two people who install `main` on different days get different code. `--upgrade --force-reinstall` replaces a released `glovebox-driver` or `glovebox-hosts` that already satisfies the version pins:
36
+
37
+ ```bash
38
+ R=git+https://github.com/AlexanderMattTurner/agent-glovebox.git@<tag-or-full-commit>
39
+ pip install --upgrade --force-reinstall \
40
+ "glovebox-hosts @ $R#subdirectory=glovebox-hosts" \
41
+ "glovebox-driver @ $R#subdirectory=glovebox-driver" \
42
+ "harbor-glovebox @ $R#subdirectory=harbor-glovebox"
43
+ ```
44
+
45
+ Then run a trial:
46
+
47
+ ```bash
48
+ harbor run --dataset <dataset> --agent claude-code \
49
+ --env harbor_glovebox.environment:GloveboxEnvironment
50
+ ```
51
+
52
+ The host needs what any glovebox launch needs. `glovebox sandbox preflight` checks it, and each trial runs that check before it builds anything.
53
+
54
+ ## Users
55
+
56
+ A command runs as the `user` Harbor names, else the task's `[agent].user` or `[verifier].user`. With neither named, it runs as glovebox's de-privileged account, never as the image's own `USER`. Before that account's first command, it takes over the image's working directory and what the image's `USER` owned in its home. A task that names the image's own account keeps those files as they are. Every command inherits the image's `ENV`, as `docker exec` would carry it. Harbor names root itself for its own setup steps, such as installing the agent. Every command, root's included, sends its traffic through the in-VM filter and then the host filter outside the VM.
57
+
58
+ ## What it refuses, and why
59
+
60
+ - **`network_mode = "public"`.** glovebox has no unfiltered mode. Use `allowlist` and list every host the task needs. Put the agent's model endpoint in Harbor's `extra_allowed_hosts`.
61
+ - **Wildcard and IP allowlist entries.** The filter matches hostnames only. Harbor rejects such a task before it starts.
62
+ - **A bare top-level name as an allowlist entry**, such as `com`. A public suffix with a dot, such as `github.io`, is accepted, but it reaches that one host alone. An ordinary entry also reaches one label below it, so `example.com` admits `api.example.com`. Under a public suffix that label is a stranger's name.
63
+ - **A download larger than 1 GiB.** The guest's archive arrives in the host's memory whole, so the transfer fails rather than grow without bound.
64
+ - **A task image that already ships `/logs`, `/tests`, `/solution` or `/harbor`.** The guest's root is read-only, so each of Harbor's fixed trees is a link onto the guest's writable `/tmp` disk, and the image build refuses to replace a tree the image carries.
65
+ - **Multi-container tasks** (a `docker-compose.yaml` in the task's environment directory). <!-- # allow-dangling-path: a file in a Harbor task's own directory, not in this repo -->
66
+
67
+ `storage_mb` is ignored, as Harbor's own Docker environment ignores it.
@@ -0,0 +1,49 @@
1
+ # harbor-glovebox
2
+
3
+ Run a [Harbor](https://github.com/harbor-framework/harbor) trial inside a glovebox microVM instead of a shared-kernel container. Each trial boots one hardware-isolated VM behind glovebox's outgoing-traffic filter, and the filter's records land beside the trial's other artifacts.
4
+
5
+ ## Status
6
+
7
+ The `harbor-trial` live check drives one trial's environment calls on a real microVM in CI, then runs `harbor run` with Harbor's oracle agent over a one-task dataset through this environment. No run over a published dataset with a model-backed agent has used it yet.
8
+
9
+ ## Use
10
+
11
+ Install the released package:
12
+
13
+ ```bash
14
+ pip install harbor-glovebox
15
+ ```
16
+
17
+ To run the unreleased code on `main` instead, install all three packages from the repository at one revision. That needs `git` on the machine. Set `R` to the same tag or full commit for all three, since two people who install `main` on different days get different code. `--upgrade --force-reinstall` replaces a released `glovebox-driver` or `glovebox-hosts` that already satisfies the version pins:
18
+
19
+ ```bash
20
+ R=git+https://github.com/AlexanderMattTurner/agent-glovebox.git@<tag-or-full-commit>
21
+ pip install --upgrade --force-reinstall \
22
+ "glovebox-hosts @ $R#subdirectory=glovebox-hosts" \
23
+ "glovebox-driver @ $R#subdirectory=glovebox-driver" \
24
+ "harbor-glovebox @ $R#subdirectory=harbor-glovebox"
25
+ ```
26
+
27
+ Then run a trial:
28
+
29
+ ```bash
30
+ harbor run --dataset <dataset> --agent claude-code \
31
+ --env harbor_glovebox.environment:GloveboxEnvironment
32
+ ```
33
+
34
+ The host needs what any glovebox launch needs. `glovebox sandbox preflight` checks it, and each trial runs that check before it builds anything.
35
+
36
+ ## Users
37
+
38
+ A command runs as the `user` Harbor names, else the task's `[agent].user` or `[verifier].user`. With neither named, it runs as glovebox's de-privileged account, never as the image's own `USER`. Before that account's first command, it takes over the image's working directory and what the image's `USER` owned in its home. A task that names the image's own account keeps those files as they are. Every command inherits the image's `ENV`, as `docker exec` would carry it. Harbor names root itself for its own setup steps, such as installing the agent. Every command, root's included, sends its traffic through the in-VM filter and then the host filter outside the VM.
39
+
40
+ ## What it refuses, and why
41
+
42
+ - **`network_mode = "public"`.** glovebox has no unfiltered mode. Use `allowlist` and list every host the task needs. Put the agent's model endpoint in Harbor's `extra_allowed_hosts`.
43
+ - **Wildcard and IP allowlist entries.** The filter matches hostnames only. Harbor rejects such a task before it starts.
44
+ - **A bare top-level name as an allowlist entry**, such as `com`. A public suffix with a dot, such as `github.io`, is accepted, but it reaches that one host alone. An ordinary entry also reaches one label below it, so `example.com` admits `api.example.com`. Under a public suffix that label is a stranger's name.
45
+ - **A download larger than 1 GiB.** The guest's archive arrives in the host's memory whole, so the transfer fails rather than grow without bound.
46
+ - **A task image that already ships `/logs`, `/tests`, `/solution` or `/harbor`.** The guest's root is read-only, so each of Harbor's fixed trees is a link onto the guest's writable `/tmp` disk, and the image build refuses to replace a tree the image carries.
47
+ - **Multi-container tasks** (a `docker-compose.yaml` in the task's environment directory). <!-- # allow-dangling-path: a file in a Harbor task's own directory, not in this repo -->
48
+
49
+ `storage_mb` is ignored, as Harbor's own Docker environment ignores it.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "harbor-glovebox"
3
+ version = "0.1.0"
4
+ description = "Run a Harbor trial in a hypervisor-isolated, egress-filtered glovebox microVM"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "AlexanderMattTurner" }]
9
+ keywords = ["harbor", "terminal-bench", "evals", "sandbox", "ai-control", "microvm"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Intended Audience :: Science/Research",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Security",
15
+ ]
16
+ # Harbor is pinned to one minor release: it is pre-1.0 and its environment interface moves
17
+ # between minors, so a wider range would admit a release this adapter was never run against.
18
+ dependencies = ["harbor>=0.23,<0.24", "glovebox-driver>=0.6.0"]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/AlexanderMattTurner/agent-glovebox"
22
+ Source = "https://github.com/AlexanderMattTurner/agent-glovebox"
23
+
24
+ [build-system]
25
+ requires = ["hatchling==1.32.0"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/harbor_glovebox"]
@@ -0,0 +1,6 @@
1
+ """Run a Harbor trial inside a glovebox microVM instead of a shared-kernel container.
2
+
3
+ Harbor loads the environment with `--env harbor_glovebox.environment:GloveboxEnvironment`.
4
+ That module is the only one that imports Harbor, so importing this package needs neither
5
+ Harbor nor a glovebox host.
6
+ """
@@ -0,0 +1,201 @@
1
+ """A Harbor task's image as the microVM's root filesystem.
2
+
3
+ PROBLEM CLASS — a task names a prebuilt `docker_image` or ships a Dockerfile, and that image
4
+ IS the environment the trial is scored in. A microVM boots ONE root filesystem, so glovebox's
5
+ guest layers are built on top of the task's image and the result is booted. Every step here
6
+ fails loud: a task whose image cannot be built must not fall back to glovebox's own guest
7
+ image, which would score it against an environment missing its setup.
8
+
9
+ INVARIANT: every image is named by its content id, never by the tag the task wrote. A tag is
10
+ repointable, so a concurrent trial or a re-push could otherwise hand one trial another's bytes.
11
+ """
12
+
13
+ import json
14
+ import subprocess
15
+ import tempfile
16
+ import threading
17
+ from dataclasses import dataclass
18
+ from hashlib import sha256
19
+ from pathlib import Path
20
+ from uuid import uuid4
21
+
22
+ from glovebox_driver import cli
23
+
24
+ # The names this module writes, so `docker image ls harbor-glovebox-*` shows what a run left.
25
+ _BASE_REPO = "harbor-glovebox-base"
26
+ _ROOTFS_REPO = "harbor-glovebox-rootfs"
27
+ _STAGING_REPO = "harbor-glovebox-staging"
28
+
29
+ # The guest reads its own startup file through this name, so a task's value would replace it.
30
+ _GUEST_OWNED_ENV = frozenset({"BASH_ENV"})
31
+
32
+ # Harbor writes these fixed root-level trees, and the guest's root is read-only. Each is baked
33
+ # as a link into this scratch directory on the guest's writable /tmp disk.
34
+ HARBOR_SCRATCH = "/tmp/gb-harbor" # noqa: S108 — a guest path, made by root before any task command
35
+ HARBOR_TREES = ("harbor", "logs", "solution", "tests")
36
+
37
+
38
+ @dataclass(frozen=True, kw_only=True, slots=True)
39
+ class ImageConfig:
40
+ """What `docker exec` inherits from an image: its `WORKDIR`, `USER` and `ENV`."""
41
+
42
+ # "" when no base in the chain set one, which is docker's own answer for "the root".
43
+ working_dir: str
44
+ # "" when no base set one, which is docker's own answer for "root" too.
45
+ user: str
46
+ environment: tuple[tuple[str, str], ...]
47
+
48
+
49
+ def pulled_image(image: str, *, refresh: bool = False) -> str:
50
+ """`image`, present on this host, as a tag bound to its content id; `refresh` pulls even when present.
51
+
52
+ The presence check, the pull and the binding run under one lock per name, so a concurrent
53
+ trial's pull cannot retag `image` between this trial's check and its binding."""
54
+ with _lock_for(_tag_locks, image):
55
+ if refresh or not _image_id(image):
56
+ _docker(["pull", image], announce=f"harbor-glovebox: pulling {image}.")
57
+ return _bound(image)
58
+
59
+
60
+ def image_from_dockerfile(dockerfile: Path, *, no_cache: bool = False) -> str:
61
+ """The task's Dockerfile built with its own directory as context, as a tag bound to the result's content id.
62
+
63
+ The build lands under a one-use tag, so a concurrent build of the same task cannot retag
64
+ it before this one reads it back. `no_cache` rebuilds every layer and re-pulls the base,
65
+ which is what Harbor's `force_build` asks for."""
66
+ resolved = dockerfile.resolve()
67
+ staging = f"{_STAGING_REPO}:{uuid4().hex}"
68
+ fresh = ["--no-cache", "--pull"] if no_cache else []
69
+ try:
70
+ _docker(
71
+ [
72
+ "build",
73
+ *fresh,
74
+ "--tag",
75
+ staging,
76
+ "--file",
77
+ str(resolved),
78
+ str(resolved.parent),
79
+ ],
80
+ announce=f"harbor-glovebox: building {resolved}.",
81
+ )
82
+ return _bound(staging)
83
+ finally:
84
+ # Not `_docker`: this name decides nothing, and a failed removal must not mask the build.
85
+ subprocess.run(
86
+ ["docker", "image", "rm", staging],
87
+ capture_output=True,
88
+ timeout=cli.IMAGE_BUILD_TIMEOUT_S,
89
+ check=False,
90
+ )
91
+
92
+
93
+ def with_harbor_trees(base: str) -> str:
94
+ """`base` with each of Harbor's root-level trees linked into `HARBOR_SCRATCH`.
95
+
96
+ COPY carries the links as links and needs no shell in the task's image. An image that
97
+ already ships one of these trees fails the build rather than lose it silently."""
98
+ with tempfile.TemporaryDirectory() as context:
99
+ root = Path(context) / "root"
100
+ root.mkdir()
101
+ for tree in HARBOR_TREES:
102
+ (root / tree).symlink_to(f"{HARBOR_SCRATCH}/{tree}")
103
+ dockerfile = Path(context) / "Dockerfile"
104
+ dockerfile.write_text(f"FROM {base}\nCOPY root/ /\n", encoding="utf-8")
105
+ return image_from_dockerfile(dockerfile)
106
+
107
+
108
+ def image_config(image: str) -> ImageConfig:
109
+ """`image`'s own `WORKDIR`, `USER` and `ENV`, as docker resolved them through every base."""
110
+ config = json.loads(
111
+ _docker(["image", "inspect", "--format", "{{json .Config}}", image])
112
+ )
113
+ pairs = (str(entry).partition("=") for entry in config.get("Env") or ())
114
+ return ImageConfig(
115
+ working_dir=str(config.get("WorkingDir") or ""),
116
+ # Docker records "user:group"; only the user half names the account.
117
+ user=str(config.get("User") or "").split(":", maxsplit=1)[0],
118
+ environment=tuple(
119
+ (name, value)
120
+ for name, _, value in pairs
121
+ if name and name not in _GUEST_OWNED_ENV
122
+ ),
123
+ )
124
+
125
+
126
+ _builds: dict[str, str] = {}
127
+ _build_locks: dict[str, threading.Lock] = {}
128
+ _tag_locks: dict[str, threading.Lock] = {}
129
+ _builds_guard = threading.Lock()
130
+
131
+
132
+ def _lock_for(locks: dict[str, threading.Lock], key: str) -> threading.Lock:
133
+ with _builds_guard:
134
+ return locks.setdefault(key, threading.Lock())
135
+
136
+
137
+ def rootfs_from_image(base: str, working_dir: str) -> str:
138
+ """`base` with glovebox's guest layers on top, loaded into the backend's image store, as the tag a boot names.
139
+
140
+ Keyed on the base's content id and `working_dir`, which the build bakes aside for the disk
141
+ the cell mounts there. Concurrent trials of one image wait on one build rather than each
142
+ running their own."""
143
+ identity = _image_id(base)
144
+ if not identity:
145
+ raise RuntimeError(f"docker holds no image for {base}.")
146
+ key = f"{identity}\n{working_dir}"
147
+ with _lock_for(_build_locks, key):
148
+ if key not in _builds:
149
+ tag = _tag(_ROOTFS_REPO, key)
150
+ print(
151
+ f"harbor-glovebox: building {base} into the microVM's root filesystem. "
152
+ "This takes minutes; later trials of this image reuse it."
153
+ )
154
+ cli.build_rootfs(base, tag, working_dir)
155
+ _builds[key] = tag
156
+ return _builds[key]
157
+
158
+
159
+ def _bound(image: str) -> str:
160
+ """A tag this module owns, bound to `image`'s content id, so no later retag of `image` moves it."""
161
+ identity = _image_id(image)
162
+ if not identity:
163
+ raise RuntimeError(f"docker holds no image for {image} after producing it.")
164
+ tag = _tag(_BASE_REPO, identity)
165
+ _docker(["tag", identity, tag])
166
+ return tag
167
+
168
+
169
+ def _image_id(image: str) -> str:
170
+ """`image`'s content id on this host, or "" when this host holds no such image."""
171
+ held = subprocess.run(
172
+ ["docker", "image", "inspect", "--format", "{{.Id}}", image],
173
+ capture_output=True,
174
+ timeout=cli.IMAGE_BUILD_TIMEOUT_S,
175
+ check=False,
176
+ )
177
+ if held.returncode != 0:
178
+ return ""
179
+ return held.stdout.decode("utf-8", errors="replace").strip()
180
+
181
+
182
+ def _tag(repo: str, key: str) -> str:
183
+ return f"{repo}:{sha256(key.encode('utf-8')).hexdigest()[:16]}"
184
+
185
+
186
+ def _docker(args: list[str], *, announce: str | None = None) -> str:
187
+ """One `docker` call's stdout, raising on any failure."""
188
+ if announce is not None:
189
+ print(announce)
190
+ proc = subprocess.run(
191
+ ["docker", *args],
192
+ capture_output=True,
193
+ timeout=cli.IMAGE_BUILD_TIMEOUT_S,
194
+ check=False,
195
+ )
196
+ if proc.returncode != 0:
197
+ detail = proc.stderr.decode("utf-8", errors="replace").strip()
198
+ raise RuntimeError(
199
+ f"docker {' '.join(args)} failed (exit {proc.returncode}).\n{detail}"
200
+ )
201
+ return proc.stdout.decode("utf-8", errors="replace")
@@ -0,0 +1,83 @@
1
+ """How a Harbor trial's network policy and exec users map onto glovebox, with no Harbor import.
2
+
3
+ Kept free of `harbor` so the rules are unit-tested without Harbor's closure, and so
4
+ `environment.py` stays a thin translation of Harbor's calls onto them.
5
+ """
6
+
7
+ from collections.abc import Sequence
8
+ from ipaddress import ip_network
9
+
10
+ from glovebox_driver.config import DEFAULT_GUEST_USER, Access
11
+
12
+ # Harbor's `NetworkMode` values, as strings so this module needs no Harbor import.
13
+ PUBLIC = "public"
14
+ NO_NETWORK = "no-network"
15
+ ALLOWLIST = "allowlist"
16
+
17
+ _ROOT_NAMES: frozenset[str | int] = frozenset({"root", "0", 0})
18
+
19
+
20
+ class UnsupportedPolicy(ValueError):
21
+ """A Harbor setting this environment cannot enforce exactly, refused before it takes effect."""
22
+
23
+
24
+ def allowlist_pairs(mode: str, hosts: Sequence[str]) -> tuple[tuple[str, Access], ...]:
25
+ """Harbor's network policy as the host-to-access pairs glovebox's allowlist file takes.
26
+
27
+ INVARIANT: Harbor requires a provider to enforce a policy exactly or refuse it. glovebox
28
+ has no unfiltered mode, so `public` is refused rather than widened to a guess. An allowed
29
+ host gets `rw`, because Harbor's allowlist admits every request to that host.
30
+
31
+ A glovebox grant also reaches one label below its host, so `example.com` admits
32
+ `api.example.com`. A public suffix such as `github.io`, whose labels strangers
33
+ register, is held to its own host by glovebox's render instead
34
+ (`glovebox_hosts.grant_is_exact`). A bare top-level name like `com` is refused.
35
+ """
36
+ if mode == PUBLIC:
37
+ raise UnsupportedPolicy(
38
+ "glovebox has no unfiltered network mode, so it cannot run "
39
+ "network_mode='public'. Set network_mode='allowlist' and list every host the "
40
+ "task needs, including the agent's model endpoint (Harbor's "
41
+ "`extra_allowed_hosts`)."
42
+ )
43
+ if mode == NO_NETWORK:
44
+ return ()
45
+ if mode != ALLOWLIST:
46
+ raise UnsupportedPolicy(f"unknown Harbor network_mode {mode!r}.")
47
+ # Judged as glovebox reads them: surrounding whitespace is stripped before a host is
48
+ # matched, so a check on the raw string would let " *.example.com" through.
49
+ hosts = [host.strip() for host in hosts]
50
+ for host in hosts:
51
+ if host.startswith("*.") or _is_address(host):
52
+ raise UnsupportedPolicy(
53
+ f"allowlist entry {host!r} is not an exact hostname, and glovebox's "
54
+ "filter matches exact hostnames only."
55
+ )
56
+ if "." not in host.strip("."):
57
+ raise UnsupportedPolicy(
58
+ f"allowlist entry {host!r} is a bare top-level name, which glovebox "
59
+ "cannot grant. List the one host the task needs instead."
60
+ )
61
+ return tuple((host, "rw") for host in dict.fromkeys(hosts))
62
+
63
+
64
+ def _is_address(host: str) -> bool:
65
+ """True for an IP address or a CIDR range, which Harbor accepts and glovebox cannot match."""
66
+ try:
67
+ ip_network(host, strict=False)
68
+ except ValueError:
69
+ return False
70
+ return True
71
+
72
+
73
+ def exec_user(requested: str | int | None, default_user: str | int | None) -> str:
74
+ """The guest account one Harbor `exec` runs as.
75
+
76
+ An explicit `user` wins, then the task's own `[agent].user` or `[verifier].user`. With
77
+ neither named, the command runs as glovebox's de-privileged account, never as the image's
78
+ `USER`, which is usually root. Harbor names root itself for its own setup steps.
79
+ """
80
+ user = requested if requested is not None else default_user
81
+ if user is None:
82
+ return DEFAULT_GUEST_USER
83
+ return "root" if user in _ROOT_NAMES else str(user)
@@ -0,0 +1,329 @@
1
+ """One Harbor trial's microVM: build its root filesystem, boot it, run commands, move files, reap it.
2
+
3
+ Everything `environment.py` does, with no Harbor import, so it runs under this repo's own
4
+ tests and type checker. Harbor's dependency closure pins `openai<3`, which this repo's
5
+ development environment cannot hold beside its own pin.
6
+ """
7
+
8
+ import asyncio
9
+ import io
10
+ import posixpath
11
+ import shutil
12
+ import subprocess
13
+ import tarfile
14
+ import tempfile
15
+ import time
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ from glovebox_driver import aio, cli
19
+ from glovebox_driver.config import Access, GloveboxSandboxConfig
20
+ from glovebox_driver.guest_account import ensure_working_dir, hand_contract_home_over
21
+ from glovebox_driver.guest_exec import ExecOutcome
22
+ from glovebox_driver.guest_prelude import EGRESS_FILTER_ENV_FILE
23
+ from glovebox_driver.run import DisposableRun, EvidenceRequest
24
+
25
+ from ._image import (
26
+ HARBOR_SCRATCH,
27
+ HARBOR_TREES,
28
+ image_config,
29
+ image_from_dockerfile,
30
+ pulled_image,
31
+ rootfs_from_image,
32
+ with_harbor_trees,
33
+ )
34
+
35
+ # A tar that writes into a guest directory as root-owned files, and one that reads out of it.
36
+ # Paths bind to `$1`/`$2` and are never spliced into the script text. `--` keeps a member
37
+ # named like `-C` a member. The read stops one byte past `$3`, so the host holds at most that.
38
+ _UNPACK = 'mkdir -p "$1" && tar -x --no-same-owner -C "$1"'
39
+ _PACK = 'set -o pipefail; tar -c -C "$1" -- "$2" | head -c "$3"'
40
+
41
+ # The most one download may carry. The guest's tar arrives as one buffered reply, so a
42
+ # directory the agent inflates must not be able to take the host's memory with it.
43
+ MAX_DOWNLOAD_BYTES = 1 << 30
44
+
45
+ # INVARIANT: every Harbor command, root's included, sources the in-VM filter's routing after
46
+ # the caller's env, so no exec leaves the guest except through that filter. Root's exec sources
47
+ # none of its own. With the file absent the command still runs, with no route out at all.
48
+ _ROUTED = '[ -r "$1" ] && . "$1"; exec bash -c "$2"'
49
+
50
+
51
+ class TransferFailed(RuntimeError):
52
+ """A file copy into or out of the guest did not complete."""
53
+
54
+
55
+ def base_image(
56
+ environment_dir: Path, docker_image: str | None, *, force_build: bool = False
57
+ ) -> str:
58
+ """The Docker image the trial's root filesystem is layered on: the task's prebuilt one, else its Dockerfile built here.
59
+
60
+ A compose file is refused before either source is read: its sidecars would otherwise be
61
+ dropped silently, whichever image the primary service names."""
62
+ if (environment_dir / "docker-compose.yaml").exists():
63
+ raise NotImplementedError(
64
+ "harbor-glovebox runs single-container tasks only; this task ships a "
65
+ "docker-compose.yaml."
66
+ )
67
+ if docker_image:
68
+ return pulled_image(docker_image, refresh=force_build)
69
+ return image_from_dockerfile(environment_dir / "Dockerfile", no_cache=force_build)
70
+
71
+
72
+ def evidence_request(evidence_dir: Path) -> EvidenceRequest:
73
+ """Where the decision log and the host-side filter log land, captured before teardown reaps the run."""
74
+ evidence_dir.mkdir(parents=True, exist_ok=True)
75
+ return EvidenceRequest(
76
+ traffic=evidence_dir / "egress.jsonl",
77
+ host_filter=evidence_dir / "egress-host-filter.jsonl",
78
+ )
79
+
80
+
81
+ def pack(members: list[tuple[Path, str]]) -> bytes:
82
+ """Each `(path, arcname)` as a tar entry, with no `.` entry, so a target directory keeps its own owner and mode."""
83
+ buffer = io.BytesIO()
84
+ with tarfile.open(fileobj=buffer, mode="w") as archive:
85
+ for path, arcname in members:
86
+ archive.add(path, arcname=arcname)
87
+ return buffer.getvalue()
88
+
89
+
90
+ def unpack(data: bytes, into: Path) -> None:
91
+ """Extract a tar the guest produced under `into`, refusing any member that would land outside it."""
92
+ into.mkdir(parents=True, exist_ok=True)
93
+ # nosemgrep: tarfile-extractall-traversal — filter="data" is the refusal the rule asks for
94
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r") as archive:
95
+ archive.extractall(into, filter="data")
96
+
97
+
98
+ def _land(data: bytes, target: Path, name: str, *, is_dir: bool) -> None:
99
+ """A guest tar at `target`: a directory's members under it, or the one member `name` as it."""
100
+ if is_dir:
101
+ unpack(data, target)
102
+ return
103
+ with tempfile.TemporaryDirectory() as scratch:
104
+ unpack(data, Path(scratch))
105
+ target.parent.mkdir(parents=True, exist_ok=True)
106
+ shutil.move(Path(scratch) / name, target)
107
+
108
+
109
+ class GloveboxTrial:
110
+ """One booted microVM, driven through `glovebox_driver`, with Harbor's operations on top."""
111
+
112
+ def __init__(self, *, config: GloveboxSandboxConfig, evidence_dir: Path) -> None:
113
+ self._config = config
114
+ self._evidence_dir = evidence_dir
115
+ self._run: DisposableRun | None = None
116
+ self._handed_over = False
117
+ self._hand_over_lock = asyncio.Lock()
118
+
119
+ @property
120
+ def run(self) -> DisposableRun:
121
+ """The booted run, which exists only between `start` and `stop`."""
122
+ if self._run is None:
123
+ raise RuntimeError("the glovebox microVM is not running.")
124
+ return self._run
125
+
126
+ async def start(self) -> None:
127
+ """Stage an empty workspace, boot the microVM onto it, and make the trees Harbor writes.
128
+
129
+ Root makes them before any other command runs, so no later account owns their roots."""
130
+ staged = await aio.stage_run(
131
+ self._config, evidence=evidence_request(self._evidence_dir)
132
+ )
133
+ self._run = await aio.boot(staged)
134
+ try:
135
+ await self._make_trees()
136
+ except BaseException:
137
+ # The caller's cleanup begins only once this returns, so the booted VM is reaped here.
138
+ await self.stop(failed=True)
139
+ raise
140
+
141
+ async def _make_trees(self) -> None:
142
+ trees = [f"{HARBOR_SCRATCH}/{tree}" for tree in HARBOR_TREES]
143
+ made = await self._guest(["mkdir", "-p", "-m", "0755", *trees], "root")
144
+ if made.returncode != 0:
145
+ raise RuntimeError(
146
+ "could not make Harbor's directories in the guest: "
147
+ + made.stderr.decode("utf-8", "replace").strip()
148
+ )
149
+
150
+ async def _hand_over_once(self) -> None:
151
+ """Give the de-privileged account what the image's own `USER` owned, before its first command.
152
+
153
+ Deferred to that command, never run at boot: a task whose `[agent].user` names the
154
+ image's own account runs as that account, which already owns its files."""
155
+ async with self._hand_over_lock:
156
+ if self._handed_over:
157
+ return
158
+ await asyncio.to_thread(self._hand_task_dirs_over, self.run)
159
+ self._handed_over = True
160
+
161
+ def _hand_task_dirs_over(self, run: DisposableRun) -> None:
162
+ config = self._config
163
+ hand_contract_home_over(run.session, config.user, config.working_dir)
164
+ if config.working_dir:
165
+ ensure_working_dir(run.session, config.working_dir, config.user)
166
+
167
+ async def stop(self, *, failed: bool = False) -> None:
168
+ """Export the egress records, then reap the VM. A second call does nothing."""
169
+ run, self._run = self._run, None
170
+ if run is not None:
171
+ await aio.close(run, failed=failed)
172
+
173
+ async def exec( # pylint: disable=too-many-arguments # Harbor's exec signature
174
+ self,
175
+ command: str,
176
+ *,
177
+ user: str,
178
+ cwd: str | None = None,
179
+ env: dict[str, str] | None = None,
180
+ timeout_sec: int | None = None,
181
+ ) -> ExecOutcome:
182
+ """Run `command` under bash in the guest as `user`, a name or a uid, as Harbor's Docker environment does.
183
+
184
+ `timeout_sec` bounds the account lookup and the command together."""
185
+ deadline = None if timeout_sec is None else time.monotonic() + timeout_sec
186
+ try:
187
+ account = await self._account(user, deadline)
188
+ if account == self._config.user:
189
+ await self._hand_over_once()
190
+ routed = ["bash", "-c", _ROUTED, "_", EGRESS_FILTER_ENV_FILE, command]
191
+ return await self._guest(
192
+ routed, account, cwd=cwd, env=env, timeout=_remaining(deadline)
193
+ )
194
+ except subprocess.TimeoutExpired as exc:
195
+ raise RuntimeError(
196
+ f"Command timed out after {timeout_sec} seconds"
197
+ ) from exc
198
+
199
+ async def _account(self, user: str, deadline: float | None) -> str:
200
+ """`user` as the account name `runuser` takes; a bare uid is looked up in the guest."""
201
+ if not user.isdigit():
202
+ return user
203
+ found = await self._guest(
204
+ ["getent", "passwd", user], "root", timeout=_remaining(deadline)
205
+ )
206
+ name = found.stdout.decode("utf-8", "replace").split(":", 1)[0]
207
+ if found.returncode != 0 or not name:
208
+ raise RuntimeError(f"uid {user} names no account in the guest.")
209
+ return name
210
+
211
+ async def upload(self, source: Path, target: str) -> None:
212
+ """Copy a host file or directory to `target` in the guest, as root."""
213
+ dest = PurePosixPath(target)
214
+ if source.is_dir():
215
+ into, members = dest, [(c, c.name) for c in sorted(source.iterdir())]
216
+ else:
217
+ into, members = dest.parent, [(source, dest.name)]
218
+ script = ["sh", "-c", _UNPACK, "_", str(into)]
219
+ # Packed off the event loop, so a large task directory stalls no other trial.
220
+ data = await asyncio.to_thread(pack, members)
221
+ outcome = await self._guest(script, "root", data=data)
222
+ if outcome.returncode != 0:
223
+ raise TransferFailed(
224
+ f"copying {source} to {target} failed in the guest: "
225
+ + outcome.stderr.decode("utf-8", "replace").strip()
226
+ )
227
+
228
+ async def download(self, source: str, target: Path, *, is_dir: bool) -> None:
229
+ """Copy a guest file or directory to `target` on the host, refusing one past `MAX_DOWNLOAD_BYTES`."""
230
+ src = PurePosixPath(source)
231
+ parent, member = (src, ".") if is_dir else (src.parent, src.name)
232
+ cap = str(MAX_DOWNLOAD_BYTES + 1)
233
+ script = ["bash", "-c", _PACK, "_", str(parent), member, cap]
234
+ outcome = await self._guest(script, "root")
235
+ if len(outcome.stdout) > MAX_DOWNLOAD_BYTES:
236
+ raise TransferFailed(
237
+ f"{source} packs to more than {MAX_DOWNLOAD_BYTES} bytes, the most one "
238
+ "download carries."
239
+ )
240
+ if outcome.returncode != 0:
241
+ raise TransferFailed(
242
+ f"reading {source} out of the guest failed: "
243
+ + outcome.stderr.decode("utf-8", "replace").strip()
244
+ )
245
+ await asyncio.to_thread(_land, outcome.stdout, target, src.name, is_dir=is_dir)
246
+
247
+ async def _guest( # pylint: disable=too-many-arguments # the run's own exec seams
248
+ self,
249
+ cmd: list[str],
250
+ user: str,
251
+ *,
252
+ cwd: str | None = None,
253
+ env: dict[str, str] | None = None,
254
+ data: bytes | None = None,
255
+ timeout: float | None = None,
256
+ ) -> ExecOutcome:
257
+ """`cmd` run through the run's own exec, off the event loop."""
258
+ return await asyncio.to_thread(
259
+ self.run.exec,
260
+ cmd,
261
+ user=user,
262
+ cwd=cwd,
263
+ env=env,
264
+ input_bytes=data,
265
+ timeout=timeout,
266
+ )
267
+
268
+
269
+ def _remaining(deadline: float | None) -> float | None:
270
+ """The seconds left before `deadline`, raising as Harbor does once none are."""
271
+ if deadline is None:
272
+ return None
273
+ left = deadline - time.monotonic()
274
+ if left <= 0:
275
+ raise RuntimeError("Command timed out before it started")
276
+ return left
277
+
278
+
279
+ def _canonical_dir(path: str) -> str:
280
+ """One spelling of an absolute directory, so the hand-over's textual refusal of system paths holds.
281
+
282
+ `/opt/../etc` would otherwise read as living under `/opt`. POSIX keeps a leading `//`
283
+ through `normpath`, and Linux resolves it to `/`, so that run is collapsed first."""
284
+ if path.startswith("//"):
285
+ path = "/" + path.lstrip("/")
286
+ return posixpath.normpath(path)
287
+
288
+
289
+ def task_config( # pylint: disable=too-many-arguments # one per task field a boot reads
290
+ *,
291
+ environment_dir: Path,
292
+ docker_image: str | None,
293
+ workdir: str | None,
294
+ allowlist: tuple[tuple[str, Access], ...],
295
+ cpus: int | None,
296
+ memory_mb: int | None,
297
+ force_build: bool = False,
298
+ ) -> GloveboxSandboxConfig:
299
+ """A task's boot config: its image under glovebox's guest layers, with the image's `WORKDIR` and `ENV`.
300
+
301
+ Refuses a host that cannot boot a microVM before the image costs minutes to build. The
302
+ image's own startup command never runs, as under Harbor's own compose files. Its `ENV`
303
+ reaches every Harbor command, as `docker exec` would carry it, under the caller's own."""
304
+ cli.preflight()
305
+ base = base_image(environment_dir, docker_image, force_build=force_build)
306
+ inherited = image_config(base)
307
+ booted = with_harbor_trees(base)
308
+ working_dir = _canonical_dir(workdir or inherited.working_dir or "")
309
+ fields: dict[str, object] = {
310
+ "allowlist": allowlist,
311
+ "cpus": cpus,
312
+ "environment": inherited.environment,
313
+ }
314
+ if memory_mb:
315
+ fields["memory"] = f"{memory_mb}m"
316
+ if working_dir != ".":
317
+ fields["working_dir"] = working_dir
318
+ fields["rootfs_image"] = rootfs_from_image(
319
+ booted, str(fields.get("working_dir", ""))
320
+ )
321
+ return GloveboxSandboxConfig.model_validate(fields)
322
+
323
+
324
+ __all__ = [
325
+ "MAX_DOWNLOAD_BYTES",
326
+ "GloveboxTrial",
327
+ "TransferFailed",
328
+ "task_config",
329
+ ]
@@ -0,0 +1,149 @@
1
+ """Harbor's `BaseEnvironment` on a glovebox microVM: `--env harbor_glovebox.environment:GloveboxEnvironment`.
2
+
3
+ A translation layer only. The policy mapping lives in `_policy` and the VM lifecycle in
4
+ `_trial`, both free of Harbor so this repo's own tests reach them. This module is the one
5
+ file that imports Harbor, whose dependency closure this repo's environment cannot install.
6
+ """
7
+ # pyright: reportMissingImports=false
8
+ # pylint: disable=import-error # Harbor pins openai<3, which this repo's dev environment cannot hold
9
+
10
+ import asyncio
11
+ from collections.abc import Sequence
12
+ from pathlib import Path
13
+
14
+ from harbor.environments.base import BaseEnvironment, EnvironmentPath, ExecResult
15
+ from harbor.environments.capabilities import EnvironmentCapabilities
16
+ from harbor.models.task.config import NetworkPolicy
17
+
18
+ from . import _policy
19
+ from ._trial import GloveboxTrial, task_config
20
+
21
+
22
+ class GloveboxEnvironment(BaseEnvironment):
23
+ """One Harbor trial in one hypervisor-isolated, egress-filtered glovebox microVM."""
24
+
25
+ _trial: GloveboxTrial | None = None
26
+
27
+ @staticmethod
28
+ def type() -> str:
29
+ return "glovebox"
30
+
31
+ @property
32
+ def capabilities(self) -> EnvironmentCapabilities:
33
+ """What Harbor may ask of this environment; Harbor refuses a task needing more before start."""
34
+ return EnvironmentCapabilities(
35
+ disable_internet=True,
36
+ network_allowlist=True,
37
+ network_allowlist_hostnames=True,
38
+ )
39
+
40
+ def _validate_definition(self) -> None:
41
+ if self.task_env_config.docker_image:
42
+ return
43
+ if not (self.environment_dir / "Dockerfile").exists():
44
+ raise FileNotFoundError(
45
+ f"{self.environment_dir} has no Dockerfile and the task names no docker_image."
46
+ )
47
+
48
+ def validate_network_policy_support(
49
+ self, network_policy: NetworkPolicy | None = None
50
+ ) -> None:
51
+ super().validate_network_policy_support(network_policy)
52
+ policy = network_policy or self.network_policy
53
+ _policy.allowlist_pairs(policy.network_mode.value, policy.allowed_hosts)
54
+
55
+ async def start(self, force_build: bool) -> None:
56
+ policy = self.network_policy
57
+ config = await asyncio.to_thread(
58
+ task_config,
59
+ environment_dir=self.environment_dir,
60
+ docker_image=self.task_env_config.docker_image,
61
+ workdir=self.task_env_config.workdir,
62
+ allowlist=_policy.allowlist_pairs(
63
+ policy.network_mode.value, policy.allowed_hosts
64
+ ),
65
+ cpus=self.task_env_config.cpus,
66
+ memory_mb=self.task_env_config.memory_mb,
67
+ force_build=force_build,
68
+ )
69
+ self._trial = GloveboxTrial(
70
+ config=config, evidence_dir=self.trial_paths.artifacts_dir / "glovebox"
71
+ )
72
+ await self._trial.start()
73
+ try:
74
+ # Nothing bind-mounts Harbor's log directories here, so they are made in the guest.
75
+ await self.ensure_dirs(self._mount_targets(writable_only=True))
76
+ await self._upload_environment_dir_after_start()
77
+ except BaseException:
78
+ await self._trial.stop(failed=True)
79
+ raise
80
+
81
+ async def ensure_dirs(
82
+ self, dirs: Sequence[EnvironmentPath], *, chmod: bool = True
83
+ ) -> ExecResult | None:
84
+ """Harbor's directory setup, raised on failure rather than returned and ignored."""
85
+ return _checked(await super().ensure_dirs(dirs, chmod=chmod), dirs)
86
+
87
+ async def empty_dirs(
88
+ self, dirs: Sequence[EnvironmentPath], *, chmod: bool = True
89
+ ) -> ExecResult | None:
90
+ """Harbor's directory reset, raised on failure: a stale `/tests` would change the score."""
91
+ return _checked(await super().empty_dirs(dirs, chmod=chmod), dirs)
92
+
93
+ async def stop(self, delete: bool) -> None: # noqa: ARG002 # pylint: disable=unused-argument # a microVM is always deleted
94
+ if self._trial is not None:
95
+ await self._trial.stop()
96
+
97
+ async def exec( # pylint: disable=too-many-arguments,too-many-positional-arguments # Harbor's exec signature
98
+ self,
99
+ command: str,
100
+ cwd: str | None = None,
101
+ env: dict[str, str] | None = None,
102
+ timeout_sec: int | None = None,
103
+ user: str | int | None = None,
104
+ ) -> ExecResult:
105
+ trial = self._require_trial()
106
+ run_user = _policy.exec_user(user, self.default_user)
107
+ outcome = await trial.exec(
108
+ command,
109
+ user=run_user,
110
+ cwd=cwd,
111
+ env=self._merge_env(env),
112
+ timeout_sec=timeout_sec,
113
+ )
114
+ return ExecResult(
115
+ stdout=outcome.stdout.decode("utf-8", "replace") or None,
116
+ stderr=outcome.stderr.decode("utf-8", "replace") or None,
117
+ return_code=outcome.returncode,
118
+ )
119
+
120
+ async def upload_file(self, source_path: Path | str, target_path: str) -> None:
121
+ await self._require_trial().upload(Path(source_path), target_path)
122
+
123
+ async def upload_dir(self, source_dir: Path | str, target_dir: str) -> None:
124
+ await self._require_trial().upload(Path(source_dir), target_dir)
125
+
126
+ async def download_file(self, source_path: str, target_path: Path | str) -> None:
127
+ await self._require_trial().download(
128
+ source_path, Path(target_path), is_dir=False
129
+ )
130
+
131
+ async def download_dir(self, source_dir: str, target_dir: Path | str) -> None:
132
+ await self._require_trial().download(source_dir, Path(target_dir), is_dir=True)
133
+
134
+ def _require_trial(self) -> GloveboxTrial:
135
+ if self._trial is None:
136
+ raise RuntimeError("the glovebox environment has not been started.")
137
+ return self._trial
138
+
139
+
140
+ def _checked(
141
+ result: ExecResult | None, dirs: Sequence[EnvironmentPath]
142
+ ) -> ExecResult | None:
143
+ """`result`, raising when the guest command behind it failed."""
144
+ if result is not None and result.return_code != 0:
145
+ raise RuntimeError(
146
+ f"preparing {dirs} in the guest failed (exit {result.return_code}): "
147
+ f"{(result.stderr or '').strip()}"
148
+ )
149
+ return result