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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ permissions:

jobs:
test:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.8] - 2026-08-02

First release with supported Windows hosts.

### Added

- `yoke.process`, a cross-platform module for resolving provider executables,
serializing paths for provider APIs, and terminating process trees.
- CI now runs the test suite on `windows-latest` as well as `ubuntu-latest`,
across Python 3.11, 3.12, and 3.13.

### Fixed

- Provider CLIs installed through npm are found on Windows. Commands are
resolved with `shutil.which`, which locates the `.CMD`/`.BAT` shims that
`asyncio.create_subprocess_exec` cannot launch by bare name. Previously
Codex and Claude were reported as "not found on PATH" on every Windows host.
- `claude auth status` no longer hangs forever when the Windows CLI stalls with
a piped stdout. The readiness check times out and reports a repair hint
instead of blocking the caller.
- Terminating a provider process now kills its descendants rather than only the
wrapper process, so npm shim trees are not left running. Windows uses
`taskkill /T /F`; POSIX signals the child's process group.
- Paths sent to provider APIs use forward slashes, so Windows drive paths
round-trip through providers that expect POSIX separators.

### Security

- A bare command name is always resolved through `PATH`. A file named, for
example, `codex` in the working directory can no longer shadow the real
executable when Yoke operates on an untrusted checkout.

[0.1.8]: https://github.com/AlmanacCode/Yoke/releases/tag/v0.1.8
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "almanac-yoke"
version = "0.1.7"
version = "0.1.8"
description = "A provider-neutral harness SDK for agent systems on Claude and Codex."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/yoke/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ def native_input(self) -> dict[str, Any]:
if self.native_name is not None:
data["name"] = self.native_name
if self.script_path is not None:
data["scriptPath"] = str(self.script_path)
data["scriptPath"] = self.script_path.as_posix()
if self.args is not None:
data["args"] = self.args
if self.resume_from_run_id is not None:
Expand Down
164 changes: 164 additions & 0 deletions src/yoke/process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Cross-platform process helpers for provider CLIs."""

from __future__ import annotations

import asyncio
import contextlib
import os
import shutil
import signal
import subprocess
from pathlib import Path

IS_WINDOWS = os.name == "nt"


def path_for_provider(path: str | Path) -> str:
"""Serialize a filesystem path for provider APIs with stable separators."""

return Path(path).as_posix()


def resolve_executable(command: str) -> str | None:
"""Resolve a command name to an executable path on the current platform.

On Windows this finds npm ``.CMD``/``.BAT`` shims that
``asyncio.create_subprocess_exec("codex", ...)`` cannot launch by bare
name.

A bare name always goes through ``PATH``. Only a command written as a path
is read from the filesystem, so a file named ``codex`` in the working
directory cannot shadow the real executable.
"""

if _is_path_like(command):
# Returned verbatim: normalising through Path would strip a leading
# ``./``, turning an explicit relative path back into a bare name that
# the exec call would then look up on PATH.
return command if Path(command).is_file() else None
return shutil.which(command)


def _is_path_like(command: str) -> bool:
"""Return whether a command names a filesystem path rather than a bare name."""

separators = (os.sep, os.altsep) if os.altsep else (os.sep,)
return any(separator in command for separator in separators)


def popen_start_new_session() -> bool:
"""Return whether new process groups are safe for this platform."""

# Windows process groups do not match POSIX killpg semantics and can leave
# npm shim trees behind when only the wrapper pid is terminated.
return not IS_WINDOWS


def process_is_alive(pid: int) -> bool:
"""Return whether a PID currently refers to a live process."""

if pid <= 0:
return False
if pid == os.getpid():
return True
if IS_WINDOWS:
return _windows_process_is_alive(pid)
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True


def _windows_process_is_alive(pid: int) -> bool:
import ctypes
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
process_query_limited_information = 0x1000
still_active = 259
handle = kernel32.OpenProcess(process_query_limited_information, 0, pid)
if not handle:
return False
try:
exit_code = wintypes.DWORD()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)) == 0:
return False
return int(exit_code.value) == still_active
finally:
kernel32.CloseHandle(handle)


def kill_process_tree(pid: int) -> None:
"""Force-terminate a process and its descendants."""

if pid <= 0:
return
if IS_WINDOWS:
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
capture_output=True,
creationflags=creationflags,
)
return
if _kill_process_group(pid):
return
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, signal.SIGKILL)


def _kill_process_group(pid: int) -> bool:
"""Kill the process group led by ``pid``, if it is safe to do so.

Children started with ``start_new_session=True`` lead their own group, so
signalling the group reaches descendants that a bare ``os.kill`` would
orphan. A child sharing this process's group is skipped: signalling that
group would kill the caller too.
"""

try:
group = os.getpgid(pid)
except (ProcessLookupError, PermissionError, OSError):
return False
if group in (os.getpgid(0), 0):
return False
try:
os.killpg(group, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
return False
return True


async def terminate_asyncio_process(process: asyncio.subprocess.Process) -> None:
"""Terminate an asyncio subprocess, including Windows npm shim trees."""

if process.returncode is not None or process.pid is None:
return
if IS_WINDOWS:
await asyncio.to_thread(kill_process_tree, process.pid)
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
process.kill()
await process.wait()
return
process.kill()
await process.wait()


def terminate_popen(process: subprocess.Popen[str]) -> None:
"""Terminate a ``subprocess.Popen`` process tree."""

if process.poll() is not None or process.pid is None:
return
kill_process_tree(process.pid)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=0.5)
18 changes: 12 additions & 6 deletions src/yoke/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
SessionOptions,
WorkflowOptions,
)
from yoke.process import path_for_provider
from yoke.providers.claude_plugins import (
is_plugin_skill_path,
plugin_paths,
Expand Down Expand Up @@ -145,6 +146,11 @@ async def check(self, harness: Harness) -> Readiness:
surface=self.surface,
available=False,
message="claude auth status timed out",
fix=(
"Claude CLI is installed, but `claude auth status` did not "
"return. Run it in an interactive terminal, or set "
f"{ANTHROPIC_API_KEY}."
),
)
if result.code != 0:
return Readiness(
Expand Down Expand Up @@ -354,7 +360,7 @@ async def read_session(
from claude_agent_sdk import get_session_info, get_session_messages
except ImportError as exc:
raise YokeError(f"Claude support requires `{CLAUDE_INSTALL}`.") from exc
directory = str(harness.cwd) if harness.cwd else None
directory = path_for_provider(harness.cwd) if harness.cwd else None
info = await asyncio.to_thread(
get_session_info,
session_id,
Expand Down Expand Up @@ -396,7 +402,7 @@ async def rename(self, session: Session, title: str) -> SessionSummary:
except ImportError as exc:
raise YokeError(f"Claude support requires `{CLAUDE_INSTALL}`.") from exc
session_id = session.provider_session_id or session.id
directory = str(session.cwd) if session.cwd else None
directory = path_for_provider(session.cwd) if session.cwd else None
await asyncio.to_thread(
rename_session,
session_id,
Expand All @@ -409,7 +415,7 @@ async def rename(self, session: Session, title: str) -> SessionSummary:
id=session_id,
provider_session_id=session_id,
title=title,
cwd=str(session.cwd) if session.cwd else None,
cwd=path_for_provider(session.cwd) if session.cwd else None,
)

async def tag(self, session: Session, tag: str | None) -> SessionSummary:
Expand All @@ -418,7 +424,7 @@ async def tag(self, session: Session, tag: str | None) -> SessionSummary:
except ImportError as exc:
raise YokeError(f"Claude support requires `{CLAUDE_INSTALL}`.") from exc
session_id = session.provider_session_id or session.id
directory = str(session.cwd) if session.cwd else None
directory = path_for_provider(session.cwd) if session.cwd else None
await asyncio.to_thread(
tag_session,
session_id,
Expand All @@ -431,7 +437,7 @@ async def tag(self, session: Session, tag: str | None) -> SessionSummary:
id=session_id,
provider_session_id=session_id,
tag=tag,
cwd=str(session.cwd) if session.cwd else None,
cwd=path_for_provider(session.cwd) if session.cwd else None,
)

async def start(self, harness: Harness, options: SessionOptions) -> Session:
Expand Down Expand Up @@ -716,7 +722,7 @@ def claude_options(
goal,
compile_inline=deployment is None,
),
"cwd": str(harness.cwd),
"cwd": path_for_provider(harness.cwd),
"model": options.model or agent.model,
"effort": options.effort or agent.effort,
"max_turns": options.max_turns,
Expand Down
8 changes: 7 additions & 1 deletion src/yoke/providers/codex_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def codex_agent_toml(
continue
lines.append("")
lines.append("[[skills.config]]")
lines.append(f"path = {toml_string(str(path))}")
lines.append(f"path = {toml_string(path_string(path))}")
lines.append(f"enabled = {'true' if enabled else 'false'}")

return "\n".join(lines) + "\n"
Expand Down Expand Up @@ -225,6 +225,12 @@ def slug(value: str) -> str:
return normalized or "agent"


def path_string(path: Path | str) -> str:
"""Serialize filesystem paths with forward slashes for Codex agent TOML."""

return Path(path).as_posix()


def toml_string(value: str) -> str:
return json.dumps(value)

Expand Down
16 changes: 14 additions & 2 deletions src/yoke/providers/codex_app/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
from pydantic import JsonValue, TypeAdapter, ValidationError

from yoke.errors import YokeError
from yoke.process import (
IS_WINDOWS,
popen_start_new_session,
resolve_executable,
terminate_popen,
)
from yoke.providers.codex_app.fields import JsonObject, as_record, string_field

JSON_VALUE = TypeAdapter(JsonValue)
Expand All @@ -39,20 +45,23 @@ def start(
cwd: Path,
env: dict[str, str] | None,
) -> JsonRpcLineProcess:
executable = resolve_executable(command)
if executable is None:
raise FileNotFoundError(command)
process_env = dict(os.environ)
process_env.setdefault("YOKE_INTERNAL_SESSION", "1")
if env is not None:
process_env.update(env)
child = subprocess.Popen(
(command, *args),
(executable, *args),
cwd=cwd,
env=process_env,
text=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
start_new_session=True,
start_new_session=popen_start_new_session(),
)
return cls(child)

Expand Down Expand Up @@ -87,6 +96,9 @@ def read_until(self, deadline: float, timeout_label: str) -> JsonObject:
def terminate(self) -> None:
if self.child.poll() is not None:
return
if IS_WINDOWS:
terminate_popen(self.child)
return
try:
os.killpg(os.getpgid(self.child.pid), signal.SIGTERM)
except (AttributeError, ProcessLookupError, PermissionError, OSError):
Expand Down
Loading