devlyn-cli 4.3.0 → 4.3.3
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.
- package/bin/skill-history.json +3 -0
- package/config/skills/_shared/harness-store.py +2 -1
- package/config/skills/_shared/task-completion.md +6 -3
- package/config/skills/_shared/warm-build-dirs.py +97 -2
- package/config/skills/devlyn-ideate/scripts/queue.py +10 -1
- package/harness/AGENTS.md +37 -0
- package/harness/CLAUDE.md +37 -0
- package/native/claude/.claude-plugin/plugin.json +6 -0
- package/native/claude/skills/_shared/.ruff.toml +2 -0
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +14 -0
- package/native/claude/skills/_shared/adapters/omp.md +6 -0
- package/native/claude/skills/_shared/codex-monitored.sh +263 -0
- package/native/claude/skills/_shared/engine-doctor.sh +70 -0
- package/native/claude/skills/_shared/engine-preflight.md +29 -0
- package/native/claude/skills/_shared/expected-contract.py +242 -0
- package/native/claude/skills/_shared/expected.schema.json +120 -0
- package/native/claude/skills/_shared/harness-store.py +202 -0
- package/native/claude/skills/_shared/invocation-receipt.py +499 -0
- package/native/claude/skills/_shared/platform-support.py +395 -0
- package/native/claude/skills/_shared/role-config.py +247 -0
- package/native/claude/skills/_shared/runtime-principles.md +35 -0
- package/native/claude/skills/_shared/task-complete.py +2865 -0
- package/native/claude/skills/_shared/task-completion.md +249 -0
- package/native/claude/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/claude/skills/devlyn-engines/SKILL.md +63 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/claude/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/claude/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/claude/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/native/codex/.agents/plugins/marketplace.json +12 -0
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/.ruff.toml +2 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +14 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/omp.md +6 -0
- package/native/codex/plugins/devlyn/skills/_shared/codex-monitored.sh +263 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +70 -0
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +29 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected-contract.py +242 -0
- package/native/codex/plugins/devlyn/skills/_shared/expected.schema.json +120 -0
- package/native/codex/plugins/devlyn/skills/_shared/harness-store.py +202 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +499 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +395 -0
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +247 -0
- package/native/codex/plugins/devlyn/skills/_shared/runtime-principles.md +35 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +2865 -0
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +249 -0
- package/native/codex/plugins/devlyn/skills/_shared/warm-build-dirs.py +1065 -0
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +63 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +132 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/elicitation.md +29 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +123 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +128 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/plan-add.md +12 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +882 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +2191 -0
- package/package.json +1 -1
- package/release.json +1630 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Dispatch `codex exec` for codex-monitored.sh: exact argv, an optional sealed prompt file, native process ownership."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import runpy
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PLATFORM = runpy.run_path(pathlib.Path(__file__).with_name("platform-support.py"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransportError(ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reject_constant(token: str) -> None:
|
|
29
|
+
raise ValueError(f"invalid JSON numeric constant: {token}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict:
|
|
33
|
+
result = {}
|
|
34
|
+
for key, value in pairs:
|
|
35
|
+
if key in result:
|
|
36
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
37
|
+
result[key] = value
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def loads_strict_json(text: str):
|
|
42
|
+
return json.loads(
|
|
43
|
+
text,
|
|
44
|
+
parse_constant=reject_constant,
|
|
45
|
+
object_pairs_hook=reject_duplicate_keys,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sha256(raw: bytes) -> str:
|
|
50
|
+
return hashlib.sha256(raw).hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def atomic_write(path: pathlib.Path, value: object) -> None:
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
raw = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
56
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".tmp.")
|
|
57
|
+
try:
|
|
58
|
+
with os.fdopen(fd, "wb") as handle:
|
|
59
|
+
handle.write(raw)
|
|
60
|
+
pathlib.Path(temporary).replace(path)
|
|
61
|
+
except BaseException:
|
|
62
|
+
pathlib.Path(temporary).unlink(missing_ok=True)
|
|
63
|
+
raise
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def file_prompt_args(argv):
|
|
67
|
+
"""File mode accepts one explicit stdin prompt and no positional competitors."""
|
|
68
|
+
values = {"-C", "--cd", "-s", "--sandbox", "-m", "--model", "-c", "--config",
|
|
69
|
+
"-o", "--output-last-message", "--output-schema", "--color", "--enable",
|
|
70
|
+
"--disable", "--add-dir"}
|
|
71
|
+
flags = {"--json", "--ignore-user-config", "--ignore-rules", "--ephemeral",
|
|
72
|
+
"--skip-git-repo-check", "--full-auto"}
|
|
73
|
+
positional = []
|
|
74
|
+
index = 0
|
|
75
|
+
while index < len(argv):
|
|
76
|
+
token = argv[index]
|
|
77
|
+
if token in values:
|
|
78
|
+
if index + 1 >= len(argv):
|
|
79
|
+
raise TransportError(f"{token} requires a value")
|
|
80
|
+
index += 2
|
|
81
|
+
elif any(token.startswith(key + "=") for key in values) or token in flags:
|
|
82
|
+
index += 1
|
|
83
|
+
elif token == "--":
|
|
84
|
+
positional.extend(argv[index + 1:])
|
|
85
|
+
break
|
|
86
|
+
elif token == "-" or not token.startswith("-"):
|
|
87
|
+
positional.append(token)
|
|
88
|
+
index += 1
|
|
89
|
+
else:
|
|
90
|
+
raise TransportError(f"unsupported Codex file-transport option: {token}")
|
|
91
|
+
if positional != ["-"]:
|
|
92
|
+
raise TransportError("file transport requires the sole prompt argument '-'; competing prompts are forbidden")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def prepare_transport(prompt_path, command, argv, seconds):
|
|
96
|
+
path = pathlib.Path(prompt_path).resolve(strict=True)
|
|
97
|
+
with PLATFORM["open_stdin"](path) as source:
|
|
98
|
+
raw = source.read()
|
|
99
|
+
record = {"schema_version": 1, "transport": "stdin-file",
|
|
100
|
+
"prompt": {"path": str(path), "sha256": sha256(raw), "bytes": len(raw)},
|
|
101
|
+
"command": command, "argv": argv, "timeout_sec": seconds,
|
|
102
|
+
"status": "started", "exit_code": None}
|
|
103
|
+
carrier = path.with_name(path.name + ".transport.json")
|
|
104
|
+
if carrier.exists():
|
|
105
|
+
raise TransportError(f"prompt transport already exists: {carrier}")
|
|
106
|
+
stream = tempfile.TemporaryFile("w+b")
|
|
107
|
+
try:
|
|
108
|
+
stream.write(raw)
|
|
109
|
+
stream.seek(0)
|
|
110
|
+
except BaseException:
|
|
111
|
+
stream.close()
|
|
112
|
+
raise
|
|
113
|
+
return stream, carrier, record
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_transport(path, record):
|
|
117
|
+
with path.open("xb") as stream:
|
|
118
|
+
stream.write((json.dumps(record, sort_keys=True) + "\n").encode("utf-8"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def finish_transport(path, exit_code):
|
|
122
|
+
record = read_transport(path)
|
|
123
|
+
if record.get("status") != "started":
|
|
124
|
+
raise TransportError("prompt transport is not open")
|
|
125
|
+
record.update(status="completed", exit_code=exit_code)
|
|
126
|
+
atomic_write(path, record)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def read_transport(path: pathlib.Path) -> dict:
|
|
130
|
+
try:
|
|
131
|
+
value = loads_strict_json(path.read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
133
|
+
raise TransportError(f"prompt transport is invalid: {exc}") from exc
|
|
134
|
+
if not isinstance(value, dict):
|
|
135
|
+
raise TransportError("prompt transport must contain an object")
|
|
136
|
+
return value
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def monitor_descendant_regression() -> None:
|
|
140
|
+
if os.name == "nt":
|
|
141
|
+
print("SKIP POSIX shell process-group fixture; test-windows-portability.py exercises native Node/Python trees")
|
|
142
|
+
return
|
|
143
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
144
|
+
work = pathlib.Path(raw_tmp)
|
|
145
|
+
fixture_bin = work / "bin"
|
|
146
|
+
fixture_bin.mkdir()
|
|
147
|
+
release_marker = work / "release"
|
|
148
|
+
codex_pid_marker = work / "codex.pid"
|
|
149
|
+
sleep_marker = work / "sleep.tsv"
|
|
150
|
+
real_bash = shutil.which("bash")
|
|
151
|
+
real_sleep = shutil.which("sleep")
|
|
152
|
+
if real_bash is None or real_sleep is None:
|
|
153
|
+
raise AssertionError("monitor regression requires bash and sleep")
|
|
154
|
+
real_bash = str(pathlib.Path(real_bash).resolve(strict=True))
|
|
155
|
+
real_sleep = str(pathlib.Path(real_sleep).resolve(strict=True))
|
|
156
|
+
|
|
157
|
+
fake_codex = work / "fake-codex"
|
|
158
|
+
fake_codex.write_text(
|
|
159
|
+
f"#!{real_bash}\n"
|
|
160
|
+
"printf '%s\\n' \"$$\" > \"$DEVLYN_WATCHDOG_TEST_CODEX_PID\"\n"
|
|
161
|
+
"while [ ! -e \"$DEVLYN_WATCHDOG_TEST_RELEASE\" ]; do\n"
|
|
162
|
+
" \"$DEVLYN_WATCHDOG_TEST_REAL_SLEEP\" 0.01\n"
|
|
163
|
+
"done\n",
|
|
164
|
+
encoding="utf-8",
|
|
165
|
+
)
|
|
166
|
+
fake_codex.chmod(0o755)
|
|
167
|
+
sleep_shim = fixture_bin / "sleep"
|
|
168
|
+
sleep_shim.write_text(
|
|
169
|
+
f"#!{real_bash}\n"
|
|
170
|
+
"printf '%s\\t%s\\n' \"$$\" \"${1-}\" >> "
|
|
171
|
+
"\"$DEVLYN_WATCHDOG_TEST_SLEEP_MARKER\"\n"
|
|
172
|
+
"exec \"$DEVLYN_WATCHDOG_TEST_REAL_SLEEP\" \"$@\"\n",
|
|
173
|
+
encoding="utf-8",
|
|
174
|
+
)
|
|
175
|
+
sleep_shim.chmod(0o755)
|
|
176
|
+
|
|
177
|
+
wrapper = pathlib.Path(__file__).resolve().with_name("codex-monitored.sh")
|
|
178
|
+
env = os.environ.copy()
|
|
179
|
+
for key in tuple(env):
|
|
180
|
+
if (
|
|
181
|
+
key.startswith("DEVLYN_WATCHDOG_TEST_")
|
|
182
|
+
or key.startswith("CODEX_MONITORED_")
|
|
183
|
+
or key in {"CODEX_BLOCKED", "CODEX_REAL_BIN", "DEVLYN_CODEX_PROMPT_FILE"}
|
|
184
|
+
):
|
|
185
|
+
env.pop(key)
|
|
186
|
+
env.update({
|
|
187
|
+
"CODEX_BIN": str(fake_codex),
|
|
188
|
+
"CODEX_MONITORED_HEARTBEAT": "1",
|
|
189
|
+
"CODEX_MONITORED_TIMEOUT_SEC": "30",
|
|
190
|
+
"DEVLYN_WATCHDOG_TEST_CODEX_PID": str(codex_pid_marker),
|
|
191
|
+
"DEVLYN_WATCHDOG_TEST_RELEASE": str(release_marker),
|
|
192
|
+
"DEVLYN_WATCHDOG_TEST_SLEEP_MARKER": str(sleep_marker),
|
|
193
|
+
"DEVLYN_WATCHDOG_TEST_REAL_SLEEP": real_sleep,
|
|
194
|
+
"PATH": str(fixture_bin) + os.pathsep + env.get("PATH", ""),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
wrapped: subprocess.Popen | None = None
|
|
198
|
+
fixture_groups: dict[int, set[int]] = {}
|
|
199
|
+
wrapper_pgid: int | None = None
|
|
200
|
+
|
|
201
|
+
def record_owned_process(pid: int, session_id: int) -> int | None:
|
|
202
|
+
try:
|
|
203
|
+
process_session = os.getsid(pid)
|
|
204
|
+
process_group = os.getpgid(pid)
|
|
205
|
+
except ProcessLookupError:
|
|
206
|
+
return None
|
|
207
|
+
except PermissionError as exc:
|
|
208
|
+
raise AssertionError(f"cannot inspect fixture pid {pid}: {exc}") from exc
|
|
209
|
+
if process_session != session_id:
|
|
210
|
+
raise AssertionError(
|
|
211
|
+
f"fixture pid {pid} escaped session {session_id}: {process_session}"
|
|
212
|
+
)
|
|
213
|
+
fixture_groups.setdefault(process_group, set()).update({pid, process_group})
|
|
214
|
+
return process_group
|
|
215
|
+
|
|
216
|
+
def read_announced_groups(session_id: int) -> tuple[
|
|
217
|
+
tuple[int, int] | None, dict[str, tuple[int, int]]
|
|
218
|
+
]:
|
|
219
|
+
codex_info = None
|
|
220
|
+
if codex_pid_marker.is_file():
|
|
221
|
+
raw_pid = codex_pid_marker.read_text(encoding="utf-8")
|
|
222
|
+
if raw_pid.endswith("\n") and re.fullmatch(r"[1-9][0-9]*\n", raw_pid):
|
|
223
|
+
codex_pid = int(raw_pid.strip())
|
|
224
|
+
codex_pgid = record_owned_process(codex_pid, session_id)
|
|
225
|
+
if codex_pgid is not None:
|
|
226
|
+
codex_info = (codex_pid, codex_pgid)
|
|
227
|
+
elif raw_pid.endswith("\n"):
|
|
228
|
+
raise AssertionError(f"invalid fake Codex PID marker: {raw_pid!r}")
|
|
229
|
+
|
|
230
|
+
sleeps: dict[str, tuple[int, int]] = {}
|
|
231
|
+
if sleep_marker.is_file():
|
|
232
|
+
raw_sleeps = sleep_marker.read_text(encoding="utf-8")
|
|
233
|
+
complete = raw_sleeps if raw_sleeps.endswith("\n") else raw_sleeps.rsplit("\n", 1)[0]
|
|
234
|
+
for line in complete.splitlines():
|
|
235
|
+
match = re.fullmatch(r"([1-9][0-9]*)\t([^\t]+)", line)
|
|
236
|
+
if match is None:
|
|
237
|
+
raise AssertionError(f"invalid monitor sleep marker: {line!r}")
|
|
238
|
+
sleep_pid = int(match.group(1))
|
|
239
|
+
sleep_pgid = record_owned_process(sleep_pid, session_id)
|
|
240
|
+
if sleep_pgid is not None:
|
|
241
|
+
sleeps[match.group(2)] = (sleep_pid, sleep_pgid)
|
|
242
|
+
return codex_info, sleeps
|
|
243
|
+
|
|
244
|
+
def owned_group_alive(pgid: int, session_id: int) -> bool:
|
|
245
|
+
for pid in fixture_groups.get(pgid, ()):
|
|
246
|
+
try:
|
|
247
|
+
if os.getsid(pid) == session_id and os.getpgid(pid) == pgid:
|
|
248
|
+
return True
|
|
249
|
+
except ProcessLookupError:
|
|
250
|
+
continue
|
|
251
|
+
except PermissionError as exc:
|
|
252
|
+
raise AssertionError(
|
|
253
|
+
f"cannot inspect fixture process group {pgid}: {exc}"
|
|
254
|
+
) from exc
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
def cleanup_fixture(session_id: int) -> list[str]:
|
|
258
|
+
failures: list[str] = []
|
|
259
|
+
if wrapped is None:
|
|
260
|
+
return failures
|
|
261
|
+
try:
|
|
262
|
+
read_announced_groups(session_id)
|
|
263
|
+
except (OSError, UnicodeError, AssertionError) as exc:
|
|
264
|
+
failures.append(f"announcement discovery failed: {exc}")
|
|
265
|
+
|
|
266
|
+
def live_groups() -> list[int]:
|
|
267
|
+
result = []
|
|
268
|
+
for pgid in sorted(fixture_groups):
|
|
269
|
+
if pgid == os.getpgrp():
|
|
270
|
+
failures.append(f"refused to signal current process group {pgid}")
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
if owned_group_alive(pgid, session_id):
|
|
274
|
+
result.append(pgid)
|
|
275
|
+
except AssertionError as exc:
|
|
276
|
+
failures.append(str(exc))
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
for pgid in live_groups():
|
|
280
|
+
try:
|
|
281
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
282
|
+
except ProcessLookupError:
|
|
283
|
+
pass
|
|
284
|
+
except PermissionError as exc:
|
|
285
|
+
failures.append(f"TERM fixture process group {pgid} failed: {exc}")
|
|
286
|
+
|
|
287
|
+
term_deadline = time.monotonic() + 1.0
|
|
288
|
+
survivors = live_groups()
|
|
289
|
+
while survivors and time.monotonic() < term_deadline:
|
|
290
|
+
time.sleep(0.01)
|
|
291
|
+
survivors = live_groups()
|
|
292
|
+
for pgid in survivors:
|
|
293
|
+
try:
|
|
294
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
295
|
+
except ProcessLookupError:
|
|
296
|
+
pass
|
|
297
|
+
except PermissionError as exc:
|
|
298
|
+
failures.append(f"KILL fixture process group {pgid} failed: {exc}")
|
|
299
|
+
|
|
300
|
+
kill_deadline = time.monotonic() + 1.0
|
|
301
|
+
survivors = live_groups()
|
|
302
|
+
while survivors and time.monotonic() < kill_deadline:
|
|
303
|
+
time.sleep(0.01)
|
|
304
|
+
survivors = live_groups()
|
|
305
|
+
if survivors:
|
|
306
|
+
failures.append(f"fixture process groups survived cleanup: {survivors}")
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
wrapped.wait(timeout=1)
|
|
310
|
+
except subprocess.TimeoutExpired:
|
|
311
|
+
failures.append("wrapper wait exceeded cleanup deadline")
|
|
312
|
+
try:
|
|
313
|
+
wrapped.kill()
|
|
314
|
+
except ProcessLookupError:
|
|
315
|
+
pass
|
|
316
|
+
except PermissionError as exc:
|
|
317
|
+
failures.append(f"direct wrapper KILL failed: {exc}")
|
|
318
|
+
try:
|
|
319
|
+
wrapped.wait(timeout=1)
|
|
320
|
+
except subprocess.TimeoutExpired:
|
|
321
|
+
failures.append("wrapper remained alive after direct KILL")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
wrapped.communicate(timeout=1)
|
|
325
|
+
except subprocess.TimeoutExpired:
|
|
326
|
+
failures.append("captured stderr did not reach EOF during cleanup")
|
|
327
|
+
if wrapped.stderr is not None:
|
|
328
|
+
wrapped.stderr.close()
|
|
329
|
+
return failures
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
wrapped = subprocess.Popen(
|
|
333
|
+
[
|
|
334
|
+
real_bash, str(wrapper), "-C", str(work), "-s", "workspace-write",
|
|
335
|
+
"-m", "gpt-wrapper", "verify monitor cleanup",
|
|
336
|
+
],
|
|
337
|
+
cwd=work,
|
|
338
|
+
env=env,
|
|
339
|
+
stdout=subprocess.DEVNULL,
|
|
340
|
+
stderr=subprocess.PIPE,
|
|
341
|
+
start_new_session=True,
|
|
342
|
+
)
|
|
343
|
+
session_id = wrapped.pid
|
|
344
|
+
wrapper_pgid = record_owned_process(wrapped.pid, session_id)
|
|
345
|
+
if wrapper_pgid != wrapped.pid:
|
|
346
|
+
raise AssertionError(
|
|
347
|
+
f"wrapper did not own its fixture session/process group: {wrapper_pgid}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
setup_deadline = time.monotonic() + 5.0
|
|
351
|
+
ready = None
|
|
352
|
+
while time.monotonic() < setup_deadline:
|
|
353
|
+
codex_info, sleeps = read_announced_groups(session_id)
|
|
354
|
+
heartbeat = sleeps.get("1")
|
|
355
|
+
watchdog = sleeps.get("30")
|
|
356
|
+
if codex_info is not None and heartbeat is not None and watchdog is not None:
|
|
357
|
+
groups = {
|
|
358
|
+
wrapper_pgid,
|
|
359
|
+
codex_info[1],
|
|
360
|
+
heartbeat[1],
|
|
361
|
+
watchdog[1],
|
|
362
|
+
}
|
|
363
|
+
if len(groups) != 4:
|
|
364
|
+
raise AssertionError(
|
|
365
|
+
"wrapper, Codex, heartbeat, and watchdog did not own distinct groups: "
|
|
366
|
+
f"{sorted(groups)}"
|
|
367
|
+
)
|
|
368
|
+
if all(owned_group_alive(pgid, session_id) for pgid in groups):
|
|
369
|
+
ready = (codex_info, heartbeat, watchdog)
|
|
370
|
+
break
|
|
371
|
+
if wrapped.poll() is not None:
|
|
372
|
+
raise AssertionError(
|
|
373
|
+
f"wrapper exited during monitor setup with code {wrapped.returncode}"
|
|
374
|
+
)
|
|
375
|
+
time.sleep(0.01)
|
|
376
|
+
if ready is None:
|
|
377
|
+
sleep_detail = sleep_marker.read_text(encoding="utf-8") if sleep_marker.exists() else ""
|
|
378
|
+
raise AssertionError(
|
|
379
|
+
"monitor setup did not announce live Codex/heartbeat=1/watchdog=30 "
|
|
380
|
+
f"within 5 seconds: {sleep_detail!r}"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
release_marker.write_bytes(b"release\n")
|
|
384
|
+
try:
|
|
385
|
+
_stdout, stderr = wrapped.communicate(timeout=5)
|
|
386
|
+
except subprocess.TimeoutExpired as exc:
|
|
387
|
+
raise AssertionError(
|
|
388
|
+
"wrapper completion or captured stderr EOF exceeded 5 seconds "
|
|
389
|
+
f"after release (returncode={wrapped.poll()})"
|
|
390
|
+
) from exc
|
|
391
|
+
if wrapped.returncode != 0:
|
|
392
|
+
raise AssertionError(stderr.decode("utf-8", errors="replace"))
|
|
393
|
+
read_announced_groups(session_id)
|
|
394
|
+
survivors = [
|
|
395
|
+
pgid
|
|
396
|
+
for pgid in sorted(fixture_groups)
|
|
397
|
+
if owned_group_alive(pgid, session_id)
|
|
398
|
+
]
|
|
399
|
+
if survivors:
|
|
400
|
+
raise AssertionError(
|
|
401
|
+
f"wrapper returned while fixture process groups remained alive: {survivors}"
|
|
402
|
+
)
|
|
403
|
+
except BaseException as exc:
|
|
404
|
+
session_id = wrapped.pid if wrapped is not None else -1
|
|
405
|
+
cleanup_failures = cleanup_fixture(session_id)
|
|
406
|
+
if cleanup_failures:
|
|
407
|
+
raise AssertionError(
|
|
408
|
+
f"monitor regression failed ({exc}); cleanup failed: "
|
|
409
|
+
+ "; ".join(cleanup_failures)
|
|
410
|
+
) from exc
|
|
411
|
+
raise
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def self_test() -> int:
|
|
415
|
+
monitor_descendant_regression()
|
|
416
|
+
if os.name == "nt":
|
|
417
|
+
print("SKIP POSIX executable-shell wrapper fixture; native npm transport is covered by test-windows-portability.py")
|
|
418
|
+
return 0
|
|
419
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
420
|
+
work = pathlib.Path(raw_tmp)
|
|
421
|
+
fake_codex = work / "fake-codex"
|
|
422
|
+
fake_codex.write_text("#!/usr/bin/env bash\ntouch native-started\n", encoding="utf-8")
|
|
423
|
+
fake_codex.chmod(0o755)
|
|
424
|
+
wrapper = pathlib.Path(__file__).with_name("codex-monitored.sh")
|
|
425
|
+
widened = subprocess.run(
|
|
426
|
+
["bash", str(wrapper), "-C", str(work), "-s", "danger-full-access", "-m", "gpt-wrapper", "verify the task"],
|
|
427
|
+
cwd=work, env={**os.environ, "CODEX_BIN": str(fake_codex)},
|
|
428
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False,
|
|
429
|
+
)
|
|
430
|
+
assert widened.returncode == 64 and b"forbidden Codex sandbox" in widened.stderr, widened.stderr
|
|
431
|
+
assert widened.stdout == b"" and not (work / "native-started").exists()
|
|
432
|
+
print("PASS monitored-wrapper descendant cleanup and authority-widening refusal")
|
|
433
|
+
return 0
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def complete_dispatch(exit_code):
|
|
437
|
+
prompt = os.environ.get("DEVLYN_CODEX_PROMPT_FILE")
|
|
438
|
+
if prompt:
|
|
439
|
+
path = pathlib.Path(prompt).resolve()
|
|
440
|
+
finish_transport(path.with_name(path.name + ".transport.json"), exit_code)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def dispatch_codex(args):
|
|
444
|
+
argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
|
|
445
|
+
command = [args.binary, "exec", *argv]
|
|
446
|
+
actual = PLATFORM["native_argv"](command)
|
|
447
|
+
prompt = os.environ.get("DEVLYN_CODEX_PROMPT_FILE")
|
|
448
|
+
if prompt == "":
|
|
449
|
+
raise TransportError("DEVLYN_CODEX_PROMPT_FILE must name a readable prompt file")
|
|
450
|
+
stream, carrier, transport = None, None, None
|
|
451
|
+
try:
|
|
452
|
+
if prompt:
|
|
453
|
+
file_prompt_args(argv)
|
|
454
|
+
stream, carrier, transport = prepare_transport(prompt, command, actual, args.timeout)
|
|
455
|
+
if transport is not None:
|
|
456
|
+
write_transport(carrier, transport)
|
|
457
|
+
if os.name != "nt":
|
|
458
|
+
if stream is not None:
|
|
459
|
+
os.dup2(stream.fileno(), 0)
|
|
460
|
+
os.execvp(actual[0], actual)
|
|
461
|
+
code = PLATFORM["run_process"](actual, stream if stream is not None else subprocess.DEVNULL,
|
|
462
|
+
args.timeout, heartbeat=args.heartbeat)
|
|
463
|
+
complete_dispatch(code)
|
|
464
|
+
print(f"[codex-monitored] codex exited: code={code}", file=sys.stderr)
|
|
465
|
+
return code
|
|
466
|
+
finally:
|
|
467
|
+
if stream is not None:
|
|
468
|
+
stream.close()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main() -> int:
|
|
472
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
473
|
+
parser.add_argument("--self-test", action="store_true")
|
|
474
|
+
subparsers = parser.add_subparsers(dest="action")
|
|
475
|
+
dispatch = subparsers.add_parser("dispatch")
|
|
476
|
+
dispatch.add_argument("--binary", required=True)
|
|
477
|
+
dispatch.add_argument("--timeout", type=int, required=True)
|
|
478
|
+
dispatch.add_argument("--heartbeat", type=int, required=True)
|
|
479
|
+
dispatch.add_argument("argv", nargs=argparse.REMAINDER)
|
|
480
|
+
completed = subparsers.add_parser("complete-dispatch")
|
|
481
|
+
completed.add_argument("--exit-code", type=int, required=True)
|
|
482
|
+
args = parser.parse_args()
|
|
483
|
+
if args.self_test:
|
|
484
|
+
return self_test()
|
|
485
|
+
try:
|
|
486
|
+
if args.action == "dispatch":
|
|
487
|
+
return dispatch_codex(args)
|
|
488
|
+
elif args.action == "complete-dispatch":
|
|
489
|
+
complete_dispatch(args.exit_code)
|
|
490
|
+
else:
|
|
491
|
+
parser.error("an action is required")
|
|
492
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
493
|
+
parser.error(str(exc))
|
|
494
|
+
return 0
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
if __name__ == "__main__":
|
|
498
|
+
runpy.run_path(str(pathlib.Path(__file__).with_name("platform-support.py")))["configure_utf8"]()
|
|
499
|
+
raise SystemExit(PLATFORM["system_exit_code"](main()))
|