Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ jobs:
with:
python-version: '3.13'
- uses: astral-sh/setup-uv@v6
- run: uv sync --frozen
- run: uv sync --frozen --extra dev
- run: uv run pytest
51 changes: 46 additions & 5 deletions scripts/gh_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
r"(?:gh[opsur]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{22,})"
)

# Per-subprocess timeout. Network calls (gh auth, gh repo create/view) may hang
# on a flaky uplink; local git commands shouldn't either. 120s covers the slowest
# legitimate case (gh repo create --push on a large initial commit) without leaving
# the build process hung indefinitely.
_SUBPROCESS_TIMEOUT_S = 120


# ---------- helpers (verbatim duplicates per project policy D-05) ----------

Expand Down Expand Up @@ -70,7 +76,7 @@ def _write_state_field(project_root: Path, field: str, value: str) -> None:
[sys.executable, str(STATE_WRITER), "write",
"--field", field, "--value", value,
"--project-root", str(project_root)],
shell=False, check=True,
shell=False, check=True, timeout=_SUBPROCESS_TIMEOUT_S,
)


Expand Down Expand Up @@ -158,13 +164,27 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
auth = subprocess.run(
["gh", "auth", "status"],
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
except (FileNotFoundError, OSError) as e:
_friendly(f"gh: command not found: {e}", tool="gh")
_write_state_field(project_root, "gh_auth_status", "unauthenticated")
return 1
except subprocess.TimeoutExpired:
_friendly(
f"gh auth status did not respond within {_SUBPROCESS_TIMEOUT_S}s — "
"network or gh CLI hang.",
tool="gh",
)
_write_state_field(project_root, "gh_auth_status", "drift")
return 1
if auth.returncode != 0:
raw = (auth.stderr or "").strip() or f"gh auth status exit {auth.returncode}"
# gh auth status writes its diagnostic to stderr in modern versions and
# to stdout in older ones; merge both so the friendly translator gets the
# full message regardless. _friendly redacts tokens from whichever stream
# they leak through (T-06-02-03).
merged = ((auth.stderr or "") + (auth.stdout or "")).strip()
raw = merged or f"gh auth status exit {auth.returncode}"
_friendly(raw, tool="gh")
_write_state_field(project_root, "gh_auth_status", "drift")
return 1
Expand All @@ -180,20 +200,23 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
init = subprocess.run(
["git", "init", "-b", "main"], cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if init.returncode != 0:
_friendly((init.stderr or "git init failed").strip(), tool="git")
return 1
add = subprocess.run(
["git", "add", "-A"], cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if add.returncode != 0:
_friendly((add.stderr or "git add failed").strip(), tool="git")
return 1
status = subprocess.run(
["git", "status", "--porcelain"], cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if status.returncode != 0:
_friendly((status.stderr or "git status failed").strip(), tool="git")
Expand All @@ -202,6 +225,7 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
commit = subprocess.run(
["git", "commit", "-m", "chore: initial scaffold by OSBuilder"],
cwd=str(project_dir), shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if commit.returncode != 0:
_friendly((commit.stderr or "git commit failed").strip(), tool="git")
Expand All @@ -211,6 +235,7 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
remote = subprocess.run(
["git", "remote", "get-url", "origin"], cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if remote.returncode != 0: # no origin → create
visibility_flag = "--private" if private else "--public"
Expand All @@ -224,9 +249,11 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
visibility_flag],
cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if create.returncode != 0:
raw = (create.stderr or "").strip() or f"gh repo create exit {create.returncode}"
merged = ((create.stderr or "") + (create.stdout or "")).strip()
raw = merged or f"gh repo create exit {create.returncode}"
_friendly(raw, tool="gh")
return 1

Expand All @@ -238,6 +265,7 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
["git", "log", "origin/main..HEAD", "--oneline"],
cwd=str(project_dir),
shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
# returncode 0 with non-empty stdout → local ahead of remote (push divergence).
# returncode != 0 → origin/main ref absent (e.g. fresh repo before push); skip.
Expand All @@ -253,9 +281,11 @@ def ship(project_dir: Path, project_root: Path, *, private: bool = True,
view = subprocess.run(
["gh", "repo", "view", "--json", "visibility,nameWithOwner,sshUrl"],
cwd=str(project_dir), shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if view.returncode != 0:
_friendly((view.stderr or "gh repo view failed").strip(), tool="gh")
merged = ((view.stderr or "") + (view.stdout or "")).strip()
_friendly(merged or "gh repo view failed", tool="gh")
return 1
try:
repo_info = json.loads(view.stdout)
Expand Down Expand Up @@ -292,11 +322,12 @@ def verify(project_dir: Path) -> dict:
result = subprocess.run(
["gh", "repo", "view", "--json", "visibility,nameWithOwner,sshUrl"],
cwd=str(project_dir), shell=False, capture_output=True, text=True,
timeout=_SUBPROCESS_TIMEOUT_S,
)
if result.returncode != 0:
return {}
return json.loads(result.stdout)
except (FileNotFoundError, OSError, json.JSONDecodeError):
except (FileNotFoundError, OSError, subprocess.TimeoutExpired, json.JSONDecodeError):
return {}


Expand Down Expand Up @@ -362,6 +393,16 @@ def main(argv: list[str] | None = None) -> int:
return args.func(args)
except SystemExit:
raise
except subprocess.TimeoutExpired as e:
# Any subprocess that hits the per-call timeout lands here so the
# friendly translator can phrase the failure consistently with other
# gh/git error paths.
cmd_str = " ".join(e.cmd) if isinstance(e.cmd, list) else str(e.cmd)
_friendly(
f"command timed out after {e.timeout}s: {cmd_str}",
tool="gh" if cmd_str.startswith("gh") else "git",
)
return 1
except Exception as e:
sys.stderr.write(f"OSBuilder: error — {e}\n")
return 1
Expand Down
7 changes: 7 additions & 0 deletions scripts/intake_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ def _extract_subtools(text: str) -> "list[str]":
depth — `_validate_project_name` is the security gate, this just prevents
obviously-bad input from reaching it).
"""
# Upfront DoS guard: the regex search below already runs on text[:500],
# but a multi-MB payload still pays for the slice copy and any prior
# `text.lower()` calls on the same input. Legitimate goal paragraphs are
# under 1 KB; truncating at 100 KB cuts pathological inputs without
# affecting any real spec.
if len(text) > 100_000:
text = text[:100_000]
m = _SUBTOOL_PATTERN.search(text[:500]) # bound input (T-07-05-04; mirrors _score_playbooks)
if not m:
return []
Expand Down
28 changes: 25 additions & 3 deletions scripts/narration.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,35 @@ def capture_subprocess(
)
t_out.start()
t_err.start()
timed_out = False
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True
proc.kill()
proc.wait()
t_out.join()
t_err.join()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
# Truly stuck child (rare — usually uninterruptible kernel state).
# Continue: closing the pipes below frees the drain threads, and
# we cap the join with a timeout so the function never hangs.
pass
if timed_out:
# On timeout, explicitly close the pipes so _drain_stream's blocking
# readline() returns EOF and the threads exit. Without this, a child
# whose grandchild inherited the pipe can keep readline() blocked
# indefinitely, leaving t_out/t_err hung after the process is gone.
for stream in (proc.stdout, proc.stderr):
if stream is not None:
try:
stream.close()
except Exception:
pass
# Bounded joins: drain threads should exit promptly once pipes are
# closed. The timeout is a last-resort guard against a stuck reader —
# we'd rather leak a daemon-ish thread than hang the build forever.
t_out.join(timeout=10)
t_err.join(timeout=10)
rc = proc.returncode if proc.returncode is not None else -1

if rc == 0:
Expand Down
43 changes: 35 additions & 8 deletions scripts/registry_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,44 +19,68 @@
import urllib.request


def _warn_inconclusive(ecosystem: str, package_name: str, code: int) -> None:
"""Emit a one-line stderr warning when a registry returns a non-404 HTTP error.

Without this, a 401/403/5xx silently fell through to "exists=True" — masking
auth or registry-outage problems as "package verified." The warning makes
the inconclusive verification visible to the user even though we still
fail-open (returning True) so the install isn't blocked by a transient
registry hiccup.
"""
sys.stderr.write(
f"OSBuilder: registry verification inconclusive for '{package_name}' on "
f"{ecosystem} (HTTP {code}); proceeding without confirmation. "
f"If installs fail, re-check the package name and registry status.\n"
)


def verify_npm(package_name: str, timeout: int = 10) -> bool:
"""Return True if the package exists on the npm registry, False if 404.

Fail-open: network errors (URLError, OSError) return True because a network
failure does not mean the package is hallucinated — only an explicit 404
means "not found."
Behavior on HTTP errors:
- 404 → False (block: package does not exist)
- any other HTTP err → True + stderr warning (fail-open, but informed)
- URLError / OSError → True silently (transport hiccup, not hallucinated)
"""
url = f"https://registry.npmjs.org/{package_name}"
req = urllib.request.Request(url, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except urllib.error.HTTPError as e:
return e.code != 404 # 404 = not found; other 4xx = network issue, not hallucinated
if e.code == 404:
return False
_warn_inconclusive("npm", package_name, e.code)
return True
except (urllib.error.URLError, OSError):
return True # fail-open: network error != hallucinated package


def verify_pypi(package_name: str, timeout: int = 10) -> bool:
"""Return True if the package exists on PyPI, False if 404.

Fail-open: network errors return True.
Same fail-open + warn semantics as `verify_npm`.
"""
url = f"https://pypi.org/pypi/{package_name}/json"
req = urllib.request.Request(url, method="HEAD")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except urllib.error.HTTPError as e:
return e.code != 404
if e.code == 404:
return False
_warn_inconclusive("pip", package_name, e.code)
return True
except (urllib.error.URLError, OSError):
return True # fail-open


def verify_cargo(package_name: str, timeout: int = 10) -> bool:
"""Return True if the crate exists on crates.io, False if 404.

crates.io requires a User-Agent header; fail-open on network errors.
crates.io requires a User-Agent header. Same fail-open + warn semantics
as `verify_npm`.
"""
url = f"https://crates.io/api/v1/crates/{package_name}"
req = urllib.request.Request(url, method="HEAD")
Expand All @@ -65,7 +89,10 @@ def verify_cargo(package_name: str, timeout: int = 10) -> bool:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status == 200
except urllib.error.HTTPError as e:
return e.code != 404
if e.code == 404:
return False
_warn_inconclusive("cargo", package_name, e.code)
return True
except (urllib.error.URLError, OSError):
return True # fail-open

Expand Down
18 changes: 9 additions & 9 deletions scripts/tests/test_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def pf():
pytest.skip("preflight_check module not yet created (Plan 02-02 target)")


def test_detect_missing_tools_macos(pf, fake_shell, fake_which, monkeypatch):
def test_detect_missing_tools_macos(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-01: detect() returns 5 missing tools on a fresh macOS fixture."""
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake_which["brew"] = "/opt/homebrew/bin/brew"
Expand All @@ -33,7 +33,7 @@ def test_detect_missing_tools_macos(pf, fake_shell, fake_which, monkeypatch):
assert set(missing) >= {"node", "python3", "git", "gh", "docker"}


def test_detect_node_below_required(pf, fake_shell, fake_which, monkeypatch):
def test_detect_node_below_required(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-01: detect() reports node version_ok=False when Node < 20."""
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake_which["node"] = "/usr/local/bin/node"
Expand All @@ -44,7 +44,7 @@ def test_detect_node_below_required(pf, fake_shell, fake_which, monkeypatch):
assert not node_status.version_ok, "Node 18 should fail version_ok (requires >= 20)"


def test_vm_detected_blocks_install(pf, fake_shell, fake_which, monkeypatch):
def test_vm_detected_blocks_install(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-01: When nvm is present, plan().blocked_by_vm includes 'node' and no node install action."""
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake_which["nvm"] = "/Users/x/.nvm/nvm.sh"
Expand All @@ -54,7 +54,7 @@ def test_vm_detected_blocks_install(pf, fake_shell, fake_which, monkeypatch):
assert len(node_actions) == 0, "plan must NOT contain a node install action when nvm is detected"


def test_detect_linux_distro_ubuntu(pf, fake_shell, fake_which, monkeypatch):
def test_detect_linux_distro_ubuntu(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-01: On Linux+Ubuntu, plan().os starts with 'linux-debian'."""
monkeypatch.setattr("platform.system", lambda: "Linux")
monkeypatch.setattr("platform.freedesktop_os_release", lambda: {"ID": "ubuntu", "ID_LIKE": "debian"})
Expand All @@ -81,7 +81,7 @@ def test_single_confirmation_for_batch(pf, fake_shell, fake_which, tmp_install_l
)


def test_macos_uses_brew(pf, fake_shell, fake_which, monkeypatch):
def test_macos_uses_brew(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-03: On Darwin with no VMs, missing node → install_command starts with 'brew install'."""
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake_which["brew"] = "/opt/homebrew/bin/brew"
Expand All @@ -94,7 +94,7 @@ def test_macos_uses_brew(pf, fake_shell, fake_which, monkeypatch):
)


def test_linux_debian_uses_apt(pf, fake_shell, fake_which, monkeypatch):
def test_linux_debian_uses_apt(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-03: On Linux+Ubuntu, git install_command contains 'apt-get install'."""
monkeypatch.setattr("platform.system", lambda: "Linux")
monkeypatch.setattr("platform.freedesktop_os_release", lambda: {"ID": "ubuntu", "ID_LIKE": "debian"})
Expand All @@ -106,7 +106,7 @@ def test_linux_debian_uses_apt(pf, fake_shell, fake_which, monkeypatch):
)


def test_linux_fedora_uses_dnf(pf, fake_shell, fake_which, monkeypatch):
def test_linux_fedora_uses_dnf(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-03: On Linux+Fedora, git install_command contains 'dnf install'."""
monkeypatch.setattr("platform.system", lambda: "Linux")
monkeypatch.setattr("platform.freedesktop_os_release", lambda: {"ID": "fedora", "ID_LIKE": "rhel"})
Expand All @@ -118,7 +118,7 @@ def test_linux_fedora_uses_dnf(pf, fake_shell, fake_which, monkeypatch):
)


def test_windows_uses_winget(pf, fake_shell, fake_which, monkeypatch):
def test_windows_uses_winget(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-03: On Windows, first install action starts with 'winget install'."""
monkeypatch.setattr("platform.system", lambda: "Windows")
plan = pf.plan()
Expand Down Expand Up @@ -254,7 +254,7 @@ def test_dry_run_no_state_change(pf, fake_shell, fake_which, monkeypatch, tmp_in
)


def test_no_docker_mode_skips_docker(pf, fake_shell, fake_which, monkeypatch):
def test_no_docker_mode_skips_docker(pf, fake_shell, fake_which, tmp_install_log, monkeypatch):
"""PRE-07: plan(no_docker=True) has no docker action and no docker detection prompt."""
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake_which["brew"] = "/opt/homebrew/bin/brew"
Expand Down
Loading
Loading