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
|
@@ -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
|