Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4bb3abe
fix(subagent): start agents on windows from a stored launch command
LivXue Oct 6, 2026
2d84011
refactor(subagent): format vendored_agents with ruff
LivXue Oct 6, 2026
bbdd1c4
fix(subagent): judge launcher paths with a shape-preserving split
LivXue Oct 6, 2026
28ba9fb
fix(subagent): group quoted tokens in the launcher probe on posix too
LivXue Oct 6, 2026
91f2cc0
fix(subagent): one quoting rule for every row producer
LivXue Oct 6, 2026
2399df1
test(subagent): spaced paths now register, so the cli tests assert it
LivXue Oct 6, 2026
f4221c2
fix(subagent): tokenise the scaffold smoke command the same way
LivXue Oct 6, 2026
1c6a039
test(subagent): register the roster row in the spaced-path cli tests
LivXue Oct 6, 2026
a164383
fix(agent): quote host acp paths for the launch platform
LivXue Oct 9, 2026
c4a9753
fix(utils): double the backslashes a windows quote would swallow
LivXue Oct 10, 2026
07b2665
fix(*): look an acp program up once, on the child's path
LivXue Oct 10, 2026
cb73700
docs(agent): say the launcher probes judge command_tokens output
LivXue Oct 10, 2026
e616436
fix(agents): pin the shipped agents' rows through the quoting rule
LivXue Oct 10, 2026
36e9ff6
fix(templates): keep a generated installer working under an older raven
LivXue Oct 10, 2026
4227f2e
docs(cli): drop the removed whitespace guard from the text that names it
LivXue Oct 10, 2026
bab9b33
test(tests): run both resolver round trips on every host
LivXue Oct 10, 2026
5f55532
test(tests): pin that an acp launch looks up a named batch file too
LivXue Oct 10, 2026
d6d4560
test(tests): make the quoted stale-row test fail on a whitespace split
LivXue Oct 10, 2026
ee68725
fix(utils): preserve literal quotes in launcher readiness checks
LivXue Oct 10, 2026
e1fe881
fix: reject unsupported spaced command paths in older installers
LivXue Oct 10, 2026
dd72c90
fix(utils): retain escape-only windows launch arguments
LivXue Oct 11, 2026
2b15442
fix(utils): match posix readiness command separators
LivXue Oct 11, 2026
4a1e1e6
docs(utils): use canonical discovered agent terminology
LivXue Oct 11, 2026
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
24 changes: 21 additions & 3 deletions agents/raven-code/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
venv -- and writes through ``raven.config.update_subagents``, the same pinned
surface the retired vendored installers used. ``{PYTHON}`` and ``{SUBAGENT_DIR}`` in
``subagent.json`` resolve against this interpreter and this file's location,
so moving the folder and re-running is the whole migration story. A live
raven holds the roster it read at startup; restart it afterwards.
quoted the way the host raven splits the command back into argv, so a path
with a space in it stays one argument. Moving the folder and re-running is the
whole migration story. A live raven holds the roster it read at startup;
restart it afterwards.
"""

from __future__ import annotations
Expand All @@ -18,12 +20,28 @@
HERE = Path(__file__).resolve().parent


def _resolve(value: str, *, quote: bool) -> str:
"""``value`` with ``{PYTHON}`` and ``{SUBAGENT_DIR}`` substituted for this folder."""
try:
from raven.utils.commands import resolve_subagent_command
except ImportError:
# Older launchers cannot keep a whitespace path together after substitution.
for placeholder, path in (("{SUBAGENT_DIR}", str(HERE)), ("{PYTHON}", sys.executable)):
if quote and placeholder in value and any(char.isspace() for char in path):
raise SystemExit(
f"Cannot register a command path containing whitespace with this Raven version: {path!r}. "
"Upgrade Raven or use paths without whitespace."
)
return value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
return resolve_subagent_command(value, python=sys.executable, subagent_dir=str(HERE), quote=quote)


def main() -> int:
row = json.loads((HERE / "subagent.json").read_text(encoding="utf-8"))
for field in ("command", "cwd"):
value = row.get(field)
if isinstance(value, str):
row[field] = value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
row[field] = _resolve(value, quote=field != "cwd")

from raven.config.update_subagents import add_third_party_subagent

Expand Down
24 changes: 21 additions & 3 deletions agents/raven-design/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
venv -- and writes through ``raven.config.update_subagents``, the same pinned
surface the retired vendored installers used. ``{PYTHON}`` and ``{SUBAGENT_DIR}`` in
``subagent.json`` resolve against this interpreter and this file's location,
so moving the folder and re-running is the whole migration story. A live
raven holds the roster it read at startup; restart it afterwards.
quoted the way the host raven splits the command back into argv, so a path
with a space in it stays one argument. Moving the folder and re-running is the
whole migration story. A live raven holds the roster it read at startup;
restart it afterwards.
"""

from __future__ import annotations
Expand All @@ -18,12 +20,28 @@
HERE = Path(__file__).resolve().parent


def _resolve(value: str, *, quote: bool) -> str:
"""``value`` with ``{PYTHON}`` and ``{SUBAGENT_DIR}`` substituted for this folder."""
try:
from raven.utils.commands import resolve_subagent_command
except ImportError:
# Older launchers cannot keep a whitespace path together after substitution.
for placeholder, path in (("{SUBAGENT_DIR}", str(HERE)), ("{PYTHON}", sys.executable)):
if quote and placeholder in value and any(char.isspace() for char in path):
raise SystemExit(
f"Cannot register a command path containing whitespace with this Raven version: {path!r}. "
"Upgrade Raven or use paths without whitespace."
)
return value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
return resolve_subagent_command(value, python=sys.executable, subagent_dir=str(HERE), quote=quote)


def main() -> int:
row = json.loads((HERE / "subagent.json").read_text(encoding="utf-8"))
for field in ("command", "cwd"):
value = row.get(field)
if isinstance(value, str):
row[field] = value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
row[field] = _resolve(value, quote=field != "cwd")

from raven.config.update_subagents import add_third_party_subagent

Expand Down
24 changes: 21 additions & 3 deletions agents/raven-oncall/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
venv -- and writes through ``raven.config.update_subagents``, the same pinned
surface the retired vendored installers used. ``{PYTHON}`` and ``{SUBAGENT_DIR}`` in
``subagent.json`` resolve against this interpreter and this file's location,
so moving the folder and re-running is the whole migration story. A live
raven holds the roster it read at startup; restart it afterwards.
quoted the way the host raven splits the command back into argv, so a path
with a space in it stays one argument. Moving the folder and re-running is the
whole migration story. A live raven holds the roster it read at startup;
restart it afterwards.
"""

from __future__ import annotations
Expand All @@ -18,12 +20,28 @@
HERE = Path(__file__).resolve().parent


def _resolve(value: str, *, quote: bool) -> str:
"""``value`` with ``{PYTHON}`` and ``{SUBAGENT_DIR}`` substituted for this folder."""
try:
from raven.utils.commands import resolve_subagent_command
except ImportError:
# Older launchers cannot keep a whitespace path together after substitution.
for placeholder, path in (("{SUBAGENT_DIR}", str(HERE)), ("{PYTHON}", sys.executable)):
if quote and placeholder in value and any(char.isspace() for char in path):
raise SystemExit(
f"Cannot register a command path containing whitespace with this Raven version: {path!r}. "
"Upgrade Raven or use paths without whitespace."
)
return value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
return resolve_subagent_command(value, python=sys.executable, subagent_dir=str(HERE), quote=quote)


def main() -> int:
row = json.loads((HERE / "subagent.json").read_text(encoding="utf-8"))
for field in ("command", "cwd"):
value = row.get(field)
if isinstance(value, str):
row[field] = value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
row[field] = _resolve(value, quote=field != "cwd")

from raven.config.update_subagents import add_third_party_subagent

Expand Down
24 changes: 21 additions & 3 deletions agents/raven-ppt/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
venv -- and writes through ``raven.config.update_subagents``, the same pinned
surface the retired vendored installers used. ``{PYTHON}`` and ``{SUBAGENT_DIR}`` in
``subagent.json`` resolve against this interpreter and this file's location,
so moving the folder and re-running is the whole migration story. A live
raven holds the roster it read at startup; restart it afterwards.
quoted the way the host raven splits the command back into argv, so a path
with a space in it stays one argument. Moving the folder and re-running is the
whole migration story. A live raven holds the roster it read at startup;
restart it afterwards.
"""

from __future__ import annotations
Expand All @@ -18,12 +20,28 @@
HERE = Path(__file__).resolve().parent


def _resolve(value: str, *, quote: bool) -> str:
"""``value`` with ``{PYTHON}`` and ``{SUBAGENT_DIR}`` substituted for this folder."""
try:
from raven.utils.commands import resolve_subagent_command
except ImportError:
# Older launchers cannot keep a whitespace path together after substitution.
for placeholder, path in (("{SUBAGENT_DIR}", str(HERE)), ("{PYTHON}", sys.executable)):
if quote and placeholder in value and any(char.isspace() for char in path):
raise SystemExit(
f"Cannot register a command path containing whitespace with this Raven version: {path!r}. "
"Upgrade Raven or use paths without whitespace."
)
return value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
return resolve_subagent_command(value, python=sys.executable, subagent_dir=str(HERE), quote=quote)


def main() -> int:
row = json.loads((HERE / "subagent.json").read_text(encoding="utf-8"))
for field in ("command", "cwd"):
value = row.get(field)
if isinstance(value, str):
row[field] = value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
row[field] = _resolve(value, quote=field != "cwd")

from raven.config.update_subagents import add_third_party_subagent

Expand Down
24 changes: 21 additions & 3 deletions agents/raven-research/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
venv -- and writes through ``raven.config.update_subagents``, the same pinned
surface the retired vendored installers used. ``{PYTHON}`` and ``{SUBAGENT_DIR}`` in
``subagent.json`` resolve against this interpreter and this file's location,
so moving the folder and re-running is the whole migration story. A live
raven holds the roster it read at startup; restart it afterwards.
quoted the way the host raven splits the command back into argv, so a path
with a space in it stays one argument. Moving the folder and re-running is the
whole migration story. A live raven holds the roster it read at startup;
restart it afterwards.
"""

from __future__ import annotations
Expand All @@ -18,12 +20,28 @@
HERE = Path(__file__).resolve().parent


def _resolve(value: str, *, quote: bool) -> str:
"""``value`` with ``{PYTHON}`` and ``{SUBAGENT_DIR}`` substituted for this folder."""
try:
from raven.utils.commands import resolve_subagent_command
except ImportError:
# Older launchers cannot keep a whitespace path together after substitution.
for placeholder, path in (("{SUBAGENT_DIR}", str(HERE)), ("{PYTHON}", sys.executable)):
if quote and placeholder in value and any(char.isspace() for char in path):
raise SystemExit(
f"Cannot register a command path containing whitespace with this Raven version: {path!r}. "
"Upgrade Raven or use paths without whitespace."
)
return value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
return resolve_subagent_command(value, python=sys.executable, subagent_dir=str(HERE), quote=quote)


def main() -> int:
row = json.loads((HERE / "subagent.json").read_text(encoding="utf-8"))
for field in ("command", "cwd"):
value = row.get(field)
if isinstance(value, str):
row[field] = value.replace("{SUBAGENT_DIR}", str(HERE)).replace("{PYTHON}", sys.executable)
row[field] = _resolve(value, quote=field != "cwd")

from raven.config.update_subagents import add_third_party_subagent

Expand Down
4 changes: 2 additions & 2 deletions raven/acp_client/acp_dialects/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
from __future__ import annotations

import re
import shlex
from typing import Any

from raven.acp_client.acp_dialects.base import AcpDialect, DialectResult, ToolCall, _dict
from raven.utils.commands import command_argv

# Codex's own item types (`codex-rs/protocol/src/items.rs`), which the adapter
# flattens into five ACP `kind` values on the way out. Recovering them is what
Expand All @@ -41,7 +41,7 @@ def _argv0(command: str) -> str:
(`codex-rs/apply-patch/src/invocation.rs`); the patch body arrives on stdin.
"""
try:
parts = shlex.split(command)
parts = command_argv(command)
except ValueError:
parts = command.split()
return parts[0] if parts else ""
Expand Down
4 changes: 2 additions & 2 deletions raven/acp_client/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import json
import os
import re
import shlex
import tempfile
import time
from collections.abc import Sequence
Expand All @@ -41,6 +40,7 @@
from raven.acp_client.permissions import auto_approver
from raven.acp_client.protocol import SESSION_MCP_CAPABILITY, STEER_CAPABILITY, AcpError, AcpRemoteError, reason_of
from raven.utils.atomic_io import atomic_update
from raven.utils.commands import command_argv

_FILENAME = "subagent_acp_capabilities.json"

Expand Down Expand Up @@ -724,7 +724,7 @@ def looks_like_auth(text: str) -> bool:
def launches_with_npx(command: str) -> bool:
"""Whether ``command`` is run by ``npx``, which fetches what it names on first use."""
try:
argv = shlex.split(command or "")
argv = command_argv(command or "")
except ValueError:
return False
if not argv:
Expand Down
8 changes: 5 additions & 3 deletions raven/acp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

import asyncio
import os
import shlex
import signal
import time
from collections import deque
Expand All @@ -35,6 +34,7 @@
AcpRemoteError,
AcpTimeoutError,
)
from raven.utils.commands import command_argv

_STDERR_LINES = 200

Expand Down Expand Up @@ -233,7 +233,7 @@ async def launch(
from raven.agent.subagent.role import subagent_role_env

try:
argv = shlex.split(command)
argv = command_argv(command)
except ValueError as exc:
raise AcpConnectionError(f"acp agent {name!r}: command cannot be parsed: {exc}") from exc
if not argv:
Expand All @@ -247,7 +247,9 @@ async def launch(
# The role goes on before the caller's own map, so a config ``env`` entry
# is the way to hand one agent back its full registry.
child_env = {**base_env, **host_identity_env(), **subagent_role_env(), **(env or {})}
argv = resolve_program(argv, child_env)
# An acp server's argv is its configuration -- its turns travel over
# stdio -- so a batch file may stand in for the program here.
argv = resolve_program(argv, child_env, batch_files=True)
try:
proc = await asyncio.create_subprocess_exec(
*argv,
Expand Down
4 changes: 2 additions & 2 deletions raven/agent/subagent/backends/cli_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import json
import os
import re
import shlex
import signal
import tempfile
import time
Expand Down Expand Up @@ -58,6 +57,7 @@
from raven.agent.subagent.role import subagent_role_env
from raven.agent.tools.snapshot import take as take_snapshot
from raven.spine.message import Media
from raven.utils.commands import command_argv

if TYPE_CHECKING:
from raven.contracts.llm_provider import LLMProvider
Expand Down Expand Up @@ -260,7 +260,7 @@ def _build_argv(
) -> tuple[list[str], bool]:
argv: list[str] = []
used_placeholder = False
for tok in shlex.split(template):
for tok in command_argv(template):
Comment thread
gloryfromca marked this conversation as resolved.
if agent_id is not None:
tok = tok.replace("{agent_id}", agent_id)
if mcp_file is not None:
Expand Down
41 changes: 31 additions & 10 deletions raven/agent/subagent/backends/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,31 +75,52 @@
# homebrew or nix `$SHELL` path still matches; the path itself is what gets run.
_DRIVABLE_SHELLS = frozenset({"bash", "zsh"})

# The batch-file kinds CreateProcess also starts, through cmd.exe, in PATHEXT's order.
_BATCH_SUFFIXES = (".bat", ".cmd")


def _on_windows() -> bool:
"""Whether this is Windows, the one switch the capture and the launch lookup below read."""
return sys.platform == "win32"


def resolve_program(argv: list[str], env: Mapping[str, str]) -> list[str]:
def resolve_program(argv: list[str], env: Mapping[str, str], *, batch_files: bool = False) -> list[str]:
r"""``argv`` with a bare program name looked up on the PATH of the env the child gets.

POSIX needs nothing here: its exec resolves the name on that PATH itself.
CreateProcess searches the gateway's own PATH instead and never the block it is
handed, so on Windows an agent installed after raven started -- found by the
refreshed capture and by the probe -- would still not start. The lookup follows
CreateProcess's own rule that a name with no extension means ``.exe``, and only
a program CreateProcess runs natively is put in its place: CreateProcess never
turns a bare name into a ``.cmd`` or ``.bat`` shim, and doing it here would put
the shim's arguments, a cli agent's prompt among them, through cmd.exe's parser.
CreateProcess's own rule that a name with no extension means ``.exe``, and by
default only a program CreateProcess runs natively is put in its place:
CreateProcess never turns a bare name into a ``.cmd`` or ``.bat`` shim, and doing
it here would put the shim's arguments, a cli agent's prompt among them, through
cmd.exe's parser.

``batch_files`` lets a ``.bat`` or ``.cmd`` stand in as well, for a caller whose
argv is configuration rather than turn content: an acp server takes its turns
over stdio, and an npm-installed one (``npx``) has no ``.exe`` to find at all.
The PATH directories are searched in order and ``.exe`` first within each, the
order a terminal finds the name in, so the program that starts is the one the
user runs there.
"""
if not _on_windows() or not argv or ntpath.dirname(argv[0]):
return argv
program = argv[0] if ntpath.splitext(argv[0])[1] else f"{argv[0]}.exe"
if ntpath.splitext(program)[1].lower() not in (".exe", ".com"):
return argv
found = shutil.which(program, path=env.get("PATH"))
return [found, *argv[1:]] if found else argv
batch = _BATCH_SUFFIXES if batch_files else ()
suffix = ntpath.splitext(argv[0])[1].lower()
if suffix:
programs = [argv[0]] if suffix in (".exe", ".com", *batch) else []
else:
programs = [f"{argv[0]}{ext}" for ext in (".exe", *batch)]
search = env.get("PATH")
if search is None:
search = os.environ.get("PATH", os.defpath)
for directory in search.split(os.pathsep):
for program in programs:
found = directory and shutil.which(program, path=directory)
if found:
return [found, *argv[1:]]
return argv


def _login_shell() -> str | None:
Expand Down
Loading
Loading