devlyn-cli 4.3.17 → 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.
Files changed (31) hide show
  1. package/README.md +19 -0
  2. package/bin/skill-history.json +9 -0
  3. package/config/skills/_shared/adapters/claude.md +15 -0
  4. package/config/skills/_shared/adapters/codex.md +10 -0
  5. package/config/skills/_shared/invocation-receipt.py +179 -9
  6. package/config/skills/_shared/platform-support.py +102 -1
  7. package/config/skills/_shared/unattended-tools.py +206 -0
  8. package/config/skills/devlyn-ideate/SKILL.md +1 -1
  9. package/config/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  10. package/config/skills/devlyn-ideate/scripts/queue.py +24 -11
  11. package/native/claude/.claude-plugin/plugin.json +1 -1
  12. package/native/claude/skills/_shared/adapters/claude.md +15 -0
  13. package/native/claude/skills/_shared/adapters/codex.md +10 -0
  14. package/native/claude/skills/_shared/invocation-receipt.py +179 -9
  15. package/native/claude/skills/_shared/platform-support.py +102 -1
  16. package/native/claude/skills/_shared/unattended-tools.py +206 -0
  17. package/native/claude/skills/devlyn-ideate/SKILL.md +1 -1
  18. package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  19. package/native/claude/skills/devlyn-ideate/scripts/queue.py +24 -11
  20. package/native/codex/.agents/plugins/marketplace.json +1 -1
  21. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  22. package/native/codex/plugins/devlyn/skills/_shared/adapters/claude.md +15 -0
  23. package/native/codex/plugins/devlyn/skills/_shared/adapters/codex.md +10 -0
  24. package/native/codex/plugins/devlyn/skills/_shared/invocation-receipt.py +179 -9
  25. package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +102 -1
  26. package/native/codex/plugins/devlyn/skills/_shared/unattended-tools.py +206 -0
  27. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +1 -1
  28. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +307 -1
  29. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +24 -11
  30. package/package.json +1 -1
  31. package/release.json +44 -29
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
@@ -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",
@@ -325,6 +329,7 @@
325
329
  "0122885c08069474ca4a0a5825f885364a62938885f0e501c09242d176c34b08",
326
330
  "05e824f63a6381e02042afb1df0f1437584e25a80de8e9db0bd9456aaeb7aab8",
327
331
  "0601ee535419b7039d5da34bd9c8e1c2b3631307214e4afaf394dd47494090c7",
332
+ "062fde2838c0edc0c20e6716948a2ae9fe9d8a98b01a8e8c544c5801641c7cdb",
328
333
  "0cef055580e00f9ed439eacb2dff9d238bb11619457472016095a8915162fa4b",
329
334
  "0dc69c20434966015176d83baa6f95b3421f9e13d0b1bd608a22f0560eae1049",
330
335
  "0fe9b270d670f70da9d2535969b6117684b7c1517806b3029ebaf9624da8006c",
@@ -345,6 +350,7 @@
345
350
  "65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
346
351
  "65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
347
352
  "694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
353
+ "6b3143bcfe9c60a76f142d533177d85d36f856a163a226351317ed688738c05a",
348
354
  "6b403afbf5a117cc08603d3648b9dded4f9ac4ca02fc1d8e1f0035615148bb5c",
349
355
  "6c3e2e4b84b0b4f81aed3759704da7c14f3cbabf3beeb0d1654feda3a15a8675",
350
356
  "6d9df58c117165e9d297aa6be0df8d076bac1ed49dc29ae93906af1969798ab1",
@@ -777,7 +783,10 @@
777
783
  "4.3.15",
778
784
  "4.3.16",
779
785
  "4.3.17",
786
+ "4.3.18",
787
+ "4.3.19",
780
788
  "4.3.2",
789
+ "4.3.20",
781
790
  "4.3.3",
782
791
  "4.3.4",
783
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, command, argv, seconds):
96
- path = pathlib.Path(prompt_path).resolve(strict=True)
97
- with PLATFORM["open_stdin"](path) as source:
98
- raw = source.read()
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
- "command": command, "argv": argv, "timeout_sec": seconds,
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
- file_prompt_args(argv)
454
- stream, carrier, transport = prepare_transport(prompt, command, actual, args.timeout)
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