ci-fleet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ci_fleet/__init__.py +12 -0
- ci_fleet/_vendor/__init__.py +16 -0
- ci_fleet/_vendor/fleet_paths.py +119 -0
- ci_fleet/_vendor/safe_path.py +73 -0
- ci_fleet/_vendor/subprocess_runner.py +227 -0
- ci_fleet/canary.py +325 -0
- ci_fleet/charlie_work_adapter.py +91 -0
- ci_fleet/config.py +218 -0
- ci_fleet/demand.py +158 -0
- ci_fleet/diff_journal.py +248 -0
- ci_fleet/durations.py +173 -0
- ci_fleet/github.py +57 -0
- ci_fleet/health.py +130 -0
- ci_fleet/heartbeat.py +206 -0
- ci_fleet/job_observation.py +254 -0
- ci_fleet/observability.py +222 -0
- ci_fleet/plan_adapter.py +126 -0
- ci_fleet/planner.py +507 -0
- ci_fleet/process_liveness.py +87 -0
- ci_fleet/provenance.py +455 -0
- ci_fleet/purity.py +177 -0
- ci_fleet/pytest_plugin.py +64 -0
- ci_fleet/runner_affinity.py +138 -0
- ci_fleet/runner_allocation.py +295 -0
- ci_fleet/runner_allocation_pass.py +737 -0
- ci_fleet/runner_slots.py +745 -0
- ci_fleet/runners.py +1841 -0
- ci_fleet/shadow_gate.py +712 -0
- ci_fleet/sitecustomize_install.py +257 -0
- ci_fleet/stall.py +178 -0
- ci_fleet/suite_coverage.py +1099 -0
- ci_fleet/suite_lease.py +522 -0
- ci_fleet-0.1.0.dist-info/METADATA +454 -0
- ci_fleet-0.1.0.dist-info/RECORD +37 -0
- ci_fleet-0.1.0.dist-info/WHEEL +4 -0
- ci_fleet-0.1.0.dist-info/entry_points.txt +2 -0
- ci_fleet_probe.pth +1 -0
ci_fleet/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""ci_fleet — self-hosted GitHub Actions runner fleet management.
|
|
2
|
+
|
|
3
|
+
Extracted from ``charlie_work`` so runner development and orchestrator
|
|
4
|
+
development stop colliding. Import concrete modules directly; this package
|
|
5
|
+
intentionally exposes no barrel re-exports.
|
|
6
|
+
|
|
7
|
+
The one exception is :mod:`ci_fleet.charlie_work_adapter`, which exists solely
|
|
8
|
+
to hold the legacy import surface stable during the migration. Nothing inside
|
|
9
|
+
this package may import from it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Vendored support code copied from ``charlie_work``.
|
|
2
|
+
|
|
3
|
+
Every module here carries a provenance header naming the source module and the
|
|
4
|
+
date it was copied. These are *copies of specific named functions and classes*,
|
|
5
|
+
not whole-file mirrors: only the surface the fleet code actually calls comes
|
|
6
|
+
across.
|
|
7
|
+
|
|
8
|
+
Hard rule for this package: **nothing under ``ci_fleet`` may import
|
|
9
|
+
``charlie_work``.** Vendoring exists precisely so the dependency arrow points
|
|
10
|
+
one way. A ``charlie_work`` import here would create the cycle the extraction
|
|
11
|
+
was meant to break.
|
|
12
|
+
|
|
13
|
+
Two readings of "vendor" were considered and rejected in the plan:
|
|
14
|
+
- having ``ci_fleet`` depend on ``charlie_work`` (creates the cycle), and
|
|
15
|
+
- extracting a third shared package (out of scope for this migration).
|
|
16
|
+
"""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# vendored from charlie_work.fleet_paths on 2026-07-30;
|
|
2
|
+
# do not import charlie_work from this package.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _paths_equal(a: Path, b: Path) -> bool:
|
|
14
|
+
"""Compare two paths for equality using the OS's own normalization.
|
|
15
|
+
|
|
16
|
+
``PurePath.__eq__`` is case-sensitive even on Windows, so ``C:\\Foo`` and
|
|
17
|
+
``c:\\foo`` (the same file) would falsely report virtualization. The probe
|
|
18
|
+
keys on a real divergence between literal and resolved paths, not on a
|
|
19
|
+
case-only spelling difference, so normalize before comparing.
|
|
20
|
+
"""
|
|
21
|
+
return os.path.normcase(os.fspath(a)) == os.path.normcase(os.fspath(b))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def detect_path_virtualization(literal: Path) -> tuple[Path, Path] | None:
|
|
25
|
+
"""Detect per-process virtualization of a path (issue #624).
|
|
26
|
+
|
|
27
|
+
MSIX/container copy-on-write redirection sits *below* the environment
|
|
28
|
+
variable: the literal path string is identical in both the container and
|
|
29
|
+
the host, but ``Path.resolve()`` follows the reparse point to the
|
|
30
|
+
redirected location. Reads pass through to the real file; the first write
|
|
31
|
+
forks a private copy that daemons reading the same path string never see.
|
|
32
|
+
|
|
33
|
+
The load-bearing signal is that the literal path and its resolved form
|
|
34
|
+
disagree — never a hardcoded package moniker (which would rot on the next
|
|
35
|
+
app update and only cover one container). Returns ``(literal, resolved)``
|
|
36
|
+
when they diverge, else ``None``.
|
|
37
|
+
|
|
38
|
+
Resolution errors are treated as "not virtualized" rather than raising: a
|
|
39
|
+
probe that crashes the caller is worse than one that stays silent on an
|
|
40
|
+
unreadable path. ``Path.resolve(strict=False)`` (the default) does not
|
|
41
|
+
require the path to exist, so an absent fleet dir still resolves cleanly
|
|
42
|
+
and only a real reparse-point divergence fires.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
resolved = literal.resolve()
|
|
46
|
+
except OSError:
|
|
47
|
+
return None
|
|
48
|
+
if not _paths_equal(resolved, literal):
|
|
49
|
+
return (literal, resolved)
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fleet_dir_virtualization(*, override: str | None = None) -> tuple[Path, Path] | None:
|
|
54
|
+
"""Detect per-process virtualization of the fleet directory (issue #624).
|
|
55
|
+
|
|
56
|
+
Thin wrapper over :func:`detect_path_virtualization` keyed on the fleet
|
|
57
|
+
dir. Repo-agnostic by construction: the fleet dir is a host-wide
|
|
58
|
+
per-process property, not a project layout, so the same probe fires for
|
|
59
|
+
every registered repo on this host.
|
|
60
|
+
"""
|
|
61
|
+
return detect_path_virtualization(fleet_dir(override=override))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def warn_fleet_dir_virtualization_on_write(literal: Path, *, context: str) -> None:
|
|
65
|
+
"""Log a warning when a host-wide write lands in a virtualized copy.
|
|
66
|
+
|
|
67
|
+
Called from fleet-dir state writers so "I deployed it" cannot be reported
|
|
68
|
+
when the write forked a private copy that daemons reading the same path
|
|
69
|
+
string will never see (issue #624; this is the exact shape of the #590
|
|
70
|
+
failure). Never raises and never blocks the write — the operator already
|
|
71
|
+
asked for it; this only names where it actually landed.
|
|
72
|
+
"""
|
|
73
|
+
diverged = detect_path_virtualization(literal)
|
|
74
|
+
if diverged is None:
|
|
75
|
+
return
|
|
76
|
+
_literal, resolved = diverged
|
|
77
|
+
logger.warning(
|
|
78
|
+
"Fleet dir virtualization detected while %s: %s resolves to %s — "
|
|
79
|
+
"this write lands in a private copy invisible to scheduled tasks and "
|
|
80
|
+
"daemons reading the same path string (issue #624; see also #590). "
|
|
81
|
+
"Daemon-visible state must be written via a non-redirected route "
|
|
82
|
+
"(e.g. a UNC path).",
|
|
83
|
+
context,
|
|
84
|
+
literal,
|
|
85
|
+
resolved,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fleet_dir(*, override: str | None = None) -> Path:
|
|
90
|
+
"""Return the user-level fleet directory for charlie-work.
|
|
91
|
+
|
|
92
|
+
Platform-specific defaults:
|
|
93
|
+
- Windows (win32): %LOCALAPPDATA%\\charlie-work\\
|
|
94
|
+
- POSIX: ${XDG_STATE_HOME:-~/.local/state}/charlie-work/
|
|
95
|
+
|
|
96
|
+
The override parameter (or CHARLIE_WORK_FLEET_DIR env var) allows
|
|
97
|
+
test isolation without hardcoding platform-specific paths in fixtures.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
override: Optional path string to use instead of the platform default.
|
|
101
|
+
If None, checks CHARLIE_WORK_FLEET_DIR env var, then uses
|
|
102
|
+
the platform default.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Path to the fleet directory.
|
|
106
|
+
"""
|
|
107
|
+
if override is not None:
|
|
108
|
+
return Path(override)
|
|
109
|
+
|
|
110
|
+
env_override = os.environ.get("CHARLIE_WORK_FLEET_DIR")
|
|
111
|
+
if env_override:
|
|
112
|
+
return Path(env_override)
|
|
113
|
+
|
|
114
|
+
if sys.platform == "win32":
|
|
115
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
116
|
+
else:
|
|
117
|
+
base = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
118
|
+
|
|
119
|
+
return base / "charlie-work"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# vendored from charlie_work.safe_path on 2026-07-30; do not import charlie_work from this package.
|
|
2
|
+
"""Single point of enforcement for path-containment checks.
|
|
3
|
+
|
|
4
|
+
Consolidates the ad hoc ``.resolve()`` + ``is_relative_to()`` pairs previously
|
|
5
|
+
duplicated across five independent call sites (``runner_slots.py``,
|
|
6
|
+
``worktree.py`` x3, ``supervise.py``) with no single source of truth for what
|
|
7
|
+
"safely contained" means. None of them resolved symlinks/junctions
|
|
8
|
+
defensively on *both* sides before comparing, and one (``worktree.py``'s
|
|
9
|
+
``_materialize_directory``) compared unresolved paths, which does not detect
|
|
10
|
+
a `..` segment escaping the base directory (``Path.is_relative_to`` is a
|
|
11
|
+
purely lexical part-prefix check; it does not collapse ``..``).
|
|
12
|
+
|
|
13
|
+
The two-function shape (a predicate plus a raising variant) is ported from a
|
|
14
|
+
prior Node implementation -- the design, not the code, since ``fs.realpathSync``
|
|
15
|
+
and ``pathlib`` differ in exactly the nonexistent-path handling that matters
|
|
16
|
+
here.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_defensive(path: Path) -> Path:
|
|
25
|
+
"""Resolve ``path``, collapsing symlinks/junctions on every existing segment.
|
|
26
|
+
|
|
27
|
+
``Path.resolve()`` does not require ``path`` to exist: it resolves every
|
|
28
|
+
existing leading segment (following symlinks/junctions along the way) and
|
|
29
|
+
leaves only trailing nonexistent components as literal names. That already
|
|
30
|
+
matches the ``realpathSync``-with-existing-parent-fallback design, so a
|
|
31
|
+
candidate path that hasn't been created yet can still be checked for
|
|
32
|
+
containment before it's materialized.
|
|
33
|
+
"""
|
|
34
|
+
return path.resolve()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def contains(base: Path, candidate: Path) -> bool:
|
|
38
|
+
"""Return ``True`` when ``candidate`` resolves to a path inside ``base``.
|
|
39
|
+
|
|
40
|
+
Both sides are resolved before comparing, so this catches a reparse point
|
|
41
|
+
or symlink that makes ``candidate`` *look* contained lexically but
|
|
42
|
+
actually escapes ``base`` on disk (the CLAUDE.md-declared ``managed_root``
|
|
43
|
+
invariant this guards). Use at any site that can simply skip a
|
|
44
|
+
non-contained entry (e.g. a discovery loop); use ``require_contained``
|
|
45
|
+
where skipping isn't an option.
|
|
46
|
+
"""
|
|
47
|
+
resolved_base = _resolve_defensive(base)
|
|
48
|
+
resolved_candidate = _resolve_defensive(candidate)
|
|
49
|
+
return resolved_candidate == resolved_base or resolved_candidate.is_relative_to(resolved_base)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def require_contained(base: Path, candidate: Path, *, context: str) -> Path:
|
|
53
|
+
"""Return the resolved ``candidate``, raising ``ValueError`` if it escapes ``base``.
|
|
54
|
+
|
|
55
|
+
For boundaries where an externally-influenced path (config, JSON state, a
|
|
56
|
+
subprocess's stdout) is about to be read from or written to, and silently
|
|
57
|
+
skipping would hide the problem rather than surface it. ``context`` is
|
|
58
|
+
included in the error message to identify the call site without needing a
|
|
59
|
+
traceback.
|
|
60
|
+
"""
|
|
61
|
+
resolved_base = _resolve_defensive(base)
|
|
62
|
+
resolved_candidate = _resolve_defensive(candidate)
|
|
63
|
+
if resolved_candidate != resolved_base and not resolved_candidate.is_relative_to(
|
|
64
|
+
resolved_base
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"{context}: {candidate} resolves to {resolved_candidate}, which "
|
|
68
|
+
f"escapes {base} (resolves to {resolved_base})"
|
|
69
|
+
)
|
|
70
|
+
return resolved_candidate
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = ["contains", "require_contained"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# vendored from charlie_work.subprocess_runner on 2026-07-30;
|
|
2
|
+
# do not import charlie_work from this package.
|
|
3
|
+
"""One subprocess runner for every adapter and cross-family invocation.
|
|
4
|
+
|
|
5
|
+
Centralizes the Windows-safe capture contract: text mode with explicit UTF-8
|
|
6
|
+
decoding and ``errors="replace"`` (never the cp1252 default), and bytes-safe
|
|
7
|
+
handling of ``TimeoutExpired`` partial output. Callers get a plain result and
|
|
8
|
+
never an encoding crash.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, TypedDict
|
|
21
|
+
|
|
22
|
+
_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
23
|
+
_DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0)
|
|
24
|
+
_CREATE_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
|
|
25
|
+
_STARTF_USESHOWWINDOW = getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
|
|
26
|
+
_SW_HIDE = getattr(subprocess, "SW_HIDE", 0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _SpawnKwargs(TypedDict, total=False):
|
|
30
|
+
creationflags: int
|
|
31
|
+
startupinfo: Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def no_console_window_kwargs(extra_creationflags: int = 0) -> _SpawnKwargs:
|
|
35
|
+
"""Return the ``creationflags`` kwargs that suppress the transient console
|
|
36
|
+
window Windows allocates for a spawned child when the parent has no
|
|
37
|
+
console/window of its own (e.g. an orchestrator running headless or from
|
|
38
|
+
a service). This is the single point of enforcement for
|
|
39
|
+
``CREATE_NO_WINDOW`` — every ``subprocess.run``/``Popen`` call site in
|
|
40
|
+
this codebase should route its creationflags through this helper rather
|
|
41
|
+
than hard-coding the flag itself.
|
|
42
|
+
|
|
43
|
+
``extra_creationflags`` composes in whatever flags the call site already
|
|
44
|
+
needs (e.g. ``CREATE_NEW_PROCESS_GROUP`` so a launched worker can still be
|
|
45
|
+
killed by process group). ``CREATE_NO_WINDOW`` is never combined with
|
|
46
|
+
``DETACHED_PROCESS`` — that combination is invalid/contradictory on
|
|
47
|
+
Windows, since ``DETACHED_PROCESS`` already fully detaches the child from
|
|
48
|
+
any console. Callers passing ``DETACHED_PROCESS`` get their flags back
|
|
49
|
+
unchanged.
|
|
50
|
+
|
|
51
|
+
On POSIX platforms (no ``creationflags`` concept), returns an empty dict
|
|
52
|
+
so callers can unconditionally do
|
|
53
|
+
``subprocess.Popen(..., **no_console_window_kwargs(...))`` cross-platform.
|
|
54
|
+
"""
|
|
55
|
+
if sys.platform != "win32" or not _CREATE_NO_WINDOW:
|
|
56
|
+
return {}
|
|
57
|
+
if extra_creationflags & _DETACHED_PROCESS:
|
|
58
|
+
return {"creationflags": extra_creationflags}
|
|
59
|
+
return {"creationflags": extra_creationflags | _CREATE_NO_WINDOW}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hidden_console_kwargs(extra_creationflags: int = 0) -> _SpawnKwargs:
|
|
63
|
+
"""Return the ``creationflags`` and ``startupinfo`` kwargs that allocate a
|
|
64
|
+
hidden console for a long-lived worker spawn (e.g. ``devin`` or ``claude``).
|
|
65
|
+
|
|
66
|
+
A ``CREATE_NEW_CONSOLE`` process is created with ``STARTF_USESHOWWINDOW`` and
|
|
67
|
+
``wShowWindow=SW_HIDE``. The worker and all of its console-subsystem
|
|
68
|
+
descendants (pytest, git, gh, bash) inherit that hidden console, so they
|
|
69
|
+
do not each allocate their own visible console window. This is the worker-
|
|
70
|
+
spawn boundary; short-lived leaf spawns continue to use
|
|
71
|
+
``no_console_window_kwargs()``.
|
|
72
|
+
|
|
73
|
+
``extra_creationflags`` composes in whatever flags the call site already
|
|
74
|
+
needs (e.g. ``CREATE_NEW_PROCESS_GROUP`` so a launched worker can still be
|
|
75
|
+
killed by process group). ``CREATE_NEW_CONSOLE`` is never combined with
|
|
76
|
+
``CREATE_NO_WINDOW`` or ``DETACHED_PROCESS`` — those console modes are
|
|
77
|
+
mutually exclusive on Windows. A ``ValueError`` is raised if the caller
|
|
78
|
+
tries to combine them.
|
|
79
|
+
|
|
80
|
+
On POSIX platforms (no ``creationflags`` concept), returns an empty dict so
|
|
81
|
+
callers can unconditionally do
|
|
82
|
+
``subprocess.Popen(..., **hidden_console_kwargs(...))`` cross-platform.
|
|
83
|
+
"""
|
|
84
|
+
if sys.platform != "win32" or not _CREATE_NEW_CONSOLE:
|
|
85
|
+
return {}
|
|
86
|
+
forbidden = extra_creationflags & (_CREATE_NO_WINDOW | _DETACHED_PROCESS)
|
|
87
|
+
if forbidden:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"CREATE_NEW_CONSOLE cannot be combined with mutually-exclusive "
|
|
90
|
+
f"console modes (CREATE_NO_WINDOW, DETACHED_PROCESS); got {forbidden:#x}"
|
|
91
|
+
)
|
|
92
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
93
|
+
startupinfo.dwFlags |= _STARTF_USESHOWWINDOW
|
|
94
|
+
startupinfo.wShowWindow = _SW_HIDE
|
|
95
|
+
return {
|
|
96
|
+
"creationflags": extra_creationflags | _CREATE_NEW_CONSOLE,
|
|
97
|
+
"startupinfo": startupinfo,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_NPM_SHIM_EXE_PATTERN = re.compile(r'"%dp0%\\([^"]+\.exe)"', re.IGNORECASE)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_cli_binary(name: str) -> str:
|
|
105
|
+
"""Resolve an npm-installed CLI tool name to its underlying ``.exe`` on Windows.
|
|
106
|
+
|
|
107
|
+
npm-published shims (``claude.CMD``, ``gemini.CMD``, ...) are batch-file
|
|
108
|
+
wrappers around the real ``.exe``. Two distinct problems follow from that,
|
|
109
|
+
and this is the single point of enforcement that fixes both:
|
|
110
|
+
|
|
111
|
+
1. ``subprocess.Popen``/``subprocess.run`` with ``shell=False`` and a bare
|
|
112
|
+
name (e.g. ``"claude"``) goes straight to Windows' ``CreateProcessW``,
|
|
113
|
+
which does **not** perform the ``PATHEXT``-based extension search that
|
|
114
|
+
``cmd.exe`` does — so it cannot find ``claude.CMD`` at all and fails
|
|
115
|
+
with ``OSError: [WinError 2] The system cannot find the file
|
|
116
|
+
specified``, even though ``claude`` is on ``PATH`` and works fine when
|
|
117
|
+
typed at a shell prompt (see charlie-work issue #487).
|
|
118
|
+
2. Pre-resolving via ``shutil.which()`` alone (which *does* honor
|
|
119
|
+
``PATHEXT`` and returns the full ``.CMD`` path) fixes (1) but trades it
|
|
120
|
+
for a subtler bug: ``CreateProcessW`` on a ``.CMD`` file implicitly
|
|
121
|
+
routes the child through ``cmd.exe``, whose argv parser uses
|
|
122
|
+
caret-escaping rather than the C-runtime backslash-escaping that
|
|
123
|
+
Python's ``subprocess.list2cmdline`` emits. A literal ``|`` in an
|
|
124
|
+
argument value (e.g. a prompt containing ``"small|mid|large"``) is
|
|
125
|
+
then interpreted as a ``cmd.exe`` pipeline separator, breaking the
|
|
126
|
+
invocation.
|
|
127
|
+
|
|
128
|
+
The real fix is to skip ``cmd.exe`` entirely: parse the ``.CMD`` shim to
|
|
129
|
+
find the underlying ``.exe`` it wraps (the npm shim pattern is stable —
|
|
130
|
+
``"%dp0%\\node_modules\\<pkg>\\bin\\<name>.exe" %*``) and use that ``.exe``
|
|
131
|
+
path directly as ``argv[0]``. ``CreateProcessW`` then invokes it without
|
|
132
|
+
any shell interposed. Ported from a sibling repo's
|
|
133
|
+
``claude_client._resolve_cli_binary`` (commit fe0cdde7), which fixed the
|
|
134
|
+
identical class of bug for the same npm-installed CLI shims.
|
|
135
|
+
|
|
136
|
+
On POSIX, or when ``shutil.which`` resolves to something other than a
|
|
137
|
+
``.cmd``/``.bat`` file, the resolved path is returned unchanged (already
|
|
138
|
+
directly executable). If the binary cannot be found on ``PATH`` at all,
|
|
139
|
+
or the shim cannot be parsed/its target ``.exe`` does not exist, the
|
|
140
|
+
original (or ``shutil.which``-resolved) value is returned unchanged — a
|
|
141
|
+
deliberately conservative fallback that preserves the caller's existing
|
|
142
|
+
"binary not found" error handling rather than masking a missing install.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
name: CLI tool name or path, e.g. ``"claude"``.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
A path safe to pass as ``argv[0]`` to ``subprocess.Popen``/``.run``
|
|
149
|
+
with ``shell=False``.
|
|
150
|
+
"""
|
|
151
|
+
path = shutil.which(name)
|
|
152
|
+
if path is None:
|
|
153
|
+
return name
|
|
154
|
+
if os.name != "nt" or not path.lower().endswith((".cmd", ".bat")):
|
|
155
|
+
return path
|
|
156
|
+
try:
|
|
157
|
+
shim_text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
158
|
+
except OSError:
|
|
159
|
+
return path
|
|
160
|
+
match = _NPM_SHIM_EXE_PATTERN.search(shim_text)
|
|
161
|
+
if match is None:
|
|
162
|
+
return path
|
|
163
|
+
exe_path = Path(os.path.normpath(Path(path).parent / match.group(1)))
|
|
164
|
+
return str(exe_path) if exe_path.exists() else path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class RunResult:
|
|
169
|
+
returncode: int | None
|
|
170
|
+
stdout: str
|
|
171
|
+
stderr: str
|
|
172
|
+
timed_out: bool = False
|
|
173
|
+
error: str | None = None
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def ok(self) -> bool:
|
|
177
|
+
return self.returncode == 0 and self.error is None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _as_text(value: object) -> str:
|
|
181
|
+
if isinstance(value, bytes):
|
|
182
|
+
return value.decode("utf-8", "replace")
|
|
183
|
+
return str(value) if value else ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_captured(
|
|
187
|
+
command: list[str] | str,
|
|
188
|
+
*,
|
|
189
|
+
cwd: Path | str,
|
|
190
|
+
timeout_seconds: int,
|
|
191
|
+
shell: bool = False,
|
|
192
|
+
stdin: str | None = None,
|
|
193
|
+
) -> RunResult:
|
|
194
|
+
"""Run ``command`` and capture output. Never raises for runtime failures —
|
|
195
|
+
timeouts, missing binaries, and non-zero exits all come back as a result."""
|
|
196
|
+
try:
|
|
197
|
+
completed = subprocess.run(
|
|
198
|
+
command,
|
|
199
|
+
cwd=str(cwd),
|
|
200
|
+
text=True,
|
|
201
|
+
encoding="utf-8",
|
|
202
|
+
errors="replace",
|
|
203
|
+
capture_output=True,
|
|
204
|
+
timeout=timeout_seconds,
|
|
205
|
+
shell=shell,
|
|
206
|
+
check=False,
|
|
207
|
+
input=stdin,
|
|
208
|
+
**hidden_console_kwargs(),
|
|
209
|
+
)
|
|
210
|
+
except subprocess.TimeoutExpired as exc:
|
|
211
|
+
return RunResult(
|
|
212
|
+
returncode=None,
|
|
213
|
+
stdout=_as_text(exc.stdout),
|
|
214
|
+
stderr=_as_text(exc.stderr),
|
|
215
|
+
timed_out=True,
|
|
216
|
+
error=f"command timed out after {timeout_seconds}s",
|
|
217
|
+
)
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
return RunResult(returncode=None, stdout="", stderr="", error=str(exc))
|
|
220
|
+
except subprocess.SubprocessError as exc:
|
|
221
|
+
return RunResult(returncode=None, stdout="", stderr="", error=str(exc))
|
|
222
|
+
return RunResult(
|
|
223
|
+
returncode=completed.returncode,
|
|
224
|
+
stdout=completed.stdout or "",
|
|
225
|
+
stderr=completed.stderr or "",
|
|
226
|
+
error=None if completed.returncode == 0 else f"command exited {completed.returncode}",
|
|
227
|
+
)
|