diff --git a/unsloth_cli/commands/start.py b/unsloth_cli/commands/start.py index 9257a7fbcb..707c2b3c90 100644 --- a/unsloth_cli/commands/start.py +++ b/unsloth_cli/commands/start.py @@ -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 `; 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. diff --git a/unsloth_cli/tests/test_start.py b/unsloth_cli/tests/test_start.py index 2405ba0480..227918f63e 100644 --- a/unsloth_cli/tests/test_start.py +++ b/unsloth_cli/tests/test_start.py @@ -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