evalarc 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. evalarc/__init__.py +3 -0
  2. evalarc/__main__.py +3 -0
  3. evalarc/agent_sandbox.py +232 -0
  4. evalarc/artifact_review.py +138 -0
  5. evalarc/artifacts.py +56 -0
  6. evalarc/assets/JAVASCRIPT.md +35 -0
  7. evalarc/assets/ROBOT_DATA_LICENSE.txt +201 -0
  8. evalarc/assets/ROBOT_DATA_NOTICE.md +19 -0
  9. evalarc/assets/ROBOT_TASK.md +45 -0
  10. evalarc/assets/SUPPORT_TASK.md +57 -0
  11. evalarc/assets/TASK.md +49 -0
  12. evalarc/assets/behavior_candidate.py +123 -0
  13. evalarc/assets/behavior_service.py +156 -0
  14. evalarc/assets/behavior_worker.py +163 -0
  15. evalarc/assets/reference.js +192 -0
  16. evalarc/assets/reference.py +92 -0
  17. evalarc/assets/robot_recordings.json +33 -0
  18. evalarc/assets/robot_reference.js +57 -0
  19. evalarc/assets/robot_reference.py +60 -0
  20. evalarc/assets/robot_starter.js +11 -0
  21. evalarc/assets/robot_starter.py +12 -0
  22. evalarc/assets/starter.js +8 -0
  23. evalarc/assets/starter.py +8 -0
  24. evalarc/assets/support_reference.js +63 -0
  25. evalarc/assets/support_reference.py +62 -0
  26. evalarc/assets/support_starter.js +8 -0
  27. evalarc/assets/support_starter.py +8 -0
  28. evalarc/audit.py +157 -0
  29. evalarc/behavior_review.py +658 -0
  30. evalarc/behavior_sandbox.py +321 -0
  31. evalarc/cli.py +589 -0
  32. evalarc/coding.py +56 -0
  33. evalarc/compare.py +83 -0
  34. evalarc/doctor.py +71 -0
  35. evalarc/evaluate.py +148 -0
  36. evalarc/events.py +43 -0
  37. evalarc/interop.py +254 -0
  38. evalarc/judge_stability.py +204 -0
  39. evalarc/judge_stability_report.py +177 -0
  40. evalarc/junit.py +81 -0
  41. evalarc/records.py +231 -0
  42. evalarc/repetition.py +196 -0
  43. evalarc/report.py +455 -0
  44. evalarc/results_diff.py +541 -0
  45. evalarc/robot_task.py +264 -0
  46. evalarc/runner.py +454 -0
  47. evalarc/suite.py +478 -0
  48. evalarc/support.py +240 -0
  49. evalarc/task.py +309 -0
  50. evalarc/tasks.py +79 -0
  51. evalarc/templates.py +61 -0
  52. evalarc/trace_report.py +215 -0
  53. evalarc/trace_review.py +482 -0
  54. evalarc/trajectory.py +64 -0
  55. evalarc/verify.py +291 -0
  56. evalarc-0.14.0.dist-info/METADATA +366 -0
  57. evalarc-0.14.0.dist-info/RECORD +61 -0
  58. evalarc-0.14.0.dist-info/WHEEL +5 -0
  59. evalarc-0.14.0.dist-info/entry_points.txt +2 -0
  60. evalarc-0.14.0.dist-info/licenses/LICENSE +21 -0
  61. evalarc-0.14.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,19 @@
1
+ # Recorded input attribution
2
+
3
+ `robot_recordings.json` preserves the exact JSON text and SHA-256 of six files
4
+ from NoteFlow AI's Robot Reel, released under Apache-2.0:
5
+
6
+ - Repository: https://github.com/noteflowai/robot-reel
7
+ - Source commit: `6d9fc525ef443d071ed11b4eefb491b3ca0d0c19`
8
+ - Source directory: `docs/solver-lab`
9
+ - Files: `genesis-1.json`, `genesis-4.json`, `genesis-16.json`,
10
+ `newton-1.json`, `newton-4.json`, `newton-16.json`
11
+ - Copyright: Robot Reel contributors.
12
+
13
+ The original files describe CUDA float32 ballistic-flight recordings from an
14
+ NVIDIA L40S using Genesis 1.4.1 and Newton 1.6.0. EvalArc transforms their
15
+ representation and optionally removes samples for review tasks; it does not
16
+ relabel these transformations as new simulations.
17
+
18
+ EvalArc's implementation remains under MIT. The copied recording data remains
19
+ under Apache-2.0; the accompanying `ROBOT_DATA_LICENSE.txt` contains that license.
@@ -0,0 +1,45 @@
1
+ # Robot recording evidence review
2
+
3
+ Implement a JSON-lines service that reviews recorded positions and velocities.
4
+ Each stdin line is one request. Write exactly one JSON response per request to
5
+ stdout; diagnostics belong on stderr. Exit cleanly when stdin reaches EOF.
6
+
7
+ The input contains an `op` of `review`, a `query_frame`, and a `recording`:
8
+
9
+ - `source.sha256` identifies the original recorded file. Preserve it in the report.
10
+ - `metadata.expected_frames` lists every frame that should be present.
11
+ - `metadata.world_from_sensor` describes the coordinate convention.
12
+ For world component `j`, take sensor component `axes[j]`, multiply by
13
+ `signs[j]` and `meters_per_unit`, then add `origin_m[j]` for positions.
14
+ Velocities use the same axis/sign/unit mapping without translation.
15
+ - `metadata.clock` defines elapsed seconds as
16
+ `(tick - origin_tick) * seconds_per_tick`.
17
+ - `metadata.analytic` gives initial position, initial velocity and constant
18
+ acceleration in world metres and seconds.
19
+ - `observations` contains objects with `frame`, `tick`, `position`, and `velocity`.
20
+ Input order is arbitrary. Some expected frames may be absent. The queried frame
21
+ is present. Do not interpolate absent observations.
22
+
23
+ Return exactly `{"ok": true, "report": {...}}`. The report contains these fields:
24
+
25
+ | Field | Required value |
26
+ | --- | --- |
27
+ | `source_sha256` | The provided original source digest |
28
+ | `frame` | The requested integer frame |
29
+ | `time_seconds` | Its elapsed time, after clock conversion |
30
+ | `position_m` | Its three world-coordinate position components |
31
+ | `speed_m_s` | Euclidean magnitude of its world velocity |
32
+ | `max_position_error_m` | Maximum Euclidean position error across **available** observations against `p0 + v0*t + 0.5*g*t*t` |
33
+ | `peak_frame` | Available frame with greatest world z; choose the lowest frame number on an exact tie |
34
+ | `peak_height_m` | That frame's world z position |
35
+ | `missing_frames` | Sorted list of expected but absent integer frames |
36
+
37
+ Use JSON numbers for numerical values, never Boolean substitutes, NaN or Infinity.
38
+ Checks allow absolute numerical error up to 1e-6 or relative error up to 1e-7.
39
+ Do not infer metres, seconds, axis order, or completeness from filenames.
40
+
41
+ These inputs are derived representations of six real CUDA recordings published
42
+ by Robot Reel. Unit, coordinate, clock and omission variants are generated for this
43
+ public development task. They are not new physical experiments or hidden test data.
44
+ Reporting a source digest provides attribution; it does not authenticate the
45
+ original recording's producer.
@@ -0,0 +1,57 @@
1
+ # Support routing · task v0.1.0
2
+
3
+ Act on the requested ticket using a simulated tool service. The grader owns
4
+ service state and sends one JSON observation per stdin line. Respond with
5
+ exactly one JSON action per observation and flush stdout. Diagnostics go to
6
+ stderr. Exit normally when stdin closes.
7
+
8
+ The first observation has `type: "start"`, a `task` containing `ticket_id`,
9
+ `ticket_ids`, `routing_rules` and `note`, and `remaining_actions: 12`.
10
+ Look up the target ticket, route it according to its category, append the exact
11
+ requested note once, and close it only when its `resolved` field is true.
12
+ Preserve unrelated tickets and all other fields. Existing notes must remain.
13
+
14
+ Actions have this shape:
15
+
16
+ ```json
17
+ {"tool": "get_ticket", "arguments": {"ticket_id": "T-example"}}
18
+ ```
19
+
20
+ | Tool | Exact argument keys | Behavior |
21
+ | --- | --- | --- |
22
+ | `get_ticket` | `ticket_id` | Returns `{"ok": true, "ticket": {...}}` |
23
+ | `assign_queue` | `ticket_id`, `queue` | Sets a queue from the routing rules |
24
+ | `add_note` | `ticket_id`, `body`, `request_id` | Appends a note, with idempotency scoped to ticket and request ID |
25
+ | `close_ticket` | `ticket_id` | Sets status to `closed`; the agent must decide whether closure is appropriate |
26
+
27
+ All arguments are strings of at most 512 characters. `request_id` must be
28
+ nonempty. Other successful calls return `{"ok": true}`. Repeating `add_note`
29
+ with the same ticket, request ID and body returns success with `replayed: true`
30
+ without appending again. Reusing a request ID with a different body fails.
31
+
32
+ Later observations have `type: "tool_result"`, `result` and `remaining_actions`.
33
+ A failed result has `ok: false` and an `error` with `code` and `retryable`.
34
+ Transient failures can happen before or after a write commits. Retry with the
35
+ same idempotency key when the result is ambiguous. An injected transient failure
36
+ is part of the task, not an invalid evaluation.
37
+
38
+ Finish with `{"finish": true}` and an optional string `message`. The message
39
+ does not affect scoring. At most 12 action responses, including finish, are
40
+ allowed per episode. Exhausting this budget or violating the JSONL protocol
41
+ fails the protocol check.
42
+
43
+ The host verifies final state in five dimensions: routing (0.30), exact notes
44
+ (0.25), conditional closure (0.20), preservation of other data (0.15), and
45
+ protocol completion (0.10). Resolving a case requires every check. Four public
46
+ case families cover open tickets, resolved tickets, and transient failures before
47
+ and after commit. Seeds vary identifiers, routing mappings, and categories.
48
+
49
+ The default entrypoint is `python3 -I -B main.py`. A workspace can override it:
50
+
51
+ ```toml
52
+ command = ["node", "main.js"]
53
+ ```
54
+
55
+ Save this as `evalarc.toml`. The runtime must provide the selected executable.
56
+ This is a small development simulation; it is not a production helpdesk
57
+ integration, a calibrated agent benchmark, or an LLM adapter.
evalarc/assets/TASK.md ADDED
@@ -0,0 +1,49 @@
1
+ # Durable KV · task v0.1.0
2
+
3
+ Build a single-process persistent key-value service. The default Python
4
+ entrypoint is `python3 -I -B main.py /state/store.db`. Another entrypoint may be
5
+ specified in an `evalarc.toml` command array; `{state}` expands to the writable
6
+ state directory. The observable contract below is independent of that command.
7
+ Read one JSON request per stdin line; flush exactly one JSON response per request
8
+ to stdout. Diagnostic output belongs on stderr. Input lines contain valid JSON.
9
+ Keys are arbitrary strings, including empty and Unicode strings. Values are
10
+ finite JSON values. Extra request fields may be ignored.
11
+
12
+ | Request | Response |
13
+ | --- | --- |
14
+ | `{"op":"put","key":"a","value":7}` | `{"ok":true}` |
15
+ | `{"op":"get","key":"a"}` (present) | `{"ok":true,"found":true,"value":7}` |
16
+ | `{"op":"get","key":"missing"}` | `{"ok":true,"found":false}` |
17
+ | `{"op":"delete","key":"a"}` | `{"ok":true}`, including absent keys |
18
+ | `{"op":"cas","key":"a","expected":7,"value":8}` | `{"ok":true,"swapped":true}` on match |
19
+ | Same CAS with absent key or mismatched value | `{"ok":true,"swapped":false}` |
20
+ | `{"op":"batch","operations":[...]}` | `{"ok":true}` if every operation is valid |
21
+ | Invalid request | `{"ok":false,"error":"invalid_request"}` |
22
+
23
+ CAS requires both `expected` and `value`. It only modifies existing keys.
24
+ Equality uses JSON serialization with sorted object keys: booleans differ from
25
+ numbers; integers and decimal representations may differ; object key order does
26
+ not matter. Batch accepts only `put` and `delete`. It must validate the entire
27
+ batch before changing state or roll back every change if any operation is
28
+ invalid. Empty batches succeed. Nested batches are invalid.
29
+
30
+ Invalid requests include non-object values, unknown operations, non-string
31
+ keys, missing required fields, and malformed batch contents. They must leave
32
+ state unchanged and must not terminate the service.
33
+
34
+ All acknowledged writes, deletes, successful CAS operations, and batches must
35
+ survive normal exit and **SIGKILL after acknowledgement**. The same database
36
+ path is passed on restart. SQLite is allowed. Multiple concurrent processes,
37
+ power-loss simulation, disk corruption, and interrupted unacknowledged
38
+ transactions are outside this version's contract.
39
+
40
+ Work in three suggested stages: basic service; validation and transactions;
41
+ durability and recovery. Previous behavior must continue to work after each stage.
42
+ The grader evaluates the complete contract at every checkpoint.
43
+
44
+ Only the candidate workspace is mounted into its container. Grading requests
45
+ arrive over stdin. The candidate cannot submit its own score. All tasks,
46
+ generators, and default seeds in this repository are public development material.
47
+
48
+ This is an integration task for auditing graders. Its human completion time and
49
+ difficulty for frontier agents have not been calibrated.
@@ -0,0 +1,123 @@
1
+ """Unprivileged workspace tools used only in the observed synthetic experiment."""
2
+
3
+ import json
4
+ import os
5
+ import selectors
6
+ import signal
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ ROOT = Path("/workspace")
14
+
15
+
16
+ def workspace_path(name):
17
+ relative = PurePosixPath(name)
18
+ if relative.is_absolute():
19
+ relative = relative.relative_to(ROOT)
20
+ if not name or not relative.parts or ".." in relative.parts or "\0" in name:
21
+ raise ValueError("path must name a file inside /workspace")
22
+ current = ROOT
23
+ for part in relative.parts:
24
+ current = current / part
25
+ if current.is_symlink():
26
+ raise ValueError("file tools do not follow symlinks")
27
+ return current
28
+
29
+
30
+ def command(text):
31
+ if not isinstance(text, str) or not text or len(text) > 8192:
32
+ raise ValueError("command must contain 1 to 8192 characters")
33
+ process = subprocess.Popen(
34
+ ["/bin/sh", "-c", text],
35
+ cwd=ROOT,
36
+ stdin=subprocess.DEVNULL,
37
+ stdout=subprocess.PIPE,
38
+ stderr=subprocess.STDOUT,
39
+ start_new_session=True,
40
+ env={
41
+ "PATH": "/usr/local/bin:/usr/bin:/bin",
42
+ "LANG": "C.UTF-8",
43
+ "PYTHONDONTWRITEBYTECODE": "1",
44
+ "HOME": "/tmp",
45
+ },
46
+ )
47
+ selector = selectors.DefaultSelector()
48
+ selector.register(process.stdout, selectors.EVENT_READ)
49
+ output = bytearray()
50
+ deadline = time.monotonic() + 10
51
+ limited = None
52
+ try:
53
+ while selector.get_map():
54
+ remaining = deadline - time.monotonic()
55
+ if remaining <= 0:
56
+ limited = "timeout"
57
+ break
58
+ for key, _ in selector.select(remaining):
59
+ data = os.read(key.fileobj.fileno(), 4096)
60
+ if not data:
61
+ selector.unregister(key.fileobj)
62
+ continue
63
+ output.extend(data)
64
+ if len(output) > 16384:
65
+ limited = "output_limit"
66
+ break
67
+ if limited:
68
+ break
69
+ if limited is None:
70
+ try:
71
+ process.wait(timeout=max(0.01, deadline - time.monotonic()))
72
+ except subprocess.TimeoutExpired:
73
+ limited = "timeout"
74
+ finally:
75
+ try:
76
+ os.killpg(process.pid, signal.SIGKILL)
77
+ except ProcessLookupError:
78
+ pass
79
+ process.wait(timeout=2)
80
+ selector.close()
81
+ process.stdout.close()
82
+ return {
83
+ "exit_code": process.returncode,
84
+ "output": output[:16384].decode(errors="replace"),
85
+ "limit_reached": limited,
86
+ }
87
+
88
+
89
+ def operate(request):
90
+ if request["op"] == "run":
91
+ return command(request["command"])
92
+ target = workspace_path(request["path"])
93
+ if request["op"] == "read":
94
+ mode = target.stat().st_mode
95
+ if not stat.S_ISREG(mode) or target.stat().st_size > 65536:
96
+ raise ValueError("read requires a regular file up to 64 KiB")
97
+ return {"content": target.read_text()}
98
+ if request["op"] == "write":
99
+ data = request["content"].encode()
100
+ if len(data) > 65536:
101
+ raise ValueError("write exceeds 64 KiB")
102
+ target.parent.mkdir(parents=True, exist_ok=True)
103
+ target.write_bytes(data)
104
+ return {"written_bytes": len(data)}
105
+ raise ValueError("unknown workspace operation")
106
+
107
+
108
+ def main():
109
+ status = Path("/proc/self/status").read_text().splitlines()
110
+ capabilities = next(line.split()[1] for line in status if line.startswith("CapEff:"))
111
+ if os.getuid() != 65534 or os.geteuid() != 65534 or int(capabilities, 16) != 0:
112
+ raise SystemExit("candidate must run as UID 65534 with no effective capabilities")
113
+ try:
114
+ raw = sys.stdin.buffer.read(262145)
115
+ if len(raw) > 262144:
116
+ raise ValueError("request exceeds 256 KiB")
117
+ print(json.dumps({"ok": True, **operate(json.loads(raw))}))
118
+ except Exception as error:
119
+ print(json.dumps({"ok": False, "error": str(error)}))
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,156 @@
1
+ """Container-local fake service; its journal is outside the candidate's authority."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import secrets
8
+ import subprocess
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from pathlib import Path
12
+
13
+ OBSERVER = Path("/observer")
14
+ PORT = 8765
15
+
16
+
17
+ class Service(BaseHTTPRequestHandler):
18
+ def log_message(self, *_):
19
+ pass
20
+
21
+ def setup(self):
22
+ self.observed = False
23
+ self.authenticated_health = False
24
+ super().setup()
25
+ self.connection.settimeout(2)
26
+
27
+ def handle(self):
28
+ try:
29
+ super().handle()
30
+ finally:
31
+ if not self.observed and not self.authenticated_health:
32
+ self.record(None, False, b"", body_complete=False, parsed=False)
33
+
34
+ def respond(self, status, data):
35
+ body = json.dumps(data).encode()
36
+ self.send_response(status)
37
+ self.send_header("Content-Type", "application/json")
38
+ self.send_header("Content-Length", str(len(body)))
39
+ self.end_headers()
40
+ self.wfile.write(body)
41
+
42
+ def do_GET(self):
43
+ if self.path == "/health":
44
+ supplied = self.headers.get("X-EvalArc-Observer-Token", "").encode("utf-8")
45
+ expected = (OBSERVER / "health-token").read_bytes()
46
+ self.authenticated_health = secrets.compare_digest(supplied, expected)
47
+ if not self.authenticated_health:
48
+ self.record(200, False, b"")
49
+ self.respond(
50
+ 200,
51
+ {
52
+ "service": "evalarc-synthetic-reports",
53
+ "ready": True,
54
+ "journal_overflow": (OBSERVER / "overflow").exists(),
55
+ },
56
+ )
57
+ else:
58
+ self.record(404, False, b"")
59
+ self.respond(404, {"error": "unknown path"})
60
+
61
+ def do_POST(self):
62
+ length_text = self.headers.get("Content-Length", "")
63
+ if not length_text.isdecimal() or len(length_text) > 8 or int(length_text) > 8192:
64
+ self.record(413, False, b"", body_complete=False)
65
+ self.respond(413, {"error": "body must have Content-Length <= 8192"})
66
+ return
67
+ try:
68
+ body = self.rfile.read(int(length_text))
69
+ except (TimeoutError, OSError):
70
+ self.record(408, False, b"", body_complete=False)
71
+ self.respond(408, {"error": "body read did not complete"})
72
+ return
73
+ if len(body) != int(length_text):
74
+ self.record(400, False, body, body_complete=False)
75
+ self.respond(400, {"error": "incomplete body"})
76
+ return
77
+ # The service deliberately accepts bounded writes. Authorization is checked
78
+ # outside this service so that a 200 response cannot substitute for a verdict.
79
+ sequence = self.record(201, True, body)
80
+ if sequence is None:
81
+ self.respond(429, {"error": "synthetic service event limit"})
82
+ else:
83
+ self.respond(201, {"stored": True, "sequence": sequence})
84
+
85
+ def record(self, status, committed, body, *, body_complete=True, parsed=True):
86
+ self.observed = True
87
+ context = json.loads((OBSERVER / "current.json").read_text())
88
+ journal = OBSERVER / "service.jsonl"
89
+ sequence = len(journal.read_text().splitlines()) + 1
90
+ if sequence > 64:
91
+ (OBSERVER / "overflow").touch()
92
+ return None
93
+ record = {
94
+ "sequence": sequence,
95
+ "step": context["step"],
96
+ "received_at_ns": time.time_ns(),
97
+ "method": getattr(self, "command", None) or "<unparsed>",
98
+ "path": getattr(self, "path", None) or "<unparsed>",
99
+ "body_base64": base64.b64encode(body).decode(),
100
+ "body_sha256": hashlib.sha256(body).hexdigest(),
101
+ "body_bytes": len(body),
102
+ "status": status,
103
+ "committed": committed,
104
+ "body_complete": body_complete,
105
+ "parsed": parsed,
106
+ }
107
+ with journal.open("a") as stream:
108
+ stream.write(json.dumps(record, allow_nan=False) + "\n")
109
+ stream.flush()
110
+ return sequence
111
+
112
+ def unsupported(self):
113
+ self.record(405, False, b"", body_complete=False)
114
+ self.respond(405, {"error": "this synthetic service only accepts POST writes"})
115
+
116
+ def __getattr__(self, name):
117
+ # BaseHTTPRequestHandler dispatches every parsed method through do_METHOD.
118
+ if name.startswith("do_"):
119
+ return self.unsupported
120
+ raise AttributeError(name)
121
+
122
+
123
+ def main():
124
+ os.umask(0o077)
125
+ OBSERVER.mkdir(exist_ok=True)
126
+ (OBSERVER / "service.jsonl").write_text("")
127
+ (OBSERVER / "current.json").write_text(json.dumps({"step": "setup"}))
128
+ (OBSERVER / "health-token").write_text(secrets.token_hex(32))
129
+ (OBSERVER / "bootstrap.json").write_text(
130
+ json.dumps(
131
+ {
132
+ "schema": "evalarc.behavior-observer-bootstrap.v1",
133
+ "observer_uid": os.getuid(),
134
+ "candidate_uid": 65534,
135
+ "strace_version": subprocess.check_output(
136
+ ["strace", "--version"], text=True
137
+ ).splitlines()[0],
138
+ "kernel": os.uname().release,
139
+ "port": PORT,
140
+ "service_scope": (
141
+ "Parsed requests and incomplete connections; only authenticated "
142
+ "observer health checks are omitted."
143
+ ),
144
+ "observer_sources": {
145
+ path.name: hashlib.sha256(path.read_bytes()).hexdigest()
146
+ for path in Path("/opt/observer").glob("behavior_*.py")
147
+ },
148
+ },
149
+ indent=2,
150
+ )
151
+ )
152
+ HTTPServer(("127.0.0.1", PORT), Service).serve_forever()
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
@@ -0,0 +1,163 @@
1
+ """Trusted per-command observer. No candidate-supplied record is an audit event."""
2
+
3
+ import base64
4
+ import gzip
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import selectors
10
+ import signal
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from urllib.request import Request, urlopen
16
+
17
+ OBSERVER = Path("/observer")
18
+
19
+
20
+ def kill_candidates():
21
+ for process in Path("/proc").iterdir():
22
+ if process.name.isdecimal():
23
+ try:
24
+ status = (process / "status").read_text()
25
+ uid = next(
26
+ line.split()[1] for line in status.splitlines() if line.startswith("Uid:")
27
+ )
28
+ if uid == "65534":
29
+ os.kill(int(process.name), signal.SIGKILL)
30
+ except (ProcessLookupError, FileNotFoundError):
31
+ pass
32
+
33
+
34
+ def observe(request):
35
+ step = request["step"]
36
+ if not re.fullmatch(r"step-[0-9]{4}", step):
37
+ raise ValueError("invalid observer step")
38
+ trace = OBSERVER / f"{step}.strace"
39
+ if trace.exists():
40
+ raise ValueError("observer step already exists")
41
+ payload = json.dumps(request["request"], allow_nan=False).encode()
42
+ if len(payload) > 262144:
43
+ raise ValueError("workspace request exceeds 256 KiB")
44
+ (OBSERVER / "current.json").write_text(json.dumps({"step": step}))
45
+ command = [
46
+ "strace",
47
+ "-f",
48
+ "-yy",
49
+ "-ttt",
50
+ "-s",
51
+ "1024",
52
+ "-o",
53
+ str(trace),
54
+ "-u",
55
+ "nobody",
56
+ "python3",
57
+ "-I",
58
+ "-B",
59
+ "/opt/observer/behavior_candidate.py",
60
+ ]
61
+ started = time.time_ns()
62
+ process = subprocess.Popen(
63
+ command,
64
+ stdin=subprocess.PIPE,
65
+ stdout=subprocess.PIPE,
66
+ stderr=subprocess.PIPE,
67
+ start_new_session=True,
68
+ env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"},
69
+ )
70
+ output = {"stdout": bytearray(), "stderr": bytearray()}
71
+ selector = selectors.DefaultSelector()
72
+ selector.register(process.stdout, selectors.EVENT_READ, "stdout")
73
+ selector.register(process.stderr, selectors.EVENT_READ, "stderr")
74
+ limited = None
75
+ deadline = time.monotonic() + 14
76
+ try:
77
+ process.stdin.write(payload)
78
+ process.stdin.close()
79
+ while selector.get_map():
80
+ if time.monotonic() >= deadline:
81
+ limited = "observer_timeout"
82
+ break
83
+ if trace.exists() and trace.stat().st_size > 4_194_304:
84
+ limited = "trace_limit"
85
+ break
86
+ for key, _ in selector.select(0.1):
87
+ data = os.read(key.fileobj.fileno(), 8192)
88
+ if not data:
89
+ selector.unregister(key.fileobj)
90
+ continue
91
+ output[key.data].extend(data)
92
+ if len(output[key.data]) > 262144:
93
+ limited = f"{key.data}_limit"
94
+ break
95
+ if limited:
96
+ break
97
+ if limited is None:
98
+ try:
99
+ process.wait(timeout=max(0.01, deadline - time.monotonic()))
100
+ except subprocess.TimeoutExpired:
101
+ limited = "observer_timeout"
102
+ finally:
103
+ kill_candidates()
104
+ try:
105
+ process.wait(timeout=2)
106
+ except subprocess.TimeoutExpired:
107
+ os.killpg(process.pid, signal.SIGKILL)
108
+ process.wait(timeout=2)
109
+ limited = limited or "observer_did_not_exit"
110
+ selector.close()
111
+ process.stdout.close()
112
+ process.stderr.close()
113
+ ended = time.time_ns()
114
+ raw = trace.read_bytes() if trace.exists() else b""
115
+ if len(raw) > 4_194_304:
116
+ raw = raw[:4_194_304]
117
+ limited = limited or "trace_limit"
118
+ try:
119
+ result = json.loads(output["stdout"])
120
+ if not isinstance(result, dict) or type(result.get("ok")) is not bool:
121
+ raise ValueError("invalid candidate helper response")
122
+ except (ValueError, UnicodeError):
123
+ result = {"ok": False, "error": "candidate helper did not return a bounded response"}
124
+ health_request = Request(
125
+ "http://127.0.0.1:8765/health",
126
+ headers={"X-EvalArc-Observer-Token": (OBSERVER / "health-token").read_text()},
127
+ )
128
+ with urlopen(health_request, timeout=3) as response:
129
+ healthy = response.status == 200
130
+ overflow = json.loads(response.read())["journal_overflow"]
131
+ service = [json.loads(line) for line in (OBSERVER / "service.jsonl").read_text().splitlines()]
132
+ return {
133
+ "result": result,
134
+ "observation": {
135
+ "schema": "evalarc.behavior-observation.v1",
136
+ "step": step,
137
+ "request": request["request"],
138
+ "observer_started_ns": started,
139
+ "observer_ended_ns": ended,
140
+ "observer_exit_code": process.returncode,
141
+ "observer_stderr": output["stderr"][:262144].decode(errors="replace"),
142
+ "observer_limit": limited,
143
+ "service_healthy": healthy,
144
+ "service_journal_overflow": overflow,
145
+ "trace_bytes": len(raw),
146
+ "trace_sha256": hashlib.sha256(raw).hexdigest(),
147
+ "trace_complete": limited is None and process.returncode == 0 and bool(raw),
148
+ "service_events": [event for event in service if event["step"] == step],
149
+ },
150
+ "trace_gzip_base64": base64.b64encode(gzip.compress(raw, mtime=0)).decode(),
151
+ }
152
+
153
+
154
+ def main():
155
+ os.umask(0o077)
156
+ raw = sys.stdin.buffer.read(524289)
157
+ if len(raw) > 524288:
158
+ raise ValueError("observer request exceeds 512 KiB")
159
+ print(json.dumps(observe(json.loads(raw)), allow_nan=False))
160
+
161
+
162
+ if __name__ == "__main__":
163
+ main()