devlyn-cli 4.3.16 → 4.3.20
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/README.md +19 -0
- package/bin/skill-history.json +12 -0
- package/config/skills/_shared/adapters/claude.md +15 -0
- package/config/skills/_shared/adapters/codex.md +10 -0
- package/config/skills/_shared/invocation-receipt.py +179 -9
- package/config/skills/_shared/platform-support.py +102 -1
- package/config/skills/_shared/task-complete.py +240 -25
- package/config/skills/_shared/task-completion.md +7 -4
- package/config/skills/_shared/unattended-tools.py +206 -0
- package/config/skills/devlyn-ideate/SKILL.md +1 -1
- package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/config/skills/devlyn-ideate/scripts/queue.py +31 -14
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/adapters/claude.md +15 -0
- package/native/claude/skills/_shared/adapters/codex.md +10 -0
- package/native/claude/skills/_shared/invocation-receipt.py +179 -9
- package/native/claude/skills/_shared/platform-support.py +102 -1
- package/native/claude/skills/_shared/task-complete.py +240 -25
- package/native/claude/skills/_shared/task-completion.md +7 -4
- package/native/claude/skills/_shared/unattended-tools.py +206 -0
- package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +31 -14
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
- package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
- package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +240 -25
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +7 -4
- package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +31 -14
- package/package.json +1 -1
- package/release.json +50 -35
package/README.md
CHANGED
|
@@ -86,6 +86,25 @@ The orchestrator is the CLI you opened (Claude Code, Codex, omp, Pi or Grok); th
|
|
|
86
86
|
|
|
87
87
|
`/devlyn-engines` with no arguments shows the executor and the engines detected on this machine; `executor <name>` pins one and `clear` removes the pin. A pin is a promise: an unavailable pinned engine stops dispatch with `BLOCKED:<engine>-unavailable`, and a name with no executor-eligible `_shared/adapters/<name>.md` stops with `BLOCKED:invalid-engine-config`. Adapters ship with devlyn-cli releases; a reinstall replaces `_shared`.
|
|
88
88
|
|
|
89
|
+
Unattended children disable owner-desktop and account drivers: every monitored
|
|
90
|
+
Codex child and every `claude` drain executor (including `.exe`/`.cmd`/`.bat`)
|
|
91
|
+
uses a launch-only policy. Wrapped Claude launches (such as `env … claude` or
|
|
92
|
+
`node …/claude-code/cli.js`) and ambiguous `node …/cli.js` wrappers are refused;
|
|
93
|
+
invoke Claude directly as argv[0]. A shell command or script that starts an engine
|
|
94
|
+
itself (for example `sh -c '…'`) is not inspected.
|
|
95
|
+
Other engines have no tool guard. Interactive sessions and user settings files stay
|
|
96
|
+
unchanged. Add your own server denies in `~/.devlyn/unattended-tools.json`, for
|
|
97
|
+
example `{"codex":["my-desktop"],"claude":["my.server"]}`; omitted keys mean no
|
|
98
|
+
extra denies, and an absent file means defaults only. Malformed files refuse
|
|
99
|
+
the launch. Codex only overrides names returned by its MCP list, verifies they
|
|
100
|
+
are disabled, and refuses denied names outside ASCII letters, digits, `_` and `-`.
|
|
101
|
+
Claude uses `--disallowedTools=mcp__<server>`: replace each character outside ASCII letters,
|
|
102
|
+
digits, `_` and `-` with `_` (`my.server` → `my_server`); `claude.ai ` names also
|
|
103
|
+
collapse and trim underscores, matching Claude's namespace normalization.
|
|
104
|
+
See [Claude's MCP permission rules](https://code.claude.com/docs/en/permissions#mcp).
|
|
105
|
+
Remote URLs are not denied by default, so workers keep remote memory MCP.
|
|
106
|
+
An account server you never list remains available.
|
|
107
|
+
|
|
89
108
|
### Migration from earlier versions
|
|
90
109
|
|
|
91
110
|
Reinstall with the new version to refresh instructions and skills for the selected
|
package/bin/skill-history.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"0b5c3f72d18f8eea538bfef7192b207ff5cbac25c7e0d2ce080cc725bfdaa2e2",
|
|
14
14
|
"0f397a4edd6b1b3063d27f6205dc6b3d069fa616624bccf661539b174535fc7e",
|
|
15
15
|
"110005e3c7052d35502186d907b169c9fc7c85d558ab7c7ad0ba2f0308aa7a5d",
|
|
16
|
+
"11ac38b21ecc139211af1b335bb3acc4f0f1bc4b5c133e81a80cafebcaaca298",
|
|
16
17
|
"135f09d5382b4d375204bba3ff6fb3f6fdc34a1ec89fe88542da02d12dda0f0d",
|
|
17
18
|
"1504c7a41e5f4a3206288d05e66fb48847222864f7825f05633883e2c562ec77",
|
|
18
19
|
"161cb4f1b27a9f81ae1e96af6ff596399c8936578a11d601108a89cbdeea241d",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"2692865ee864aebedb2b385384b2a8220bc780bd92310bc1cf2f56927de1e0b8",
|
|
28
29
|
"27d4da7863ed4652187f6a101da0e56b1d3d18e4565c720d5f8150ad0a193402",
|
|
29
30
|
"2a4361bca0a9fadc4a8e5e4578c383171cd64c65296fe882990502250df44d53",
|
|
31
|
+
"2aa0153b91573dc82ac7d21f1431862258334da30b52403434213cb0933ac38c",
|
|
30
32
|
"2bcac1ab5995e474c7dd46705413982876384d8b4da704126a66c10770a1a4d2",
|
|
31
33
|
"2bcae17ca31adaf306d1e5d21ad0c211bbd6f57605f68168f656e078dbf524e2",
|
|
32
34
|
"326a71c447fe4d98f3460eda3fc541920b7cc2205b8288743e8622d90215a49d",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"3e39fbe29d0bd607f4ad940f4ca40f9abf82d468e418d0362fb0fd4fa6812475",
|
|
49
51
|
"3e5135877e733f1dcff3b747444228f5eece7cb047956c87a3af02a485cfaad9",
|
|
50
52
|
"3feb469793d0101c2cd939684a9fcdebd333d0532c68455f0f2df33c4b312bfc",
|
|
53
|
+
"3ffbcb95d487c8847f0fc70524656280c416abcf85a227b1f59602c21807a2fd",
|
|
51
54
|
"406b982f6ca0b9f5c0faa6cb50749b52aaf23545e06174ec39f831929dc929bd",
|
|
52
55
|
"40f50f3c3c0d030d1efeb6955c0c6f32f518e737d8dd3bec6610ff02badebbdb",
|
|
53
56
|
"430d156eb8c62c7edba3d660050f90b267f928976c3a3688e95d54f00dc34747",
|
|
@@ -142,6 +145,7 @@
|
|
|
142
145
|
"c3a37f7502b5e2d7108845907520cf6fef2ebcd70dd59d75f42cbf5bf5f21fbe",
|
|
143
146
|
"c5d2130bb864db373d36878b96dd974fee4d64b3bf3455cc722b4f6787b44c3c",
|
|
144
147
|
"c86770d97e721fa796925cbd64544955c8d6f88fbb696069bbf1cf74e5e907cb",
|
|
148
|
+
"c99a7702f5f5ee88fd8bb78067d23977e5f0f77e48fec3b0c67b8c5a09eb8d11",
|
|
145
149
|
"ca5af169c57dff8d038b99376a7b984a220102c43406ccdef79c9d1eadbce6fb",
|
|
146
150
|
"cf13e851d0ee9b8f2f0bb44765b1e87eaec4c22a0f10aad8ad42c597c8fc0cf9",
|
|
147
151
|
"d023806548fe634679cee0d31639a272e6404398415c3ac72fcfa66f924d8d00",
|
|
@@ -161,6 +165,7 @@
|
|
|
161
165
|
"de4f504265e960cb8d81752e9ae922f664790ee026b4454f1b07252d1a6282a4",
|
|
162
166
|
"e0a305cf3bf47c8bc357195d4d50b0f6177069e870c33040c258c93edae68646",
|
|
163
167
|
"e115205a744fef918a59d1b3a601df09a8b6df1d3e60613b9e8b87240929f8d8",
|
|
168
|
+
"e19204055bb16fff4263f95fac092ee8f971ee9055b7a969497e5714782a454c",
|
|
164
169
|
"e41013abf5e17445ef83a86974ac1de77beaa61e484e1916577ecc64bb34c220",
|
|
165
170
|
"e725ec4eca42f737ee4bdaceb10c11db289414a1d4bb8be8fe103f89c89f07d5",
|
|
166
171
|
"e812d02ebdbdb8d82eb85842d676e21bedddb0d00154eed99b9d29b43cb8fc02",
|
|
@@ -324,6 +329,7 @@
|
|
|
324
329
|
"0122885c08069474ca4a0a5825f885364a62938885f0e501c09242d176c34b08",
|
|
325
330
|
"05e824f63a6381e02042afb1df0f1437584e25a80de8e9db0bd9456aaeb7aab8",
|
|
326
331
|
"0601ee535419b7039d5da34bd9c8e1c2b3631307214e4afaf394dd47494090c7",
|
|
332
|
+
"062fde2838c0edc0c20e6716948a2ae9fe9d8a98b01a8e8c544c5801641c7cdb",
|
|
327
333
|
"0cef055580e00f9ed439eacb2dff9d238bb11619457472016095a8915162fa4b",
|
|
328
334
|
"0dc69c20434966015176d83baa6f95b3421f9e13d0b1bd608a22f0560eae1049",
|
|
329
335
|
"0fe9b270d670f70da9d2535969b6117684b7c1517806b3029ebaf9624da8006c",
|
|
@@ -344,6 +350,7 @@
|
|
|
344
350
|
"65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
|
|
345
351
|
"65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
|
|
346
352
|
"694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
|
|
353
|
+
"6b3143bcfe9c60a76f142d533177d85d36f856a163a226351317ed688738c05a",
|
|
347
354
|
"6b403afbf5a117cc08603d3648b9dded4f9ac4ca02fc1d8e1f0035615148bb5c",
|
|
348
355
|
"6c3e2e4b84b0b4f81aed3759704da7c14f3cbabf3beeb0d1654feda3a15a8675",
|
|
349
356
|
"6d9df58c117165e9d297aa6be0df8d076bac1ed49dc29ae93906af1969798ab1",
|
|
@@ -362,6 +369,7 @@
|
|
|
362
369
|
"aba66b674683935e4c28c93333896dc116ffe67e7a35fe30b3599a8be507e94e",
|
|
363
370
|
"b2e4437fa981317362d03ee032e1cfd8b686316e966c6ba12ecd619a009c80a6",
|
|
364
371
|
"bdd0751eea04532c050334fda3bc5c61c6b429e485d97e0b8ebb4fae154da2c7",
|
|
372
|
+
"bf302e48c189813849849563a117b412cecb627323e8af7c12dc729ea3e6e4cd",
|
|
365
373
|
"c1b8e3ebf38e2be8972aa003e9614a782cb4f195c69d20b95a475ebf9aaaab9e",
|
|
366
374
|
"c8e375aab574148542f392b093cbbf3f2e8dc623826b45e35df0a1a2c3862226",
|
|
367
375
|
"ce282a55328c2d071cacc377670fbebea4da4b9a7fee81b25293fb26763f17c9",
|
|
@@ -774,7 +782,11 @@
|
|
|
774
782
|
"4.3.14",
|
|
775
783
|
"4.3.15",
|
|
776
784
|
"4.3.16",
|
|
785
|
+
"4.3.17",
|
|
786
|
+
"4.3.18",
|
|
787
|
+
"4.3.19",
|
|
777
788
|
"4.3.2",
|
|
789
|
+
"4.3.20",
|
|
778
790
|
"4.3.3",
|
|
779
791
|
"4.3.4",
|
|
780
792
|
"4.3.5",
|
|
@@ -7,6 +7,21 @@ A Claude executor starts as
|
|
|
7
7
|
where `<common git dir>` is `git rev-parse --path-format=absolute --git-common-dir`:
|
|
8
8
|
a linked task worktree commits there.
|
|
9
9
|
|
|
10
|
+
For drain children, `queue.py` adds `--no-chrome` and a temporary `--settings`
|
|
11
|
+
file (0600), sets `ENABLE_CLAUDEAI_MCP_SERVERS=false`, disables plugin sync and
|
|
12
|
+
every enabled plugin (including synced plugins), and verifies the overrides
|
|
13
|
+
before spawning. Discovery or verification failure refuses the launch. Normal
|
|
14
|
+
user settings, including `BASH_MAX_TIMEOUT_MS`, remain loaded; interactive and
|
|
15
|
+
non-drain Claude invocations are unchanged.
|
|
16
|
+
The launch settings also force `env.ENABLE_CLAUDEAI_MCP_SERVERS="false"` and
|
|
17
|
+
`disableClaudeAiConnectors=true` (recognized by Claude 2.1.314). Policy options
|
|
18
|
+
precede all caller arguments; server denies use `--disallowedTools=<list>`.
|
|
19
|
+
Drain refuses caller `--settings`, `--setting-sources`, `--plugin-dir` and
|
|
20
|
+
`--plugin-url` or `--chrome` tokens (including `=<value>` forms), and `--add-dir` directories
|
|
21
|
+
whose `.claude/settings.json` or `settings.local.json` contains `enabledPlugins`.
|
|
22
|
+
Wrapped Claude executors must be replaced with a direct Claude argv; a shell
|
|
23
|
+
command or script that starts Claude itself is not inspected.
|
|
24
|
+
|
|
10
25
|
**Availability probe**: `command -v claude >/dev/null 2>&1`; record
|
|
11
26
|
`claude --version` as evidence. The probe is necessary, not sufficient:
|
|
12
27
|
`claude -p` needs network access to the Anthropic API, so in a network-denying
|
|
@@ -12,3 +12,13 @@ denies network, which installing a new dependency needs:
|
|
|
12
12
|
`-s workspace-write -c 'sandbox_workspace_write.writable_roots=["<git dir>","<worktree git dir>"]' -c 'sandbox_workspace_write.network_access=true'`.
|
|
13
13
|
Deliver a multiline prompt as exact file bytes: set `DEVLYN_CODEX_PROMPT_FILE=<file>`
|
|
14
14
|
and pass the sole prompt argument `-`.
|
|
15
|
+
|
|
16
|
+
Every monitored child applies `_shared/unattended-tools.py` before recording its
|
|
17
|
+
effective argv: account/plugin and browser/computer features are disabled, then
|
|
18
|
+
listed desktop servers are disabled. Policy switches precede all exec arguments;
|
|
19
|
+
feature states and MCP overrides are read back before launch. A still-enabled
|
|
20
|
+
target, malformed output or discovery failure refuses the launch; an unsupported feature error names the installed Codex version.
|
|
21
|
+
Interactive Codex and user configuration files are unchanged.
|
|
22
|
+
If an exec-only configuration option (such as `--ignore-user-config` on Codex
|
|
23
|
+
0.164.0) cannot be applied to `mcp list`, discovery refuses the launch; it never
|
|
24
|
+
falls back to listing a different configuration.
|
|
@@ -92,13 +92,16 @@ def file_prompt_args(argv):
|
|
|
92
92
|
raise TransportError("file transport requires the sole prompt argument '-'; competing prompts are forbidden")
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
def prepare_transport(prompt_path,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
def prepare_transport(prompt_path, seconds):
|
|
96
|
+
try:
|
|
97
|
+
path = pathlib.Path(prompt_path).resolve(strict=True)
|
|
98
|
+
with PLATFORM["open_stdin"](path) as source:
|
|
99
|
+
raw = source.read()
|
|
100
|
+
except (OSError, ValueError) as exc:
|
|
101
|
+
raise TransportError(f"DEVLYN_CODEX_PROMPT_FILE is not a readable prompt file: {prompt_path}") from exc
|
|
99
102
|
record = {"schema_version": 1, "transport": "stdin-file",
|
|
100
103
|
"prompt": {"path": str(path), "sha256": sha256(raw), "bytes": len(raw)},
|
|
101
|
-
"
|
|
104
|
+
"timeout_sec": seconds,
|
|
102
105
|
"status": "started", "exit_code": None}
|
|
103
106
|
carrier = path.with_name(path.name + ".transport.json")
|
|
104
107
|
if carrier.exists():
|
|
@@ -157,6 +160,7 @@ def monitor_descendant_regression() -> None:
|
|
|
157
160
|
fake_codex = work / "fake-codex"
|
|
158
161
|
fake_codex.write_text(
|
|
159
162
|
f"#!{real_bash}\n"
|
|
163
|
+
"case \" $* \" in *' features list '*) for f in apps plugins remote_plugin computer_use browser_use browser_use_external in_app_browser; do printf '%s stable false\\n' \"$f\"; done; exit 0;; *' mcp list --json '*) printf '[]\\n'; exit 0;; esac\n"
|
|
160
164
|
"printf '%s\\n' \"$$\" > \"$DEVLYN_WATCHDOG_TEST_CODEX_PID\"\n"
|
|
161
165
|
"while [ ! -e \"$DEVLYN_WATCHDOG_TEST_RELEASE\" ]; do\n"
|
|
162
166
|
" \"$DEVLYN_WATCHDOG_TEST_REAL_SLEEP\" 0.01\n"
|
|
@@ -184,6 +188,7 @@ def monitor_descendant_regression() -> None:
|
|
|
184
188
|
):
|
|
185
189
|
env.pop(key)
|
|
186
190
|
env.update({
|
|
191
|
+
"HOME": str(work), "USERPROFILE": str(work),
|
|
187
192
|
"CODEX_BIN": str(fake_codex),
|
|
188
193
|
"CODEX_MONITORED_HEARTBEAT": "1",
|
|
189
194
|
"CODEX_MONITORED_TIMEOUT_SEC": "30",
|
|
@@ -411,11 +416,173 @@ def monitor_descendant_regression() -> None:
|
|
|
411
416
|
raise
|
|
412
417
|
|
|
413
418
|
|
|
419
|
+
def unattended_tools_regression():
|
|
420
|
+
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
421
|
+
work = pathlib.Path(raw_tmp).resolve()
|
|
422
|
+
fake = work / "codex"
|
|
423
|
+
fake.write_text(f"#!{sys.executable}\n" + '''
|
|
424
|
+
import json, os, pathlib, re, sys
|
|
425
|
+
a = sys.argv[1:]
|
|
426
|
+
with open("probes.jsonl", "a") as stream:
|
|
427
|
+
stream.write(json.dumps({"argv": a, "cwd": os.getcwd()}) + "\\n")
|
|
428
|
+
features = "apps plugins remote_plugin computer_use browser_use browser_use_external in_app_browser".split()
|
|
429
|
+
config, disabled = {}, set()
|
|
430
|
+
i = 0
|
|
431
|
+
while i < len(a):
|
|
432
|
+
token = a[i]
|
|
433
|
+
if token == "--": break
|
|
434
|
+
if token.startswith("-c") and not token.startswith("--") and len(token) > 2:
|
|
435
|
+
key, value = "-c", token[2:].removeprefix("=")
|
|
436
|
+
else:
|
|
437
|
+
key, equal, value = token.partition("=")
|
|
438
|
+
if key in ("-c", "--config", "--disable", "--enable", "-p", "--profile", "-C", "--cd") and not equal:
|
|
439
|
+
i += 1
|
|
440
|
+
value = a[i]
|
|
441
|
+
if key in ("-c", "--config"):
|
|
442
|
+
name, sep, val = value.partition("=")
|
|
443
|
+
if not sep: sys.exit("missing config value")
|
|
444
|
+
if name.startswith('mcp_servers."'): sys.exit("invalid transport in " + name)
|
|
445
|
+
config[name] = val
|
|
446
|
+
if key == "--disable": disabled.add(value)
|
|
447
|
+
i += 1
|
|
448
|
+
if a == ["--version"]:
|
|
449
|
+
print("codex-fixture 1.2.3")
|
|
450
|
+
elif a[:2] == ["features", "list"]:
|
|
451
|
+
if os.environ.get("FEATURE_FAIL"):
|
|
452
|
+
sys.exit("Unknown feature flag: in_app_browser")
|
|
453
|
+
for f in features:
|
|
454
|
+
mode = os.environ.get("FEATURE_MODE") if f == "apps" else None
|
|
455
|
+
if mode == "missing": continue
|
|
456
|
+
print(f, "under development", "garbled" if mode == "malformed" else str(mode == "sticky" or f not in disabled).lower())
|
|
457
|
+
elif "mcp" in a:
|
|
458
|
+
assert a[a.index("mcp"):a.index("mcp") + 3] == ["mcp", "list", "--json"], a
|
|
459
|
+
verify = any(k.endswith(".enabled") for k in config)
|
|
460
|
+
if os.environ.get("LIST_FAIL") or (verify and os.environ.get("VERIFY_FAIL")):
|
|
461
|
+
sys.exit("cannot list servers")
|
|
462
|
+
raw = os.environ.get("VERIFY_SERVERS" if verify else "SERVERS", os.environ.get("SERVERS", '[{"name":"node_repl","enabled":true},{"name":"pyx-memory","enabled":true}]'))
|
|
463
|
+
try: servers = json.loads(raw)
|
|
464
|
+
except ValueError:
|
|
465
|
+
print(raw); sys.exit(0)
|
|
466
|
+
if isinstance(servers, list) and all(isinstance(s, dict) and "name" in s and isinstance(s.get("enabled"), bool) for s in servers):
|
|
467
|
+
servers = {s["name"]: s for s in servers}
|
|
468
|
+
for key in config:
|
|
469
|
+
match = re.fullmatch(r"mcp_servers\\.([A-Za-z0-9_-]+)\\.command", key)
|
|
470
|
+
if match:
|
|
471
|
+
name = match[1]
|
|
472
|
+
servers.setdefault(name, {"name": name, "enabled": True})
|
|
473
|
+
for key, val in config.items():
|
|
474
|
+
match = re.fullmatch(r"mcp_servers\\.([A-Za-z0-9_-]+)\\.(command|enabled)", key)
|
|
475
|
+
if match:
|
|
476
|
+
name, field = match.groups()
|
|
477
|
+
if field == "command": servers.setdefault(name, {"name": name, "enabled": True})
|
|
478
|
+
elif not os.environ.get("MCP_STICKY"):
|
|
479
|
+
if name not in servers: sys.exit("invalid transport")
|
|
480
|
+
servers[name]["enabled"] = val == "true"
|
|
481
|
+
servers = list(servers.values())
|
|
482
|
+
print(json.dumps(servers))
|
|
483
|
+
else:
|
|
484
|
+
assert a[0] == "exec" and a[1:15] == [v for f in features for v in ("--disable", f)], a
|
|
485
|
+
pathlib.Path("seen.json").write_text(json.dumps(a))
|
|
486
|
+
pathlib.Path("stdin").write_bytes(sys.stdin.buffer.read())
|
|
487
|
+
sys.exit(int(os.environ.get("EXEC_CODE", "0")))
|
|
488
|
+
''', encoding="utf-8")
|
|
489
|
+
fake.chmod(0o755)
|
|
490
|
+
wrapper = pathlib.Path(__file__).resolve().with_name("codex-monitored.sh")
|
|
491
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("CODEX_") and k != "DEVLYN_CODEX_PROMPT_FILE"}
|
|
492
|
+
env.update(HOME=str(work), USERPROFILE=str(work), CODEX_BIN=str(fake))
|
|
493
|
+
seen = work / "seen.json"
|
|
494
|
+
deny = work / ".devlyn/unattended-tools.json"
|
|
495
|
+
deny.parent.mkdir()
|
|
496
|
+
|
|
497
|
+
def launch(extra=None, code=0, args=None):
|
|
498
|
+
seen.unlink(missing_ok=True)
|
|
499
|
+
with (work / "stdout").open("wb") as stdout, (work / "stderr").open("wb") as stderr:
|
|
500
|
+
result = subprocess.run(["bash", str(wrapper), *(args or ["hello"])], cwd=work,
|
|
501
|
+
env={**env, **(extra or {})}, stdout=stdout, stderr=stderr, timeout=60)
|
|
502
|
+
error = (work / "stderr").read_text(encoding="utf-8")
|
|
503
|
+
assert result.returncode == code, (result.returncode, error)
|
|
504
|
+
if code == 2:
|
|
505
|
+
assert not seen.exists(), error
|
|
506
|
+
return error
|
|
507
|
+
|
|
508
|
+
launch(code=7, extra={"EXEC_CODE": "7"})
|
|
509
|
+
argv = json.loads(seen.read_text())
|
|
510
|
+
shutdown = [v for f in ("apps", "plugins", "remote_plugin", "computer_use", "browser_use",
|
|
511
|
+
"browser_use_external", "in_app_browser") for v in ("--disable", f)]
|
|
512
|
+
assert argv == ["exec", *shutdown, "-c", "mcp_servers.node_repl.enabled=false", "hello"], argv
|
|
513
|
+
assert not any("computer-use" in a or "pyx-memory" in a for a in argv)
|
|
514
|
+
assert (work / "stdin").read_bytes() == b""
|
|
515
|
+
|
|
516
|
+
name = "desktop-custom"
|
|
517
|
+
deny.write_text(json.dumps({"codex": [name, "absent"]}), encoding="utf-8")
|
|
518
|
+
discovery = work / "project"
|
|
519
|
+
discovery.mkdir()
|
|
520
|
+
prompt = work / "prompt.md"
|
|
521
|
+
prompt.write_bytes(b"exact prompt\nsecond line\n")
|
|
522
|
+
launch(extra={"CODEX_BIN": "./codex", "SERVERS": json.dumps([{"name": name, "enabled": True}]),
|
|
523
|
+
"DEVLYN_CODEX_PROMPT_FILE": str(prompt)},
|
|
524
|
+
args=["-C", str(discovery), "--config=model_reasoning_effort=high", "--", "-"])
|
|
525
|
+
argv = json.loads(seen.read_text())
|
|
526
|
+
assert f"mcp_servers.{name}.enabled=false" in argv, argv
|
|
527
|
+
assert not any("absent" in a or "node_repl" in a for a in argv)
|
|
528
|
+
assert argv[-2:] == ["--", "-"] and (work / "stdin").read_bytes() == prompt.read_bytes()
|
|
529
|
+
receipt = json.loads(prompt.with_name("prompt.md.transport.json").read_text())
|
|
530
|
+
assert receipt["command"] == ["./codex", *argv] and receipt["status"] == "completed"
|
|
531
|
+
probes = [json.loads(line) for line in (discovery / "probes.jsonl").read_text().splitlines()]
|
|
532
|
+
listing = [p for p in probes if "mcp" in p["argv"]][-1]
|
|
533
|
+
assert listing["cwd"] == str(discovery) and "model_reasoning_effort=high" in listing["argv"], listing
|
|
534
|
+
|
|
535
|
+
deny.write_text(json.dumps({"codex": ["desktop.quoted"]}), encoding="utf-8")
|
|
536
|
+
assert "desktop.quoted" in launch({"SERVERS": '[{"name":"desktop.quoted","enabled":true}]'}, code=2)
|
|
537
|
+
deny.unlink()
|
|
538
|
+
# Every accepted spelling reaches discovery, without moving or altering caller argv.
|
|
539
|
+
for option, value in (("-c", 'mcp_servers.node_repl.command="echo"'), ("--config", 'mcp_servers.node_repl.command="echo"'),
|
|
540
|
+
("-p", "fixture"), ("--profile", "fixture"), ("--enable", "hooks"), ("--disable", "hooks"),
|
|
541
|
+
("-C", str(discovery)), ("--cd", str(discovery))):
|
|
542
|
+
forms = [[option, value], [option + "=" + value]]
|
|
543
|
+
if option in {"-c", "-p", "-C"}:
|
|
544
|
+
forms.append([option + value])
|
|
545
|
+
for form in forms:
|
|
546
|
+
for log in (work / "probes.jsonl", discovery / "probes.jsonl"):
|
|
547
|
+
log.unlink(missing_ok=True)
|
|
548
|
+
launch(args=[*form, "--", "prompt"], extra={"SERVERS": '[]'})
|
|
549
|
+
actual = json.loads(seen.read_text())
|
|
550
|
+
assert actual[-len(form) - 2:] == [*form, "--", "prompt"], actual
|
|
551
|
+
directory = discovery if option in {"-C", "--cd"} else work
|
|
552
|
+
probes = [json.loads(line) for line in (directory / "probes.jsonl").read_text().splitlines()]
|
|
553
|
+
listings = [p for p in probes if "mcp" in p["argv"]]
|
|
554
|
+
assert len(listings) == 2, listings
|
|
555
|
+
for listing in listings:
|
|
556
|
+
assert listing["cwd"] == str(directory), listing
|
|
557
|
+
if option not in {"-C", "--cd"}:
|
|
558
|
+
assert [option, value] in [listing["argv"][i:i + 2] for i in range(len(listing["argv"]) - 1)], listing
|
|
559
|
+
if option in {"-c", "--config"}:
|
|
560
|
+
assert "mcp_servers.node_repl.enabled=false" in actual, actual
|
|
561
|
+
|
|
562
|
+
for mode in ("sticky", "missing", "malformed"):
|
|
563
|
+
assert "Codex feature" in launch({"FEATURE_MODE": mode}, code=2)
|
|
564
|
+
assert "overrides did not disable" in launch({"MCP_STICKY": "1"}, code=2)
|
|
565
|
+
assert "overrides did not disable" in launch(args=["-c", "mcp_servers.node_repl.enabled=true", "hello"], code=2)
|
|
566
|
+
assert "cannot list servers" in launch({"VERIFY_FAIL": "1"}, code=2)
|
|
567
|
+
assert "malformed Codex mcp list" in launch({"VERIFY_SERVERS": "not json"}, code=2)
|
|
568
|
+
|
|
569
|
+
for bad in ('{', '[]', '{"codex": "x"}', '{"claude": [1]}', '{"codex":[],"codex":[]}'):
|
|
570
|
+
deny.write_text(bad, encoding="utf-8")
|
|
571
|
+
assert str(deny) in launch(code=2)
|
|
572
|
+
deny.unlink()
|
|
573
|
+
assert "codex-fixture 1.2.3" in launch({"FEATURE_FAIL": "1"}, code=2)
|
|
574
|
+
assert "cannot list servers" in launch({"LIST_FAIL": "1"}, code=2)
|
|
575
|
+
for bad in ('not json', '{}', '[{}]', '[{"name":"node_repl","enabled":"true"}]'):
|
|
576
|
+
assert "malformed Codex mcp list" in launch({"SERVERS": bad}, code=2)
|
|
577
|
+
print("PASS unattended Codex defaults, discovery, receipts and fail-closed refusals")
|
|
578
|
+
|
|
579
|
+
|
|
414
580
|
def self_test() -> int:
|
|
415
581
|
monitor_descendant_regression()
|
|
416
582
|
if os.name == "nt":
|
|
417
583
|
print("SKIP POSIX executable-shell wrapper fixture; native npm transport is covered by test-windows-portability.py")
|
|
418
584
|
return 0
|
|
585
|
+
unattended_tools_regression()
|
|
419
586
|
with tempfile.TemporaryDirectory() as raw_tmp:
|
|
420
587
|
work = pathlib.Path(raw_tmp)
|
|
421
588
|
fake_codex = work / "fake-codex"
|
|
@@ -442,17 +609,20 @@ def complete_dispatch(exit_code):
|
|
|
442
609
|
|
|
443
610
|
def dispatch_codex(args):
|
|
444
611
|
argv = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
|
|
445
|
-
command = [args.binary, "exec", *argv]
|
|
446
|
-
actual = PLATFORM["native_argv"](command)
|
|
447
612
|
prompt = os.environ.get("DEVLYN_CODEX_PROMPT_FILE")
|
|
448
613
|
if prompt == "":
|
|
449
614
|
raise TransportError("DEVLYN_CODEX_PROMPT_FILE must name a readable prompt file")
|
|
615
|
+
if prompt:
|
|
616
|
+
file_prompt_args(argv)
|
|
450
617
|
stream, carrier, transport = None, None, None
|
|
451
618
|
try:
|
|
452
619
|
if prompt:
|
|
453
|
-
|
|
454
|
-
|
|
620
|
+
stream, carrier, transport = prepare_transport(prompt, args.timeout)
|
|
621
|
+
argv = runpy.run_path(str(pathlib.Path(__file__).with_name("unattended-tools.py")))["codex_launch"](args.binary, argv)
|
|
622
|
+
command = [args.binary, "exec", *argv]
|
|
623
|
+
actual = PLATFORM["native_argv"](command)
|
|
455
624
|
if transport is not None:
|
|
625
|
+
transport.update(command=command, argv=actual)
|
|
456
626
|
write_transport(carrier, transport)
|
|
457
627
|
if os.name != "nt":
|
|
458
628
|
if stream is not None:
|
|
@@ -6,6 +6,7 @@ import contextlib
|
|
|
6
6
|
import errno
|
|
7
7
|
import os
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
import platform
|
|
9
10
|
import re
|
|
10
11
|
import shutil
|
|
11
12
|
import signal
|
|
@@ -81,7 +82,14 @@ NPM_CLI = re.compile(r'SET "(NP[MX]_CLI_JS)=%~dp0\\([^"\r\n]+)"')
|
|
|
81
82
|
|
|
82
83
|
def native_argv(argv, cwd=None):
|
|
83
84
|
"""Resolve argv as Windows does, for a command that will run in cwd; a Node command shim runs its Node and script
|
|
84
|
-
directly, never cmd /c
|
|
85
|
+
directly, never cmd /c; on macOS avoid kernel #! exec, whose process tree keeps kernel memory for each later
|
|
86
|
+
#! exec until restart."""
|
|
87
|
+
if sys.platform == "darwin":
|
|
88
|
+
try:
|
|
89
|
+
prefix = darwin_script_argv(argv[0], cwd or os.getcwd())
|
|
90
|
+
return [*prefix, *argv[1:]] if prefix else list(argv)
|
|
91
|
+
except (OSError, ValueError, TypeError, NotImplementedError, IndexError):
|
|
92
|
+
return list(argv) # Preserve CPython's PATH search and errors without a shell fallback.
|
|
85
93
|
if os.name != "nt":
|
|
86
94
|
return list(argv)
|
|
87
95
|
binary = shutil.which(argv[0])
|
|
@@ -100,6 +108,99 @@ def native_argv(argv, cwd=None):
|
|
|
100
108
|
return [node, str(script.resolve(strict=True)), *argv[1:]]
|
|
101
109
|
|
|
102
110
|
|
|
111
|
+
def darwin_script_argv(program, cwd):
|
|
112
|
+
"""The argv prefix macOS would build for a safely resolved script with a native interpreter, else None.
|
|
113
|
+
|
|
114
|
+
Search with CPython's os.get_exec_path(), including os.defpath when PATH is unset. A CPU-compatible
|
|
115
|
+
interpreter with an otherwise damaged image fails against the interpreter instead of retrying PATH.
|
|
116
|
+
The interpreter path is executed as it is at exec time: replacing it requires write access that
|
|
117
|
+
already lets the writer run code as the user. A set-id replacement owned by another user cannot
|
|
118
|
+
raise privilege; only root can create root-owned set-id files. Quarantined scripts are declined;
|
|
119
|
+
other exec-only MAC policies that permit reading but deny executing the script are not consulted.
|
|
120
|
+
"""
|
|
121
|
+
cpu = {"arm64": 0x0100000c, "x86_64": 0x01000007}.get(platform.machine())
|
|
122
|
+
if cpu is None:
|
|
123
|
+
return None
|
|
124
|
+
subtypes = (0, 1, 2) if cpu == 0x0100000c else (3,)
|
|
125
|
+
entries = [None] if "/" in program else os.get_exec_path()
|
|
126
|
+
with contextlib.ExitStack() as handles:
|
|
127
|
+
try:
|
|
128
|
+
cwd_fd = os.open(cwd, os.O_RDONLY | os.O_DIRECTORY)
|
|
129
|
+
handles.callback(os.close, cwd_fd)
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if entry is not None and len(os.fsencode(entry)) + len(os.fsencode(program)) + 2 > 1024:
|
|
132
|
+
return None
|
|
133
|
+
candidate = program if entry is None else os.path.join(entry, program)
|
|
134
|
+
try:
|
|
135
|
+
mode = os.stat(candidate, dir_fd=cwd_fd).st_mode
|
|
136
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
137
|
+
continue
|
|
138
|
+
if not stat.S_ISREG(mode) or not os.access(candidate, os.X_OK, dir_fd=cwd_fd, effective_ids=True):
|
|
139
|
+
continue
|
|
140
|
+
with os.fdopen(os.open(candidate, os.O_RDONLY | os.O_NONBLOCK, dir_fd=cwd_fd), "rb") as stream:
|
|
141
|
+
mode = os.fstat(stream.fileno()).st_mode
|
|
142
|
+
if not stat.S_ISREG(mode) or mode & (stat.S_ISUID | stat.S_ISGID):
|
|
143
|
+
return None
|
|
144
|
+
try:
|
|
145
|
+
if hasattr(os, "getxattr"):
|
|
146
|
+
os.getxattr(stream.fileno(), "com.apple.quarantine")
|
|
147
|
+
else:
|
|
148
|
+
# CPython exposes os.getxattr on Linux, but macOS needs its native fd API.
|
|
149
|
+
import ctypes
|
|
150
|
+
getxattr = ctypes.CDLL(None, use_errno=True).fgetxattr
|
|
151
|
+
getxattr.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_void_p, ctypes.c_size_t,
|
|
152
|
+
ctypes.c_uint32, ctypes.c_int]
|
|
153
|
+
getxattr.restype = ctypes.c_ssize_t
|
|
154
|
+
if getxattr(stream.fileno(), b"com.apple.quarantine", None, 0, 0, 0) < 0:
|
|
155
|
+
error = ctypes.get_errno()
|
|
156
|
+
raise OSError(error, os.strerror(error))
|
|
157
|
+
except OSError as exc:
|
|
158
|
+
if exc.errno != getattr(errno, "ENOATTR", None):
|
|
159
|
+
return None
|
|
160
|
+
else:
|
|
161
|
+
return None
|
|
162
|
+
header = stream.read(512)
|
|
163
|
+
if not header.startswith(b"#!"):
|
|
164
|
+
return None
|
|
165
|
+
line = re.split(b"[#\n]", header[2:], maxsplit=1)
|
|
166
|
+
if len(line) != 2:
|
|
167
|
+
return None
|
|
168
|
+
words = re.split(b"[ \t]+", line[0].strip(b" \t"))
|
|
169
|
+
if b"\0" in line[0] or not words[0].startswith(b"/"):
|
|
170
|
+
return None
|
|
171
|
+
interpreter = os.fsdecode(words[0])
|
|
172
|
+
mode = os.stat(interpreter).st_mode
|
|
173
|
+
if (not stat.S_ISREG(mode) or mode & (stat.S_ISUID | stat.S_ISGID)
|
|
174
|
+
or not os.access(interpreter, os.X_OK, effective_ids=True)):
|
|
175
|
+
return None
|
|
176
|
+
with open_stdin(interpreter) as stream:
|
|
177
|
+
image = stream.read(512)
|
|
178
|
+
magic = int.from_bytes(image[:4], "big")
|
|
179
|
+
if magic not in (0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe,
|
|
180
|
+
0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca):
|
|
181
|
+
return None
|
|
182
|
+
order = "little" if magic in (0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca) else "big"
|
|
183
|
+
if magic in (0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe):
|
|
184
|
+
size = 32 if magic in (0xfeedfacf, 0xcffaedfe) else 28
|
|
185
|
+
if (len(image) < size or int.from_bytes(image[4:8], order) != cpu
|
|
186
|
+
or int.from_bytes(image[8:12], order) & ~0xff000000 not in subtypes):
|
|
187
|
+
return None
|
|
188
|
+
else:
|
|
189
|
+
count = int.from_bytes(image[4:8], order)
|
|
190
|
+
size = 32 if magic in (0xcafebabf, 0xbfbafeca) else 20
|
|
191
|
+
if len(image) < 8 or count > (len(image) - 8) // size:
|
|
192
|
+
return None
|
|
193
|
+
if not any(int.from_bytes(image[8 + i * size:12 + i * size], order) == cpu
|
|
194
|
+
and int.from_bytes(image[12 + i * size:16 + i * size], order) & ~0xff000000 in subtypes
|
|
195
|
+
for i in range(count)):
|
|
196
|
+
return None
|
|
197
|
+
return [*map(os.fsdecode, words), candidate]
|
|
198
|
+
except (OSError, ValueError, TypeError, NotImplementedError):
|
|
199
|
+
# Preserve the original exec, including PATH retries, whenever inspection is uncertain.
|
|
200
|
+
return None
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
103
204
|
def shim_script(shim, raw, node, cwd=None):
|
|
104
205
|
"""The script a Node command shim runs, as cmd.exe would resolve it, else None: the one target of an npm, pnpm
|
|
105
206
|
or corepack cmd-shim that sets no environment, or for Node.js's own npm.cmd and npx.cmd the global prefix's
|