Merge remote-tracking branch 'origin/main' into r7237
This commit is contained in:
commit
3dca7e4cba
2 changed files with 388 additions and 36 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue