Merge remote-tracking branch 'origin/main' into r7237

This commit is contained in:
danielhanchen 2026-07-20 05:55:57 +00:00
commit 3dca7e4cba
2 changed files with 388 additions and 36 deletions

View file

@ -49,13 +49,22 @@ _HERMES_PROVIDER = "unsloth"
# the wizard's global API-key/model prompts would block the launch and point the
# user at a different (global) provider than the one Unsloth just configured.
# Both installers expose a skip flag: `-SkipSetup` (PowerShell) and
# `--skip-setup` (POSIX; passed to the piped script via `bash -s --`).
# `--skip-setup` (POSIX; passed to the piped script via `bash -s --`). Pin both
# the fetched script and the repository checkout it performs to the same full
# commit so a later change to either upstream branch cannot silently replace
# code that Unsloth executes with the user's privileges.
_HERMES_INSTALL_COMMIT = "f1af945f6c576eccb126fa955edc9be258b33020"
_HERMES_INSTALL_BASE = (
"https://raw.githubusercontent.com/NousResearch/hermes-agent/"
f"{_HERMES_INSTALL_COMMIT}/scripts"
)
_HERMES_WINDOWS_INSTALL_HINT = (
"& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -SkipSetup"
f"& ([scriptblock]::Create((irm {_HERMES_INSTALL_BASE}/install.ps1)))"
f" -SkipSetup -Commit {_HERMES_INSTALL_COMMIT}"
)
_HERMES_POSIX_INSTALL_HINT = (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash -s -- --skip-setup"
f"curl -fsSL {_HERMES_INSTALL_BASE}/install.sh | bash -s --"
f" --skip-setup --commit {_HERMES_INSTALL_COMMIT}"
)
# Hermes refuses to initialize when the model window is under 64,000 tokens; its
# error message points at the model.context_length / auxiliary.compression
@ -149,8 +158,8 @@ _PERSIST_OPTION = typer.Option(
),
)
# Per-agent CLI flag for "run tools without prompting". opencode and openclaw have no
# such flag (config only) and are handled in their config writers, so they are absent.
# Per-agent CLI flag for "run tools without prompting". OpenCode (native --auto is
# command-scoped, handled below) and OpenClaw (config-only) are absent from this prefix map.
_YOLO_COMMAND_FLAGS = {
"claude": ["--dangerously-skip-permissions"],
"codex": ["--dangerously-bypass-approvals-and-sandbox"],
@ -166,6 +175,84 @@ def _yolo_command_flags(agent: str, yolo: bool) -> list:
return _YOLO_COMMAND_FLAGS.get(agent, []) if yolo else []
# Subcommands that reject --auto (OpenCode exposes it only on the default TUI and `run`),
# so `opencode serve --auto` is never emitted. Includes console/generate, hidden from
# `opencode --help` but still registered. Unknown first positionals are TUI paths -> --auto.
_OPENCODE_NON_AUTO_SUBCOMMANDS = frozenset(
"completion acp mcp attach debug providers auth agent upgrade uninstall serve web "
"models stats export import github pr session plugin plug db console generate".split()
)
_OPENCODE_GLOBAL_BOOLEAN_OPTIONS = frozenset(
"-h --help -v --version --print-logs --pure --mdns".split()
)
_OPENCODE_GLOBAL_VALUE_OPTIONS = frozenset(
"--log-level --port --hostname --mdns-domain --cors".split()
)
_OPENCODE_NATIVE_AUTO_MIN_VERSION = (1, 17, 12)
def _opencode_supports_native_auto() -> bool:
executable = shutil.which("opencode")
if executable is None:
# No local binary: a --no-launch recipe may run elsewhere, and _run installs the
# current release on launch -- either way assume native --auto is available.
return True
try:
output = subprocess.check_output(
[executable, "--version"],
text = True,
timeout = 10,
stderr = subprocess.DEVNULL,
)
except Exception:
return False
match = re.search(r"(\d+)\.(\d+)\.(\d+)", output)
return bool(match) and tuple(int(part) for part in match.groups()) >= (
_OPENCODE_NATIVE_AUTO_MIN_VERSION
)
def _opencode_subcommand(args: list[str]) -> Optional[str]:
"""Return an explicit OpenCode subcommand after supported global options."""
index = 0
while index < len(args):
arg = args[index]
if arg == "--":
return None
if arg in _OPENCODE_GLOBAL_BOOLEAN_OPTIONS:
index += 1
continue
if arg in _OPENCODE_GLOBAL_VALUE_OPTIONS:
index += 2
continue
if any(arg.startswith(f"{option}=") for option in _OPENCODE_GLOBAL_VALUE_OPTIONS):
index += 1
continue
# A non-global option (e.g. --session) is a TUI flag; stop before its value is
# mistaken for a subcommand.
if arg.startswith("-"):
return None
return arg
return None
def _opencode_native_auto_args(args: list[str], yolo: bool) -> tuple[list[str], bool]:
"""Add OpenCode's native --auto when the selected command supports it."""
routed = list(args)
if not yolo:
return routed, False
if _opencode_subcommand(routed) in _OPENCODE_NON_AUTO_SUBCOMMANDS:
return routed, False
separator = routed.index("--") if "--" in routed else len(routed)
# --mini's runMini TUI forces auto=false and never forwards --auto, so appending it is
# useless; fall back to the config permission block so --yolo still auto-approves.
if any(arg == "--mini" or arg.startswith("--mini=") for arg in routed[:separator]):
return routed, False
if "--auto" not in routed[:separator]:
routed.insert(separator, "--auto")
return routed, True
def _hermes_install_hint() -> str:
return _HERMES_WINDOWS_INSTALL_HINT if os.name == "nt" else _HERMES_POSIX_INSTALL_HINT
@ -1121,6 +1208,16 @@ def _install_source(install_hint: str) -> Optional[str]:
return match.group(0) if match else None
def _pinned_raw_github_commit(source: str) -> Optional[str]:
"""Return the immutable full commit in a raw GitHub URL, if present."""
match = re.match(
r"^https://raw\.githubusercontent\.com/[^/]+/[^/]+/([0-9a-f]{40})/",
source,
flags = re.IGNORECASE,
)
return match.group(1).lower() if match else None
def _install_agent(name: str, install_hint: str) -> Optional[str]:
# Missing agent under --launch: offer to run its documented install command, then
# re-resolve it on PATH. Consent-based (we never auto-run a remote install script
@ -1134,12 +1231,27 @@ def _install_agent(name: str, install_hint: str) -> Optional[str]:
# and nothing checks a signature or hash on the fetched content. Naming the source
# turns a blind "yes" into informed consent.
source = _install_source(install_hint)
warning = (
f"This will download and RUN a script from {source} with your privileges"
if source
else f"This will RUN `{install_hint}` with your privileges"
)
typer.secho(f"{warning}; there is no signature or hash check.", fg = "yellow", err = True)
if source:
pinned_commit = _pinned_raw_github_commit(source)
if pinned_commit:
warning = (
"Security warning: This will download and execute a third-party script "
f"from {source} with your privileges. Unsloth pins this content to "
f"immutable upstream commit {pinned_commit}, but does not independently "
"verify or sandbox it. Continue only if you trust this source and commit."
)
else:
warning = (
"Security warning: This will download and execute an unverified third-party "
f"script from {source} with your privileges. Unsloth does not pin or verify "
"the downloaded content. Continue only if you trust this source."
)
else:
warning = (
f"This will RUN `{install_hint}` with your privileges; "
"there is no signature or hash check."
)
typer.secho(warning, fg = "yellow", err = True)
if not typer.confirm(f"Install `{name}` now with `{install_hint}`?", default = False):
return None
# Run each hint through the shell it is written for: PowerShell (irm | iex, or npm)
@ -1162,6 +1274,16 @@ def _install_agent(name: str, install_hint: str) -> Optional[str]:
return executable
def _wsl_shim_env(command: list, env: dict, unset_env: tuple) -> tuple[dict, tuple]:
wsl_env_bridge = _wsl_bridge_names(env, unset_env) if _wsl_windows_executable(command) else ()
if not wsl_env_bridge:
return env, wsl_env_bridge
# Bridge PWD via WSLENV (PWD/p) so the Windows shim finds its project root from the
# live cwd, not a stale inherited Linux PWD. Don't freeze env["PWD"]: a --no-launch
# recipe must translate the live PWD when run, not when generated; _launch overrides it.
return env, (*wsl_env_bridge, "PWD/p")
def _launch(
command: list,
env: dict,
@ -1171,9 +1293,11 @@ def _launch(
executable = shutil.which(command[0]) or _install_agent(command[0], install_hint)
if executable is None:
_fail(f"`{command[0]}` not found on PATH. Install it with: {install_hint}")
wsl_env_bridge = _wsl_bridge_names(env, unset_env) if _wsl_windows_executable(command) else ()
env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env)
child_env = dict(os.environ)
if wsl_env_bridge:
# Override stale inherited PWD with the real cwd so the shim resolves the project root.
env = {**env, "PWD": os.getcwd()}
child_env["WSLENV"] = _merge_wslenv(child_env.get("WSLENV", ""), wsl_env_bridge)
for name in unset_env:
child_env[name] = ""
@ -1241,8 +1365,8 @@ def _run(
if launch and clear_screen:
click.clear()
typer.echo(f"Unsloth {base} · model {entry['id']}")
wsl_env_bridge = _wsl_bridge_names(env, unset_env) if _wsl_windows_executable(command) else ()
if not launch:
env, wsl_env_bridge = _wsl_shim_env(command, env, unset_env)
_print_env(env, command, unset_env = unset_env, wsl_env_bridge = wsl_env_bridge)
return
try:
@ -1465,10 +1589,10 @@ def write_opencode_config(
compaction["reserved"] = max(1, window // 10)
tools = ("edit", "bash", "webfetch")
if yolo:
# OpenCode has no --yolo flag; auto-approve is the config `permission` block
# (singular). Allow the prompting tools and paths outside the launch directory so
# tool calls don't block on the TUI. This rides inline (OPENCODE_CONFIG_CONTENT) so
# --yolo works even over a project config.
# Fallback for commands without native --auto and for the append-safe bare
# --no-launch command (subcommand unknown yet). Rides inline (OPENCODE_CONFIG_CONTENT)
# so it wins over a project config. TUI and `run` launches use --auto and call here
# with yolo=False, letting OpenCode preserve explicit deny rules.
session_permission = {t: "allow" for t in tools}
session_permission["external_directory"] = {"*": "allow"}
config["permission"] = dict(session_permission)
@ -1807,11 +1931,20 @@ def opencode(
# --no-launch, where the printed command is consumed by drivers that append a
# subcommand such as `run <prompt>`; a leading --model would land before that
# subcommand and break it. Those paths rely on the inline pin instead.
native_auto = False
route_native_auto = yolo and _opencode_supports_native_auto()
if ctx.args:
command = ["opencode", *ctx.args]
opencode_args, native_auto = _opencode_native_auto_args(list(ctx.args), route_native_auto)
command = ["opencode", *opencode_args]
elif launch:
command = ["opencode", "--model", opencode_model]
opencode_args, native_auto = _opencode_native_auto_args(
["--model", opencode_model],
route_native_auto,
)
command = ["opencode", *opencode_args]
else:
# Append-safe base: `opencode --auto run ...` parses as the TUI with a project
# "run", not the run subcommand. Command unknown here, so keep the config fallback.
command = ["opencode"]
# opencode keeps sessions in ~/.local/share/opencode (never relocated), so resume
# already survives exit; reopen the last one by passing `opencode --continue` through.
@ -1820,12 +1953,18 @@ def opencode(
# OPENCODE_CONFIG is an overlay (loaded between the user's global and project
# configs), so this adds the Unsloth provider/model for the session without
# changing the user's default model. Key lives in the config, not the env.
session_permission = write_opencode_config(base, key, entry, config_path, yolo = yolo)
session_permission = write_opencode_config(
base,
key,
entry,
config_path,
yolo = yolo and not native_auto,
)
# A project's own opencode.json outranks OPENCODE_CONFIG, so the session model pin
# would silently lose to a repo config. Carry it in OPENCODE_CONFIG_CONTENT, which
# outranks project config; the API key stays in the private file, never the env.
# Only --yolo carries a permission here (its allow must win over a project config);
# a non-yolo session returns no permission, so the project's own rules are honored.
# Only the config fallback carries a permission. Native --auto omits it (auto-approve
# asks, keep explicit denies); a non-yolo session omits it too, honoring project rules.
# opencode filters every provider (a config-defined custom one included) through
# its enabled_providers allowlist and disabled_providers denylist, and a model pin
# does not bypass that gate -- a filtered provider resolves to ModelNotFoundError.

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import json
import os
import re
import shlex
import sys
import urllib.error
@ -128,7 +129,7 @@ def test_install_agent_uses_powershell_on_windows(monkeypatch):
assert ran == [["powershell", "-NoProfile", "-Command", install_hint]]
def test_install_agent_warns_and_names_remote_source(monkeypatch, capsys):
def test_install_agent_warns_remote_installer_is_unverified_third_party(monkeypatch, capsys):
# Before the confirm, a remote installer must name the URL it fetches so the
# user consents to a specific source rather than blindly accepting.
monkeypatch.setattr(start.os, "name", "nt")
@ -137,9 +138,23 @@ def test_install_agent_warns_and_names_remote_source(monkeypatch, capsys):
hint = "& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -SkipSetup"
assert start._install_agent("hermes", hint) is None
err = capsys.readouterr().err
assert "Security warning" in err
assert "unverified third-party script" in err
assert "https://hermes-agent.nousresearch.com/install.ps1" in err
assert "download and RUN" in err
assert "signature or hash" in err
assert "Unsloth does not pin or verify the downloaded content" in err
assert "Continue only if you trust this source" in err
def test_install_agent_reports_immutable_remote_installer_pin(monkeypatch, capsys):
monkeypatch.setattr(start.os, "name", "posix")
monkeypatch.setattr(start.sys, "stdin", SimpleNamespace(isatty = lambda: True))
monkeypatch.setattr(start.typer, "confirm", lambda *a, **k: False)
assert start._install_agent("hermes", start._HERMES_POSIX_INSTALL_HINT) is None
err = capsys.readouterr().err
assert start._HERMES_INSTALL_COMMIT in err
assert "immutable upstream commit" in err
assert "does not independently verify or sandbox it" in err
assert "does not pin or verify" not in err
def test_install_agent_warns_for_package_installer(monkeypatch, capsys):
@ -160,8 +175,8 @@ def test_hermes_install_hint_is_windows_native_on_windows(monkeypatch):
# Scriptblock form so `-SkipSetup` reaches the installer and the interactive
# setup wizard is skipped during the unattended `unsloth start hermes` run.
assert start._hermes_install_hint() == (
"& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1)))"
" -SkipSetup"
f"& ([scriptblock]::Create((irm {start._HERMES_INSTALL_BASE}/install.ps1)))"
f" -SkipSetup -Commit {start._HERMES_INSTALL_COMMIT}"
)
@ -170,11 +185,20 @@ def test_hermes_install_hint_is_bash_on_posix(monkeypatch):
# `bash -s -- --skip-setup` forwards the skip flag to the piped installer.
assert start._hermes_install_hint() == (
"curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent"
"/main/scripts/install.sh | bash -s -- --skip-setup"
f"curl -fsSL {start._HERMES_INSTALL_BASE}/install.sh | bash -s --"
f" --skip-setup --commit {start._HERMES_INSTALL_COMMIT}"
)
def test_hermes_install_hints_pin_script_and_checkout_to_full_commit():
commit = start._HERMES_INSTALL_COMMIT
assert re.fullmatch(r"[0-9a-f]{40}", commit)
for hint in (start._HERMES_WINDOWS_INSTALL_HINT, start._HERMES_POSIX_INSTALL_HINT):
assert hint.count(commit) == 2
assert "/main/" not in hint
assert "hermes-agent.nousresearch.com" not in hint
def test_refresh_windows_path_noop_off_windows(monkeypatch):
monkeypatch.setattr(start.os, "name", "posix")
before = os.environ.get("PATH", "")
@ -452,8 +476,10 @@ def test_connect_claude_launch_scrubs_conflicting_auth_env(fake_studio, monkeypa
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
)
def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypatch):
def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypatch, tmp_path):
captured = {}
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PWD", "/stale/outer/repo")
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-anthropic-stale")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-stale")
@ -481,6 +507,8 @@ def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypat
assert captured["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-unsloth-feedfacefeedface"
assert captured["env"]["ANTHROPIC_BASE_URL"] == BASE
assert captured["env"]["ANTHROPIC_MODEL"] == MODEL["id"]
assert captured["env"]["PWD"] == str(tmp_path)
assert "PWD/p" in captured["env"]["WSLENV"].split(":")
for name in (
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
@ -496,7 +524,11 @@ def test_connect_claude_windows_shim_from_wsl_bridges_env(fake_studio, monkeypat
reason = "WSL-from-Linux scenario (calling a Windows agent .exe from inside WSL); "
"os.name is 'posix' under WSL, so this path can't run on a native Windows runner.",
)
def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(fake_studio, monkeypatch):
def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(
fake_studio, monkeypatch, tmp_path
):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PWD", "/stale/outer/repo")
monkeypatch.setenv("WSL_DISTRO_NAME", "Ubuntu")
monkeypatch.setattr(
start.shutil, "which", lambda _: "/mnt/c/Users/samle/AppData/Roaming/npm/claude"
@ -508,6 +540,10 @@ def test_connect_claude_no_launch_windows_shim_from_wsl_prints_wslenv(fake_studi
assert "export ANTHROPIC_API_KEY=" in result.output
assert "export CLAUDE_CODE_OAUTH_TOKEN=" in result.output
assert "export WSLENV=" in result.output
# PWD must NOT be frozen into the recipe (no `export PWD=`): WSLENV PWD/p translates the
# shell's live PWD at run time, so a recipe reused from another dir resolves the project root.
assert "export PWD=" not in result.output
assert "PWD/p" in result.output
assert "ANTHROPIC_AUTH_TOKEN" in result.output
assert "CLAUDE_CODE_OAUTH_TOKEN" in result.output
@ -2190,8 +2226,18 @@ def test_yolo_aliases_are_interchangeable(fake_studio, alias):
assert "--dangerously-bypass-approvals-and-sandbox" in codex.output
assert "--dangerously-skip-permissions" not in codex.output
opencode = CliRunner().invoke(
start.start_app,
["opencode", alias, "--no-launch", "run", "hello"],
)
assert opencode.exit_code == 0, opencode.output
assert _launch_command(opencode.output) == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(opencode.output)
def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
def test_yolo_opencode_bare_no_launch_uses_permission_fallback(fake_studio, tmp_path):
# A bare --no-launch recipe stays append-safe (callers add a subcommand later);
# `opencode --auto run ...` would select the TUI, not `run`, so keep the config fallback.
result = CliRunner().invoke(start.start_app, ["opencode", "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
@ -2203,6 +2249,172 @@ def test_yolo_opencode_writes_permission_block(fake_studio, tmp_path):
}
def test_yolo_opencode_run_uses_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(result.output)
def test_yolo_opencode_tui_resume_uses_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "--session", "sid"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "--session", "sid", "--auto"]
assert "permission" not in _opencode_inline_config(result.output)
def test_no_yolo_opencode_run_omits_native_auto(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", "run", "hello"]
assert "permission" not in _opencode_inline_config(result.output)
def test_yolo_opencode_bare_launch_uses_native_auto(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start, "_opencode_supports_native_auto", lambda: True)
captured = _capture_launch(monkeypatch, ["opencode", "--yolo"])
assert captured["command"][1:] == [
"--model",
f"{start._OPENCODE_PROVIDER}/{MODEL['id']}",
"--auto",
]
assert "permission" not in json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"])
def test_yolo_opencode_native_auto_clears_prior_config_fallback(fake_studio, tmp_path):
fallback = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch"],
)
assert fallback.exit_code == 0, fallback.output
native = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert native.exit_code == 0, native.output
assert _launch_command(native.output) == ["opencode", "run", "hello", "--auto"]
assert "permission" not in _opencode_inline_config(native.output)
config = json.loads((tmp_path / "agents" / "opencode" / "opencode.json").read_text())
assert config["permission"] == {
"edit": "ask",
"bash": "ask",
"webfetch": "ask",
"external_directory": {"*": "ask"},
}
@pytest.mark.parametrize(
("version", "expected"),
[
("1.17.11", False),
("1.17.12", True),
("opencode 1.18.2", True),
("development build", False),
],
)
def test_opencode_native_auto_version_gate(monkeypatch, version, expected):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: version)
assert start._opencode_supports_native_auto() is expected
def test_opencode_native_auto_assumes_current_without_local_binary(monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: None)
assert start._opencode_supports_native_auto() is True
def test_yolo_opencode_old_version_uses_config_fallback(fake_studio, monkeypatch):
monkeypatch.setattr(start.shutil, "which", lambda _: "/usr/local/bin/opencode")
monkeypatch.setattr(start.subprocess, "check_output", lambda *args, **kwargs: "1.17.11")
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "run", "hello"],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", "run", "hello"]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
@pytest.mark.parametrize(
("args", "expected", "native"),
[
([], ["--auto"], True),
(["run", "hello"], ["run", "hello", "--auto"], True),
(
["run", "hello", "--", "--literal"],
["run", "hello", "--auto", "--", "--literal"],
True,
),
(["--print-logs", "run", "hello"], ["--print-logs", "run", "hello", "--auto"], True),
(["--session", "serve"], ["--session", "serve", "--auto"], True),
(["serve"], ["serve"], False),
(["--print-logs", "serve"], ["--print-logs", "serve"], False),
(["run", "--auto", "hello"], ["run", "--auto", "hello"], True),
# Hidden commands that reject --auto fall back like the visible utility ones.
(["generate"], ["generate"], False),
(["console", "login"], ["console", "login"], False),
# --mini ignores --auto (runMini forces auto=false), so use the config fallback.
(["--mini"], ["--mini"], False),
(["--session", "sid", "--mini"], ["--session", "sid", "--mini"], False),
],
)
def test_opencode_native_auto_args(args, expected, native):
assert start._opencode_native_auto_args(args, True) == (expected, native)
assert start._opencode_native_auto_args(args, False) == (args, False)
def test_yolo_opencode_non_agent_subcommand_uses_config_fallback(fake_studio):
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", "serve"],
)
assert result.exit_code == 0, result.output
command = _launch_command(result.output)
assert command == ["opencode", "serve"]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
@pytest.mark.parametrize("passthrough", (["generate"], ["console", "login"], ["--mini"]))
def test_yolo_opencode_no_auto_command_uses_config_fallback(fake_studio, passthrough):
# generate/console are hidden and reject --auto, --mini ignores it: none get --auto,
# all keep the config permission fallback.
result = CliRunner().invoke(
start.start_app,
["opencode", "--yolo", "--no-launch", *passthrough],
)
assert result.exit_code == 0, result.output
assert _launch_command(result.output) == ["opencode", *passthrough]
assert _opencode_inline_config(result.output)["permission"] == {
"edit": "allow",
"bash": "allow",
"webfetch": "allow",
"external_directory": {"*": "allow"},
}
def test_no_yolo_opencode_has_no_permission_block(fake_studio, tmp_path):
result = CliRunner().invoke(start.start_app, ["opencode", "--no-launch"])
assert result.exit_code == 0, result.output
@ -2549,15 +2761,16 @@ def test_openclaw_non_yolo_preserves_full_mode(tmp_path):
def test_yolo_command_flags_unmapped_agent_is_empty():
# Config-based agents (and any typo) must yield no flag, not a KeyError.
# Placement-aware/config-based agents (and any typo) must yield no prefix flag.
assert start._yolo_command_flags("opencode", True) == []
assert start._yolo_command_flags("openclaw", True) == []
assert start._yolo_command_flags("claude", True) == ["--dangerously-skip-permissions"]
assert start._yolo_command_flags("claude", False) == []
def test_yolo_config_agents_add_no_command_flag(fake_studio):
# opencode/openclaw auto-approve is config-only; nothing should leak onto argv.
def test_yolo_config_fallbacks_add_no_legacy_command_flag(fake_studio):
# OpenClaw is config-only; OpenCode's append-safe bare recipe uses its config fallback.
# Neither should leak a legacy yolo/dangerous alias onto argv.
for agent in ("opencode", "openclaw"):
result = CliRunner().invoke(start.start_app, [agent, "--yolo", "--no-launch"])
assert result.exit_code == 0, result.output