From 7b19a516bc8a0fccbe7ca6d72ca0abb990c797f8 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 14:20:24 -0700 Subject: [PATCH 1/2] local: install.py, verify_install.py, mock Ollama, delegate-local skill Python port of the legacy harness installer for codex-local: release selection (local-v*), SHA256SUMS verification, Windows PATH wiring, content-marker legacy cleanup with backup, offline --quick verification against a stdlib mock Ollama/Responses server, and tiny unittest suite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qp4Gg5UQcjWVPZz3y58bWs --- local/scripts/_lib.py | 137 ++++++++ local/scripts/install.py | 474 +++++++++++++++++++++++++++ local/scripts/mock_ollama.py | 114 +++++++ local/scripts/verify_install.py | 225 +++++++++++++ local/skills/delegate-local/SKILL.md | 96 ++++++ local/tests/test_install.py | 171 ++++++++++ 6 files changed, 1217 insertions(+) create mode 100644 local/scripts/_lib.py create mode 100644 local/scripts/install.py create mode 100644 local/scripts/mock_ollama.py create mode 100644 local/scripts/verify_install.py create mode 100644 local/skills/delegate-local/SKILL.md create mode 100644 local/tests/test_install.py diff --git a/local/scripts/_lib.py b/local/scripts/_lib.py new file mode 100644 index 000000000000..e1539b8c09aa --- /dev/null +++ b/local/scripts/_lib.py @@ -0,0 +1,137 @@ +"""Shared helpers for install.py and verify_install.py (stdlib only, cp1252-safe output).""" + +from __future__ import annotations + +import hashlib +import os +import platform +import re +import shutil +import subprocess +import sys +from pathlib import Path + +DEFAULT_REPO = "PixelatedMountainStream/codex" +REPO_ENV = "CODEX_LOCAL_REPO" +TAG_PREFIX = "local-v" +IS_WINDOWS = os.name == "nt" +DEFAULT_OLLAMA_URL = "http://localhost:11434" + + +def make_output_safe() -> None: + """Windows consoles default to cp1252; never let a stray glyph kill the run. + + Keeps the console encoding but replaces unencodable characters. All of our own + messages are plain ASCII anyway; this covers text echoed from child processes. + """ + for s in (sys.stdout, sys.stderr): + try: + s.reconfigure(errors="replace") + except Exception: + pass + + +def triple() -> str: + sysname, mach = platform.system(), platform.machine().lower() + arm = mach in ("arm64", "aarch64") + if sysname == "Darwin": + return "aarch64-apple-darwin" if arm else "x86_64-apple-darwin" + if sysname == "Linux": + return f"{'aarch64' if arm else 'x86_64'}-unknown-linux-musl" + if sysname == "Windows": + return "aarch64-pc-windows-msvc" if arm else "x86_64-pc-windows-msvc" + return f"{mach}-{sysname.lower()}" + + +def asset_name(target: str) -> str: + return f"codex-local-{target}" + (".exe" if "windows" in target else "") + + +def exe_name() -> str: + return "codex-local.exe" if IS_WINDOWS else "codex-local" + + +def default_bin_dir() -> Path: + return Path.home() / ".local" / "bin" + + +def stock_home(env=None) -> Path: + env = os.environ if env is None else env + return Path(env.get("CODEX_HOME") or Path.home() / ".codex") + + +def local_home(env=None) -> Path: + """CODEX_LOCAL_HOME only; an inherited CODEX_HOME is never the local home.""" + env = os.environ if env is None else env + return Path(env.get("CODEX_LOCAL_HOME") or Path.home() / ".codex" / "local") + + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def parse_sha256sums(text: str) -> dict[str, str]: + """` ` or ` *` lines -> {name: hex}.""" + out: dict[str, str] = {} + for line in text.splitlines(): + m = re.match(r"^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$", line) + if m: + out[m.group(2)] = m.group(1).lower() + return out + + +_VER_RE = re.compile(r"(\d+\.\d+\.\d+(?:-[0-9A-Za-z.\-]+)?)(\+local[0-9A-Za-z.\-]*)?") + + +def parse_version(output: str) -> tuple[str, str] | None: + """`codex-cli 0.162.1+local.abc` -> ('0.162.1', '+local.abc'); None if no version.""" + m = _VER_RE.search(output or "") + return (m.group(1), m.group(2) or "") if m else None + + +def run(argv, timeout=60, env=None, cwd=None): + """subprocess.run that never raises on missing/slow binaries; returns (rc, out, err).""" + try: + r = subprocess.run([str(a) for a in argv], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + stdin=subprocess.DEVNULL, env=env, cwd=cwd) + return r.returncode, r.stdout or "", r.stderr or "" + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout}s" + except OSError as e: + return 127, "", str(e) + + +def probe_version(exe: str, timeout=30): + rc, out, err = run([exe, "--version"], timeout=timeout) + return rc, parse_version(out), (out + err).strip() + + +def stock_codex() -> str | None: + """Stock `codex` on PATH, ignoring a binary carrying the `+local` stamp.""" + exe = shutil.which("codex") + if not exe: + return None + _, ver, _ = probe_version(exe) + if ver and ver[1]: + return None + return exe + + +def ollama_root(env=None, local_cfg: Path | None = None) -> str: + """Resolve the Ollama root: env CODEX_LOCAL_OLLAMA_URL, OLLAMA_HOST, config.toml, default.""" + env = os.environ if env is None else env + raw = env.get("CODEX_LOCAL_OLLAMA_URL") or env.get("OLLAMA_HOST") + if not raw and local_cfg and local_cfg.is_file(): + m = re.search(r'^\s*ollama_(?:url|base_url)\s*=\s*"([^"]+)"', + local_cfg.read_text(encoding="utf-8", errors="replace"), re.M) + raw = m.group(1) if m else None + raw = (raw or DEFAULT_OLLAMA_URL).strip() + if "://" not in raw: + raw = "http://" + raw + raw = raw.rstrip("/") + return raw[:-3] if raw.endswith("/v1") else raw diff --git a/local/scripts/install.py b/local/scripts/install.py new file mode 100644 index 000000000000..cb3bcfd5445a --- /dev/null +++ b/local/scripts/install.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""Install codex-local beside stock codex. + + install.py [--repo OWNER/REPO] [--tag local-vX.Y.Z] [--dry-run] [--no-cleanup] + [--release-dir DIR] [--bin-dir DIR] [--stock-home DIR] [--local-home DIR] + +* Picks the newest release whose tag starts with `local-v` (ignores `rust-v*`), downloads + `codex-local-[.exe]`, verifies it against SHA256SUMS, installs to ~/.local/bin + (Windows: %USERPROFILE%\\.local\\bin plus the user PATH). +* Never touches the stock `codex` binary; only warns if it is missing. +* Runs `codex-local sync-config` to create the home and links (no link logic here). +* Legacy cleanup (the ONLY code that writes to stock's home): matched by parsed TOML keys (never by text), + backed up to /legacy-backup//, then removed. Interactive runs + ask first; non-interactive runs and --dry-run only report. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +try: + import tomllib +except ImportError: # Python < 3.11: legacy detection is disabled (nothing is touched) + tomllib = None +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib # noqa: E402 + +_lib.make_output_safe() + +WIN_HELPERS = ("codex-windows-sandbox-setup", "codex-command-runner") +# Line locators used ONLY to find candidate lines to delete in config.toml; whether a file is +# legacy is decided from the parsed TOML, and every edit is re-parsed and verified. +CHAT_LINE_RE = re.compile(r'^[ \t]*wire_api[ \t]*=[ \t]*(?:"chat"|\'chat\')[ \t]*(?:#.*)?$') +PATCH_LINE_RE = re.compile(r'^[ \t]*model_apply_patch_tool_type[ \t]*=[ \t]*(?:"[^"\n]*"|\'[^\'\n]*\'|[^\s#]+)[ \t]*(?:#.*)?$') +PATCH_KEY = "model_apply_patch_tool_type" +TUNNEL_PORT = "127.0.0.1:11434" # legacy fleet_lib.tunnel_argv: ssh -N ... -L :127.0.0.1:11434 + + +# ---------------------------------------------------------------- release selection + +def select_release(releases: list[dict], tag: str | None = None) -> dict | None: + """Newest non-draft release with a `local-v` tag (or exactly `tag`).""" + cands = [r for r in releases + if str(r.get("tag_name", "")).startswith(_lib.TAG_PREFIX) and not r.get("draft")] + if tag: + cands = [r for r in cands if r["tag_name"] == tag] + cands.sort(key=lambda r: r.get("published_at") or r.get("created_at") or "", reverse=True) + return cands[0] if cands else None + + +def find_asset(release: dict, name: str) -> dict | None: + return next((a for a in release.get("assets", []) if a.get("name") == name), None) + + +def _token() -> str | None: + return os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + + +def _http(url: str, accept: str = "application/vnd.github+json") -> bytes: + h = {"Accept": accept, "User-Agent": "codex-local-install"} + if _token(): + h["Authorization"] = f"Bearer {_token()}" + with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=60) as r: + return r.read() + + +def fetch_releases(repo: str) -> list[dict]: + return json.loads(_http(f"https://api.github.com/repos/{repo}/releases?per_page=100")) + + +def fetch_asset(asset: dict) -> bytes: + if _token(): # API url + octet-stream also works for private repos + return _http(asset["url"], "application/octet-stream") + return _http(asset["browser_download_url"], "application/octet-stream") + + +def verify_asset(data: bytes, name: str, sums_text: str) -> str: + """Return the hex digest if `data` matches SHA256SUMS for `name`; raise otherwise.""" + import hashlib + sums = _lib.parse_sha256sums(sums_text) + if name not in sums: + raise ValueError(f"{name} is not listed in SHA256SUMS") + got = hashlib.sha256(data).hexdigest() + if got != sums[name]: + raise ValueError(f"SHA256 mismatch for {name}: expected {sums[name]}, got {got}") + return got + + +# ---------------------------------------------------------------- binary placement + +def place_binary(data: bytes, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(dest.name + ".new") + tmp.write_bytes(data) + if not _lib.IS_WINDOWS: + os.chmod(tmp, 0o755) + os.replace(tmp, dest) + if sys.platform == "darwin": + run = subprocess.run + run(["xattr", "-d", "com.apple.quarantine", str(dest)], capture_output=True) + run(["codesign", "--remove-signature", str(dest)], capture_output=True) + run(["codesign", "-s", "-", "-f", str(dest)], capture_output=True) + + +def ensure_on_user_path(directory: Path) -> None: + """Windows: add `directory` to the persistent User PATH (.NET API, never setx).""" + target = os.path.normcase(os.path.normpath(str(directory))) + rc, out, _ = _lib.run(["powershell", "-NoProfile", "-Command", + "[Environment]::GetEnvironmentVariable('Path','User')"]) + if rc != 0: + print(f" WARN: could not read the user PATH; add {directory} to PATH yourself") + return + entries = [e for e in out.strip().split(";") if e] + if any(os.path.normcase(os.path.normpath(e)) == target for e in entries): + print(f" {directory} is already on the user PATH") + else: + env = {**os.environ, "CLH_NEW_PATH": ";".join(entries + [str(directory)])} + rc, _, _ = _lib.run(["powershell", "-NoProfile", "-Command", + "[Environment]::SetEnvironmentVariable('Path', $env:CLH_NEW_PATH, 'User')"], + env=env) + print(f" added {directory} to the user PATH; open a NEW shell" if rc == 0 + else f" WARN: could not update the user PATH; add {directory} yourself") + if target not in [os.path.normcase(os.path.normpath(p)) + for p in os.environ.get("PATH", "").split(os.pathsep) if p]: + os.environ["PATH"] = f"{directory}{os.pathsep}{os.environ.get('PATH', '')}" + + +# ---------------------------------------------------------------- TOML-aware matching + +def _parse(text: str): + if tomllib is None: + return None + try: + return tomllib.loads(text) + except (tomllib.TOMLDecodeError, ValueError): + return None + + +def _is_legacy_key(k, v) -> bool: + return k == PATCH_KEY or (k == "wire_api" and v == "chat") + + +def count_legacy_keys(data) -> int: + """Number of real `wire_api = "chat"` / `model_apply_patch_tool_type` keys, at any depth.""" + if isinstance(data, dict): + return sum(1 if _is_legacy_key(k, v) else count_legacy_keys(v) for k, v in data.items()) + if isinstance(data, list): + return sum(count_legacy_keys(v) for v in data) + return 0 + + +def _without_legacy_keys(data): + if isinstance(data, dict): + return {k: _without_legacy_keys(v) for k, v in data.items() if not _is_legacy_key(k, v)} + if isinstance(data, list): + return [_without_legacy_keys(v) for v in data] + return data + + +def is_legacy_toml(text: str) -> bool: + data = _parse(text) + return data is not None and count_legacy_keys(data) > 0 + + +def _ml_state_after(line: str, delim: str | None) -> str | None: + """Track whether a TOML multi-line string (triple quotes) is open at the end of `line`. + Naive on purpose: any misjudgement is caught by the re-parse verification.""" + i = 0 + while i < len(line): + if delim: + j = line.find(delim, i) + if j < 0: + return delim + i, delim = j + 3, None + else: + hits = [(line.find(d, i), d) for d in ('"""', "'''")] + hits = [(j, d) for j, d in hits if j >= 0] + hash_at = line.find("#", i) + if not hits or (0 <= hash_at < min(j for j, _ in hits)): + return None + j, delim = min(hits) + i = j + 3 + return delim + + +def strip_config_keys(text: str) -> str | None: + """Remove only the legacy key lines. Returns None (caller must skip) unless re-parsing the + result proves that exactly those keys disappeared and everything else is equal.""" + data = _parse(text) + if data is None: + return None + keep, delim = [], None + for ln in text.splitlines(keepends=True): + bare = ln.rstrip("\r\n") + if delim is None and (CHAT_LINE_RE.match(bare) or PATCH_LINE_RE.match(bare)): + continue # starts outside any multi-line string, so it is a real assignment line + keep.append(ln) + delim = _ml_state_after(bare, delim) + new = "".join(keep) + if _parse(new) != _without_legacy_keys(data): + return None + return new + + +# ---------------------------------------------------------------- legacy cleanup + +def find_tunnel_procs(ps_lines: list[str] | None = None) -> list[tuple[int, str]]: + """Legacy fleet tunnels: `ssh -N ... -L :127.0.0.1:11434 host`. [(pid, cmdline)].""" + if ps_lines is None: + if _lib.IS_WINDOWS: + _, out, _ = _lib.run(["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process -Filter \"Name='ssh.exe'\" | " + "ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }"]) + else: + _, out, _ = _lib.run(["ps", "-axo", "pid=,command="]) + ps_lines = out.splitlines() + found = [] + for line in ps_lines: + m = re.match(r"\s*(\d+)\s+(.*)$", line) + if not m: + continue + cmd = m.group(2) + if (re.match(r'^"?([^"]*[\\/])?ssh(\.exe)?"?\s', cmd + " ") and re.search(r"\s-N\b", cmd) + and re.search(r"-L\s*\S*:" + re.escape(TUNNEL_PORT), cmd)): + found.append((int(m.group(1)), cmd)) + return found + + +def scan_legacy(stock: Path, ps_lines: list[str] | None = None) -> list[dict]: + """Findings: {kind: file|config-keys|process, path/pid, detail}. Parsed-TOML key matching.""" + out: list[dict] = [] + + def read(p: Path) -> str: + try: + return p.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + agents = stock / "agents" + for p in sorted(agents.glob("*.toml")) if agents.is_dir() else []: + if is_legacy_toml(read(p)): + out.append({"kind": "file", "path": p, "detail": "agent toml with fork-only keys"}) + p = stock / "ollama.config.toml" + if p.is_file() and is_legacy_toml(read(p)): + out.append({"kind": "file", "path": p, "detail": "legacy ollama profile"}) + p = stock / "config.toml" + if p.is_file(): + t = read(p) + data = _parse(t) + n = count_legacy_keys(data) if data is not None else 0 + if n and strip_config_keys(t) is not None: + out.append({"kind": "config-keys", "path": p, + "detail": f"{n} fork-only key(s); only those lines are removed"}) + elif n: + out.append({"kind": "config-skip", "path": p, + "detail": f"{n} fork-only key(s) not safely removable by line; " + "left untouched, edit by hand"}) + for pid, cmd in find_tunnel_procs(ps_lines): + out.append({"kind": "process", "pid": pid, "detail": cmd[:120]}) + return out + + +def backup_and_remove(findings: list[dict], stock: Path, backup: Path, kill=None) -> None: + backup.mkdir(parents=True, exist_ok=True) + for f in findings: # back up everything first + if f["kind"] in ("file", "config-keys"): + dst = backup / f["path"].relative_to(stock) + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f["path"], dst) + procs = [f for f in findings if f["kind"] == "process"] + if procs: + (backup / "tunnel-processes.txt").write_text( + "\n".join(f"{f['pid']} {f['detail']}" for f in procs) + "\n", encoding="utf-8") + for f in findings: + if f["kind"] == "file": + f["path"].unlink() + elif f["kind"] == "config-keys": + p = f["path"] + new = strip_config_keys(p.read_text(encoding="utf-8", errors="replace")) + if new is None: # verification failed: report and skip, never write + print(f" SKIP: {p} failed post-edit verification; left untouched") + continue + p.write_text(new, encoding="utf-8") + elif f["kind"] == "process": + (kill or _kill)(f["pid"]) + + +def _kill(pid: int) -> None: + if _lib.IS_WINDOWS: + _lib.run(["taskkill", "/PID", str(pid), "/F"]) + else: + try: + os.kill(pid, 15) + except OSError: + pass + + +def describe(f: dict) -> str: + where = f["path"] if "path" in f else f"pid {f['pid']}" + return f"{f['kind']:<12} {where} ({f['detail']})" + + +def legacy_cleanup(stock: Path, local: Path, dry_run: bool, interactive: bool, + ps_lines=None) -> list[dict]: + findings = scan_legacy(stock, ps_lines) + if not findings: + print(" no legacy artifacts found") + return findings + for f in findings: + print(f" legacy: {describe(f)}") + if not any(f["kind"] != "config-skip" for f in findings): + return findings + if dry_run: + print(" dry-run: nothing changed") + return findings + if not interactive: + print(" non-interactive: reported only; re-run in a terminal to clean up") + return findings + if input(" Back up and remove these? [y/N] ").strip().lower() != "y": + print(" skipped") + return findings + backup = local / "legacy-backup" / time.strftime("%Y%m%d-%H%M%S") + backup_and_remove(findings, stock, backup) + print(f" backed up to {backup} and removed") + return findings + + +# ---------------------------------------------------------------- main + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Install codex-local beside stock codex.") + ap.add_argument("--repo", default=os.environ.get(_lib.REPO_ENV) or _lib.DEFAULT_REPO) + ap.add_argument("--tag", help="exact local-v* release tag (default: newest)") + ap.add_argument("--release-dir", help="offline: dir holding the asset(s) and SHA256SUMS") + ap.add_argument("--bin-dir", default=None) + ap.add_argument("--stock-home", default=None) + ap.add_argument("--local-home", default=None) + ap.add_argument("--dry-run", action="store_true", + help="report what would happen; change nothing") + ap.add_argument("--no-cleanup", action="store_true", help="skip legacy cleanup") + ap.add_argument("--no-verify", action="store_true", help="skip the final verify --quick") + a = ap.parse_args(argv) + + stock = Path(a.stock_home) if a.stock_home else _lib.stock_home() + local = Path(a.local_home) if a.local_home else _lib.local_home() + bin_dir = Path(a.bin_dir) if a.bin_dir else _lib.default_bin_dir() + target = _lib.triple() + name = _lib.asset_name(target) + dest = bin_dir / _lib.exe_name() + env = {**os.environ, "CODEX_LOCAL_HOME": str(local), "CODEX_HOME": str(stock)} + + print("==> Stock codex (never modified)") + exe = _lib.stock_codex() + print(f" stock codex: {exe}" if exe else + " WARN: stock `codex` not found on PATH; codex-local works without it, " + "but cross-resume needs it (npm i -g @openai/codex)") + + print(f"==> Release ({a.repo}, asset {name})") + if a.release_dir: + d = Path(a.release_dir) + try: + data = (d / name).read_bytes() + sums = (d / "SHA256SUMS").read_text(encoding="utf-8") + except OSError as e: + print(f" ERROR: cannot read release files from {d}: {e}; " + f"expected {name} and SHA256SUMS") + return 1 + tag, helpers = "local", {} + if a.dry_run: + print(f" dry-run: would install {name} from {d} to {dest}") + data = None + for h in WIN_HELPERS if (_lib.IS_WINDOWS and data is not None) else (): + hp = d / f"{h}-{target}.exe" + if hp.is_file(): + helpers[h] = (hp.name, hp.read_bytes()) + else: + print(f" ERROR: {hp.name} is required on Windows but missing from {d}") + return 1 + else: + try: + rel = select_release(fetch_releases(a.repo), a.tag) + except Exception as e: + print(f" ERROR: could not list releases for {a.repo}: {e}") + return 1 + if not rel: + print(f" ERROR: no release with tag {a.tag or _lib.TAG_PREFIX + '*'} in {a.repo}") + return 1 + tag = rel["tag_name"] + asset, sums_asset = find_asset(rel, name), find_asset(rel, "SHA256SUMS") + if not asset or not sums_asset: + print(f" ERROR: {tag} lacks {name if not asset else 'SHA256SUMS'}") + return 1 + print(f" {tag}") + if a.dry_run: + print(f" dry-run: would install {name} to {dest}") + data = sums = None + else: + data = fetch_asset(asset) + sums = fetch_asset(sums_asset).decode("utf-8", "replace") + helpers = {} + for h in WIN_HELPERS if _lib.IS_WINDOWS else (): + ha = find_asset(rel, f"{h}-{target}.exe") + if not ha: + print(f" ERROR: {tag} lacks {h}-{target}.exe (required on Windows)") + return 1 + helpers[h] = (ha["name"], fetch_asset(ha)) + + if data is not None: + try: + digest = verify_asset(data, name, sums) + for hname, hdata in helpers.values(): + verify_asset(hdata, hname, sums) + except ValueError as e: + print(f" ERROR: {e}") + return 1 + print(f" SHA256 ok {digest}") + print(f"==> Installing {dest}") + try: + place_binary(data, dest) + for h, (_, hdata) in helpers.items(): + place_binary(hdata, bin_dir / f"{h}.exe") + except OSError as e: + print(f" ERROR: could not write {dest}: {e} (is codex-local running?)") + return 1 + if _lib.IS_WINDOWS: + ensure_on_user_path(bin_dir) + elif str(bin_dir) not in os.environ.get("PATH", "").split(os.pathsep): + print(f" NOTE: add {bin_dir} to PATH") + local.mkdir(parents=True, exist_ok=True) + (local / "install.json").write_text(json.dumps( + {"tag": tag, "asset": name, "sha256": digest, + "installed_sha256": _lib.sha256_file(dest), "path": str(dest)}, indent=2), + encoding="utf-8") + + print("==> Home and links (codex-local sync-config)") + rc, out, err = _lib.run([dest, "sync-config"], env=env, timeout=120) + for line in (out + err).strip().splitlines(): + print(f" {line}") + if rc != 0: + print(f" ERROR: sync-config exited {rc}") + return 1 + + if not a.no_cleanup: + print("==> Legacy cleanup (stock home: only fork-only content is touched)") + interactive = sys.stdin.isatty() and sys.stdout.isatty() + legacy_cleanup(stock, local, a.dry_run, interactive) + + if a.dry_run or data is None: + return 0 + if not a.no_verify: + print("==> Verifying (verify_install.py --quick)") + rc, out, err = _lib.run([sys.executable, Path(__file__).with_name("verify_install.py"), + "--quick", "--binary", dest], env=env, timeout=300) + for line in (out + err).splitlines(): + print(f" {line}") + if rc != 0: + print("\nINSTALL FAILED verification. Not reporting success.") + return 1 + print(f"\nDone. Run `{dest.name} --version`; full check: verify_install.py") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/local/scripts/mock_ollama.py b/local/scripts/mock_ollama.py new file mode 100644 index 000000000000..ad4a6edf30ff --- /dev/null +++ b/local/scripts/mock_ollama.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Minimal mock Ollama + OpenAI Responses server (stdlib) for offline verification. + +Serves: GET /api/tags, /api/version, /v1/models; POST /api/show; POST /v1/responses +(SSE stream, or JSON when stream is false) with one fixed text reply. + +Scope: the minimum event sequence for a single text reply (created, output_item.added, +output_text.delta, output_item.done, completed with usage). No tool calls, no chat +completions. Not validated against a real codex-local build yet. +""" + +from __future__ import annotations + +import argparse +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MODEL = "qwen3.8:27b" +REPLY = "MOCK-OK" + + +def _sse(event: str, data: dict) -> bytes: + return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() + + +def responses_events(text: str = REPLY, model: str = MODEL) -> list[bytes]: + rid, mid = "resp_mock", "msg_mock" + msg = {"type": "message", "role": "assistant", "id": mid, + "content": [{"type": "output_text", "text": text}]} + usage = {"input_tokens": 1, "input_tokens_details": None, + "output_tokens": 1, "output_tokens_details": None, "total_tokens": 2} + return [ + _sse("response.created", {"type": "response.created", + "response": {"id": rid, "model": model}}), + _sse("response.output_item.added", {"type": "response.output_item.added", + "output_index": 0, "item": {**msg, "content": []}}), + _sse("response.output_text.delta", {"type": "response.output_text.delta", + "item_id": mid, "output_index": 0, "content_index": 0, "delta": text}), + _sse("response.output_item.done", {"type": "response.output_item.done", + "output_index": 0, "item": msg}), + _sse("response.completed", {"type": "response.completed", + "response": {"id": rid, "model": model, "usage": usage}}), + ] + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # silence + pass + + def _json(self, obj, code=200): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + p = self.path.split("?")[0] + if p == "/api/tags": + self._json({"models": [{"name": MODEL, "model": MODEL}]}) + elif p == "/api/version": + self._json({"version": "0.0.0-mock"}) + elif p == "/v1/models": + self._json({"object": "list", "data": [{"id": MODEL, "object": "model"}]}) + else: + self._json({"error": "not found"}, 404) + + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(n) if n else b"{}" + try: + req = json.loads(raw or b"{}") + except ValueError: + req = {} + p = self.path.split("?")[0] + if p == "/api/show": + self._json({"capabilities": ["completion", "tools"], "details": {}, + "model_info": {"general.architecture": "mock", + "mock.context_length": 32768}}) + elif p == "/v1/responses": + model = req.get("model") or MODEL + events = responses_events(model=model) + if req.get("stream") is False: + last = json.loads(events[-1].split(b"data: ", 1)[1]) + self._json(last["response"]) + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Connection", "close") + self.end_headers() + for e in events: + self.wfile.write(e) + self.wfile.flush() + self.close_connection = True + else: + self._json({"error": "not found"}, 404) + + +def start(port: int = 0) -> tuple[ThreadingHTTPServer, str]: + """Start on 127.0.0.1 in a daemon thread; returns (server, base_url).""" + srv = ThreadingHTTPServer(("127.0.0.1", port), Handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, f"http://127.0.0.1:{srv.server_address[1]}" + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=0) + a = ap.parse_args() + s, url = start(a.port) + print(url, flush=True) + threading.Event().wait() diff --git a/local/scripts/verify_install.py b/local/scripts/verify_install.py new file mode 100644 index 000000000000..c9e24cc272a7 --- /dev/null +++ b/local/scripts/verify_install.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +"""Verify a codex-local install. One line per check; exits non-zero on any FAIL. + + verify_install.py full: stock codex + config, /api/tags, version skew, + links into stock's home, a live one-shot exec + verify_install.py --quick offline only: binary runs, version format, SHA256, + home/links in a temp CODEX_LOCAL_HOME, one-shot exec + against a stdlib mock Ollama (mock_ollama.py) + +Options: --binary PATH, --sums SHA256SUMS (quick SHA256 source; default is the +install record /install.json). All output is plain ASCII (cp1252-safe). +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import _lib # noqa: E402 +import mock_ollama # noqa: E402 + +_lib.make_output_safe() + +EXEC_ARGS = ["exec", "--skip-git-repo-check", "Reply with the single word OK."] + + +class Report: + def __init__(self) -> None: + self.failed = 0 + + def _p(self, mark, label, detail): + print(f"{mark:<5} {label:<18} {detail}") + + def ok(self, l, d): self._p("PASS", l, d) + def warn(self, l, d): self._p("WARN", l, d) + def info(self, l, d): self._p("--", l, d) + + def fail(self, l, d): + self._p("FAIL", l, d) + self.failed += 1 + + +def find_binary(arg: str | None) -> str | None: + if arg: # resolve now: later steps run the binary with a different cwd + return str(Path(arg).expanduser().resolve()) + return shutil.which("codex-local") + + +def check_binary(rep, exe) -> tuple | None: + if not exe: + rep.fail("binary", "codex-local not found (run install.py)") + return None + rc, ver, raw = _lib.probe_version(exe) + if rc != 0 or not ver: + rep.fail("binary", f"`{exe} --version` rc={rc}: {raw[:150] or ''}") + return None + rep.ok("binary", f"{exe} -> {raw.splitlines()[0]}") + if not ver[1]: + rep.fail("version format", f"{ver[0]} has no +local stamp; not a codex-local build") + return None + rep.ok("version format", f"{ver[0]}{ver[1]}") + return ver + + +def check_sha(rep, exe, local: Path, sums: str | None) -> None: + got = _lib.sha256_file(Path(exe)) + want = src = None + if sums and sys.platform == "darwin": + rep.warn("sha256", "--sums skipped on macOS: install re-signs the binary, so it " + "cannot match the release SHA256SUMS; verify via install.json") + return + if sums: + want = _lib.parse_sha256sums(Path(sums).read_text(encoding="utf-8")).get( + _lib.asset_name(_lib.triple())) + src = sums + elif (local / "install.json").is_file(): + rec = json.loads((local / "install.json").read_text(encoding="utf-8")) + want, src = rec.get("installed_sha256") or rec.get("sha256"), "install record" + if not want: + rep.warn("sha256", f"no recorded checksum to compare ({got[:12]}...); pass --sums") + elif got == want: + rep.ok("sha256", f"matches {src}") + else: + rep.fail("sha256", f"binary {got[:12]}... != {src} {want[:12]}...") + + +def check_links(rep, stock: Path, local: Path) -> None: + def same(a: Path, b: Path) -> bool: + return a.exists() and b.exists() and os.path.realpath(a) == os.path.realpath(b) + + bad = [] + for d in ("sessions", "archived_sessions"): + if (stock / d).exists() and not same(local / d, stock / d): + bad.append(d) + if bad: + rep.fail("links", f"{', '.join(bad)} in {local} do not resolve to {stock}") + else: + rep.ok("links", "sessions/ and archived_sessions/ resolve to stock's home") + if (stock / "AGENTS.md").exists(): + a = local / "AGENTS.md" + if same(a, stock / "AGENTS.md"): + rep.ok("AGENTS.md", "linked to stock") + elif a.is_file(): + rep.warn("AGENTS.md", "copy fallback in use (not a link); edits in stock do not propagate live") + else: + rep.fail("AGENTS.md", "missing in codex-local home") + skills = stock / "skills" + if skills.is_dir(): + miss = [c.name for c in skills.iterdir() + if c.name != ".system" and not same(local / "skills" / c.name, c)] + if miss: + rep.fail("skill links", "not linked: " + ", ".join(sorted(miss))) + else: + rep.ok("skill links", "user skills resolve to stock's") + + +def one_shot(rep, label, exe, env, cwd) -> None: + rc, out, err = _lib.run([exe] + EXEC_ARGS, env=env, cwd=cwd, timeout=300) + text = (out + err).strip() + if rc != 0: + rep.fail(label, f"exec rc={rc}: {text[-200:]}") + elif not out.strip(): + rep.fail(label, "exec exited 0 but printed no reply") + else: + rep.ok(label, "reply: " + out.strip().splitlines()[-1][:60]) + + +def quick(rep, exe, sums) -> None: + ver = check_binary(rep, exe) + if not ver: + return + with tempfile.TemporaryDirectory() as td: + td = Path(td) + stock, local, work = td / "stock", td / "local", td / "work" + for d in (stock / "sessions", stock / "archived_sessions", stock / "skills" / "demo", work): + d.mkdir(parents=True) + (stock / "AGENTS.md").write_text("# demo\n", encoding="utf-8") + check_sha(rep, exe, _lib.local_home(), sums) + srv, url = mock_ollama.start() + try: + env = {**os.environ, "CODEX_HOME": str(stock), "CODEX_LOCAL_HOME": str(local), + "CODEX_LOCAL_OLLAMA_URL": url} + env.pop("OLLAMA_HOST", None) + rc, out, err = _lib.run([exe, "sync-config"], env=env) + if rc != 0: + rep.fail("home setup", f"sync-config rc={rc}: {(out + err).strip()[-200:]}") + return + rep.ok("home setup", f"sync-config created {local}") + check_links(rep, stock, local) + one_shot(rep, "mock exec", exe, env, work) + finally: + srv.shutdown() + + +def full(rep, exe, sums) -> None: + stock, local = _lib.stock_home(), _lib.local_home() + ver = check_binary(rep, exe) + sexe = _lib.stock_codex() + sver = None + if not sexe: + rep.warn("stock codex", "not found on PATH; cross-resume unavailable") + else: + rc, sver, raw = _lib.probe_version(sexe) + (rep.ok if rc == 0 and sver else rep.fail)("stock codex", f"{sexe} -> {raw[:80]}") + cfg = stock / "config.toml" + if cfg.is_file(): + try: + import tomllib + tomllib.loads(cfg.read_text(encoding="utf-8")) + rep.ok("stock config", f"{cfg} parses") + except ImportError: + rep.info("stock config", "tomllib unavailable (Python < 3.11); not parsed") + except Exception as e: + rep.fail("stock config", f"{cfg}: {e}") + else: + rep.info("stock config", f"{cfg} absent") + root = _lib.ollama_root(local_cfg=local / "config.toml") + try: + with urllib.request.urlopen(root + "/api/tags", timeout=5) as r: + n = len(json.loads(r.read().decode()).get("models", [])) + rep.ok("ollama", f"{root}/api/tags reachable, {n} model(s)") + except Exception as e: + rep.fail("ollama", f"{root}/api/tags unreachable: {e}") + if ver and sver: + if ver[0] == sver[0]: + rep.ok("version skew", f"upstream {ver[0]} on both") + else: + rep.warn("version skew", f"codex-local upstream {ver[0]} vs stock {sver[0]}; " + "cross-resume may misbehave") + check_sha(rep, exe, local, sums) if ver else None + if local.is_dir(): + check_links(rep, stock, local) + else: + rep.fail("links", f"{local} missing; run `codex-local sync-config`") + if ver: + env = {**os.environ, "CODEX_HOME": str(stock), "CODEX_LOCAL_HOME": str(local)} + with tempfile.TemporaryDirectory() as td: + one_shot(rep, "live exec", exe, env, td) + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description="Verify a codex-local install.") + ap.add_argument("--quick", action="store_true", help="offline checks only") + ap.add_argument("--binary", help="path to codex-local (default: PATH)") + ap.add_argument("--sums", help="SHA256SUMS file to check the binary against") + a = ap.parse_args(argv) + rep = Report() + exe = find_binary(a.binary) + (quick if a.quick else full)(rep, exe, a.sums) + if rep.failed: + print(f"\n{rep.failed} check(s) FAILED.") + return 1 + print(f"\nAll checks passed ({'quick' if a.quick else 'full'}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/local/skills/delegate-local/SKILL.md b/local/skills/delegate-local/SKILL.md new file mode 100644 index 000000000000..28f2be2b2442 --- /dev/null +++ b/local/skills/delegate-local/SKILL.md @@ -0,0 +1,96 @@ +--- +name: delegate-local +description: > + Delegate bounded, token-heavy, judgment-light sub-tasks to the local Ollama model + (qwen3.8:27b) via `codex-local exec` to cut cloud-token usage. Use when the user asks to + "delegate locally", "offload this", "run it on the local model", runs /delegate-local, or + when a task has large mechanical read / edit / summarize / search / verify work that the + orchestrator should hand off instead of doing in-context. +--- + +# Delegate to a local model + +Offload narrow, mechanical work to the free local model and ingest only a **compact, +verified** result, never the raw transcript. You stay the orchestrator: you decompose, +decide what is safe to delegate, and consume the signal. + +**Map locally, reduce in cloud.** The local model is slow and a weak agentic driver. It is +reliable ONLY on narrow, fully-specified, single-shot units paired with a cheap verifier. + +## Setup (zero-config) + +`codex-local` is installed beside stock `codex` (`local/scripts/install.py`). It defaults to +model `qwen3.8:27b` on `http://localhost:11434` (plain Ollama or a PAIR proxy). Overrides: +`-m MODEL`, `--ollama-url URL`, env `CODEX_LOCAL_MODEL` / `CODEX_LOCAL_OLLAMA_URL` / +`OLLAMA_HOST`. No profiles, tunnels, fleet files or node locks. Check with +`verify_install.py` (full) if a unit fails oddly. + +## Green / Yellow / Red decisioning + +- GREEN, delegate: narrow, fully specified, single-shot, cheaply verifiable. E.g. rename + symbol X to Y across these files then `cargo build`; summarize each of 8 files in 2 lines; + count call sites of `foo`; boilerplate from a template. +- YELLOW, pin down first: multi-file edits with a clear spec AND a compile/test gate. Split + into smaller green units, each with its own verifier. +- RED, keep in cloud: open-ended design, architecture, ambiguous requirements, judgment, or + anything with no cheap verifier. + +When unsure, treat it as Red. + +## How to delegate + +1. **Decompose** into green units: a self-contained prompt, a working directory, and a + verifier command (exit 0 = pass). +2. **One `codex-local exec` per unit.** Independent units may run as parallel processes: + each issues its own request, and a PAIR proxy distributes them. Never share a working + directory between concurrent units that touch the same files. +3. **Write the prompt to a file** and pipe it (avoids shell-escaping): + ```bash + codex-local exec --skip-git-repo-check -C /abs/workspace -s workspace-write \ + --output-last-message /tmp/unit1.out - < /tmp/unit1.txt + ``` + Then run the verifier yourself: `cd /abs/workspace && `; trust its exit code, + not the model's prose. Retrying rarely helps: re-scope the prompt instead. +4. **Prompt rules** (each fixed an observed failure): + - "INVOKE THE SHELL TOOL and run the command now. Do NOT just print the command." + Without it the model often narrates a correct command and changes nothing. + - Never delegate `apply_patch`; the model cannot produce valid hunks. Tell it to edit with + a `python3 -c "..."` one-liner (portable across macOS, Windows, Linux). `sed -i` differs + between BSD and GNU; always `python3`, never bare `python`. + - State the exact change and tell it to act without asking questions. +5. **Consume only the compact result**: the `--output-last-message` file plus + `git diff --stat` and the verifier exit code. + +## Verification per task class + +| Task class | Verifier | +|------------|----------| +| Code edit / refactor | project build/test: `cargo build`, `cargo test -q`, `npm test` | +| Pattern refactor | `grep -q 'new' file` and/or `! grep -rq 'old' --include='*.py' .` | +| Summary | a `grep` that a cited fact really appears | +| Search / inventory | `grep -c` sanity, or the output file is non-empty | + +Scope verifiers tightly (explicit paths or `--include`); stale artifacts such as +`__pycache__` make broad greps falsely fail. If a unit has no cheap verifier, it was Red. + +## Compression boundary + +- Never paste the raw transcript or full diffs into your context. Look at a log only for + triage, and do not echo it back. +- On verifier failure: fix it in cloud or re-scope into smaller green units. +- Use `git diff --stat` to see what landed; re-read specific changed lines yourself if you + need certainty. + +## Triage + +- Verifier fails and nothing changed: the model narrated instead of acting. Re-issue with the + shell-tool rule. +- Files changed but the verifier fails: it acted wrongly, or the verifier is mis-scoped. + Check the verifier first, then the diff. +- `codex-local exec` exits non-zero or hangs: check Ollama is up (`curl $URL/api/tags`), + then escalate or split the unit. + +## Honesty about savings + +Savings are real for read / summarize / mechanical-edit / search, and marginal-to-negative +for open-ended or architectural work. Delegate Green freely; keep Red in cloud. diff --git a/local/tests/test_install.py b/local/tests/test_install.py new file mode 100644 index 000000000000..911929de2515 --- /dev/null +++ b/local/tests/test_install.py @@ -0,0 +1,171 @@ +"""Tiny tests for install.py / mock_ollama. Temp dirs only; never touches the real ~/.codex.""" + +import hashlib +import json +import sys +import tempfile +import unittest +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import _lib # noqa: E402 +import install # noqa: E402 +import mock_ollama # noqa: E402 + +PS = [" 101 /usr/bin/ssh -N -o ExitOnForwardFailure=yes -L 11435:127.0.0.1:11434 gpu1", + " 102 /usr/bin/ssh -N -L 8080:127.0.0.1:80 web", + " 103 vim ssh -N -L 1:127.0.0.1:11434"] + + +def releases(): + return [ + {"tag_name": "rust-v0.200.0", "published_at": "2026-10-10T00:00:00Z"}, + {"tag_name": "local-v0.1.0", "published_at": "2026-10-01T00:00:00Z"}, + {"tag_name": "local-v0.2.0", "published_at": "2026-10-05T00:00:00Z", + "assets": [{"name": "codex-local-x86_64-pc-windows-msvc.exe"}]}, + {"tag_name": "local-v0.3.0", "draft": True, "published_at": "2026-10-09T00:00:00Z"}, + ] + + +class Selection(unittest.TestCase): + def test_newest_local_ignores_rust_and_drafts(self): + self.assertEqual(install.select_release(releases())["tag_name"], "local-v0.2.0") + + def test_pinned_tag_and_none(self): + self.assertEqual(install.select_release(releases(), "local-v0.1.0")["tag_name"], "local-v0.1.0") + self.assertIsNone(install.select_release([{"tag_name": "rust-v1"}])) + + def test_asset_names(self): + self.assertEqual(_lib.asset_name("aarch64-apple-darwin"), "codex-local-aarch64-apple-darwin") + self.assertEqual(_lib.asset_name("x86_64-pc-windows-msvc"), "codex-local-x86_64-pc-windows-msvc.exe") + self.assertIsNotNone(install.find_asset(releases()[2], "codex-local-x86_64-pc-windows-msvc.exe")) + + def test_repo_default(self): + self.assertEqual(_lib.DEFAULT_REPO, "PixelatedMountainStream/codex") + + +class Sha(unittest.TestCase): + def test_verify(self): + data = b"binary" + h = hashlib.sha256(data).hexdigest() + sums = f"{h} codex-local-x\n{'0' * 64} *other\n" + self.assertEqual(install.verify_asset(data, "codex-local-x", sums), h) + with self.assertRaises(ValueError): + install.verify_asset(b"tampered", "codex-local-x", sums) + with self.assertRaises(ValueError): + install.verify_asset(data, "missing", sums) + + def test_version_parse(self): + self.assertEqual(_lib.parse_version("codex-cli 0.162.1+local.abc1"), ("0.162.1", "+local.abc1")) + self.assertEqual(_lib.parse_version("codex-cli 0.162.1"), ("0.162.1", "")) + + +class Legacy(unittest.TestCase): + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.stock = Path(self._td.name) / "stock" + self.local = Path(self._td.name) / "local" + (self.stock / "agents").mkdir(parents=True) + (self.stock / "agents" / "legacy.toml").write_text('wire_api = "chat"\n') + (self.stock / "agents" / "keep.toml").write_text('name = "keep"\n') + (self.stock / "ollama.config.toml").write_text("model_apply_patch_tool_type = \"function\"\n") + self.cfg = 'model = "x"\n[model_providers.a]\nwire_api = "chat"\nname = "n"\n' + (self.stock / "config.toml").write_text(self.cfg) + + def tearDown(self): + self._td.cleanup() + + def test_matching_by_content(self): + f = install.scan_legacy(self.stock, PS) + paths = {x["path"].name for x in f if "path" in x} + self.assertEqual(paths, {"legacy.toml", "ollama.config.toml", "config.toml"}) + self.assertEqual([x["pid"] for x in f if x["kind"] == "process"], [101]) + + def test_dry_run_reports_and_changes_nothing(self): + before = {p: p.read_text() for p in self.stock.rglob("*") if p.is_file()} + f = install.legacy_cleanup(self.stock, self.local, True, True, PS) + self.assertEqual(len(f), 4) + self.assertEqual(before, {p: p.read_text() for p in self.stock.rglob("*") if p.is_file()}) + self.assertFalse(self.local.exists()) + + def test_non_interactive_reports_only(self): + install.legacy_cleanup(self.stock, self.local, False, False, PS) + self.assertTrue((self.stock / "agents" / "legacy.toml").exists()) + + def test_backup_then_remove(self): + killed = [] + f = install.scan_legacy(self.stock, PS) + bk = self.local / "legacy-backup" / "t" + install.backup_and_remove(f, self.stock, bk, kill=killed.append) + self.assertFalse((self.stock / "agents" / "legacy.toml").exists()) + self.assertTrue((self.stock / "agents" / "keep.toml").exists()) + self.assertEqual((bk / "agents" / "legacy.toml").read_text(), 'wire_api = "chat"\n') + self.assertEqual((bk / "config.toml").read_text(), self.cfg) + self.assertEqual((self.stock / "config.toml").read_text(), + 'model = "x"\n[model_providers.a]\nname = "n"\n') + self.assertEqual(killed, [101]) + + def test_multiline_string_mentioning_key_is_not_matched(self): + text = ('developer_instructions = """\nnever write\nwire_api = "chat"\n' + 'model_apply_patch_tool_type = "x"\n"""\n') + self.assertFalse(install.is_legacy_toml(text)) + (self.stock / "agents" / "legacy.toml").write_text(text) + (self.stock / "ollama.config.toml").unlink() + (self.stock / "config.toml").write_text(text) + f = install.scan_legacy(self.stock, []) + self.assertEqual(f, []) + + def test_config_edit_removes_only_real_keys_and_refuses_unsafe(self): + text = ('developer_instructions = """\nwire_api = "chat"\n"""\n' + '[p]\nwire_api = "chat" # c\nmodel_apply_patch_tool_type = "function"\nname = "n"\n') + out = install.strip_config_keys(text) + self.assertEqual(out, 'developer_instructions = """\nwire_api = "chat"\n"""\n[p]\nname = "n"\n') + # a quoted key is a real key but not locatable by line -> verification fails, skipped + self.assertIsNone(install.strip_config_keys('"wire_api" = "chat"\n')) + # inline-table key cannot be removed by line -> skipped, file untouched + self.assertIsNone(install.strip_config_keys('p = {wire_api = "chat"}\n')) + + +class Mock(unittest.TestCase): + def test_tags_and_responses_sse(self): + srv, url = mock_ollama.start() + try: + tags = json.load(urllib.request.urlopen(url + "/api/tags")) + self.assertEqual(tags["models"][0]["name"], "qwen3.8:27b") + req = urllib.request.Request(url + "/v1/responses", b'{"stream": true}', + {"Content-Type": "application/json"}) + body = urllib.request.urlopen(req).read().decode() + self.assertIn("response.output_text.delta", body) + self.assertIn("response.completed", body) + finally: + srv.shutdown() + + +class InstallRecord(unittest.TestCase): + def test_installed_sha256_passes_verify(self): + import verify_install + with tempfile.TemporaryDirectory() as td: + td = Path(td) + exe = td / "codex-local" + exe.write_bytes(b"resigned") + (td / "install.json").write_text(json.dumps( + {"sha256": "0" * 64, "installed_sha256": _lib.sha256_file(exe)})) + rep = verify_install.Report() + verify_install.check_sha(rep, str(exe), td, None) + self.assertEqual(rep.failed, 0) + + def test_missing_release_dir_asset_exits_1(self): + import contextlib + import io + with tempfile.TemporaryDirectory() as td: + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = install.main(["--release-dir", td, "--bin-dir", td, "--stock-home", td, + "--local-home", td, "--no-cleanup", "--no-verify"]) + self.assertEqual(rc, 1) + self.assertIn("SHA256SUMS", out.getvalue()) + + +if __name__ == "__main__": + unittest.main() From f443d3864ed8afe6d85a5451b78a3faf571b0957 Mon Sep 17 00:00:00 2001 From: "erik.hauan" Date: Wed, 14 Oct 2026 15:13:40 -0700 Subject: [PATCH 2/2] local: reconcile install scripts with codex-local (config URL key); mock validated against real binary Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Qp4Gg5UQcjWVPZz3y58bWs --- local/scripts/_lib.py | 5 +++-- local/scripts/mock_ollama.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/local/scripts/_lib.py b/local/scripts/_lib.py index e1539b8c09aa..e6e9a417a1b1 100644 --- a/local/scripts/_lib.py +++ b/local/scripts/_lib.py @@ -123,11 +123,12 @@ def stock_codex() -> str | None: def ollama_root(env=None, local_cfg: Path | None = None) -> str: - """Resolve the Ollama root: env CODEX_LOCAL_OLLAMA_URL, OLLAMA_HOST, config.toml, default.""" + """Resolve the Ollama root: env CODEX_LOCAL_OLLAMA_URL, OLLAMA_HOST, then `base_url` of + [model_providers.codex-local] in config.toml (codex-local keeps no `ollama_url` key), default.""" env = os.environ if env is None else env raw = env.get("CODEX_LOCAL_OLLAMA_URL") or env.get("OLLAMA_HOST") if not raw and local_cfg and local_cfg.is_file(): - m = re.search(r'^\s*ollama_(?:url|base_url)\s*=\s*"([^"]+)"', + m = re.search(r'^\s*base_url\s*=\s*"([^"]+)"', local_cfg.read_text(encoding="utf-8", errors="replace"), re.M) raw = m.group(1) if m else None raw = (raw or DEFAULT_OLLAMA_URL).strip() diff --git a/local/scripts/mock_ollama.py b/local/scripts/mock_ollama.py index ad4a6edf30ff..da871a97928e 100644 --- a/local/scripts/mock_ollama.py +++ b/local/scripts/mock_ollama.py @@ -6,7 +6,7 @@ Scope: the minimum event sequence for a single text reply (created, output_item.added, output_text.delta, output_item.done, completed with usage). No tool calls, no chat -completions. Not validated against a real codex-local build yet. +completions. Validated against a real codex-local build (verify_install --quick). """ from __future__ import annotations