opendsec-harbor 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,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: opendsec-harbor
3
+ Version: 0.1.0
4
+ Summary: Harbor environment provider for opendsec: run each Harbor trial as an opendsec World.
5
+ Keywords: harbor,sandbox,reinforcement-learning,evals,agents
6
+ Author: Vibrant Labs
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: harbor>=0.8.1
16
+ Requires-Dist: opendsec>=0.1,<0.2
17
+ Requires-Dist: pyyaml>=6.0.3
18
+ Requires-Python: >=3.12
19
+ Project-URL: Documentation, https://opendsec.vibrantlabs.com
20
+ Description-Content-Type: text/markdown
21
+
22
+ # opendsec-harbor
23
+
24
+ A [Harbor](https://github.com/harbor-framework/harbor) environment provider for [opendsec](https://opendsec.vibrantlabs.com): each Harbor trial runs as one opendsec World, and each service in the task's compose file as one Sandbox in it.
25
+
26
+ ```sh
27
+ pip install opendsec-harbor
28
+ ```
29
+
30
+ ```sh
31
+ export OPENDSEC_URL=https://your-apiserver OPENDSEC_TOKEN=...
32
+ harbor run -p path/to/task -a oracle -e opendsec_harbor:OpenDSecEnvironment
33
+ ```
34
+
35
+ The agent and the task stay the same; only the environment flag changes. The provider builds each task's images and pushes them to `OPENDSEC_REGISTRY`, which your opendsec nodes pull from, so the machine running Harbor needs `docker` and [`crane`](https://github.com/google/go-containerregistry/tree/main/cmd/crane) on `PATH` and push access to that registry.
36
+
37
+ | Option (`--ek key=value`) | Variable | Default |
38
+ |---|---|---|
39
+ | `url` | `OPENDSEC_URL` | `http://localhost:7700` |
40
+ | `token` | `OPENDSEC_TOKEN` | none |
41
+ | `registry` | `OPENDSEC_REGISTRY` | `localhost:5001` |
42
+ | `pull_registry` | `OPENDSEC_PULL_REGISTRY` | `registry`; `host.lima.internal:5001` when `registry` is the local default |
43
+ | `platform` | `OPENDSEC_PLATFORM` | this machine's architecture |
44
+ | `ttl` | | `3h` |
45
+
46
+ Tasks may use only the compose keys `image`, `build`, `command`, `environment`, `volumes`, `expose`, `networks`, `healthcheck` and `depends_on`; anything else fails the trial before it starts rather than running it wrong.
47
+
48
+ Full guide: https://opendsec.vibrantlabs.com/how-to/run-a-harbor-job/
49
+
50
+ Licensed under Apache-2.0.
@@ -0,0 +1,29 @@
1
+ # opendsec-harbor
2
+
3
+ A [Harbor](https://github.com/harbor-framework/harbor) environment provider for [opendsec](https://opendsec.vibrantlabs.com): each Harbor trial runs as one opendsec World, and each service in the task's compose file as one Sandbox in it.
4
+
5
+ ```sh
6
+ pip install opendsec-harbor
7
+ ```
8
+
9
+ ```sh
10
+ export OPENDSEC_URL=https://your-apiserver OPENDSEC_TOKEN=...
11
+ harbor run -p path/to/task -a oracle -e opendsec_harbor:OpenDSecEnvironment
12
+ ```
13
+
14
+ The agent and the task stay the same; only the environment flag changes. The provider builds each task's images and pushes them to `OPENDSEC_REGISTRY`, which your opendsec nodes pull from, so the machine running Harbor needs `docker` and [`crane`](https://github.com/google/go-containerregistry/tree/main/cmd/crane) on `PATH` and push access to that registry.
15
+
16
+ | Option (`--ek key=value`) | Variable | Default |
17
+ |---|---|---|
18
+ | `url` | `OPENDSEC_URL` | `http://localhost:7700` |
19
+ | `token` | `OPENDSEC_TOKEN` | none |
20
+ | `registry` | `OPENDSEC_REGISTRY` | `localhost:5001` |
21
+ | `pull_registry` | `OPENDSEC_PULL_REGISTRY` | `registry`; `host.lima.internal:5001` when `registry` is the local default |
22
+ | `platform` | `OPENDSEC_PLATFORM` | this machine's architecture |
23
+ | `ttl` | | `3h` |
24
+
25
+ Tasks may use only the compose keys `image`, `build`, `command`, `environment`, `volumes`, `expose`, `networks`, `healthcheck` and `depends_on`; anything else fails the trial before it starts rather than running it wrong.
26
+
27
+ Full guide: https://opendsec.vibrantlabs.com/how-to/run-a-harbor-job/
28
+
29
+ Licensed under Apache-2.0.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "opendsec-harbor"
3
+ version = "0.1.0"
4
+ description = "Harbor environment provider for opendsec: run each Harbor trial as an opendsec World."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ keywords = [
10
+ "harbor",
11
+ "sandbox",
12
+ "reinforcement-learning",
13
+ "evals",
14
+ "agents",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "harbor>=0.8.1",
26
+ "opendsec>=0.1,<0.2",
27
+ "pyyaml>=6.0.3",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "Vibrant Labs"
32
+
33
+ [project.urls]
34
+ Documentation = "https://opendsec.vibrantlabs.com"
35
+
36
+ [build-system]
37
+ requires = ["uv_build>=0.13.0,<0.14.0"]
38
+ build-backend = "uv_build"
39
+
40
+ [tool.uv.sources.opendsec]
41
+ path = "../../sdk/python"
42
+ editable = true
43
+
44
+ [dependency-groups]
45
+ dev = ["pytest>=9.1.1"]
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "opendsec-harbor"
3
+ version = "0.1.0"
4
+ description = "Harbor environment provider for opendsec: run each Harbor trial as an opendsec World."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Vibrant Labs" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["harbor", "sandbox", "reinforcement-learning", "evals", "agents"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Science/Research",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "harbor>=0.8.1",
21
+ # Released together with the SDK; a minor bump may break the API.
22
+ "opendsec>=0.1,<0.2",
23
+ "pyyaml>=6.0.3",
24
+ ]
25
+
26
+ [project.urls]
27
+ Documentation = "https://opendsec.vibrantlabs.com"
28
+
29
+ [build-system]
30
+ requires = ["uv_build>=0.13.0,<0.14.0"]
31
+ build-backend = "uv_build"
32
+
33
+ [tool.uv.sources]
34
+ opendsec = { path = "../../sdk/python", editable = true }
35
+
36
+ [dependency-groups]
37
+ dev = [
38
+ "pytest>=9.1.1",
39
+ ]
@@ -0,0 +1,5 @@
1
+ """Harbor environment provider for opendsec."""
2
+
3
+ from opendsec_harbor.environment import OpenDSecEnvironment
4
+
5
+ __all__ = ["OpenDSecEnvironment"]
@@ -0,0 +1,183 @@
1
+ """Translate the subset of docker-compose Harbor tasks use into a World's Sandboxes.
2
+
3
+ Anything outside the subset is rejected, never approximated: a silently
4
+ dropped key (a missing alias, a missing mount) produces a degraded task setup that
5
+ can still score, which is worse than a trial that refuses to start.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ MAIN = "main"
17
+
18
+ SUPPORTED_KEYS = {
19
+ "image",
20
+ "build",
21
+ "command",
22
+ "environment",
23
+ "volumes",
24
+ "expose",
25
+ "networks",
26
+ "healthcheck",
27
+ "depends_on",
28
+ }
29
+
30
+
31
+ class UnsupportedCompose(ValueError):
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class FileMount:
37
+ source: Path # on the host, resolved against the compose file's directory
38
+ target: str # inside the Sandbox
39
+
40
+
41
+ @dataclass
42
+ class ServicePlan:
43
+ """One compose service, before images are built and pushed."""
44
+
45
+ name: str
46
+ image: str | None = None
47
+ build_context: Path | None = None
48
+ dockerfile: str | None = None
49
+ command: list[str] | None = None
50
+ env: dict[str, str] = field(default_factory=dict)
51
+ files: list[FileMount] = field(default_factory=list)
52
+ aliases: list[str] = field(default_factory=list)
53
+ readiness: list[str] | None = None
54
+ readiness_interval_s: float = 1.0
55
+ readiness_timeout_s: float = 60.0
56
+
57
+
58
+ def plan_services(
59
+ environment_dir: Path, docker_image: str | None, prefer_prebuilt: bool
60
+ ) -> list[ServicePlan]:
61
+ """Harbor's base `main` service merged with the task's compose file."""
62
+ main = ServicePlan(name=MAIN, command=["sh", "-c", "sleep infinity"])
63
+ dockerfile = environment_dir / "Dockerfile"
64
+ if docker_image and (prefer_prebuilt or not dockerfile.exists()):
65
+ main.image = docker_image
66
+ elif dockerfile.exists():
67
+ main.build_context = environment_dir
68
+ plans = {MAIN: main}
69
+
70
+ compose_path = environment_dir / "docker-compose.yaml"
71
+ if compose_path.exists():
72
+ doc = yaml.safe_load(compose_path.read_text()) or {}
73
+ if extra := set(doc) - {"services"}:
74
+ raise UnsupportedCompose(f"top-level keys not supported: {sorted(extra)}")
75
+ for name, svc in (doc.get("services") or {}).items():
76
+ plans[name] = _merge(plans.get(name) or ServicePlan(name=name), svc or {}, environment_dir)
77
+
78
+ for p in plans.values():
79
+ if not p.image and not p.build_context:
80
+ raise UnsupportedCompose(f"service {p.name!r} has neither image nor build")
81
+ return list(plans.values())
82
+
83
+
84
+ def _merge(p: ServicePlan, svc: dict, base: Path) -> ServicePlan:
85
+ if extra := set(svc) - SUPPORTED_KEYS:
86
+ raise UnsupportedCompose(f"service {p.name!r}: keys not supported: {sorted(extra)}")
87
+ if "image" in svc:
88
+ p.image = svc["image"]
89
+ if "build" in svc:
90
+ b = svc["build"]
91
+ if isinstance(b, str):
92
+ b = {"context": b}
93
+ if extra := set(b) - {"context", "dockerfile"}:
94
+ raise UnsupportedCompose(f"service {p.name!r}: build keys not supported: {sorted(extra)}")
95
+ p.build_context = (base / b.get("context", ".")).resolve()
96
+ p.dockerfile = b.get("dockerfile")
97
+ p.image = None
98
+ if "command" in svc:
99
+ c = svc["command"]
100
+ p.command = shlex.split(c) if isinstance(c, str) else [str(x) for x in c]
101
+ if "environment" in svc:
102
+ env = svc["environment"]
103
+ if isinstance(env, list):
104
+ env = dict(e.split("=", 1) for e in env)
105
+ p.env.update({k: str(v) for k, v in env.items()})
106
+ for v in svc.get("volumes", []):
107
+ p.files.append(_file_mount(p.name, v, base))
108
+ p.aliases += _aliases(p.name, svc.get("networks"))
109
+ if "healthcheck" in svc:
110
+ _readiness(p, svc["healthcheck"])
111
+ # depends_on is satisfied by construction: a World is ready only when
112
+ # every Sandbox's readiness probe passes, and nothing runs before that.
113
+ for dep, cond in _depends_on(svc.get("depends_on")).items():
114
+ if cond not in ("service_started", "service_healthy"):
115
+ raise UnsupportedCompose(f"service {p.name!r}: depends_on {dep} condition {cond!r}")
116
+ return p
117
+
118
+
119
+ def _file_mount(service: str, v, base: Path) -> FileMount:
120
+ if not isinstance(v, str):
121
+ raise UnsupportedCompose(f"service {service!r}: long-form volumes not supported")
122
+ parts = v.split(":")
123
+ if len(parts) not in (2, 3) or not parts[0].startswith("."):
124
+ raise UnsupportedCompose(
125
+ f"service {service!r}: only read-only binds of task files are supported, got {v!r}"
126
+ )
127
+ if len(parts) == 3 and parts[2] != "ro":
128
+ raise UnsupportedCompose(f"service {service!r}: volume {v!r} must be :ro")
129
+ src = (base / parts[0]).resolve()
130
+ if not src.exists():
131
+ raise UnsupportedCompose(f"service {service!r}: volume source {src} does not exist")
132
+ return FileMount(source=src, target=parts[1])
133
+
134
+
135
+ def _aliases(service: str, networks) -> list[str]:
136
+ if networks is None or isinstance(networks, list):
137
+ if networks and networks != ["default"]:
138
+ raise UnsupportedCompose(f"service {service!r}: only the default network is supported")
139
+ return []
140
+ out = []
141
+ for net, cfg in networks.items():
142
+ if net != "default":
143
+ raise UnsupportedCompose(f"service {service!r}: only the default network is supported")
144
+ cfg = cfg or {}
145
+ if extra := set(cfg) - {"aliases"}:
146
+ raise UnsupportedCompose(f"service {service!r}: network keys not supported: {sorted(extra)}")
147
+ out += cfg.get("aliases", [])
148
+ return out
149
+
150
+
151
+ def _readiness(p: ServicePlan, hc: dict) -> None:
152
+ if extra := set(hc) - {"test", "interval", "timeout", "retries", "start_period"}:
153
+ raise UnsupportedCompose(f"service {p.name!r}: healthcheck keys not supported: {sorted(extra)}")
154
+ test = hc["test"]
155
+ if isinstance(test, str):
156
+ p.readiness = ["sh", "-c", test]
157
+ elif test[0] == "CMD":
158
+ p.readiness = [str(x) for x in test[1:]]
159
+ elif test[0] == "CMD-SHELL":
160
+ p.readiness = ["sh", "-c", test[1]]
161
+ else:
162
+ raise UnsupportedCompose(f"service {p.name!r}: healthcheck test {test[0]!r}")
163
+ interval = _seconds(hc.get("interval", "30s"))
164
+ p.readiness_interval_s = interval
165
+ p.readiness_timeout_s = _seconds(hc.get("start_period", "0s")) + interval * hc.get("retries", 3)
166
+
167
+
168
+ def _depends_on(d) -> dict[str, str]:
169
+ if d is None:
170
+ return {}
171
+ if isinstance(d, list):
172
+ return {k: "service_started" for k in d}
173
+ return {k: (v or {}).get("condition", "service_started") for k, v in d.items()}
174
+
175
+
176
+ def _seconds(s) -> float:
177
+ if isinstance(s, (int, float)):
178
+ return float(s)
179
+ units = {"ms": 0.001, "s": 1, "m": 60, "h": 3600}
180
+ for u in ("ms", "s", "m", "h"):
181
+ if s.endswith(u) and s[: -len(u)].replace(".", "", 1).isdigit():
182
+ return float(s[: -len(u)]) * units[u]
183
+ raise UnsupportedCompose(f"duration {s!r}")
@@ -0,0 +1,210 @@
1
+ """Harbor environment provider backed by opendsec.
2
+
3
+ harbor run -e opendsec_harbor:OpenDSecEnvironment \\
4
+ --ek url=http://localhost:7700 --ek registry=localhost:5001 ...
5
+
6
+ Each Harbor trial is one World; each compose service is one Sandbox in it.
7
+
8
+ Works with upstream Harbor (0.22) and Marin's fork (0.8.1); the two differ in
9
+ how they express network policy, handled in `_egress`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from harbor.environments.base import BaseEnvironment, ExecResult
21
+ from harbor.environments.capabilities import EnvironmentCapabilities
22
+ from harbor.environments.definition import require_agent_environment_definition
23
+
24
+ import opendsec
25
+ from opendsec_harbor.compose import MAIN, plan_services
26
+ from opendsec_harbor.images import resolve_image
27
+
28
+
29
+ class OpenDSecEnvironment(BaseEnvironment):
30
+ def __init__(self, *args, **kwargs):
31
+ self._url = kwargs.pop("url", None) or os.environ.get("OPENDSEC_URL", "http://localhost:7700")
32
+ self._token = kwargs.pop("token", None) or os.environ.get("OPENDSEC_TOKEN")
33
+ # Where images are pushed from this machine, and the same registry as
34
+ # the platform's nodes reach it (they differ in local dev: Docker
35
+ # Desktop's localhost is the Lima VM's host.lima.internal).
36
+ self._registry = kwargs.pop("registry", None) or os.environ.get("OPENDSEC_REGISTRY", "localhost:5001")
37
+ self._pull_registry = (
38
+ kwargs.pop("pull_registry", None)
39
+ or os.environ.get("OPENDSEC_PULL_REGISTRY")
40
+ or ("host.lima.internal:5001" if self._registry == "localhost:5001" else self._registry)
41
+ )
42
+ self._ttl = kwargs.pop("ttl", "3h")
43
+ self._platform = kwargs.pop("platform", None)
44
+ self._metrics: dict = {}
45
+ self._started_at: float | None = None
46
+ self._client: opendsec.Client | None = None
47
+ self._world: opendsec.World | None = None
48
+ super().__init__(*args, **kwargs)
49
+
50
+ @staticmethod
51
+ def type() -> str:
52
+ return "opendsec"
53
+
54
+ @property
55
+ def capabilities(self) -> EnvironmentCapabilities:
56
+ # Egress is all-or-nothing for now: no allowlists, and no switching
57
+ # policy mid-trial. Harbor rejects tasks that need either rather than
58
+ # run them with the wrong network.
59
+ return EnvironmentCapabilities(docker_compose=True, disable_internet=True)
60
+
61
+ def _egress(self) -> str:
62
+ policy = getattr(self, "_network_policy", None)
63
+ if policy is not None: # upstream Harbor
64
+ mode = str(getattr(policy.network_mode, "value", policy.network_mode))
65
+ if mode == "public":
66
+ return "public"
67
+ if mode == "no-network":
68
+ return "none"
69
+ raise NotImplementedError(f"opendsec: network mode {mode!r} is not supported yet")
70
+ # Marin's fork: a boolean on the task's environment config.
71
+ return "public" if getattr(self.task_env_config, "allow_internet", True) else "none"
72
+
73
+ def _validate_definition(self):
74
+ require_agent_environment_definition(
75
+ self.environment_dir, docker_image=self.task_env_config.docker_image
76
+ )
77
+
78
+ async def start(self, force_build: bool) -> None:
79
+ t0 = time.monotonic()
80
+ plans = plan_services(
81
+ self.environment_dir, self.task_env_config.docker_image, prefer_prebuilt=not force_build
82
+ )
83
+ reports = await asyncio.gather(
84
+ *(resolve_image(p, self._registry, force_build=force_build, platform=self._platform) for p in plans)
85
+ )
86
+ t_images = time.monotonic()
87
+ cpus, memory_mb = self._effective_cpus or 1, self._effective_memory_mb or 2048
88
+ egress = self._egress()
89
+ sandboxes = []
90
+ for p, rep in zip(plans, reports):
91
+ env = dict(p.env)
92
+ if p.name == MAIN:
93
+ env.update(self.task_env_config.env or {})
94
+ sandboxes.append(
95
+ opendsec.Sandbox(
96
+ name=p.name,
97
+ # Nodes may reach the registry by another name (local dev).
98
+ image=self._pull_registry + rep.ref.removeprefix(self._registry),
99
+ cpus=cpus,
100
+ memory_mb=memory_mb,
101
+ command=p.command,
102
+ env=env,
103
+ aliases=p.aliases,
104
+ egress=egress,
105
+ readiness=opendsec.Readiness(
106
+ command=p.readiness,
107
+ interval=f"{p.readiness_interval_s}s",
108
+ timeout=f"{p.readiness_timeout_s}s",
109
+ )
110
+ if p.readiness
111
+ else None,
112
+ )
113
+ )
114
+ self._client = opendsec.Client(self._url, self._token)
115
+ self.logger.debug(f"creating world with sandboxes {[sb.name for sb in sandboxes]}")
116
+ try:
117
+ self._world = await self._client.create_world(sandboxes, ttl=self._ttl)
118
+ finally:
119
+ self._metrics = {
120
+ "egress": egress,
121
+ "images": [vars(r) for r in reports],
122
+ "images_s": round(t_images - t0, 3),
123
+ "create_s": round(time.monotonic() - t_images, 3),
124
+ }
125
+ self._metrics |= {"world_id": self._world.id, "node_timings_ms": self._world.timings}
126
+ self.logger.debug(f"world {self._world.id} ready")
127
+ # Harbor's /logs/* mounts become plain directories it copies back.
128
+ await self.ensure_dirs(self._mount_targets(writable_only=True))
129
+ await self._upload_environment_dir_after_start()
130
+ self._metrics["start_s"] = round(time.monotonic() - t0, 3)
131
+ self._started_at = time.monotonic()
132
+
133
+ async def stop(self, delete: bool):
134
+ try:
135
+ if self._started_at is not None:
136
+ self._metrics["in_world_s"] = round(time.monotonic() - self._started_at, 3)
137
+ if self._world:
138
+ await self._world.stop()
139
+ finally:
140
+ self._write_metrics()
141
+ if self._client:
142
+ await self._client.close()
143
+ self._world = self._client = None
144
+
145
+ def _write_metrics(self) -> None:
146
+ if not self._metrics:
147
+ return
148
+ role = "verifier" if "__verifier__" in self.session_id else "agent"
149
+ try:
150
+ path = Path(str(self.trial_paths.trial_dir)) / f"opendsec-{role}.json"
151
+ path.write_text(json.dumps(self._metrics, indent=2) + "\n")
152
+ except OSError as e:
153
+ self.logger.warning(f"opendsec: could not write metrics: {e}")
154
+
155
+ @property
156
+ def world(self) -> opendsec.World:
157
+ if not self._world:
158
+ raise RuntimeError("environment not started")
159
+ return self._world
160
+
161
+ async def exec(self, command, cwd=None, env=None, timeout_sec=None, user=None) -> ExecResult:
162
+ return await self.service_exec(
163
+ command, service=MAIN, cwd=cwd, env=env, timeout_sec=timeout_sec, user=user
164
+ )
165
+
166
+ async def service_exec(
167
+ self, command, *, service=None, cwd=None, env=None, timeout_sec=None, user=None
168
+ ) -> ExecResult:
169
+ sandbox = service or MAIN
170
+ user = self._resolve_user(user)
171
+ if user not in (None, 0, "0", "root"):
172
+ raise NotImplementedError(f"opendsec: exec as user {user!r} is not supported yet")
173
+ if sandbox == MAIN:
174
+ # Harbor builds main and it always has bash; sidecars may not.
175
+ argv = ["bash", "-c", command]
176
+ cwd = cwd or self.task_env_config.workdir
177
+ env = self._merge_env(env)
178
+ else:
179
+ argv = ["sh", "-c", command]
180
+ res = await self.world.exec(sandbox, argv, env=env, workdir=cwd, timeout=timeout_sec)
181
+ if res.timed_out:
182
+ raise RuntimeError(f"Command timed out after {timeout_sec} seconds")
183
+ return ExecResult(stdout=res.stdout, stderr=res.stderr, return_code=res.exit_code)
184
+
185
+ async def upload_file(self, source_path, target_path):
186
+ await self.world.upload(MAIN, source_path, target_path)
187
+
188
+ async def upload_dir(self, source_dir, target_dir):
189
+ await self.world.upload(MAIN, source_dir, target_dir)
190
+
191
+ async def download_file(self, source_path, target_path):
192
+ await self.world.download(MAIN, source_path, Path(target_path))
193
+
194
+ async def download_dir(self, source_dir, target_dir):
195
+ await self.world.download(MAIN, source_dir, Path(target_dir))
196
+
197
+ async def service_download_file(self, source_path, target_path, *, service=None):
198
+ await self.world.download(service or MAIN, source_path, Path(target_path))
199
+
200
+ async def service_download_dir(self, source_dir, target_dir, *, service=None):
201
+ await self.world.download(service or MAIN, source_dir, Path(target_dir))
202
+
203
+ async def stop_service(self, service: str) -> None:
204
+ # Stopping one Sandbox would fail the whole World (a World is placed
205
+ # and destroyed as a unit), taking the service holding the evidence
206
+ # with it. Recorded as a divergent-lifecycle case for the World-model
207
+ # experiment (docs/plan.md, M2); main is left running.
208
+ self.logger.warning(
209
+ f"opendsec: stop_service({service!r}) is a no-op; Sandboxes cannot stop independently"
210
+ )
@@ -0,0 +1,202 @@
1
+ """Content-addressed task images.
2
+
3
+ The platform never builds. Every compose service becomes an image in a
4
+ registry, tagged by a hash of everything that determines it: the build
5
+ context's files, the Dockerfile name, any bind-mounted task files baked in,
6
+ and the platform. If the registry already holds that tag, nothing is built, so
7
+ `prebuild` (run ahead of a batch) and the provider (at trial start) share one
8
+ code path and one cache: the registry itself.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import platform as host_platform
18
+ import re
19
+ import shutil
20
+ import tempfile
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from opendsec_harbor.compose import ServicePlan
26
+
27
+ REPO = "opendsec/task-images"
28
+ _IGNORE = {".DS_Store", ".git", "__pycache__"}
29
+ _locks: dict[str, asyncio.Lock] = {}
30
+ _cache: dict[str, str] = {}
31
+
32
+
33
+ @dataclass
34
+ class ImageReport:
35
+ service: str
36
+ ref: str # registry/repo@sha256:...
37
+ status: str # "cached" (already in registry) or "built"
38
+ seconds: float
39
+
40
+
41
+ def default_platform() -> str:
42
+ """OPENDSEC_PLATFORM, else this machine's architecture.
43
+
44
+ Images must match the nodes'; this default suits a driver that shares
45
+ the nodes' architecture (an amd64 VM next to amd64 nodes, or a Mac next
46
+ to its arm64 Lima VM).
47
+ """
48
+ if p := os.environ.get("OPENDSEC_PLATFORM"):
49
+ return p
50
+ arch = host_platform.machine().lower()
51
+ return "linux/arm64" if arch in ("arm64", "aarch64") else "linux/amd64"
52
+
53
+
54
+ def content_hash(plan: ServicePlan, platform: str) -> str:
55
+ """Hash of every input that determines the service's image."""
56
+ h = hashlib.sha256()
57
+
58
+ def add(*parts: str | bytes) -> None:
59
+ for p in parts:
60
+ b = p.encode() if isinstance(p, str) else p
61
+ h.update(len(b).to_bytes(8, "big") + b)
62
+
63
+ add("v1", platform, plan.dockerfile or "Dockerfile")
64
+ if plan.build_context:
65
+ add("context")
66
+ _add_tree(add, plan.build_context)
67
+ else:
68
+ add("image", plan.image or "")
69
+ for f in plan.files:
70
+ add("file", f.target)
71
+ _add_tree(add, f.source)
72
+ return h.hexdigest()[:24]
73
+
74
+
75
+ def _add_tree(add, root: Path) -> None:
76
+ if root.is_file():
77
+ add(root.read_bytes())
78
+ return
79
+ for p in sorted(root.rglob("*")):
80
+ if any(part in _IGNORE for part in p.relative_to(root).parts) or not p.is_file():
81
+ continue
82
+ add(p.relative_to(root).as_posix(), oct(p.stat().st_mode & 0o111), p.read_bytes())
83
+
84
+
85
+ async def resolve_image(
86
+ plan: ServicePlan, registry: str, *, force_build: bool = False, platform: str | None = None
87
+ ) -> ImageReport:
88
+ """Ensure the service's image is in the registry; return it by digest."""
89
+ platform = platform or default_platform()
90
+ tag = f"{registry}/{REPO}:{content_hash(plan, platform)}"
91
+ lock = _locks.setdefault(tag, asyncio.Lock())
92
+ async with lock:
93
+ start = time.monotonic()
94
+ if tag in _cache and not force_build:
95
+ return ImageReport(plan.name, _cache[tag], "cached", 0.0)
96
+ if not force_build and (digest := await _digest(tag)):
97
+ ref = _by_digest(tag, digest)
98
+ status = "cached"
99
+ else:
100
+ await _build(plan, tag, platform, no_cache=force_build)
101
+ await _docker("push", "-q", tag)
102
+ digest = await _digest(tag)
103
+ if not digest:
104
+ raise RuntimeError(f"pushed {tag} but the registry does not report a digest")
105
+ ref = _by_digest(tag, digest)
106
+ status = "built"
107
+ _cache[tag] = ref
108
+ return ImageReport(plan.name, ref, status, time.monotonic() - start)
109
+
110
+
111
+ async def compressed_size(ref: str, plat: str | None = None) -> int:
112
+ """Total compressed layer bytes of an image, as a node would pull it."""
113
+ _, out = await _crane("manifest", "--platform", plat or default_platform(), ref)
114
+ return sum(layer["size"] for layer in json.loads(out).get("layers", []))
115
+
116
+
117
+ async def _build(plan: ServicePlan, tag: str, platform: str, no_cache: bool) -> None:
118
+ if plan.build_context:
119
+ args = ["build", "-q", "--platform", platform, "-t", tag]
120
+ if plan.dockerfile:
121
+ args += ["-f", str(plan.build_context / plan.dockerfile)]
122
+ if no_cache:
123
+ args.append("--no-cache")
124
+ await _docker(*args, str(plan.build_context))
125
+ else:
126
+ assert plan.image
127
+ # Prefer a local image (e.g. one built by the environment's own
128
+ # tooling) when it matches the platform; otherwise pull.
129
+ code, local = await _docker(
130
+ "image", "inspect", "--format", "{{.Os}}/{{.Architecture}}", plan.image, check=False
131
+ )
132
+ if code != 0 or local.strip() != platform:
133
+ await _docker("pull", "-q", "--platform", platform, plan.image)
134
+ await _docker("tag", plan.image, tag)
135
+ if plan.files:
136
+ # A remote World cannot see this machine's files: bake them in.
137
+ with tempfile.TemporaryDirectory() as ctx:
138
+ lines = [f"FROM {tag}"]
139
+ for i, f in enumerate(plan.files):
140
+ name = f"f{i}"
141
+ if f.source.is_dir():
142
+ shutil.copytree(f.source, Path(ctx) / name)
143
+ else:
144
+ shutil.copy2(f.source, Path(ctx) / name)
145
+ lines.append(f"COPY {name} {f.target}")
146
+ (Path(ctx) / "Dockerfile").write_text("\n".join(lines) + "\n")
147
+ await _docker("build", "-q", "--platform", platform, "-t", tag, ctx)
148
+
149
+
150
+ _NOT_FOUND = ("MANIFEST_UNKNOWN", "NAME_UNKNOWN", "NOT_FOUND", "404 Not Found")
151
+
152
+
153
+ async def _digest(tag: str) -> str | None:
154
+ """The tag's digest, or None if the registry does not have it.
155
+
156
+ Only a definite "not found" is a cache miss. Any other failure (expired
157
+ credentials, network) raises: treating it as a miss would rebuild and
158
+ push an image that already exists, and fail confusingly at the push.
159
+ """
160
+ code, out = await _crane("digest", tag, check=False)
161
+ if code == 0 and out.startswith("sha256:"):
162
+ return out.strip()
163
+ if any(marker in out for marker in _NOT_FOUND):
164
+ return None
165
+ raise RuntimeError(f"cannot query registry for {tag} (credentials expired?):\n{out.strip()[-500:]}")
166
+
167
+
168
+ def _by_digest(tag: str, digest: str) -> str:
169
+ return tag.rsplit(":", 1)[0] + "@" + digest
170
+
171
+
172
+ def _insecure(ref: str) -> bool:
173
+ host = ref.split("/", 1)[0].split(":", 1)[0]
174
+ return host in ("localhost", "127.0.0.1")
175
+
176
+
177
+ async def _crane(*args: str, check: bool = True) -> tuple[int, str]:
178
+ """Run crane; on failure the returned text includes stderr."""
179
+ ref = args[-1]
180
+ extra = ["--insecure"] if _insecure(ref) else []
181
+ return await _run("crane", *args[:-1], *extra, ref, check=check, stderr_on_error=True)
182
+
183
+
184
+ async def _docker(*args: str, check: bool = True) -> tuple[int, str]:
185
+ return await _run("docker", *args, check=check)
186
+
187
+
188
+ async def _run(*argv: str, check: bool = True, stderr_on_error: bool = False) -> tuple[int, str]:
189
+ proc = await asyncio.create_subprocess_exec(
190
+ *argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
191
+ )
192
+ out, err = await proc.communicate()
193
+ if check and proc.returncode != 0:
194
+ raise RuntimeError(
195
+ f"{' '.join(argv[:3])} failed ({proc.returncode}):\n{(out + err).decode(errors='replace')[-2000:]}"
196
+ )
197
+ text = out if proc.returncode == 0 or not stderr_on_error else out + err
198
+ return proc.returncode or 0, text.decode(errors="replace")
199
+
200
+
201
+ def slug(s: str) -> str:
202
+ return re.sub(r"[^a-z0-9._-]+", "-", s.lower()).strip("-.")[:60]
@@ -0,0 +1,90 @@
1
+ """Build and push the images for many Harbor tasks ahead of a run.
2
+
3
+ uv run python -m opendsec_harbor.prebuild --registry myacr.azurecr.io -j 8 \\
4
+ --out prebuild.jsonl tasks/*
5
+
6
+ Uses the provider's own content-addressed path, so anything built here is a
7
+ cache hit at trial start. Writes one JSON line per image, failures included.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import json
15
+ import sys
16
+ import time
17
+ import tomllib
18
+ from pathlib import Path
19
+
20
+ from opendsec_harbor.compose import plan_services
21
+ from opendsec_harbor.images import compressed_size, default_platform, resolve_image
22
+
23
+
24
+ def task_environments(task: Path) -> list[tuple[str, Path, str | None]]:
25
+ """(role, environment dir, prebuilt docker_image) for each environment a
26
+ trial of this task starts: the agent's, and a separate verifier's."""
27
+ cfg = tomllib.loads((task / "task.toml").read_text())
28
+ envs = [("agent", task / "environment", cfg.get("environment", {}).get("docker_image"))]
29
+ verifier = cfg.get("verifier", {})
30
+ if verifier.get("environment_mode") == "separate":
31
+ envs.append(("verifier", task / "tests", verifier.get("environment", {}).get("docker_image")))
32
+ return envs
33
+
34
+
35
+ async def prebuild_task(task: Path, registry: str, platform: str, sem: asyncio.Semaphore) -> list[dict]:
36
+ rows = []
37
+ async with sem:
38
+ for role, env_dir, docker_image in task_environments(task):
39
+ try:
40
+ plans = plan_services(env_dir, docker_image, prefer_prebuilt=True)
41
+ except Exception as e: # noqa: BLE001 - report every failure, keep going
42
+ rows.append({"task": task.name, "role": role, "error": f"plan: {e}"})
43
+ continue
44
+ for plan in plans:
45
+ row = {"task": task.name, "role": role, "service": plan.name}
46
+ start = time.monotonic()
47
+ try:
48
+ rep = await resolve_image(plan, registry, platform=platform)
49
+ row |= {
50
+ "status": rep.status,
51
+ "seconds": round(rep.seconds, 2),
52
+ "ref": rep.ref,
53
+ "compressed_bytes": await compressed_size(rep.ref, platform),
54
+ }
55
+ except Exception as e: # noqa: BLE001
56
+ row |= {"error": str(e)[-1000:], "seconds": round(time.monotonic() - start, 2)}
57
+ rows.append(row)
58
+ return rows
59
+
60
+
61
+ async def main() -> int:
62
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
63
+ ap.add_argument("tasks", nargs="+", type=Path, help="Harbor task directories")
64
+ ap.add_argument("--registry", required=True)
65
+ ap.add_argument("--platform", default=default_platform())
66
+ ap.add_argument("-j", "--jobs", type=int, default=4, help="tasks built concurrently")
67
+ ap.add_argument("--out", type=Path, default=Path("prebuild.jsonl"))
68
+ args = ap.parse_args()
69
+
70
+ sem = asyncio.Semaphore(args.jobs)
71
+ tasks = [t for t in args.tasks if (t / "task.toml").exists()]
72
+ failures = built = cached = 0
73
+ with args.out.open("w") as out:
74
+ for coro in asyncio.as_completed([prebuild_task(t, args.registry, args.platform, sem) for t in tasks]):
75
+ for row in await coro:
76
+ out.write(json.dumps(row) + "\n")
77
+ out.flush()
78
+ if "error" in row:
79
+ failures += 1
80
+ print(f"FAIL {row['task']}/{row.get('service', '?')}: {row['error'][:200]}", file=sys.stderr)
81
+ elif row["status"] == "built":
82
+ built += 1
83
+ else:
84
+ cached += 1
85
+ print(f"{len(tasks)} tasks: {built} built, {cached} cached, {failures} failed -> {args.out}")
86
+ return 1 if failures else 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(asyncio.run(main()))
File without changes