do-again 0.1.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.
@@ -0,0 +1,1298 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import mimetypes
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import signal
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .schema import OperatorError, atomic_json, expand_path, path_within, read_json, request_fingerprint
18
+
19
+
20
+ class LocalExecutor:
21
+ def __init__(self, *, repo: Path, policy_path: Path, state_dir: Path):
22
+ self.repo = repo.resolve()
23
+ self.policy_path = policy_path.resolve()
24
+ self.state_dir = state_dir.resolve()
25
+ self.policy = read_json(self.policy_path)
26
+ self.state_dir.mkdir(parents=True, exist_ok=True)
27
+ self.cwd_roots = [
28
+ expand_path(value, repo=self.repo)
29
+ for value in self.policy.get("approved_cwd_roots", [])
30
+ ]
31
+ self.read_roots = [
32
+ expand_path(value, repo=self.repo)
33
+ for value in self.policy.get("read_roots", [])
34
+ ]
35
+ if self.state_dir not in self.cwd_roots:
36
+ self.cwd_roots.append(self.state_dir)
37
+ if self.state_dir not in self.read_roots:
38
+ self.read_roots.append(self.state_dir)
39
+
40
+ def authority_snapshot(self) -> dict[str, Any]:
41
+ control = self._optional_json(self.repo / "automation/direct_chat_chatgpt_control.json")
42
+ desired = self._optional_json(self.repo / "automation/control_plane_desired.json")
43
+ lease = self._optional_json(self.repo / "automation/direct_chat_controller_lease.json")
44
+ head = self._capture(["git", "-C", str(self.repo), "rev-parse", "HEAD"], timeout=15)
45
+ branch = self._capture(
46
+ ["git", "-C", str(self.repo), "branch", "--show-current"], timeout=15
47
+ )
48
+ dirty = self._capture(
49
+ ["git", "-C", str(self.repo), "status", "--porcelain"], timeout=15
50
+ )
51
+ return {
52
+ "repo_head": head["stdout"].strip() if head["returncode"] == 0 else None,
53
+ "repo_branch": branch["stdout"].strip() if branch["returncode"] == 0 else None,
54
+ "repo_dirty": bool(dirty["stdout"].strip()) if dirty["returncode"] == 0 else None,
55
+ "generation": control.get("generation"),
56
+ "control_state": control.get("state"),
57
+ "revision": desired.get("revision"),
58
+ "desired_mode": desired.get("mode"),
59
+ "bundle_sha256": desired.get("bundle_sha256"),
60
+ "lease_id": desired.get("ownership", {}).get("lease_id")
61
+ or lease.get("lease_id")
62
+ or lease.get("controller_lease_id"),
63
+ "lease_epoch": desired.get("ownership", {}).get("epoch")
64
+ or lease.get("epoch")
65
+ or lease.get("controller_lease_epoch"),
66
+ }
67
+
68
+ def execute(self, request: dict[str, Any]) -> dict[str, Any]:
69
+ operation = request["operation"]
70
+ if operation not in set(self.policy.get("allowed_operations", [])):
71
+ raise OperatorError(f"operation is not allowed: {operation}")
72
+ before = self.authority_snapshot()
73
+ self._validate_expected(request.get("expected", {}), before)
74
+ timeout = self._request_timeout(request)
75
+ args = request.get("args", {})
76
+ if operation == "status":
77
+ result = self._status(timeout)
78
+ elif operation == "read_file":
79
+ result = self._read_file(args)
80
+ elif operation == "run_tests":
81
+ result = self._run_tests(args, timeout)
82
+ elif operation == "repo_script":
83
+ result = self._repo_script(args, timeout)
84
+ elif operation == "control_plane_launcher":
85
+ result = self._control_plane_launcher(request, args, timeout)
86
+ elif operation == "deploy_cp1_bundle_at_stop":
87
+ result = self._deploy_cp1_bundle_at_stop(request, timeout)
88
+ elif operation == "scratch_script":
89
+ result = self._scratch_script(request, args, timeout)
90
+ elif operation == "self_update":
91
+ result = self._self_update(request, args, timeout)
92
+ elif operation == "artifact_get":
93
+ result = self._artifact_get(args)
94
+ elif operation == "artifact_put":
95
+ result = self._artifact_put(request, args)
96
+ elif operation == "capture_screenshot":
97
+ result = self._capture_screenshot(request, args, timeout)
98
+ elif operation == "full_process_list":
99
+ result = self._full_process_list(timeout)
100
+ elif operation == "extended_exec":
101
+ result = self._extended_exec(args, timeout)
102
+ elif operation == "process_signal":
103
+ result = self._process_signal(args)
104
+ elif operation == "launchctl_action":
105
+ result = self._launchctl_action(args, timeout)
106
+ else:
107
+ raise OperatorError(f"unsupported operation: {operation}")
108
+ after = self.authority_snapshot()
109
+ return {
110
+ "operation": operation,
111
+ "request_fingerprint": request_fingerprint(request),
112
+ "authority_before": before,
113
+ "authority_after": after,
114
+ "result": result,
115
+ }
116
+
117
+ def _optional_json(self, path: Path) -> dict[str, Any]:
118
+ try:
119
+ value = read_json(path)
120
+ except (FileNotFoundError, json.JSONDecodeError):
121
+ return {}
122
+ return value if isinstance(value, dict) else {}
123
+
124
+ def _validate_expected(self, expected: dict[str, Any], actual: dict[str, Any]) -> None:
125
+ aliases = {
126
+ "state": "control_state",
127
+ "mode": "desired_mode",
128
+ "control_revision": "revision",
129
+ "controller_lease_id": "lease_id",
130
+ "controller_lease_epoch": "lease_epoch",
131
+ }
132
+ for key, wanted in expected.items():
133
+ actual_key = aliases.get(key, key)
134
+ if actual_key not in actual:
135
+ raise OperatorError(f"unknown authority fence: {key}")
136
+ observed = actual.get(actual_key)
137
+ if observed != wanted:
138
+ raise OperatorError(
139
+ f"authority fence mismatch for {key}: expected {wanted!r}, observed {observed!r}"
140
+ )
141
+
142
+ def _require_clean_main(self) -> None:
143
+ snapshot = self.authority_snapshot()
144
+ if snapshot.get("repo_branch") != "main":
145
+ raise OperatorError("authority mutation requires local main checkout")
146
+ if snapshot.get("repo_dirty"):
147
+ raise OperatorError("authority mutation requires a clean repository")
148
+
149
+ def _request_timeout(self, request: dict[str, Any]) -> float:
150
+ limits = request.get("limits", {})
151
+ requested = limits.get("timeout_seconds", self.policy.get("default_timeout_seconds", 120))
152
+ try:
153
+ value = float(requested)
154
+ except (TypeError, ValueError) as exc:
155
+ raise OperatorError("timeout_seconds must be numeric") from exc
156
+ absolute = float(self.policy.get("absolute_timeout_seconds", 3600))
157
+ if value <= 0 or value > absolute:
158
+ raise OperatorError(f"timeout_seconds must be between 1 and {absolute:g}")
159
+ return value
160
+
161
+ def _safe_cwd(self, value: str | None) -> Path:
162
+ path = self.repo if not value else expand_path(value, repo=self.repo)
163
+ if not path_within(path, self.cwd_roots):
164
+ raise OperatorError(f"cwd is outside approved roots: {path}")
165
+ if not path.is_dir():
166
+ raise OperatorError(f"cwd does not exist: {path}")
167
+ return path
168
+
169
+
170
+ def _base_env(self, extra: dict[str, Any] | None = None) -> dict[str, str]:
171
+ names = {
172
+ "PATH",
173
+ "HOME",
174
+ "USER",
175
+ "LOGNAME",
176
+ "SHELL",
177
+ "LANG",
178
+ "LC_ALL",
179
+ "LC_CTYPE",
180
+ "TMPDIR",
181
+ "SSH_AUTH_SOCK",
182
+ "GIT_SSH_COMMAND",
183
+ "TERM",
184
+ }
185
+ env = {key: value for key, value in os.environ.items() if key in names}
186
+ if extra:
187
+ for key, value in extra.items():
188
+ if not isinstance(key, str) or not key.replace("_", "").isalnum():
189
+ raise OperatorError(f"invalid environment variable name: {key!r}")
190
+ env[key] = str(value)
191
+ return env
192
+
193
+ def _truncate(self, value: str) -> tuple[str, bool]:
194
+ limit = int(self.policy.get("max_output_bytes", 262144))
195
+ data = value.encode("utf-8", errors="replace")
196
+ if len(data) <= limit:
197
+ return value, False
198
+ half = max(1, limit // 2)
199
+ clipped = data[:half] + b"\n...<truncated>...\n" + data[-half:]
200
+ return clipped.decode("utf-8", errors="replace"), True
201
+
202
+ def _capture(
203
+ self,
204
+ argv: list[str],
205
+ *,
206
+ timeout: float,
207
+ cwd: Path | None = None,
208
+ env: dict[str, Any] | None = None,
209
+ ) -> dict[str, Any]:
210
+ started = time.time()
211
+ try:
212
+ proc = subprocess.Popen(
213
+ argv,
214
+ cwd=str(cwd or self.repo),
215
+ env=self._base_env(env),
216
+ stdout=subprocess.PIPE,
217
+ stderr=subprocess.PIPE,
218
+ text=True,
219
+ start_new_session=True,
220
+ )
221
+ except OSError as exc:
222
+ raise OperatorError(f"failed to start {argv[0]!r}: {exc}") from exc
223
+ timed_out = False
224
+ try:
225
+ stdout, stderr = proc.communicate(timeout=timeout)
226
+ except subprocess.TimeoutExpired:
227
+ timed_out = True
228
+ os.killpg(proc.pid, signal.SIGTERM)
229
+ try:
230
+ stdout, stderr = proc.communicate(timeout=5)
231
+ except subprocess.TimeoutExpired:
232
+ os.killpg(proc.pid, signal.SIGKILL)
233
+ stdout, stderr = proc.communicate()
234
+ stdout, stdout_truncated = self._truncate(stdout or "")
235
+ stderr, stderr_truncated = self._truncate(stderr or "")
236
+ return {
237
+ "argv": argv,
238
+ "cwd": str(cwd or self.repo),
239
+ "returncode": proc.returncode,
240
+ "timed_out": timed_out,
241
+ "duration_seconds": round(time.time() - started, 3),
242
+ "stdout": stdout,
243
+ "stderr": stderr,
244
+ "stdout_truncated": stdout_truncated,
245
+ "stderr_truncated": stderr_truncated,
246
+ }
247
+
248
+ def _process_rows(self, text: str) -> list[dict[str, Any]]:
249
+ rows = []
250
+ for line in text.splitlines():
251
+ parts = line.strip().split(None, 6)
252
+ if len(parts) != 7:
253
+ continue
254
+ pid, ppid, elapsed, cpu, mem, rss, command = parts
255
+ try:
256
+ row = {
257
+ "pid": int(pid),
258
+ "ppid": int(ppid),
259
+ "elapsed": elapsed,
260
+ "cpu_percent": float(cpu),
261
+ "memory_percent": float(mem),
262
+ "rss_kib": int(rss),
263
+ "command": command,
264
+ }
265
+ except ValueError:
266
+ continue
267
+ rows.append(row)
268
+ return rows
269
+
270
+ def _process_summary(self, timeout: float) -> dict[str, Any]:
271
+ result = self._capture(
272
+ ["ps", "-axo", "pid=,ppid=,etime=,%cpu=,%mem=,rss=,command="],
273
+ timeout=min(timeout, 30),
274
+ cwd=self.repo,
275
+ )
276
+ if result["returncode"] != 0:
277
+ return {"error": result["stderr"], "top_cpu": [], "top_memory": [], "relevant": []}
278
+ rows = self._process_rows(result["stdout"])
279
+ top_cpu = sorted(rows, key=lambda item: item["cpu_percent"], reverse=True)[:10]
280
+ top_memory = sorted(rows, key=lambda item: item["rss_kib"], reverse=True)[:10]
281
+ needles = (
282
+ "direct_chat",
283
+ "mac_operator",
284
+ "persona",
285
+ "llama",
286
+ "ollama",
287
+ "python",
288
+ "next-server",
289
+ "desktop-commander",
290
+ )
291
+ relevant = [
292
+ row for row in rows
293
+ if any(needle in row["command"].lower() for needle in needles)
294
+ ][:30]
295
+ return {
296
+ "count": len(rows),
297
+ "top_cpu": top_cpu,
298
+ "top_memory": top_memory,
299
+ "relevant": relevant,
300
+ }
301
+
302
+ def _memory_summary(self) -> dict[str, Any]:
303
+ total = self._capture(["sysctl", "-n", "hw.memsize"], timeout=10, cwd=self.repo)
304
+ vm = self._capture(["vm_stat"], timeout=10, cwd=self.repo)
305
+ result = {
306
+ "total_bytes": None,
307
+ "page_size": None,
308
+ "free_bytes": None,
309
+ "active_bytes": None,
310
+ "inactive_bytes": None,
311
+ "wired_bytes": None,
312
+ "compressed_bytes": None,
313
+ }
314
+ if total["returncode"] == 0:
315
+ try:
316
+ result["total_bytes"] = int(total["stdout"].strip())
317
+ except ValueError:
318
+ pass
319
+ if vm["returncode"] != 0:
320
+ return result
321
+ page_size = 4096
322
+ first = vm["stdout"].splitlines()[:1]
323
+ if first and "page size of" in first[0]:
324
+ try:
325
+ page_size = int(first[0].split("page size of", 1)[1].split("bytes", 1)[0].strip())
326
+ except ValueError:
327
+ pass
328
+ result["page_size"] = page_size
329
+ values = {}
330
+ for line in vm["stdout"].splitlines()[1:]:
331
+ if ":" not in line:
332
+ continue
333
+ key, value = line.split(":", 1)
334
+ try:
335
+ values[key.strip()] = int(value.strip().rstrip("."))
336
+ except ValueError:
337
+ continue
338
+ mapping = {
339
+ "free_bytes": "Pages free",
340
+ "active_bytes": "Pages active",
341
+ "inactive_bytes": "Pages inactive",
342
+ "wired_bytes": "Pages wired down",
343
+ "compressed_bytes": "Pages occupied by compressor",
344
+ }
345
+ for output_key, vm_key in mapping.items():
346
+ if vm_key in values:
347
+ result[output_key] = values[vm_key] * page_size
348
+ return result
349
+
350
+ def _disk_summary(self) -> dict[str, Any]:
351
+ result = self._capture(["df", "-k", "/"], timeout=10, cwd=self.repo)
352
+ if result["returncode"] != 0:
353
+ return {"error": result["stderr"]}
354
+ lines = [line for line in result["stdout"].splitlines() if line.strip()]
355
+ if len(lines) < 2:
356
+ return {"raw": result["stdout"]}
357
+ parts = lines[-1].split()
358
+ if len(parts) < 6:
359
+ return {"raw": result["stdout"]}
360
+ try:
361
+ return {
362
+ "filesystem": parts[0],
363
+ "total_bytes": int(parts[1]) * 1024,
364
+ "used_bytes": int(parts[2]) * 1024,
365
+ "available_bytes": int(parts[3]) * 1024,
366
+ "used_percent": parts[4],
367
+ "mount": parts[-1],
368
+ }
369
+ except ValueError:
370
+ return {"raw": result["stdout"]}
371
+
372
+ def _compact_supervisor(self, value: dict[str, Any]) -> dict[str, Any]:
373
+ keys = (
374
+ "status",
375
+ "phase",
376
+ "pid",
377
+ "last_error",
378
+ "last_exit_code",
379
+ "last_generation_seen",
380
+ "last_progress_utc",
381
+ "active_failure_class",
382
+ "autoheal_blocked_reason",
383
+ "bundle_sha256",
384
+ "updated_utc",
385
+ )
386
+ return {key: value.get(key) for key in keys if key in value}
387
+
388
+ def _compact_observed(self, value: dict[str, Any]) -> dict[str, Any]:
389
+ keys = (
390
+ "status",
391
+ "mode",
392
+ "generation",
393
+ "revision",
394
+ "bundle_sha256",
395
+ "review_handoff_active",
396
+ "checked_epoch",
397
+ )
398
+ result = {key: value.get(key) for key in keys if key in value}
399
+ job = value.get("job")
400
+ if isinstance(job, dict):
401
+ result["job"] = {
402
+ key: job.get(key)
403
+ for key in ("proposal_id", "request_id", "run_id", "runner", "state", "status")
404
+ if key in job
405
+ }
406
+ else:
407
+ result["job"] = job
408
+ return result
409
+
410
+ def _compact_checker(self, value: dict[str, Any]) -> dict[str, Any]:
411
+ result = {
412
+ key: value.get(key)
413
+ for key in (
414
+ "status",
415
+ "generation_recovery_tax",
416
+ "pending_intake",
417
+ "bundle_sha256",
418
+ "checked_epoch",
419
+ )
420
+ if key in value
421
+ }
422
+ actors = value.get("actors")
423
+ if isinstance(actors, dict):
424
+ result["actors"] = {
425
+ name: {
426
+ "registered": details.get("registered"),
427
+ "running": details.get("running"),
428
+ }
429
+ for name, details in actors.items()
430
+ if isinstance(details, dict)
431
+ }
432
+ return result
433
+
434
+ def _status(self, timeout: float) -> dict[str, Any]:
435
+ supervisor = self._optional_json(
436
+ Path.home() / ".direct_chat_chatgpt_bridge/supervisor_status.json"
437
+ )
438
+ observed = self._optional_json(
439
+ Path.home() / ".direct_chat_control_plane/state/observed.json"
440
+ )
441
+ checker = self._optional_json(
442
+ Path.home() / ".direct_chat_control_plane/state/checker.json"
443
+ )
444
+ return {
445
+ "authority": self.authority_snapshot(),
446
+ "supervisor": self._compact_supervisor(supervisor),
447
+ "observed": self._compact_observed(observed),
448
+ "checker": self._compact_checker(checker),
449
+ "system": {
450
+ "memory": self._memory_summary(),
451
+ "disk": self._disk_summary(),
452
+ },
453
+ "processes": self._process_summary(timeout),
454
+ }
455
+
456
+ def _full_process_list(self, timeout: float) -> dict[str, Any]:
457
+ return self._capture(
458
+ ["ps", "-axo", "pid,ppid,etime,%cpu,%mem,rss,command"],
459
+ timeout=min(timeout, 30),
460
+ cwd=self.repo,
461
+ )
462
+
463
+ def _read_file(self, args: dict[str, Any]) -> dict[str, Any]:
464
+ path = expand_path(args.get("path", ""), repo=self.repo)
465
+ if not path_within(path, self.read_roots):
466
+ raise OperatorError(f"read path is outside approved roots: {path}")
467
+ if not path.is_file():
468
+ raise OperatorError(f"read path is not a file: {path}")
469
+ max_bytes = int(args.get("max_bytes", self.policy.get("max_output_bytes", 262144)))
470
+ max_bytes = max(1, min(max_bytes, int(self.policy.get("max_output_bytes", 262144))))
471
+ mode = str(args.get("mode", "tail"))
472
+ data = path.read_bytes()
473
+ if mode == "head":
474
+ data = data[:max_bytes]
475
+ elif mode == "tail":
476
+ data = data[-max_bytes:]
477
+ else:
478
+ raise OperatorError("read_file mode must be head or tail")
479
+ return {
480
+ "path": str(path),
481
+ "size_bytes": path.stat().st_size,
482
+ "content": data.decode("utf-8", errors="replace"),
483
+ }
484
+
485
+ def _run_tests(self, args: dict[str, Any], timeout: float) -> dict[str, Any]:
486
+ modules = args.get("modules")
487
+ discover = bool(args.get("discover", False))
488
+ if discover:
489
+ start = str(args.get("start_directory", "control_plane/tests"))
490
+ pattern = str(args.get("pattern", "test_*.py"))
491
+ argv = ["python3", "-m", "unittest", "discover", "-s", start, "-p", pattern]
492
+ else:
493
+ if not isinstance(modules, list) or not modules:
494
+ raise OperatorError("run_tests requires modules or discover=true")
495
+ argv = ["python3", "-m", "unittest", *[str(value) for value in modules]]
496
+ return self._capture(argv, timeout=timeout, cwd=self.repo)
497
+
498
+ def _repo_script(self, args: dict[str, Any], timeout: float) -> dict[str, Any]:
499
+ relative = str(args.get("path", ""))
500
+ if relative.startswith("/") or ".." in Path(relative).parts:
501
+ raise OperatorError("repo_script path must be repository-relative")
502
+ prefixes = tuple(str(value) for value in self.policy.get("repo_script_prefixes", []))
503
+ if not relative.startswith(prefixes):
504
+ raise OperatorError(f"repo_script path is not approved: {relative}")
505
+ script = (self.repo / relative).resolve()
506
+ if not path_within(script, [self.repo]) or not script.is_file():
507
+ raise OperatorError(f"repo_script does not exist: {relative}")
508
+ script_args = args.get("argv", [])
509
+ if not isinstance(script_args, list) or not all(isinstance(value, str) for value in script_args):
510
+ raise OperatorError("repo_script argv must be a string list")
511
+ if script.suffix == ".py":
512
+ argv = ["python3", str(script), *script_args]
513
+ elif script.suffix in {".sh", ".bash", ".zsh"}:
514
+ script_arg = str(script)
515
+ argv = ["bash" if script.suffix != ".zsh" else "zsh", script_arg, *script_args]
516
+ else:
517
+ if not os.access(script, os.X_OK):
518
+ raise OperatorError("repo_script must be executable or use a supported script suffix")
519
+ argv = [str(script), *script_args]
520
+ return self._capture(
521
+ argv,
522
+ timeout=timeout,
523
+ cwd=self._safe_cwd(args.get("cwd")),
524
+ env=args.get("env"),
525
+ )
526
+
527
+ def _control_plane_launcher(
528
+ self,
529
+ request: dict[str, Any],
530
+ args: dict[str, Any],
531
+ timeout: float,
532
+ ) -> dict[str, Any]:
533
+ self._require_clean_main()
534
+ subcommand = str(args.get("subcommand", ""))
535
+ allowed = set(self.policy.get("allowed_launcher_subcommands", []))
536
+ if subcommand not in allowed:
537
+ raise OperatorError(f"launcher subcommand is not allowed: {subcommand}")
538
+ expected = request.get("expected", {})
539
+ if subcommand == "session-intake":
540
+ if "generation" not in expected or not (
541
+ "revision" in expected or "control_revision" in expected
542
+ ):
543
+ raise OperatorError("session-intake requires generation and revision authority fences")
544
+ if "repo_head" not in expected:
545
+ raise OperatorError("session-intake requires an exact repo_head fence")
546
+ elif subcommand == "session-resolution":
547
+ if "generation" not in expected:
548
+ raise OperatorError("session-resolution requires a generation authority fence")
549
+ if "repo_head" not in expected:
550
+ raise OperatorError("session-resolution requires an exact repo_head fence")
551
+ values = args.get("argv", [])
552
+ if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
553
+ raise OperatorError("launcher argv must be a string list")
554
+ materialized = {}
555
+ json_files = args.get("json_files", {})
556
+ if not isinstance(json_files, dict):
557
+ raise OperatorError("launcher json_files must be an object")
558
+ request_dir = self.state_dir / "request_files" / request["request_id"]
559
+ for name, value in json_files.items():
560
+ if (
561
+ not isinstance(name, str)
562
+ or not name
563
+ or "/" in name
564
+ or "\\" in name
565
+ or name in {".", ".."}
566
+ ):
567
+ raise OperatorError(f"invalid launcher json file name: {name!r}")
568
+ path = request_dir / f"{name}.json"
569
+ atomic_json(path, value)
570
+ materialized[name] = str(path)
571
+ expanded_values = []
572
+ for value in values:
573
+ if value.startswith("@json:"):
574
+ name = value.split(":", 1)[1]
575
+ if name not in materialized:
576
+ raise OperatorError(f"unknown launcher json placeholder: {name}")
577
+ expanded_values.append(materialized[name])
578
+ else:
579
+ expanded_values.append(value)
580
+ values = expanded_values
581
+ launcher = Path.home() / ".direct_chat_control_plane/launcher.py"
582
+ if not launcher.is_file():
583
+ raise OperatorError(f"launcher is missing: {launcher}")
584
+ return self._capture(
585
+ ["python3", str(launcher), subcommand, *values],
586
+ timeout=timeout,
587
+ cwd=self.repo,
588
+ env=args.get("env"),
589
+ )
590
+
591
+ def _fast_forward_clean_main_to_origin(self, timeout: float) -> dict[str, Any]:
592
+ """Reconcile a clean local main after a helper published commits via a clone."""
593
+ self._require_clean_main()
594
+ fetch = self._capture(
595
+ ["git", "-C", str(self.repo), "fetch", "--quiet", "origin", "main"],
596
+ timeout=min(timeout, 120),
597
+ cwd=self.repo,
598
+ )
599
+ if fetch["returncode"] != 0:
600
+ return {"returncode": fetch["returncode"], "stage": "fetch", "fetch": fetch}
601
+
602
+ current_result = self._capture(
603
+ ["git", "-C", str(self.repo), "rev-parse", "HEAD"],
604
+ timeout=15,
605
+ cwd=self.repo,
606
+ )
607
+ remote_result = self._capture(
608
+ ["git", "-C", str(self.repo), "rev-parse", "origin/main"],
609
+ timeout=15,
610
+ cwd=self.repo,
611
+ )
612
+ if current_result["returncode"] != 0 or remote_result["returncode"] != 0:
613
+ return {
614
+ "returncode": current_result["returncode"] or remote_result["returncode"],
615
+ "stage": "resolve_heads",
616
+ "current": current_result,
617
+ "remote": remote_result,
618
+ }
619
+ current_head = current_result["stdout"].strip()
620
+ remote_head = remote_result["stdout"].strip()
621
+ if current_head == remote_head:
622
+ return {
623
+ "returncode": 0,
624
+ "stage": "already_current",
625
+ "previous_head": current_head,
626
+ "remote_head": remote_head,
627
+ "new_head": current_head,
628
+ }
629
+
630
+ ff_check = self._capture(
631
+ ["git", "-C", str(self.repo), "merge-base", "--is-ancestor", current_head, remote_head],
632
+ timeout=15,
633
+ cwd=self.repo,
634
+ )
635
+ if ff_check["returncode"] != 0:
636
+ return {
637
+ "returncode": ff_check["returncode"],
638
+ "stage": "non_fast_forward",
639
+ "previous_head": current_head,
640
+ "remote_head": remote_head,
641
+ "check": ff_check,
642
+ }
643
+
644
+ merge = self._capture(
645
+ ["git", "-C", str(self.repo), "merge", "--ff-only", remote_head],
646
+ timeout=min(timeout, 120),
647
+ cwd=self.repo,
648
+ )
649
+ if merge["returncode"] != 0:
650
+ return {
651
+ "returncode": merge["returncode"],
652
+ "stage": "fast_forward",
653
+ "previous_head": current_head,
654
+ "remote_head": remote_head,
655
+ "merge": merge,
656
+ }
657
+ after = self._capture(
658
+ ["git", "-C", str(self.repo), "rev-parse", "HEAD"],
659
+ timeout=15,
660
+ cwd=self.repo,
661
+ )
662
+ return {
663
+ "returncode": after["returncode"],
664
+ "stage": "complete" if after["returncode"] == 0 else "verify",
665
+ "previous_head": current_head,
666
+ "remote_head": remote_head,
667
+ "new_head": after["stdout"].strip() if after["returncode"] == 0 else None,
668
+ "merge": merge,
669
+ }
670
+
671
+ def _deploy_cp1_bundle_at_stop(
672
+ self,
673
+ request: dict[str, Any],
674
+ timeout: float,
675
+ ) -> dict[str, Any]:
676
+ self._require_clean_main()
677
+ before = self.authority_snapshot()
678
+ if before.get("control_state") != "stop" or before.get("desired_mode") != "STOP":
679
+ raise OperatorError("bundle deployment requires authoritative STOP")
680
+ expected = request.get("expected", {})
681
+ if "generation" not in expected or not (
682
+ "revision" in expected or "control_revision" in expected
683
+ ):
684
+ raise OperatorError("bundle deployment requires generation and revision authority fences")
685
+ if "repo_head" not in expected:
686
+ raise OperatorError("bundle deployment requires an exact repo_head fence")
687
+ destination = self.state_dir / "bundles" / request["request_id"]
688
+ destination.parent.mkdir(parents=True, exist_ok=True)
689
+ build = self._capture(
690
+ [
691
+ "python3",
692
+ str(self.repo / "control_plane/build_cp1_bundle.py"),
693
+ "--out",
694
+ str(destination),
695
+ ],
696
+ timeout=min(timeout, 300),
697
+ cwd=self.repo,
698
+ )
699
+ if build["returncode"] != 0:
700
+ return {"build": build, "retarget": None}
701
+ bundle_sha = build["stdout"].strip().splitlines()[-1] if build["stdout"].strip() else ""
702
+ if len(bundle_sha) != 64:
703
+ raise OperatorError("bundle builder did not return a SHA-256")
704
+ retarget = self._capture(
705
+ [
706
+ "python3",
707
+ str(self.repo / "control_plane/retarget_bundle_at_stop.py"),
708
+ "--repo",
709
+ str(self.repo),
710
+ "--bundle",
711
+ str(destination),
712
+ "--apply",
713
+ ],
714
+ timeout=timeout,
715
+ cwd=self.repo,
716
+ )
717
+ local_sync = None
718
+ if retarget["returncode"] == 0:
719
+ local_sync = self._fast_forward_clean_main_to_origin(min(timeout, 120))
720
+ return {
721
+ "bundle_sha256": bundle_sha,
722
+ "build": build,
723
+ "retarget": retarget,
724
+ "local_sync": local_sync,
725
+ "local_sync_ok": bool(local_sync and local_sync.get("returncode") == 0),
726
+ }
727
+
728
+ def _artifact_payload(self, path: Path, *, max_bytes: int | None = None) -> dict[str, Any]:
729
+ maximum = int(max_bytes or self.policy.get("max_inline_artifact_bytes", 450000))
730
+ size = path.stat().st_size
731
+ if size > maximum:
732
+ raise OperatorError(
733
+ f"artifact is {size} bytes and exceeds inline maximum of {maximum} bytes"
734
+ )
735
+ data = path.read_bytes()
736
+ mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
737
+ return {
738
+ "path": str(path),
739
+ "name": path.name,
740
+ "size_bytes": len(data),
741
+ "sha256": hashlib.sha256(data).hexdigest(),
742
+ "mime_type": mime_type,
743
+ "base64": base64.b64encode(data).decode("ascii"),
744
+ }
745
+
746
+ def _artifact_get(self, args: dict[str, Any]) -> dict[str, Any]:
747
+ path = expand_path(str(args.get("path", "")), repo=self.repo)
748
+ if not path_within(path, self.read_roots):
749
+ raise OperatorError(f"artifact path is outside approved roots: {path}")
750
+ if not path.is_file():
751
+ raise OperatorError(f"artifact path is not a file: {path}")
752
+ requested = args.get("max_bytes")
753
+ maximum = (
754
+ min(int(requested), int(self.policy.get("max_inline_artifact_bytes", 450000)))
755
+ if requested is not None
756
+ else int(self.policy.get("max_inline_artifact_bytes", 450000))
757
+ )
758
+ return self._artifact_payload(path, max_bytes=maximum)
759
+
760
+ def _artifact_put(
761
+ self,
762
+ request: dict[str, Any],
763
+ args: dict[str, Any],
764
+ ) -> dict[str, Any]:
765
+ name = str(args.get("name", "")).strip()
766
+ if not name or name in {".", ".."} or Path(name).name != name:
767
+ raise OperatorError("artifact_put name must be a simple file name")
768
+ encoded = args.get("base64")
769
+ if not isinstance(encoded, str) or not encoded:
770
+ raise OperatorError("artifact_put base64 must be a non-empty string")
771
+ try:
772
+ data = base64.b64decode(encoded, validate=True)
773
+ except Exception as exc:
774
+ raise OperatorError("artifact_put base64 is invalid") from exc
775
+ maximum = int(self.policy.get("max_inline_artifact_bytes", 450000))
776
+ if len(data) > maximum:
777
+ raise OperatorError(
778
+ f"artifact_put exceeds inline maximum of {maximum} bytes"
779
+ )
780
+ directory = self.state_dir / "inbox" / request["request_id"]
781
+ directory.mkdir(parents=True, exist_ok=True)
782
+ path = directory / name
783
+ path.write_bytes(data)
784
+ os.chmod(path, 0o600)
785
+ return {
786
+ "path": str(path),
787
+ "name": name,
788
+ "size_bytes": len(data),
789
+ "sha256": hashlib.sha256(data).hexdigest(),
790
+ "mime_type": str(args.get("mime_type") or mimetypes.guess_type(name)[0] or "application/octet-stream"),
791
+ }
792
+
793
+ def _capture_screenshot(
794
+ self,
795
+ request: dict[str, Any],
796
+ args: dict[str, Any],
797
+ timeout: float,
798
+ ) -> dict[str, Any]:
799
+ directory = self.state_dir / "artifacts" / request["request_id"]
800
+ directory.mkdir(parents=True, exist_ok=True)
801
+ raw = directory / "screen.png"
802
+ output = directory / "screen.jpg"
803
+ capture_argv = ["/usr/sbin/screencapture", "-x"]
804
+ display = args.get("display")
805
+ if display is not None:
806
+ try:
807
+ display_number = int(display)
808
+ except (TypeError, ValueError) as exc:
809
+ raise OperatorError("capture_screenshot display must be an integer") from exc
810
+ if display_number < 1:
811
+ raise OperatorError("capture_screenshot display must be >= 1")
812
+ capture_argv.extend(["-D", str(display_number)])
813
+ capture_argv.append(str(raw))
814
+ captured = self._capture(
815
+ capture_argv,
816
+ timeout=min(timeout, 30),
817
+ cwd=self.repo,
818
+ )
819
+ if captured["returncode"] != 0 or not raw.is_file():
820
+ detail = captured["stderr"] or captured["stdout"] or "no screenshot file produced"
821
+ raise OperatorError(
822
+ "screenshot capture failed; macOS Screen Recording permission may be required: "
823
+ + detail.strip()
824
+ )
825
+ maximum = int(self.policy.get("max_inline_artifact_bytes", 450000))
826
+ configured_width = int(self.policy.get("max_screenshot_width", 1440))
827
+ configured_quality = int(self.policy.get("screenshot_jpeg_quality", 55))
828
+ attempts = [
829
+ (configured_width, configured_quality),
830
+ (1200, min(configured_quality, 45)),
831
+ (960, min(configured_quality, 35)),
832
+ ]
833
+ conversion = None
834
+ for width, quality in attempts:
835
+ conversion = self._capture(
836
+ [
837
+ "/usr/bin/sips",
838
+ "-s",
839
+ "format",
840
+ "jpeg",
841
+ "-s",
842
+ "formatOptions",
843
+ str(quality),
844
+ "-Z",
845
+ str(width),
846
+ str(raw),
847
+ "--out",
848
+ str(output),
849
+ ],
850
+ timeout=min(timeout, 30),
851
+ cwd=self.repo,
852
+ )
853
+ if conversion["returncode"] == 0 and output.is_file() and output.stat().st_size <= maximum:
854
+ break
855
+ if not output.is_file():
856
+ raise OperatorError("screenshot conversion failed")
857
+ if output.stat().st_size > maximum:
858
+ raise OperatorError(
859
+ f"compressed screenshot still exceeds inline maximum of {maximum} bytes"
860
+ )
861
+ artifact = self._artifact_payload(output, max_bytes=maximum)
862
+ artifact["source"] = "screenshot"
863
+ artifact["display"] = display
864
+ return artifact
865
+
866
+ def _commit_json(self, commit: str, path: str, timeout: float = 15) -> dict[str, Any]:
867
+ got = self._capture(
868
+ ["git", "-C", str(self.repo), "show", f"{commit}:{path}"],
869
+ timeout=min(timeout, 30),
870
+ cwd=self.repo,
871
+ )
872
+ if got["returncode"] != 0:
873
+ raise OperatorError(f"cannot read authority document at target commit: {path}")
874
+ try:
875
+ value = json.loads(got["stdout"])
876
+ except Exception as exc:
877
+ raise OperatorError(f"invalid authority JSON at target commit: {path}") from exc
878
+ if not isinstance(value, dict):
879
+ raise OperatorError(f"authority document is not an object at target commit: {path}")
880
+ return value
881
+
882
+ def _validate_stale_local_self_update_target(
883
+ self,
884
+ *,
885
+ target: str,
886
+ before: dict[str, Any],
887
+ args: dict[str, Any],
888
+ ) -> dict[str, Any]:
889
+ if args.get("allow_stale_local_authority_reconcile") is not True:
890
+ raise OperatorError("self_update requires authoritative STOP")
891
+ proof = args.get("target_authority")
892
+ if not isinstance(proof, dict):
893
+ raise OperatorError("stale local self_update requires target_authority proof")
894
+
895
+ control = self._commit_json(target, "automation/direct_chat_chatgpt_control.json")
896
+ desired = self._commit_json(target, "automation/control_plane_desired.json")
897
+ lease = self._commit_json(target, "automation/direct_chat_controller_lease.json")
898
+
899
+ generation = control.get("generation")
900
+ revision = desired.get("revision")
901
+ lease_id = str(lease.get("lease_id") or "")
902
+ lease_epoch = lease.get("epoch")
903
+ if (
904
+ control.get("state") != "stop"
905
+ or desired.get("mode") != "STOP"
906
+ or desired.get("generation") != generation
907
+ or not isinstance(generation, int)
908
+ or isinstance(revision, bool)
909
+ or not isinstance(revision, int)
910
+ or not lease_id
911
+ or isinstance(lease_epoch, bool)
912
+ or not isinstance(lease_epoch, int)
913
+ or lease.get("state") != "active"
914
+ or str(control.get("controller_lease_id") or "") != lease_id
915
+ or int(control.get("controller_lease_epoch") or -1) != lease_epoch
916
+ or str((desired.get("ownership") or {}).get("lease_id") or "") != lease_id
917
+ or int((desired.get("ownership") or {}).get("epoch") or -1) != lease_epoch
918
+ ):
919
+ raise OperatorError("stale local self_update target is not coherent authoritative STOP")
920
+
921
+ required = {
922
+ "generation": generation,
923
+ "revision": revision,
924
+ "state": "stop",
925
+ "mode": "STOP",
926
+ "controller_lease_id": lease_id,
927
+ "controller_lease_epoch": lease_epoch,
928
+ "repo_head": target,
929
+ }
930
+ aliases = {"control_revision": "revision"}
931
+ normalized = {aliases.get(k, k): v for k, v in proof.items()}
932
+ if normalized != required:
933
+ raise OperatorError("target_authority proof does not exactly match target STOP authority")
934
+ local_generation = before.get("generation")
935
+ if isinstance(local_generation, int) and generation < local_generation:
936
+ raise OperatorError("stale local self_update target generation regresses local generation")
937
+ return required
938
+
939
+ def _self_update(
940
+ self,
941
+ request: dict[str, Any],
942
+ args: dict[str, Any],
943
+ timeout: float,
944
+ ) -> dict[str, Any]:
945
+ self._require_clean_main()
946
+ before = self.authority_snapshot()
947
+ local_is_stop = (
948
+ before.get("control_state") == "stop"
949
+ and before.get("desired_mode") == "STOP"
950
+ )
951
+ if (
952
+ not local_is_stop
953
+ and args.get("allow_stale_local_authority_reconcile") is not True
954
+ ):
955
+ raise OperatorError("self_update requires authoritative STOP")
956
+ expected = request.get("expected", {})
957
+ if "generation" not in expected or not (
958
+ "revision" in expected or "control_revision" in expected
959
+ ) or "repo_head" not in expected:
960
+ raise OperatorError(
961
+ "self_update requires generation, revision, and exact repo_head fences"
962
+ )
963
+ fetch = self._capture(
964
+ ["git", "-C", str(self.repo), "fetch", "--quiet", "origin", "main"],
965
+ timeout=min(timeout, 120),
966
+ cwd=self.repo,
967
+ )
968
+ if fetch["returncode"] != 0:
969
+ return {"returncode": fetch["returncode"], "stage": "fetch", "fetch": fetch}
970
+ remote_result = self._capture(
971
+ ["git", "-C", str(self.repo), "rev-parse", "origin/main"],
972
+ timeout=15,
973
+ cwd=self.repo,
974
+ )
975
+ if remote_result["returncode"] != 0:
976
+ return {"returncode": remote_result["returncode"], "stage": "resolve_remote", "resolve": remote_result}
977
+ remote_head = remote_result["stdout"].strip()
978
+ target = str(args.get("target_commit") or remote_head).strip()
979
+ if not local_is_stop:
980
+ self._validate_stale_local_self_update_target(
981
+ target=target,
982
+ before=before,
983
+ args=args,
984
+ )
985
+ target_check = self._capture(
986
+ ["git", "-C", str(self.repo), "merge-base", "--is-ancestor", target, remote_head],
987
+ timeout=15,
988
+ cwd=self.repo,
989
+ )
990
+ if target_check["returncode"] != 0:
991
+ raise OperatorError("self_update target_commit is not contained in origin/main")
992
+ current_head = str(before.get("repo_head") or "")
993
+ ff_check = self._capture(
994
+ ["git", "-C", str(self.repo), "merge-base", "--is-ancestor", current_head, remote_head],
995
+ timeout=15,
996
+ cwd=self.repo,
997
+ )
998
+ if ff_check["returncode"] != 0:
999
+ raise OperatorError("self_update would not be a fast-forward")
1000
+
1001
+ preflight = self.state_dir / "preflight" / request["request_id"]
1002
+ if preflight.exists():
1003
+ shutil.rmtree(preflight)
1004
+ preflight.parent.mkdir(parents=True, exist_ok=True)
1005
+ add = self._capture(
1006
+ ["git", "-C", str(self.repo), "worktree", "add", "--detach", str(preflight), remote_head],
1007
+ timeout=min(timeout, 120),
1008
+ cwd=self.repo,
1009
+ )
1010
+ if add["returncode"] != 0:
1011
+ return {"returncode": add["returncode"], "stage": "preflight_worktree", "worktree": add}
1012
+ checks = []
1013
+ try:
1014
+ commands = [
1015
+ [
1016
+ "python3",
1017
+ "-m",
1018
+ "py_compile",
1019
+ "tools/mac_operator_agent.py",
1020
+ "control_plane/mac_operator/schema.py",
1021
+ "control_plane/mac_operator/executor.py",
1022
+ ],
1023
+ ["bash", "-n", "tools/install_mac_operator.sh", "tools/uninstall_mac_operator.sh"],
1024
+ ["python3", "-m", "unittest", "control_plane.tests.test_mac_operator"],
1025
+ ]
1026
+ for argv in commands:
1027
+ check = self._capture(
1028
+ argv,
1029
+ timeout=min(timeout, 300),
1030
+ cwd=preflight,
1031
+ )
1032
+ checks.append(check)
1033
+ if check["returncode"] != 0 or check["timed_out"]:
1034
+ return {
1035
+ "returncode": check["returncode"] or 124,
1036
+ "stage": "preflight_validation",
1037
+ "preflight_head": remote_head,
1038
+ "checks": checks,
1039
+ }
1040
+ finally:
1041
+ self._capture(
1042
+ ["git", "-C", str(self.repo), "worktree", "remove", "--force", str(preflight)],
1043
+ timeout=60,
1044
+ cwd=self.repo,
1045
+ )
1046
+
1047
+ merge = self._capture(
1048
+ ["git", "-C", str(self.repo), "merge", "--ff-only", remote_head],
1049
+ timeout=min(timeout, 120),
1050
+ cwd=self.repo,
1051
+ )
1052
+ if merge["returncode"] != 0:
1053
+ return {"returncode": merge["returncode"], "stage": "fast_forward", "merge": merge}
1054
+ after_head = self._capture(
1055
+ ["git", "-C", str(self.repo), "rev-parse", "HEAD"],
1056
+ timeout=15,
1057
+ cwd=self.repo,
1058
+ )["stdout"].strip()
1059
+ return {
1060
+ "returncode": 0,
1061
+ "stage": "complete",
1062
+ "previous_head": current_head,
1063
+ "target_commit": target,
1064
+ "remote_head": remote_head,
1065
+ "new_head": after_head,
1066
+ "preflight_checks": checks,
1067
+ "stale_local_authority_reconciled": not local_is_stop,
1068
+ "restart_after_receipt": True,
1069
+ }
1070
+
1071
+ def _scratch_script(
1072
+ self,
1073
+ request: dict[str, Any],
1074
+ args: dict[str, Any],
1075
+ timeout: float,
1076
+ ) -> dict[str, Any]:
1077
+ language = str(args.get("language", "")).strip().lower()
1078
+ if language not in {"python", "bash"}:
1079
+ raise OperatorError("scratch_script language must be python or bash")
1080
+ if language == "bash" and os.name == "nt":
1081
+ raise OperatorError("bash scratch scripts are not supported on native Windows")
1082
+
1083
+ content = args.get("content")
1084
+ if not isinstance(content, str) or not content.strip():
1085
+ raise OperatorError("scratch_script content must be a non-empty string")
1086
+
1087
+ data = content.encode("utf-8")
1088
+ maximum = int(self.policy.get("max_scratch_script_bytes", 65536))
1089
+ if len(data) > maximum:
1090
+ raise OperatorError(
1091
+ f"scratch_script exceeds maximum size of {maximum} bytes"
1092
+ )
1093
+
1094
+ values = args.get("argv", [])
1095
+ if not isinstance(values, list) or not all(
1096
+ isinstance(value, str) for value in values
1097
+ ):
1098
+ raise OperatorError("scratch_script argv must be a string list")
1099
+
1100
+ cwd = self._safe_cwd(args.get("cwd"))
1101
+ directory = self.state_dir / "scratch" / request["request_id"]
1102
+ directory.mkdir(parents=True, exist_ok=True)
1103
+
1104
+ suffix = ".py" if language == "python" else ".sh"
1105
+ path = directory / f"script{suffix}"
1106
+ path.write_text(content, encoding="utf-8")
1107
+ os.chmod(path, 0o600)
1108
+
1109
+ digest = hashlib.sha256(data).hexdigest()
1110
+ runner = "python3" if language == "python" else "bash"
1111
+ script_arg = str(path)
1112
+ result = self._capture(
1113
+ [runner, script_arg, *values],
1114
+ timeout=timeout,
1115
+ cwd=cwd,
1116
+ env=args.get("env"),
1117
+ )
1118
+
1119
+ return {
1120
+ "language": language,
1121
+ "script_path": str(path),
1122
+ "script_sha256": digest,
1123
+ "script_bytes": len(data),
1124
+ **result,
1125
+ }
1126
+
1127
+ def _extended_exec(self, args: dict[str, Any], timeout: float) -> dict[str, Any]:
1128
+ argv = args.get("argv")
1129
+ if not isinstance(argv, list) or not argv or not all(isinstance(value, str) for value in argv):
1130
+ raise OperatorError("extended_exec argv must be a non-empty string list")
1131
+ binary = os.path.basename(argv[0])
1132
+ denied = set(self.policy.get("hard_denied_binaries", []))
1133
+ allowed = set(self.policy.get("extended_exec_binaries", []))
1134
+ if binary in denied or binary not in allowed:
1135
+ raise OperatorError(f"binary is not allowed for extended_exec: {binary}")
1136
+ executable = shutil.which(argv[0]) or shutil.which(binary)
1137
+ if not executable:
1138
+ raise OperatorError(f"binary is unavailable: {argv[0]}")
1139
+ normalized = [executable, *argv[1:]]
1140
+ cwd = self._safe_cwd(args.get("cwd"))
1141
+ self._validate_extended_argv(binary, normalized, cwd)
1142
+ return self._capture(
1143
+ normalized,
1144
+ timeout=timeout,
1145
+ cwd=cwd,
1146
+ env=args.get("env"),
1147
+ )
1148
+
1149
+ def _validate_path_arguments(self, values: list[str], cwd: Path) -> None:
1150
+ for value in values:
1151
+ if not value or value.startswith("-"):
1152
+ continue
1153
+ candidate = Path(os.path.expanduser(value))
1154
+ check = None
1155
+ if candidate.is_absolute():
1156
+ check = candidate
1157
+ elif ".." in candidate.parts:
1158
+ check = cwd / candidate
1159
+ else:
1160
+ local = cwd / candidate
1161
+ if local.exists() or local.is_symlink():
1162
+ check = local
1163
+ if check is not None and not path_within(check, self.read_roots):
1164
+ raise OperatorError(f"extended_exec path is outside approved roots: {value}")
1165
+
1166
+ def _validate_extended_argv(self, binary: str, argv: list[str], cwd: Path) -> None:
1167
+ tail = argv[1:]
1168
+ if binary in {"bash", "zsh", "sh"}:
1169
+ if any(value in {"-c", "-s"} for value in tail):
1170
+ raise OperatorError("inline shell execution is not allowed")
1171
+ script = next((value for value in tail if not value.startswith("-")), None)
1172
+ if script is None:
1173
+ raise OperatorError("shell execution requires a script file")
1174
+ path = expand_path(script, repo=self.repo)
1175
+ if not path_within(path, self.cwd_roots) or not path.is_file():
1176
+ raise OperatorError("shell script must be inside an approved root")
1177
+ elif binary == "python3":
1178
+ if "-c" in tail or "-" in tail:
1179
+ raise OperatorError("inline Python execution is not allowed")
1180
+ if "-m" in tail:
1181
+ index = tail.index("-m")
1182
+ if index + 1 >= len(tail):
1183
+ raise OperatorError("python -m requires a module")
1184
+ module = tail[index + 1]
1185
+ if module not in set(self.policy.get("python_allowed_modules", [])):
1186
+ raise OperatorError(f"python module is not allowed: {module}")
1187
+ else:
1188
+ script = next((value for value in tail if not value.startswith("-")), None)
1189
+ if script:
1190
+ path = expand_path(script, repo=self.repo)
1191
+ if not path_within(path, self.cwd_roots) or not path.is_file():
1192
+ raise OperatorError("Python script must be inside an approved root")
1193
+ elif binary == "git":
1194
+ if "-c" in tail:
1195
+ raise OperatorError("git -c is not allowed")
1196
+ values = list(tail)
1197
+ if values[:1] == ["-C"]:
1198
+ if len(values) < 3:
1199
+ raise OperatorError("git -C requires path and subcommand")
1200
+ path = expand_path(values[1], repo=self.repo)
1201
+ if not path_within(path, self.cwd_roots):
1202
+ raise OperatorError("git -C path is outside approved roots")
1203
+ values = values[2:]
1204
+ subcommand = next((value for value in values if not value.startswith("-")), "")
1205
+ if subcommand not in set(self.policy.get("git_allowed_subcommands", [])):
1206
+ raise OperatorError(f"git subcommand is not allowed: {subcommand}")
1207
+ joined = " ".join(values).lower()
1208
+ forbidden = (
1209
+ "--force",
1210
+ "--force-with-lease",
1211
+ "reset --hard",
1212
+ "clean -f",
1213
+ "clean -d",
1214
+ "push -f",
1215
+ )
1216
+ if any(value in joined for value in forbidden):
1217
+ raise OperatorError("destructive git option is not allowed")
1218
+ elif binary == "find":
1219
+ forbidden = {"-exec", "-execdir", "-delete", "-ok", "-okdir"}
1220
+ if any(value in forbidden for value in tail):
1221
+ raise OperatorError("mutating find actions are not allowed")
1222
+ elif binary == "sed":
1223
+ if "-i" in tail or any(value.startswith("-i") for value in tail):
1224
+ raise OperatorError("sed in-place editing is not allowed")
1225
+ self._validate_path_arguments(tail, cwd)
1226
+ elif binary in {
1227
+ "cat",
1228
+ "tail",
1229
+ "head",
1230
+ "grep",
1231
+ "rg",
1232
+ "find",
1233
+ "ls",
1234
+ "stat",
1235
+ "shasum",
1236
+ "wc",
1237
+ "sort",
1238
+ "uniq",
1239
+ "cut",
1240
+ "tr",
1241
+ "du",
1242
+ }:
1243
+ self._validate_path_arguments(tail, cwd)
1244
+
1245
+ def _process_signal(self, args: dict[str, Any]) -> dict[str, Any]:
1246
+ try:
1247
+ pid = int(args.get("pid"))
1248
+ except (TypeError, ValueError) as exc:
1249
+ raise OperatorError("process_signal requires an integer pid") from exc
1250
+ if pid <= 100:
1251
+ raise OperatorError("refusing to signal low system pid")
1252
+ signal_name = str(args.get("signal", "TERM")).upper()
1253
+ if signal_name not in set(self.policy.get("process_signal_allowlist", [])):
1254
+ raise OperatorError(f"signal is not allowed: {signal_name}")
1255
+ command_result = self._capture(
1256
+ ["ps", "-p", str(pid), "-o", "command="],
1257
+ timeout=10,
1258
+ cwd=self.repo,
1259
+ )
1260
+ if command_result["returncode"] != 0 or not command_result["stdout"].strip():
1261
+ raise OperatorError(f"pid is not running: {pid}")
1262
+ expected_command = str(args.get("expected_command_contains", "")).strip()
1263
+ if signal_name == "KILL" and not expected_command:
1264
+ raise OperatorError("KILL requires expected_command_contains")
1265
+ if expected_command and expected_command not in command_result["stdout"]:
1266
+ raise OperatorError("process command does not match expected_command_contains")
1267
+ os.kill(pid, getattr(signal, f"SIG{signal_name}"))
1268
+ return {
1269
+ "pid": pid,
1270
+ "signal": signal_name,
1271
+ "command": command_result["stdout"].strip(),
1272
+ }
1273
+
1274
+ def _launchctl_action(self, args: dict[str, Any], timeout: float) -> dict[str, Any]:
1275
+ action = str(args.get("action", ""))
1276
+ allowed = set(self.policy.get("launchctl_allowed_subcommands", []))
1277
+ if action not in allowed:
1278
+ raise OperatorError(f"launchctl action is not allowed: {action}")
1279
+ prefixes = tuple(str(value) for value in self.policy.get("launchctl_label_prefixes", []))
1280
+ domain = str(args.get("domain", f"gui/{os.getuid()}"))
1281
+ if domain != f"gui/{os.getuid()}":
1282
+ raise OperatorError("launchctl domain must be the current user GUI domain")
1283
+ if action == "bootstrap":
1284
+ plist = expand_path(args.get("plist", ""), repo=self.repo)
1285
+ if not path_within(plist, self.cwd_roots) or not plist.is_file():
1286
+ raise OperatorError("launchctl bootstrap plist is not approved")
1287
+ argv = ["launchctl", "bootstrap", domain, str(plist)]
1288
+ else:
1289
+ label = str(args.get("label", "")).strip()
1290
+ if not label.startswith(prefixes):
1291
+ raise OperatorError("launchctl label is not approved")
1292
+ target = f"{domain}/{label}"
1293
+ argv = ["launchctl", action]
1294
+ if bool(args.get("kill", False)) and action == "kickstart":
1295
+ argv.append("-k")
1296
+ argv.append(target)
1297
+ return self._capture(argv, timeout=timeout, cwd=self.repo)
1298
+