From 546484b943599709997ebf00900d2f53c8baa489 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 02:02:09 -0500 Subject: [PATCH 01/13] Fix model discovery fallback parity --- src/backends/copilot/copilot_client.erl | 25 +- src/backends/opencode/opencode_client.erl | 40 ++- src/core/beam_agent_auth_core.erl | 333 ++++++++++++++++-- src/public/beam_agent_auth.erl | 3 +- src/public/beam_agent_catalog.erl | 6 + test/core/beam_agent_auth_core_tests.erl | 105 ++++++ .../public/beam_agent_catalog_model_tests.erl | 8 + 7 files changed, 467 insertions(+), 53 deletions(-) diff --git a/src/backends/copilot/copilot_client.erl b/src/backends/copilot/copilot_client.erl index 06d0221..47c9357 100644 --- a/src/backends/copilot/copilot_client.erl +++ b/src/backends/copilot/copilot_client.erl @@ -544,6 +544,17 @@ supported_models(Session) -> %% Query the CLI via the native models.list RPC command. If the ACP %% transport cannot answer, fall back to a direct non-interactive CLI %% probe so model discovery still works. + case health(Session) of + ready -> + supported_models_from_native_or_prompt(Session); + active_query -> + supported_models_from_native_or_prompt(Session); + _ -> + prompt_or_init_models(Session) + end. + +-spec supported_models_from_native_or_prompt(pid()) -> {ok, list()} | {error, term()}. +supported_models_from_native_or_prompt(Session) -> case catch model_list(Session) of {ok, #{<<"models">> := Models}} when is_list(Models) -> {ok, Models}; @@ -1248,7 +1259,19 @@ model_entry(ModelId) -> -spec run_model_cli(string(), [string(), ...]) -> {ok, [string()]} | {error, term()}. run_model_cli(Program, Args) -> - beam_agent_auth_core:run_capture_cli(Program, Args, 30000). + beam_agent_auth_core:run_capture_cli(Program, Args, 60000, + neutral_probe_run_opts()). + +-spec neutral_probe_run_opts() -> map(). +neutral_probe_run_opts() -> + case os:getenv("HOME") of + false -> + #{}; + [] -> + #{}; + Home -> + #{cwd => Home} + end. %%==================================================================== %% beam_agent_adapter callbacks diff --git a/src/backends/opencode/opencode_client.erl b/src/backends/opencode/opencode_client.erl index b07f3b5..c1f6e6a 100644 --- a/src/backends/opencode/opencode_client.erl +++ b/src/backends/opencode/opencode_client.erl @@ -891,22 +891,30 @@ supported_commands(Session) -> extract_init_field(Session, commands, slash_commands, []). -spec supported_models(pid()) -> {ok, list()} | {error, term()}. supported_models(Session) -> - %% Opencode does not populate models during init. Query the - %% provider endpoint which returns the full model catalog. - case provider_list(Session) of - {ok, #{<<"models">> := Models}} when is_list(Models) -> + %% Prefer the CLI-backed model catalog because it reflects the local + %% Opencode provider wiring even when the session transport is unhealthy + %% or the HTTP provider endpoint only exposes a partial view. + case cli_or_init_models(Session) of + {ok, Models} when is_list(Models), Models =/= [] -> {ok, Models}; - {ok, #{<<"providers">> := Provs}} when is_list(Provs) -> - %% Flatten models across all providers into a single list. - Models = lists:flatmap( - fun(#{<<"models">> := Ms}) when is_list(Ms) -> Ms; - (_) -> [] - end, Provs), - {ok, Models}; - {ok, _} -> - cli_or_init_models(Session); - {error, _} -> - cli_or_init_models(Session) + _ -> + %% Fall back to the provider endpoint when the CLI path does not + %% yield any models (for example if the binary is unavailable). + case provider_list(Session) of + {ok, #{<<"models">> := Models}} when is_list(Models) -> + {ok, Models}; + {ok, #{<<"providers">> := Provs}} when is_list(Provs) -> + %% Flatten models across all providers into a single list. + Models = lists:flatmap( + fun(#{<<"models">> := Ms}) when is_list(Ms) -> Ms; + (_) -> [] + end, Provs), + {ok, Models}; + {ok, _} -> + extract_init_field(Session, models, models, []); + {error, _} -> + extract_init_field(Session, models, models, []) + end end. -spec supported_agents(pid()) -> {ok, list()} | {error, term()}. supported_agents(Session) -> @@ -1056,7 +1064,7 @@ model_entry(ModelId) -> -spec run_model_cli(string(), [string(), ...]) -> {ok, [string()]} | {error, term()}. run_model_cli(Program, Args) -> - beam_agent_auth_core:run_capture_cli(Program, Args, 10000). + beam_agent_auth_core:run_capture_login_shell(Program, Args, 60000). -spec with_adapter_source(pid(), map()) -> session_view(). with_adapter_source(_Session, Result) -> diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 067c47e..996a639 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -51,7 +51,8 @@ for Claude) rather than passing the secret on the command line — avoiding `/proc` exposure on Linux. """). --export([status/2, login/2, login/3, logout/2, resolve_cli/2, run_capture_cli/3, strip_ansi/1, +-export([status/2, login/2, login/3, logout/2, resolve_cli/2, run_capture_cli/3, run_capture_cli/4, + run_capture_login_shell/3, strip_ansi/1, hash_executable/1, from_vault/1, sanitize_for_agent/1]). %% Utility — exposed for external callers (e.g. account_core fallback) @@ -582,8 +583,10 @@ copilot_logout(Opts) -> %% OpenCode — REST API via httpc + env fallback %%==================================================================== -%% OpenCode connects to a running server via HTTP. For session-independent -%% auth we probe the server health endpoint; if unreachable we check env. +%% OpenCode connects to a running server via HTTP. For session-independent +%% auth we first probe the server provider endpoint; if unreachable we +%% check explicit environment credentials and finally the CLI-managed +%% credential store exposed by `opencode auth list`. -define(OPENCODE_DEFAULT_URL, "http://localhost:4096"). opencode_status(Opts) -> @@ -606,22 +609,14 @@ opencode_status(Opts) -> {ok, Code, Body} -> {error, {unexpected_status, Code, Body}}; {error, _Reason} -> - %% Server not reachable — check env vars - opencode_env_status() + %% Server not reachable — check local auth state + opencode_local_auth_status(Opts) end. -opencode_env_status() -> +opencode_local_auth_status(Opts) -> case os:getenv("OPENAI_API_KEY") of false -> - {ok, - #{backend => opencode, - authenticated => false, - method => env, - details => - #{hint => - <<"OpenCode server unreachable and OPENAI_API_KEY " - "not set. Start the OpenCode server or set the " - "environment variable.">>}}}; + opencode_cli_auth_status(Opts); _Key -> {ok, #{backend => opencode, @@ -630,6 +625,90 @@ opencode_env_status() -> details => #{source => <<"OPENAI_API_KEY env">>}}} end. +opencode_cli_auth_status(Opts) -> + Cli = resolve_cli(opencode, Opts), + Timeout = maps:get(timeout, Opts, ?STATUS_TIMEOUT), + case run_capture_cli(Cli, ["auth", "list"], Timeout) of + {ok, Lines} -> + case opencode_credential_count(Lines) of + Count when Count > 0 -> + {ok, + #{backend => opencode, + authenticated => true, + method => cli, + details => + #{source => <<"opencode auth list">>, + credential_count => Count}}}; + 0 -> + {ok, + #{backend => opencode, + authenticated => false, + method => cli, + details => + #{hint => + <<"OpenCode server unreachable, OPENAI_API_KEY " + "not set, and `opencode auth list` reported " + "no configured credentials.">>}}} + end; + {error, {cli_exit, _ExitCode, Lines}} -> + case opencode_credential_count(Lines) of + Count when Count > 0 -> + {ok, + #{backend => opencode, + authenticated => true, + method => cli, + details => + #{source => <<"opencode auth list">>, + credential_count => Count}}}; + 0 -> + opencode_missing_auth_status() + end; + {error, _Reason} -> + opencode_missing_auth_status() + end. + +opencode_missing_auth_status() -> + {ok, + #{backend => opencode, + authenticated => false, + method => cli, + details => + #{hint => + <<"OpenCode server unreachable, OPENAI_API_KEY not set, " + "and no configured OpenCode CLI credentials were found.">>}}}. + +-spec opencode_credential_count([string()]) -> non_neg_integer(). +opencode_credential_count(Lines) -> + Sanitized = + [string:trim(strip_ansi(Line)) + || Line <- Lines, + string:trim(strip_ansi(Line)) =/= []], + case lists:filtermap(fun opencode_count_line/1, Sanitized) of + [Count | _] -> + Count; + [] -> + length([Line || Line <- Sanitized, + opencode_auth_entry(Line)]) + end. + +-spec opencode_count_line(string()) -> false | {true, non_neg_integer()}. +opencode_count_line(Line) -> + case re:run(Line, "([0-9]+) credentials", [{capture, [1], list}]) of + {match, [Count]} -> + {true, list_to_integer(Count)}; + nomatch -> + false + end. + +-spec opencode_auth_entry(string()) -> boolean(). +opencode_auth_entry(Line) -> + case re:run(Line, "(oauth|api)$", [caseless]) of + {match, _} -> + not lists:member($/, Line); + nomatch -> + false + end. + opencode_login(#{api_key := Key} = Opts, _VaultEnv) when is_binary(Key) -> BaseUrl = validate_base_url(Opts), Url = BaseUrl ++ "/auth", @@ -1386,15 +1465,27 @@ All invocations are logged for operational visibility. -spec run_cli(string(), [string()], [{string(), string()}], pos_integer()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, Timeout) -> + run_cli_with_opts(Program, Args, InternalEnv, Timeout, #{}). + +-spec run_cli_with_opts(string(), [string()], [{string(), string()}], pos_integer(), map()) -> + {ok, non_neg_integer(), [string()]} | {error, term()}. +run_cli_with_opts(Program, Args, InternalEnv, Timeout, RunOpts) -> + ProgramStr = + case Program of + B when is_binary(B) -> + binary_to_list(B); + L when is_list(L) -> + L + end, validate_env(InternalEnv), - case os:find_executable(Program) of + case os:find_executable(ProgramStr) of false -> - logger:warning("Auth CLI not found: ~s", [Program]), - {error, {cli_not_found, Program}}; + logger:warning("Auth CLI not found: ~s", [ProgramStr]), + {error, {cli_not_found, ProgramStr}}; ExePath -> verify_executable_safety(ExePath), - logger:info("Auth CLI exec: ~s ~s", [Program, args_summary(Args)]), - run_port(ExePath, Args, InternalEnv, Timeout) + logger:info("Auth CLI exec: ~s ~s", [ProgramStr, args_summary(Args)]), + run_port(ExePath, Args, InternalEnv, Timeout, RunOpts) end. %% Internal: run_cli with merged VaultEnv from Vault. @@ -1403,15 +1494,27 @@ run_cli(Program, Args, InternalEnv, Timeout) -> -spec run_cli(string(), [string()], [{string(), string()}], vault_env(), pos_integer()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout) -> + run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout, #{}). + +-spec run_cli(string(), [string()], [{string(), string()}], vault_env(), pos_integer(), map()) -> + {ok, non_neg_integer(), [string()]} | {error, term()}. +run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout, RunOpts) -> + ProgramStr = + case Program of + B when is_binary(B) -> + binary_to_list(B); + L when is_list(L) -> + L + end, validate_env(InternalEnv), - case os:find_executable(Program) of + case os:find_executable(ProgramStr) of false -> - logger:warning("Auth CLI not found: ~s", [Program]), - {error, {cli_not_found, Program}}; + logger:warning("Auth CLI not found: ~s", [ProgramStr]), + {error, {cli_not_found, ProgramStr}}; ExePath -> verify_executable_safety(ExePath), - logger:info("Auth CLI exec: ~s ~s", [Program, args_summary(Args)]), - run_port(ExePath, Args, InternalEnv ++ VaultVars, Timeout) + logger:info("Auth CLI exec: ~s ~s", [ProgramStr, args_summary(Args)]), + run_port(ExePath, Args, InternalEnv ++ VaultVars, Timeout, RunOpts) end. -doc(""" @@ -1422,7 +1525,12 @@ exits successfully. -spec run_capture_cli(string(), [string()], pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_cli(Program, Args, Timeout) -> - case run_cli(Program, Args, [], Timeout) of + run_capture_cli(Program, Args, Timeout, #{}). + +-spec run_capture_cli(string(), [string()], pos_integer(), map()) -> + {ok, [string()]} | {error, term()}. +run_capture_cli(Program, Args, Timeout, RunOpts) when is_map(RunOpts) -> + case run_cli_with_opts(Program, Args, [], Timeout, RunOpts) of {ok, 0, Lines} -> {ok, Lines}; {ok, ExitCode, Lines} -> @@ -1431,6 +1539,46 @@ run_capture_cli(Program, Args, Timeout) -> Err end. +-doc(""" +Execute a command through the user's login shell using the shell configured +in `SHELL`, preserving the same startup semantics as an interactive shell +invocation while still capturing output with BeamAgent's hardened runner. +"""). +-spec run_capture_login_shell(string(), [string()], pos_integer()) -> + {ok, [string()]} | {error, term()}. +run_capture_login_shell(Program, Args, Timeout) -> + ProgramStr = + case Program of + B when is_binary(B) -> + binary_to_list(B); + L when is_list(L) -> + L + end, + case os:find_executable(ProgramStr) of + false -> + {error, {cli_not_found, ProgramStr}}; + ExePath -> + verify_executable_safety(ExePath), + case login_shell_program() of + {ok, Shell} -> + verify_executable_safety(Shell), + ShellProgram = + case string:find(ProgramStr, "/") of + nomatch -> ProgramStr; + _ -> ExePath + end, + Command = login_shell_command_string(ShellProgram, Args), + case run_capture_login_shell_with_best_runner(Shell, Command, Timeout) of + {ok, Lines} -> + {ok, strip_shell_startup_lines(Lines)}; + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end + end. + run_copilot_auth_command(Program, PreferredArgs, LegacyArgs, InternalEnv, Timeout, Command) -> case run_cli(Program, PreferredArgs, InternalEnv, Timeout) of {ok, ExitCode, Lines} -> @@ -1534,16 +1682,25 @@ unsupported_copilot_auth_command() -> "legacy auth commands. Upgrade the CLI and use `copilot login` " "or `copilot logout`.">>}. --spec run_port(string(), [string()], [{string(), string()}], pos_integer()) -> +-spec run_port(string(), [string()], [{string(), string()}], pos_integer(), map()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. -run_port(ExePath, Args, AllEnv, Timeout) -> +run_port(ExePath, Args, AllEnv, Timeout, RunOpts) -> + CwdOpt = + case maps:get(cwd, RunOpts, undefined) of + Cwd when is_list(Cwd), Cwd =/= [] -> + [{cd, Cwd}]; + Cwd when is_binary(Cwd), byte_size(Cwd) > 0 -> + [{cd, binary_to_list(Cwd)}]; + _ -> + [] + end, PortOpts = [{args, Args}, {env, scrub_env(AllEnv)}, exit_status, stderr_to_stdout, {line, ?LINE_LENGTH}, - hide], + hide] ++ CwdOpt, try Port = open_port({spawn_executable, ExePath}, PortOpts), collect_output(Port, [], Timeout) @@ -1572,12 +1729,13 @@ collect_output(Port, Acc, LineBuf, Timeout) -> {Port, {data, {noeol, Line}}} -> collect_output(Port, Acc, [Line | LineBuf], Timeout); {Port, {exit_status, ExitCode}} -> - flush_port(Port), - FinalAcc = case LineBuf of - [] -> Acc; - _ -> [lists:flatten(lists:reverse(LineBuf)) | Acc] - end, - Lines = lists:reverse(FinalAcc), + {FinalAcc, FinalLineBuf} = collect_remaining_output(Port, Acc, LineBuf), + Lines = + lists:reverse( + case FinalLineBuf of + [] -> FinalAcc; + _ -> [lists:flatten(lists:reverse(FinalLineBuf)) | FinalAcc] + end), log_cli_result(ExitCode, Lines), {ok, ExitCode, Lines} after Timeout -> @@ -1587,6 +1745,27 @@ collect_output(Port, Acc, LineBuf, Timeout) -> {error, timeout} end. +-spec shell_output_lines(binary()) -> [string()]. +shell_output_lines(Buffer) -> + [binary_to_list(strip_ansi(Line)) + || Line <- binary:split(Buffer, <<"\n">>, [global]), + strip_ansi(Line) =/= <<>>]. + +-spec collect_remaining_output(port(), [string()], [string()]) -> + {[string()], [string()]}. +collect_remaining_output(Port, Acc, LineBuf) -> + receive + {Port, {data, {eol, Line}}} -> + CompletedLine = lists:flatten(lists:reverse([Line | LineBuf])), + collect_remaining_output(Port, [CompletedLine | Acc], []); + {Port, {data, {noeol, Line}}} -> + collect_remaining_output(Port, Acc, [Line | LineBuf]); + {Port, {exit_status, _}} -> + collect_remaining_output(Port, Acc, LineBuf) + after 0 -> + {Acc, LineBuf} + end. + %% Drain any pending messages from a closed port. -spec flush_port(port()) -> ok. flush_port(Port) -> @@ -1698,6 +1877,90 @@ pty_command_string(Exe, Args) -> Parts = [shell_escape(Exe) | [shell_escape(A) || A <- Args]], string:join(Parts, " "). +-spec login_shell_program() -> {ok, string()} | {error, {shell_not_found, string()}}. +login_shell_program() -> + case os:getenv("SHELL") of + false -> + fallback_login_shell(); + [] -> + fallback_login_shell(); + Shell -> + {ok, Shell} + end. + +-spec fallback_login_shell() -> {ok, string()} | {error, {shell_not_found, string()}}. +fallback_login_shell() -> + case os:find_executable("sh") of + false -> + {error, {shell_not_found, "sh"}}; + Shell -> + {ok, Shell} + end. + +-spec login_shell_command_string(string(), [string()]) -> string(). +login_shell_command_string(Exe, Args) -> + ExeStr = normalize_shell_string(Exe), + ArgStrs = [normalize_shell_string(A) || A <- Args], + pty_command_string(ExeStr, ArgStrs). + +-spec normalize_shell_string(string() | binary()) -> string(). +normalize_shell_string(B) when is_binary(B) -> + binary_to_list(B); +normalize_shell_string(L) when is_list(L) -> + L. + +-spec strip_shell_startup_lines([string()]) -> [string()]. +strip_shell_startup_lines(Lines) -> + [Clean + || Line <- Lines, + Clean0 <- [string:trim(binary_to_list(strip_ansi(unicode:characters_to_binary(Line))))], + Clean <- [Clean0], + Clean =/= [], + not shell_startup_line(Clean)]. + +-spec shell_startup_line(string()) -> boolean(). +shell_startup_line(Line) -> + lists:prefix("ShellIntegrationVersion=", Line) orelse + lists:prefix("CurrentDir=", Line) orelse + lists:prefix("RemoteHost=", Line). + +-spec run_capture_login_shell_with_best_runner(string(), string(), pos_integer()) -> + {ok, [string()]} | {error, term()}. +run_capture_login_shell_with_best_runner(Shell, Command, Timeout) -> + case code:ensure_loaded('Elixir.System') of + {module, 'Elixir.System'} -> + run_capture_login_shell_via_system_cmd(Shell, Command, Timeout); + _ -> + run_capture_login_shell_via_port(Shell, Command, Timeout) + end. + +-spec run_capture_login_shell_via_system_cmd(string(), string(), pos_integer()) -> + {ok, [string()]} | {error, term()}. +run_capture_login_shell_via_system_cmd(Shell, Command, Timeout) -> + ShellBin = unicode:characters_to_binary(Shell), + ArgBins = [<<"-lic">>, unicode:characters_to_binary(Command)], + _ = Timeout, + Opts = [{stderr_to_stdout, true}], + try 'Elixir.System':cmd(ShellBin, ArgBins, Opts) of + {Output, 0} -> + {ok, shell_output_lines(iolist_to_binary(Output))}; + {Output, ExitCode} -> + {error, {cli_exit, ExitCode, shell_output_lines(iolist_to_binary(Output))}} + catch + Class:Reason -> + {error, {system_cmd_error, {Class, Reason}}} + end. + +-spec run_capture_login_shell_via_port(string(), string(), pos_integer()) -> + {ok, [string()]} | {error, term()}. +run_capture_login_shell_via_port(Shell, Command, Timeout) -> + case run_capture_cli(Shell, ["-l", "-i", "-c", Command], Timeout) of + {ok, Lines} -> + {ok, Lines}; + {error, _} = Err -> + Err + end. + %% Shell-escape a string for safe inclusion in `sh -c '...'`. %% Uses POSIX single-quoting with escaped embedded single-quotes. -spec shell_escape(string()) -> string(). diff --git a/src/public/beam_agent_auth.erl b/src/public/beam_agent_auth.erl index 79e9bde..3a18ede 100644 --- a/src/public/beam_agent_auth.erl +++ b/src/public/beam_agent_auth.erl @@ -38,7 +38,7 @@ beam_agent_auth (public — this module) ├─ claude: `claude auth status|login|logout` ├─ codex: `codex login status`, `codex login [--with-api-key]` ├─ copilot: env/config status, `copilot login|logout` - ├─ opencode: REST via httpc (localhost-only) + ├─ opencode: provider endpoint, env, or `opencode auth list` └─ gemini: env checks / PTY-interactive OAuth ``` """. @@ -88,6 +88,7 @@ Options: - `cli_path` — override the CLI binary location - `timeout` — override the default 10 s timeout - `base_url` — OpenCode server URL (localhost only, default `http://localhost:4096`) + used for provider-endpoint auth probing before local CLI/env fallback ```erlang {ok, #{authenticated := Auth}} = beam_agent_auth:status(claude). diff --git a/src/public/beam_agent_catalog.erl b/src/public/beam_agent_catalog.erl index e4f898f..fd8243b 100644 --- a/src/public/beam_agent_catalog.erl +++ b/src/public/beam_agent_catalog.erl @@ -335,6 +335,12 @@ maybe_fallback_model_list({error, {timeout, _}} = Err, Fallback) -> prefer_model_fallback(Err, Fallback); maybe_fallback_model_list({error, {session_error, _}} = Err, Fallback) -> prefer_model_fallback(Err, Fallback); +maybe_fallback_model_list({error, {native_call_failed, _Kind, {timeout, _}}} = Err, + Fallback) -> + prefer_model_fallback(Err, Fallback); +maybe_fallback_model_list({error, {native_call_failed, _Kind, {session_error, _}}} = Err, + Fallback) -> + prefer_model_fallback(Err, Fallback); maybe_fallback_model_list(Result, _Fallback) -> Result. diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 8cb2f12..72e44e9 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -391,6 +391,95 @@ copilot_status_rejects_unknown_token_prefix_test() -> end) end). +opencode_status_accepts_cli_auth_list_credentials_test() -> + TmpDir = make_tmp_dir(), + try + CliPath = write_fake_cli( + TmpDir, + "opencode", + ["printf '%s\\n' 'Credentials ~/.local/share/opencode/auth.json'\n", + "printf '%s\\n' 'Anthropic oauth'\n", + "printf '%s\\n' 'OpenAI oauth'\n", + "printf '%s\\n' '2 credentials'\n"]), + with_env_unset( + "OPENAI_API_KEY", + fun() -> + {ok, Status} = + beam_agent_auth_core:status( + opencode, + #{cli_path => CliPath, + base_url => "http://localhost:1"}), + ?assertEqual(true, maps:get(authenticated, Status)), + ?assertEqual(cli, maps:get(method, Status)) + end) + after + rm_rf(TmpDir) + end. + +opencode_status_rejects_empty_cli_auth_list_test() -> + TmpDir = make_tmp_dir(), + try + CliPath = write_fake_cli( + TmpDir, + "opencode", + ["printf '%s\\n' 'Credentials ~/.local/share/opencode/auth.json'\n", + "printf '%s\\n' '0 credentials'\n"]), + with_env_unset( + "OPENAI_API_KEY", + fun() -> + {ok, Status} = + beam_agent_auth_core:status( + opencode, + #{cli_path => CliPath, + base_url => "http://localhost:1"}), + ?assertEqual(false, maps:get(authenticated, Status)), + ?assertEqual(cli, maps:get(method, Status)) + end) + after + rm_rf(TmpDir) + end. + +run_capture_cli_respects_cwd_option_test() -> + TmpDir = make_tmp_dir(), + try + CliPath = write_fake_cli( + TmpDir, + "pwd-probe", + ["pwd\n"]), + {ok, [Pwd]} = + beam_agent_auth_core:run_capture_cli( + CliPath, + [], + 5000, + #{cwd => TmpDir}), + ?assertEqual(TmpDir, string:trim(Pwd)) + after + rm_rf(TmpDir) + end. + +run_capture_login_shell_uses_configured_shell_test() -> + TmpDir = make_tmp_dir(), + try + ShellPath = write_fake_shell(TmpDir), + CliPath = write_fake_cli( + TmpDir, + "probe-cli", + ["printf '%s\\n' 'probe-ok'\n"]), + with_env_value( + "SHELL", + ShellPath, + fun() -> + {ok, Lines} = + beam_agent_auth_core:run_capture_login_shell( + CliPath, + [], + 5000), + ?assertEqual(["probe-ok"], Lines) + end) + after + rm_rf(TmpDir) + end. + %%==================================================================== %% Helpers %%==================================================================== @@ -418,6 +507,22 @@ write_fake_cli(Dir, Name, BodyLines) -> ok = file:change_mode(Path, 8#755), Path. +write_fake_shell(Dir) -> + Path = filename:join(Dir, "fake-shell"), + ok = file:write_file( + Path, + <<"#!/bin/sh\n" + "while [ $# -gt 0 ]; do\n" + " if [ \"$1\" = \"-c\" ]; then\n" + " shift\n" + " exec /bin/sh -c \"$1\"\n" + " fi\n" + " shift\n" + "done\n" + "exit 1\n">>), + ok = file:change_mode(Path, 8#755), + Path. + read_lines(Path) -> {ok, Bin} = file:read_file(Path), [binary_to_list(Line) diff --git a/test/public/beam_agent_catalog_model_tests.erl b/test/public/beam_agent_catalog_model_tests.erl index e28734c..85e2e65 100644 --- a/test/public/beam_agent_catalog_model_tests.erl +++ b/test/public/beam_agent_catalog_model_tests.erl @@ -46,3 +46,11 @@ maybe_fallback_model_list_keeps_native_error_when_fallback_exits_test() -> beam_agent_catalog:maybe_fallback_model_list( {error, session_error}, fun() -> exit(timeout) end)). + +maybe_fallback_model_list_uses_supported_models_on_native_timeout_exit_test() -> + Models = [#{<<"modelId">> => <<"gpt-5">>}], + ?assertEqual( + {ok, #{<<"models">> => Models}}, + beam_agent_catalog:maybe_fallback_model_list( + {error, {native_call_failed, exit, {timeout, probe_timeout}}}, + fun() -> {ok, #{<<"models">> => Models}} end)). From 4b92487e0f0a4cf4af4e984a74916f7b7d3dd91a Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 02:44:09 -0500 Subject: [PATCH 02/13] Preserve Claude 1m model variants in discovery --- src/backends/claude/claude_agent_sdk.erl | 111 ++++++++++++++++-- .../claude/claude_model_discovery_tests.erl | 43 ++++++- 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/backends/claude/claude_agent_sdk.erl b/src/backends/claude/claude_agent_sdk.erl index 7ac6097..cbb949e 100644 --- a/src/backends/claude/claude_agent_sdk.erl +++ b/src/backends/claude/claude_agent_sdk.erl @@ -124,6 +124,8 @@ -export([discover_models_from_claude_config/0, discover_models_from_claude_config/1, collect_claude_model_ids/1, + discoverable_claude_model_ids/1, + expand_claude_model_entries/1, normalize_claude_model_id/1, claude_config_path/0]). -endif. @@ -231,7 +233,7 @@ supported_commands(Session) -> supported_models(Session) -> case extract_init_field(Session, models, models, []) of {ok, Models} when is_list(Models), Models =/= [] -> - {ok, Models}; + {ok, expand_claude_model_entries(Models)}; {ok, _} -> discover_models_from_claude_config(); {error, _} = Err -> @@ -813,7 +815,7 @@ discover_models_from_claude_config(Path) -> try json:decode(Bin) of Decoded -> ModelIds = ordsets:from_list(collect_claude_model_ids(Decoded)), - {ok, [claude_model_entry(Id) || Id <- ModelIds]} + {ok, expand_claude_model_entries([claude_model_entry(Id) || Id <- ModelIds])} catch _:_ -> {ok, []} @@ -828,11 +830,7 @@ collect_claude_model_ids(#{<<"lastModelUsage">> := Usage} = Map) usage_model_ids(Usage) ++ lists:flatmap(fun collect_claude_model_ids/1, maps:values(Map)); collect_claude_model_ids(#{<<"model">> := Model} = Map) when is_binary(Model) -> - MaybeModel = - case normalize_claude_model_id(Model) of - <<"claude-", _/binary>> = ClaudeModel -> [ClaudeModel]; - _ -> [] - end, + MaybeModel = discoverable_claude_model_ids(Model), MaybeModel ++ lists:flatmap(fun collect_claude_model_ids/1, maps:values(Map)); collect_claude_model_ids(Map) when is_map(Map) -> @@ -844,11 +842,11 @@ collect_claude_model_ids(_) -> -spec usage_model_ids(map()) -> [binary()]. usage_model_ids(Usage) -> - [Normalized + [Model || ModelId <- maps:keys(Usage), is_binary(ModelId), - Normalized <- [normalize_claude_model_id(ModelId)], - case Normalized of + Model <- discoverable_claude_model_ids(ModelId), + case Model of <<"claude-", _/binary>> -> true; _ -> false end]. @@ -857,6 +855,99 @@ usage_model_ids(Usage) -> normalize_claude_model_id(ModelId) when is_binary(ModelId) -> hd(binary:split(ModelId, <<"[">>)). +-spec discoverable_claude_model_ids(binary()) -> [binary()]. +discoverable_claude_model_ids(ModelId) when is_binary(ModelId) -> + BaseModelId = normalize_claude_model_id(ModelId), + case supports_context_variant(BaseModelId) of + true -> + ordsets:from_list([BaseModelId, context_variant_model_id(BaseModelId)]); + false when BaseModelId =:= <<>> -> + []; + false -> + [BaseModelId] + end. + +-spec supports_context_variant(binary()) -> boolean(). +supports_context_variant(<<"claude-sonnet-4-6">>) -> true; +supports_context_variant(<<"claude-opus-4-6">>) -> true; +supports_context_variant(_) -> false. + +-spec expand_claude_model_entries([map()]) -> [map()]. +expand_claude_model_entries(Models) when is_list(Models) -> + lists:reverse( + lists:foldl( + fun expand_claude_model_entry/2, + [], + Models)). + +-spec expand_claude_model_entry(map(), [map()]) -> [map()]. +expand_claude_model_entry(Model, Acc) when is_map(Model) -> + case claude_model_entry_ids(Model) of + [] -> + [Model | Acc]; + ModelIds -> + lists:foldl( + fun(ModelId, InnerAcc) -> + put_unique_claude_model_entry(clone_claude_model_entry(Model, ModelId), InnerAcc) + end, + Acc, + ModelIds) + end; +expand_claude_model_entry(Model, Acc) -> + [Model | Acc]. + +-spec put_unique_claude_model_entry(map(), [map()]) -> [map()]. +put_unique_claude_model_entry(Model, Acc) -> + case claude_model_entry_id(Model) of + undefined -> + [Model | Acc]; + ModelId -> + case lists:any( + fun(Existing) -> + claude_model_entry_id(Existing) =:= ModelId + end, + Acc) of + true -> Acc; + false -> [Model | Acc] + end + end. + +-spec claude_model_entry_ids(map()) -> [binary()]. +claude_model_entry_ids(Model) when is_map(Model) -> + case claude_model_entry_id(Model) of + undefined -> []; + ModelId -> discoverable_claude_model_ids(ModelId) + end. + +-spec claude_model_entry_id(map()) -> binary() | undefined. +claude_model_entry_id(#{<<"modelId">> := ModelId}) when is_binary(ModelId) -> + ModelId; +claude_model_entry_id(#{modelId := ModelId}) when is_binary(ModelId) -> + ModelId; +claude_model_entry_id(#{model_id := ModelId}) when is_binary(ModelId) -> + ModelId; +claude_model_entry_id(_) -> + undefined. + +-spec clone_claude_model_entry(map(), binary()) -> map(). +clone_claude_model_entry(Model, ModelId) when is_map(Model), is_binary(ModelId) -> + Model1 = + case Model of + #{<<"modelId">> := _} -> Model#{<<"modelId">> => ModelId}; + #{modelId := _} -> Model#{modelId => ModelId}; + #{model_id := _} -> Model#{model_id => ModelId}; + _ -> Model + end, + case Model1 of + #{<<"name">> := _} -> Model1#{<<"name">> => ModelId}; + #{name := _} -> Model1#{name => ModelId}; + _ -> Model1 + end. + +-spec context_variant_model_id(binary()) -> binary(). +context_variant_model_id(BaseModelId) when is_binary(BaseModelId) -> + <>. + -spec claude_model_entry(binary()) -> map(). claude_model_entry(ModelId) -> #{<<"modelId">> => ModelId, diff --git a/test/backends/claude/claude_model_discovery_tests.erl b/test/backends/claude/claude_model_discovery_tests.erl index 53c9844..11b3e88 100644 --- a/test/backends/claude/claude_model_discovery_tests.erl +++ b/test/backends/claude/claude_model_discovery_tests.erl @@ -14,7 +14,7 @@ discover_models_from_claude_config_test() -> } }, <<"workspace">> => #{ - <<"model">> => <<"claude-haiku-4-5-20251001[1m]">> + <<"model">> => <<"claude-haiku-4-5-20251001">> } }, ok = file:write_file(ConfigPath, json:encode(Config)), @@ -23,7 +23,9 @@ discover_models_from_claude_config_test() -> ?assertEqual( ordsets:from_list([ <<"claude-haiku-4-5-20251001">>, + <<"claude-opus-4-6[1m]">>, <<"claude-opus-4-6">>, + <<"claude-sonnet-4-6[1m]">>, <<"claude-sonnet-4-6">> ]), ModelIds) @@ -31,11 +33,48 @@ discover_models_from_claude_config_test() -> rm_rf(TmpDir) end. -normalize_claude_model_id_strips_context_suffix_test() -> +normalize_claude_model_id_returns_base_model_test() -> ?assertEqual(<<"claude-sonnet-4-6">>, claude_agent_sdk:normalize_claude_model_id( <<"claude-sonnet-4-6[1m]">>)). +discoverable_claude_model_ids_expand_known_context_variants_test() -> + ?assertEqual( + ordsets:from_list([<<"claude-sonnet-4-6">>, <<"claude-sonnet-4-6[1m]">>]), + ordsets:from_list( + claude_agent_sdk:discoverable_claude_model_ids(<<"claude-sonnet-4-6">>) + )), + ?assertEqual( + ordsets:from_list([<<"claude-opus-4-6">>, <<"claude-opus-4-6[1m]">>]), + ordsets:from_list( + claude_agent_sdk:discoverable_claude_model_ids(<<"claude-opus-4-6[1m]">>) + )), + ?assertEqual( + [<<"claude-haiku-4-5-20251001">>], + claude_agent_sdk:discoverable_claude_model_ids(<<"claude-haiku-4-5-20251001">>) + ). + +expand_claude_model_entries_add_known_context_variants_test() -> + Expanded = + claude_agent_sdk:expand_claude_model_entries([ + #{<<"modelId">> => <<"claude-sonnet-4-6">>, + <<"name">> => <<"claude-sonnet-4-6">>}, + #{<<"modelId">> => <<"claude-opus-4-6">>, + <<"name">> => <<"claude-opus-4-6">>}, + #{<<"modelId">> => <<"claude-haiku-4-5-20251001">>, + <<"name">> => <<"claude-haiku-4-5-20251001">>} + ]), + ModelIds = ordsets:from_list([maps:get(<<"modelId">>, M) || M <- Expanded]), + ?assertEqual( + ordsets:from_list([ + <<"claude-haiku-4-5-20251001">>, + <<"claude-opus-4-6">>, + <<"claude-opus-4-6[1m]">>, + <<"claude-sonnet-4-6">>, + <<"claude-sonnet-4-6[1m]">> + ]), + ModelIds). + claude_config_path_without_home_returns_undefined_test() -> with_env_unset( "HOME", From 95e8c28c7475f312f719bb9105e67d6182ccc967 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 03:36:34 -0500 Subject: [PATCH 03/13] Harden login-shell CLI runner --- src/core/beam_agent_auth_core.erl | 193 ++++++++++++++++------- test/core/beam_agent_auth_core_tests.erl | 56 +++++++ 2 files changed, 195 insertions(+), 54 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 996a639..33ad529 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -63,7 +63,8 @@ for Claude) rather than passing the secret on the command line — avoiding -export([validate_base_url/1, verify_executable_safety/1, resolve_symlinks/1, compute_file_hash/1, is_localhost/1, scrub_env/1, home_dir/0, - check_copilot_config/0]). + check_copilot_config/0, login_shell_program/0, + fallback_login_shell/0, login_shell_args/2]). -endif. @@ -90,6 +91,8 @@ for Claude) rather than passing the secret on the command line — avoiding timeout => pos_integer(), base_url => string() | binary()}. +-type cli_program() :: string() | binary(). + %% OpenCode-specific -type json_term() :: @@ -1462,12 +1465,12 @@ All invocations are logged for operational visibility. - Executable permissions are verified (not world-writable) """). --spec run_cli(string(), [string()], [{string(), string()}], pos_integer()) -> +-spec run_cli(cli_program(), [string()], [{string(), string()}], pos_integer()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, Timeout) -> run_cli_with_opts(Program, Args, InternalEnv, Timeout, #{}). --spec run_cli_with_opts(string(), [string()], [{string(), string()}], pos_integer(), map()) -> +-spec run_cli_with_opts(cli_program(), [string()], [{string(), string()}], pos_integer(), map()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli_with_opts(Program, Args, InternalEnv, Timeout, RunOpts) -> ProgramStr = @@ -1491,12 +1494,12 @@ run_cli_with_opts(Program, Args, InternalEnv, Timeout, RunOpts) -> %% Internal: run_cli with merged VaultEnv from Vault. %% InternalEnv is validated against the allowlist; VaultEnv is unwrapped %% from its opaque wrapper and merged — bypasses the allowlist. --spec run_cli(string(), [string()], [{string(), string()}], vault_env(), pos_integer()) -> +-spec run_cli(cli_program(), [string()], [{string(), string()}], vault_env(), pos_integer()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout) -> run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout, #{}). --spec run_cli(string(), [string()], [{string(), string()}], vault_env(), pos_integer(), map()) -> +-spec run_cli(cli_program(), [string()], [{string(), string()}], vault_env(), pos_integer(), map()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout, RunOpts) -> ProgramStr = @@ -1522,12 +1525,12 @@ Execute a CLI command with the same executable and environment hardening as the auth flows, returning captured stdout/stderr lines only when the command exits successfully. """). --spec run_capture_cli(string(), [string()], pos_integer()) -> +-spec run_capture_cli(cli_program(), [string()], pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_cli(Program, Args, Timeout) -> run_capture_cli(Program, Args, Timeout, #{}). --spec run_capture_cli(string(), [string()], pos_integer(), map()) -> +-spec run_capture_cli(cli_program(), [string()], pos_integer(), map()) -> {ok, [string()]} | {error, term()}. run_capture_cli(Program, Args, Timeout, RunOpts) when is_map(RunOpts) -> case run_cli_with_opts(Program, Args, [], Timeout, RunOpts) of @@ -1544,7 +1547,7 @@ Execute a command through the user's login shell using the shell configured in `SHELL`, preserving the same startup semantics as an interactive shell invocation while still capturing output with BeamAgent's hardened runner. """). --spec run_capture_login_shell(string(), [string()], pos_integer()) -> +-spec run_capture_login_shell(cli_program(), [string()], pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_login_shell(Program, Args, Timeout) -> ProgramStr = @@ -1554,19 +1557,11 @@ run_capture_login_shell(Program, Args, Timeout) -> L when is_list(L) -> L end, - case os:find_executable(ProgramStr) of - false -> - {error, {cli_not_found, ProgramStr}}; - ExePath -> - verify_executable_safety(ExePath), - case login_shell_program() of - {ok, Shell} -> - verify_executable_safety(Shell), - ShellProgram = - case string:find(ProgramStr, "/") of - nomatch -> ProgramStr; - _ -> ExePath - end, + case login_shell_program() of + {ok, Shell} -> + verify_executable_safety(Shell), + case shell_command_executable(ProgramStr) of + {ok, ShellProgram} -> Command = login_shell_command_string(ShellProgram, Args), case run_capture_login_shell_with_best_runner(Shell, Command, Timeout) of {ok, Lines} -> @@ -1576,7 +1571,9 @@ run_capture_login_shell(Program, Args, Timeout) -> end; {error, _} = Err -> Err - end + end; + {error, _} = Err -> + Err end. run_copilot_auth_command(Program, PreferredArgs, LegacyArgs, InternalEnv, Timeout, Command) -> @@ -1745,12 +1742,6 @@ collect_output(Port, Acc, LineBuf, Timeout) -> {error, timeout} end. --spec shell_output_lines(binary()) -> [string()]. -shell_output_lines(Buffer) -> - [binary_to_list(strip_ansi(Line)) - || Line <- binary:split(Buffer, <<"\n">>, [global]), - strip_ansi(Line) =/= <<>>]. - -spec collect_remaining_output(port(), [string()], [string()]) -> {[string()], [string()]}. collect_remaining_output(Port, Acc, LineBuf) -> @@ -1890,19 +1881,135 @@ login_shell_program() -> -spec fallback_login_shell() -> {ok, string()} | {error, {shell_not_found, string()}}. fallback_login_shell() -> - case os:find_executable("sh") of + Candidates = ["bash", "zsh", "ksh", "mksh", "fish", "sh"], + case find_login_shell(Candidates) of + {ok, Shell} -> + {ok, Shell}; + false -> + {error, {shell_not_found, "bash/zsh/ksh/mksh/fish/sh"}} + end. + +-spec find_login_shell([string()]) -> {ok, string()} | false. +find_login_shell([]) -> + false; +find_login_shell([Candidate | Rest]) -> + case os:find_executable(Candidate) of false -> - {error, {shell_not_found, "sh"}}; + find_login_shell(Rest); Shell -> {ok, Shell} end. +-spec shell_command_executable(string()) -> {ok, string()} | {error, term()}. +shell_command_executable(ProgramStr) -> + case string:find(ProgramStr, "/") of + nomatch -> + {ok, ProgramStr}; + _ -> + Path0 = + case filename:pathtype(ProgramStr) of + relative -> + filename:absname(ProgramStr); + _ -> + ProgramStr + end, + case filelib:is_regular(Path0) of + true -> + verify_executable_safety(Path0), + {ok, Path0}; + false -> + {error, {cli_not_found, ProgramStr}} + end + end. + -spec login_shell_command_string(string(), [string()]) -> string(). login_shell_command_string(Exe, Args) -> ExeStr = normalize_shell_string(Exe), ArgStrs = [normalize_shell_string(A) || A <- Args], pty_command_string(ExeStr, ArgStrs). +-spec login_shell_args(string(), string()) -> [string()]. +login_shell_args(Shell, Command) -> + ShellPath = normalize_shell_string(Shell), + case shell_mode(ShellPath) of + interactive_login -> + ["-l", "-i", "-c", Command]; + login_only -> + ["-l", "-c", Command]; + profile_sourced -> + ["-c", profile_sourced_command(Command)]; + plain -> + ["-c", Command] + end. + +-type shell_mode() :: interactive_login | login_only | profile_sourced | plain. + +-spec shell_mode(string()) -> shell_mode(). +shell_mode(ShellPath) -> + case shell_family(ShellPath) of + bash -> interactive_login; + zsh -> interactive_login; + ksh -> interactive_login; + mksh -> interactive_login; + fish -> interactive_login; + sh -> profile_sourced; + dash -> profile_sourced; + busybox -> profile_sourced; + ash -> profile_sourced; + _ -> plain + end. + +-spec shell_family(string()) -> atom(). +shell_family(ShellPath) -> + Base = string:lowercase(filename:basename(ShellPath)), + case Base of + "sh" -> + resolved_shell_family(ShellPath, 4, sh); + "bash" -> bash; + "zsh" -> zsh; + "ksh" -> ksh; + "mksh" -> mksh; + "fish" -> fish; + "dash" -> dash; + "ash" -> ash; + "busybox" -> busybox; + _ -> unknown + end. + +-spec resolved_shell_family(string(), non_neg_integer(), atom()) -> atom(). +resolved_shell_family(_ShellPath, 0, Default) -> + Default; +resolved_shell_family(ShellPath, Depth, Default) -> + case file:read_link(ShellPath) of + {ok, Target0} -> + Target = + case filename:pathtype(Target0) of + relative -> + filename:join(filename:dirname(ShellPath), Target0); + _ -> + Target0 + end, + case string:lowercase(filename:basename(Target)) of + "bash" -> bash; + "zsh" -> zsh; + "ksh" -> ksh; + "mksh" -> mksh; + "fish" -> fish; + "dash" -> dash; + "ash" -> ash; + "busybox" -> busybox; + "sh" -> resolved_shell_family(Target, Depth - 1, Default); + _ -> Default + end; + _ -> + Default + end. + +-spec profile_sourced_command(string()) -> string(). +profile_sourced_command(Command) -> + Profile = "$HOME/.profile", + "[ -f " ++ Profile ++ " ] && . " ++ Profile ++ " >/dev/null 2>&1; " ++ Command. + -spec normalize_shell_string(string() | binary()) -> string(). normalize_shell_string(B) when is_binary(B) -> binary_to_list(B); @@ -1927,34 +2034,12 @@ shell_startup_line(Line) -> -spec run_capture_login_shell_with_best_runner(string(), string(), pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_login_shell_with_best_runner(Shell, Command, Timeout) -> - case code:ensure_loaded('Elixir.System') of - {module, 'Elixir.System'} -> - run_capture_login_shell_via_system_cmd(Shell, Command, Timeout); - _ -> - run_capture_login_shell_via_port(Shell, Command, Timeout) - end. - --spec run_capture_login_shell_via_system_cmd(string(), string(), pos_integer()) -> - {ok, [string()]} | {error, term()}. -run_capture_login_shell_via_system_cmd(Shell, Command, Timeout) -> - ShellBin = unicode:characters_to_binary(Shell), - ArgBins = [<<"-lic">>, unicode:characters_to_binary(Command)], - _ = Timeout, - Opts = [{stderr_to_stdout, true}], - try 'Elixir.System':cmd(ShellBin, ArgBins, Opts) of - {Output, 0} -> - {ok, shell_output_lines(iolist_to_binary(Output))}; - {Output, ExitCode} -> - {error, {cli_exit, ExitCode, shell_output_lines(iolist_to_binary(Output))}} - catch - Class:Reason -> - {error, {system_cmd_error, {Class, Reason}}} - end. + run_capture_login_shell_via_port(Shell, Command, Timeout). -spec run_capture_login_shell_via_port(string(), string(), pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_login_shell_via_port(Shell, Command, Timeout) -> - case run_capture_cli(Shell, ["-l", "-i", "-c", Command], Timeout) of + case run_capture_cli(Shell, login_shell_args(Shell, Command), Timeout) of {ok, Lines} -> {ok, Lines}; {error, _} = Err -> diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 72e44e9..61bd9d7 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -480,6 +480,52 @@ run_capture_login_shell_uses_configured_shell_test() -> rm_rf(TmpDir) end. +run_capture_login_shell_resolves_bare_program_via_shell_path_test() -> + TmpDir = make_tmp_dir(), + try + ShellPath = write_fake_shell(TmpDir), + _CliPath = write_fake_cli( + TmpDir, + "probe-cli", + ["printf '%s\\n' 'probe-from-shell-path'\n"]), + ?assertEqual(false, os:find_executable("probe-cli")), + with_env_value( + "SHELL", + ShellPath, + fun() -> + {ok, Lines} = + beam_agent_auth_core:run_capture_login_shell( + "probe-cli", + [], + 5000), + ?assertEqual(["probe-from-shell-path"], Lines) + end) + after + rm_rf(TmpDir) + end. + +fallback_login_shell_prefers_compatible_shell_test() -> + TmpDir = make_tmp_dir(), + PreviousPath = os:getenv("PATH"), + try + BashPath = write_named_executable(TmpDir, "bash"), + _ShPath = write_named_executable(TmpDir, "sh"), + os:putenv("PATH", TmpDir), + ?assertEqual({ok, BashPath}, beam_agent_auth_core:fallback_login_shell()) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + +login_shell_args_adjust_for_sh_family_test() -> + ?assertEqual(["-c", "[ -f $HOME/.profile ] && . $HOME/.profile >/dev/null 2>&1; echo ok"], + beam_agent_auth_core:login_shell_args("/bin/sh", "echo ok")), + ?assertEqual(["-l", "-i", "-c", "echo ok"], + beam_agent_auth_core:login_shell_args("/bin/zsh", "echo ok")). + %%==================================================================== %% Helpers %%==================================================================== @@ -512,6 +558,8 @@ write_fake_shell(Dir) -> ok = file:write_file( Path, <<"#!/bin/sh\n" + "PATH=\"", (list_to_binary(Dir))/binary, ":$PATH\"\n" + "export PATH\n" "while [ $# -gt 0 ]; do\n" " if [ \"$1\" = \"-c\" ]; then\n" " shift\n" @@ -523,6 +571,14 @@ write_fake_shell(Dir) -> ok = file:change_mode(Path, 8#755), Path. +write_named_executable(Dir, Name) -> + Path = filename:join(Dir, Name), + ok = file:write_file( + Path, + <<"#!/bin/sh\nexit 0\n">>), + ok = file:change_mode(Path, 8#755), + Path. + read_lines(Path) -> {ok, Bin} = file:read_file(Path), [binary_to_list(Line) From 30bfc29fc6bc3c9d8410f45cedacf30bdb8e4d3e Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 03:48:36 -0500 Subject: [PATCH 04/13] Address PR review portability follow-ups --- src/backends/claude/claude_agent_sdk.erl | 8 ++++++- src/core/beam_agent_auth_core.erl | 26 ++++++++++++++++---- test/core/beam_agent_auth_core_tests.erl | 30 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/backends/claude/claude_agent_sdk.erl b/src/backends/claude/claude_agent_sdk.erl index cbb949e..c367bfa 100644 --- a/src/backends/claude/claude_agent_sdk.erl +++ b/src/backends/claude/claude_agent_sdk.erl @@ -830,7 +830,13 @@ collect_claude_model_ids(#{<<"lastModelUsage">> := Usage} = Map) usage_model_ids(Usage) ++ lists:flatmap(fun collect_claude_model_ids/1, maps:values(Map)); collect_claude_model_ids(#{<<"model">> := Model} = Map) when is_binary(Model) -> - MaybeModel = discoverable_claude_model_ids(Model), + MaybeModel = + case Model of + <<"claude-", _/binary>> -> + discoverable_claude_model_ids(Model); + _ -> + [] + end, MaybeModel ++ lists:flatmap(fun collect_claude_model_ids/1, maps:values(Map)); collect_claude_model_ids(Map) when is_map(Map) -> diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 33ad529..3e3db73 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -63,7 +63,7 @@ for Claude) rather than passing the secret on the command line — avoiding -export([validate_base_url/1, verify_executable_safety/1, resolve_symlinks/1, compute_file_hash/1, is_localhost/1, scrub_env/1, home_dir/0, - check_copilot_config/0, login_shell_program/0, + check_copilot_config/0, opencode_credential_count/1, login_shell_program/0, fallback_login_shell/0, login_shell_args/2]). -endif. @@ -683,9 +683,9 @@ opencode_missing_auth_status() -> -spec opencode_credential_count([string()]) -> non_neg_integer(). opencode_credential_count(Lines) -> Sanitized = - [string:trim(strip_ansi(Line)) + [binary_to_list(string:trim(strip_ansi(unicode:characters_to_binary(Line)))) || Line <- Lines, - string:trim(strip_ansi(Line)) =/= []], + string:trim(strip_ansi(unicode:characters_to_binary(Line))) =/= <<>>], case lists:filtermap(fun opencode_count_line/1, Sanitized) of [Count | _] -> Count; @@ -1876,9 +1876,27 @@ login_shell_program() -> [] -> fallback_login_shell(); Shell -> - {ok, Shell} + {ok, resolve_login_shell(normalize_shell_string(Shell))} + end. + +-spec resolve_login_shell(string()) -> string(). +resolve_login_shell(Shell) -> + case has_path_separator(Shell) of + true -> + Shell; + false -> + case os:find_executable(Shell) of + false -> + Shell; + ResolvedShell -> + ResolvedShell + end end. +-spec has_path_separator(string()) -> boolean(). +has_path_separator(Path) -> + lists:member($/, Path) orelse lists:member($\\, Path). + -spec fallback_login_shell() -> {ok, string()} | {error, {shell_not_found, string()}}. fallback_login_shell() -> Candidates = ["bash", "zsh", "ksh", "mksh", "fish", "sh"], diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 61bd9d7..8e00f86 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -439,6 +439,16 @@ opencode_status_rejects_empty_cli_auth_list_test() -> rm_rf(TmpDir) end. +opencode_credential_count_handles_binary_lines_test() -> + ?assertEqual( + 2, + beam_agent_auth_core:opencode_credential_count([ + <<"Credentials ~/.local/share/opencode/auth.json">>, + <<"">>, + <<"OpenAI oauth">>, + <<"Google api">> + ])). + run_capture_cli_respects_cwd_option_test() -> TmpDir = make_tmp_dir(), try @@ -526,6 +536,26 @@ login_shell_args_adjust_for_sh_family_test() -> ?assertEqual(["-l", "-i", "-c", "echo ok"], beam_agent_auth_core:login_shell_args("/bin/zsh", "echo ok")). +login_shell_program_resolves_shell_basename_test() -> + TmpDir = make_tmp_dir(), + PreviousPath = os:getenv("PATH"), + try + BashPath = write_named_executable(TmpDir, "bash"), + os:putenv("PATH", TmpDir), + with_env_value( + "SHELL", + "bash", + fun() -> + ?assertEqual({ok, BashPath}, beam_agent_auth_core:login_shell_program()) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + %%==================================================================== %% Helpers %%==================================================================== From c07b14f5fe932e9acd994919dafb82451210e07c Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 03:57:32 -0500 Subject: [PATCH 05/13] Fix CI login-shell regressions --- src/core/beam_agent_auth_core.erl | 36 +++++++++--------------- test/core/beam_agent_auth_core_tests.erl | 2 +- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 3e3db73..a859c13 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -1502,13 +1502,7 @@ run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout) -> -spec run_cli(cli_program(), [string()], [{string(), string()}], vault_env(), pos_integer(), map()) -> {ok, non_neg_integer(), [string()]} | {error, term()}. run_cli(Program, Args, InternalEnv, {vault_env, VaultVars}, Timeout, RunOpts) -> - ProgramStr = - case Program of - B when is_binary(B) -> - binary_to_list(B); - L when is_list(L) -> - L - end, + ProgramStr = normalize_program(Program), validate_env(InternalEnv), case os:find_executable(ProgramStr) of false -> @@ -1550,13 +1544,7 @@ invocation while still capturing output with BeamAgent's hardened runner. -spec run_capture_login_shell(cli_program(), [string()], pos_integer()) -> {ok, [string()]} | {error, term()}. run_capture_login_shell(Program, Args, Timeout) -> - ProgramStr = - case Program of - B when is_binary(B) -> - binary_to_list(B); - L when is_list(L) -> - L - end, + ProgramStr = normalize_program(Program), case login_shell_program() of {ok, Shell} -> verify_executable_safety(Shell), @@ -1950,8 +1938,6 @@ login_shell_command_string(Exe, Args) -> login_shell_args(Shell, Command) -> ShellPath = normalize_shell_string(Shell), case shell_mode(ShellPath) of - interactive_login -> - ["-l", "-i", "-c", Command]; login_only -> ["-l", "-c", Command]; profile_sourced -> @@ -1960,16 +1946,16 @@ login_shell_args(Shell, Command) -> ["-c", Command] end. --type shell_mode() :: interactive_login | login_only | profile_sourced | plain. +-type shell_mode() :: login_only | profile_sourced | plain. -spec shell_mode(string()) -> shell_mode(). shell_mode(ShellPath) -> case shell_family(ShellPath) of - bash -> interactive_login; - zsh -> interactive_login; - ksh -> interactive_login; - mksh -> interactive_login; - fish -> interactive_login; + bash -> login_only; + zsh -> login_only; + ksh -> login_only; + mksh -> login_only; + fish -> login_only; sh -> profile_sourced; dash -> profile_sourced; busybox -> profile_sourced; @@ -2034,6 +2020,12 @@ normalize_shell_string(B) when is_binary(B) -> normalize_shell_string(L) when is_list(L) -> L. +-spec normalize_program(cli_program()) -> string(). +normalize_program(B) when is_binary(B) -> + binary_to_list(B); +normalize_program(L) when is_list(L) -> + L. + -spec strip_shell_startup_lines([string()]) -> [string()]. strip_shell_startup_lines(Lines) -> [Clean diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 8e00f86..6a745ee 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -533,7 +533,7 @@ fallback_login_shell_prefers_compatible_shell_test() -> login_shell_args_adjust_for_sh_family_test() -> ?assertEqual(["-c", "[ -f $HOME/.profile ] && . $HOME/.profile >/dev/null 2>&1; echo ok"], beam_agent_auth_core:login_shell_args("/bin/sh", "echo ok")), - ?assertEqual(["-l", "-i", "-c", "echo ok"], + ?assertEqual(["-l", "-c", "echo ok"], beam_agent_auth_core:login_shell_args("/bin/zsh", "echo ok")). login_shell_program_resolves_shell_basename_test() -> From 72149aeba0b12842dd2cd0178cb281a923d2f2e4 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 04:08:32 -0500 Subject: [PATCH 06/13] Harden shell env fallback handling --- src/core/beam_agent_auth_core.erl | 22 ++++++++++++++++------ test/core/beam_agent_auth_core_tests.erl | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index a859c13..8b51b7f 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -680,7 +680,7 @@ opencode_missing_auth_status() -> <<"OpenCode server unreachable, OPENAI_API_KEY not set, " "and no configured OpenCode CLI credentials were found.">>}}}. --spec opencode_credential_count([string()]) -> non_neg_integer(). +-spec opencode_credential_count([string() | binary()]) -> non_neg_integer(). opencode_credential_count(Lines) -> Sanitized = [binary_to_list(string:trim(strip_ansi(unicode:characters_to_binary(Line)))) @@ -1864,20 +1864,30 @@ login_shell_program() -> [] -> fallback_login_shell(); Shell -> - {ok, resolve_login_shell(normalize_shell_string(Shell))} + case resolve_login_shell(normalize_shell_string(Shell)) of + {ok, ResolvedShell} -> + {ok, ResolvedShell}; + error -> + fallback_login_shell() + end end. --spec resolve_login_shell(string()) -> string(). +-spec resolve_login_shell(string()) -> {ok, string()} | error. resolve_login_shell(Shell) -> case has_path_separator(Shell) of true -> - Shell; + case filelib:is_regular(Shell) of + true -> + {ok, Shell}; + false -> + error + end; false -> case os:find_executable(Shell) of false -> - Shell; + error; ResolvedShell -> - ResolvedShell + {ok, ResolvedShell} end end. diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 6a745ee..d8c18ff 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -556,6 +556,26 @@ login_shell_program_resolves_shell_basename_test() -> rm_rf(TmpDir) end. +login_shell_program_falls_back_when_shell_env_is_unusable_test() -> + TmpDir = make_tmp_dir(), + PreviousPath = os:getenv("PATH"), + try + BashPath = write_named_executable(TmpDir, "bash"), + os:putenv("PATH", TmpDir), + with_env_value( + "SHELL", + "definitely-not-a-shell", + fun() -> + ?assertEqual({ok, BashPath}, beam_agent_auth_core:login_shell_program()) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + %%==================================================================== %% Helpers %%==================================================================== From 5aa7ac25ab58476f9dfe431a11bd413999c1295a Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 04:25:29 -0500 Subject: [PATCH 07/13] Drain port close and honor backslash paths --- src/core/beam_agent_auth_core.erl | 11 +++++++---- test/core/beam_agent_auth_core_tests.erl | 8 ++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 8b51b7f..3b19f51 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -64,7 +64,7 @@ for Claude) rather than passing the secret on the command line — avoiding -export([validate_base_url/1, verify_executable_safety/1, resolve_symlinks/1, compute_file_hash/1, is_localhost/1, scrub_env/1, home_dir/0, check_copilot_config/0, opencode_credential_count/1, login_shell_program/0, - fallback_login_shell/0, login_shell_args/2]). + fallback_login_shell/0, login_shell_args/2, shell_command_executable/1]). -endif. @@ -1721,6 +1721,7 @@ collect_output(Port, Acc, LineBuf, Timeout) -> [] -> FinalAcc; _ -> [lists:flatten(lists:reverse(FinalLineBuf)) | FinalAcc] end), + flush_port(Port), log_cli_result(ExitCode, Lines), {ok, ExitCode, Lines} after Timeout -> @@ -1739,6 +1740,8 @@ collect_remaining_output(Port, Acc, LineBuf) -> collect_remaining_output(Port, [CompletedLine | Acc], []); {Port, {data, {noeol, Line}}} -> collect_remaining_output(Port, Acc, [Line | LineBuf]); + {Port, closed} -> + collect_remaining_output(Port, Acc, LineBuf); {Port, {exit_status, _}} -> collect_remaining_output(Port, Acc, LineBuf) after 0 -> @@ -1918,10 +1921,10 @@ find_login_shell([Candidate | Rest]) -> -spec shell_command_executable(string()) -> {ok, string()} | {error, term()}. shell_command_executable(ProgramStr) -> - case string:find(ProgramStr, "/") of - nomatch -> + case has_path_separator(ProgramStr) of + false -> {ok, ProgramStr}; - _ -> + true -> Path0 = case filename:pathtype(ProgramStr) of relative -> diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index d8c18ff..d3be204 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -576,6 +576,14 @@ login_shell_program_falls_back_when_shell_env_is_unusable_test() -> rm_rf(TmpDir) end. +shell_command_executable_treats_backslash_paths_as_explicit_test() -> + %% A backslash-separated path should be treated as an explicit path rather + %% than a bare program name, even on non-Windows hosts. + ?assertEqual( + {error, {cli_not_found, "C:\\tools\\opencode.exe"}}, + beam_agent_auth_core:shell_command_executable("C:\\tools\\opencode.exe") + ). + %%==================================================================== %% Helpers %%==================================================================== From ee7e3f8b1205d8a7f11cfa3a29557c93b6f1ba91 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 04:50:30 -0500 Subject: [PATCH 08/13] Improve opencode auth probe fallbacks --- src/core/beam_agent_auth_core.erl | 51 ++++++++++++++++--- test/core/beam_agent_auth_core_tests.erl | 64 ++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 3b19f51..2c1b487 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -631,7 +631,7 @@ opencode_local_auth_status(Opts) -> opencode_cli_auth_status(Opts) -> Cli = resolve_cli(opencode, Opts), Timeout = maps:get(timeout, Opts, ?STATUS_TIMEOUT), - case run_capture_cli(Cli, ["auth", "list"], Timeout) of + case run_opencode_auth_list(Cli, Timeout) of {ok, Lines} -> case opencode_credential_count(Lines) of Count when Count > 0 -> @@ -653,7 +653,7 @@ opencode_cli_auth_status(Opts) -> "not set, and `opencode auth list` reported " "no configured credentials.">>}}} end; - {error, {cli_exit, _ExitCode, Lines}} -> + {error, {cli_exit, ExitCode, Lines}} -> case opencode_credential_count(Lines) of Count when Count > 0 -> {ok, @@ -664,13 +664,48 @@ opencode_cli_auth_status(Opts) -> #{source => <<"opencode auth list">>, credential_count => Count}}}; 0 -> - opencode_missing_auth_status() + opencode_probe_failed_status(Cli, {error, {cli_exit, ExitCode, Lines}}) end; - {error, _Reason} -> - opencode_missing_auth_status() + {error, Reason} -> + opencode_probe_failed_status(Cli, {error, Reason}) + end. + +-spec run_opencode_auth_list(string(), pos_integer()) -> + {ok, [string()]} | {error, term()}. +run_opencode_auth_list(Cli, Timeout) -> + case run_capture_cli(Cli, ["auth", "list"], Timeout) of + {error, {cli_not_found, _}} -> + run_capture_login_shell(Cli, ["auth", "list"], Timeout); + Other -> + Other end. -opencode_missing_auth_status() -> +-spec opencode_probe_failed_status(string(), {error, term()}) -> {ok, map()}. +opencode_probe_failed_status(Cli, {error, {cli_not_found, _}}) -> + {ok, + #{backend => opencode, + authenticated => false, + method => cli, + details => + #{hint => + <<"OpenCode server unreachable, OPENAI_API_KEY not set, " + "and the OpenCode CLI could not be found via PATH or the login shell.">>, + source => <<"opencode auth list">>, + cli => list_to_binary(Cli), + failure => cli_not_found}}}; +opencode_probe_failed_status(_Cli, {error, {cli_exit, ExitCode, _Lines}}) -> + {ok, + #{backend => opencode, + authenticated => false, + method => cli, + details => + #{hint => + <<"OpenCode server unreachable, OPENAI_API_KEY not set, " + "and `opencode auth list` failed before credentials could be read.">>, + source => <<"opencode auth list">>, + failure => cli_exit, + exit_code => ExitCode}}}; +opencode_probe_failed_status(_Cli, {error, Reason}) -> {ok, #{backend => opencode, authenticated => false, @@ -678,7 +713,9 @@ opencode_missing_auth_status() -> details => #{hint => <<"OpenCode server unreachable, OPENAI_API_KEY not set, " - "and no configured OpenCode CLI credentials were found.">>}}}. + "and the OpenCode CLI probe failed.">>, + source => <<"opencode auth list">>, + failure => list_to_binary(io_lib:format("~0p", [Reason]))}}}. -spec opencode_credential_count([string() | binary()]) -> non_neg_integer(). opencode_credential_count(Lines) -> diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index d3be204..cdd16ea 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -449,6 +449,70 @@ opencode_credential_count_handles_binary_lines_test() -> <<"Google api">> ])). +opencode_status_retries_auth_list_via_login_shell_when_cli_not_on_path_test() -> + TmpDir = make_tmp_dir(), + PreviousPath = os:getenv("PATH"), + try + ShellPath = write_fake_shell(TmpDir), + _CliPath = write_fake_cli( + TmpDir, + "opencode", + ["if [ \"$1\" = \"auth\" ] && [ \"$2\" = \"list\" ]; then\n", + " printf '%s\\n' 'OpenAI oauth'\n", + " exit 0\n", + "fi\n", + "exit 1\n"]), + os:putenv("PATH", ""), + with_env_unset( + "OPENAI_API_KEY", + fun() -> + with_env_value( + "SHELL", + ShellPath, + fun() -> + {ok, Status} = + beam_agent_auth_core:status( + opencode, + #{base_url => "http://localhost:1"}), + ?assertEqual(true, maps:get(authenticated, Status)), + ?assertEqual(cli, maps:get(method, Status)), + ?assertEqual(1, maps:get(credential_count, maps:get(details, Status))) + end) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + +opencode_status_reports_cli_probe_failures_distinctly_test() -> + TmpDir = make_tmp_dir(), + try + CliPath = write_fake_cli( + TmpDir, + "opencode", + ["printf '%s\\n' 'permission denied'\n", + "exit 2\n"]), + with_env_unset( + "OPENAI_API_KEY", + fun() -> + {ok, Status} = + beam_agent_auth_core:status( + opencode, + #{cli_path => CliPath, + base_url => "http://localhost:1"}), + ?assertEqual(false, maps:get(authenticated, Status)), + ?assertEqual(cli, maps:get(method, Status)), + Details = maps:get(details, Status), + ?assertEqual(cli_exit, maps:get(failure, Details)), + ?assertEqual(2, maps:get(exit_code, Details)) + end) + after + rm_rf(TmpDir) + end. + run_capture_cli_respects_cwd_option_test() -> TmpDir = make_tmp_dir(), try From 9ec35e5e245470612fe4d854dff725a4b01b47d7 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 05:03:21 -0500 Subject: [PATCH 09/13] Fix latest PR review follow-ups --- src/backends/claude/claude_agent_sdk.erl | 2 +- src/core/beam_agent_auth_core.erl | 2 +- test/core/beam_agent_auth_core_tests.erl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backends/claude/claude_agent_sdk.erl b/src/backends/claude/claude_agent_sdk.erl index c367bfa..50331ce 100644 --- a/src/backends/claude/claude_agent_sdk.erl +++ b/src/backends/claude/claude_agent_sdk.erl @@ -815,7 +815,7 @@ discover_models_from_claude_config(Path) -> try json:decode(Bin) of Decoded -> ModelIds = ordsets:from_list(collect_claude_model_ids(Decoded)), - {ok, expand_claude_model_entries([claude_model_entry(Id) || Id <- ModelIds])} + {ok, [claude_model_entry(Id) || Id <- ModelIds]} catch _:_ -> {ok, []} diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 2c1b487..9a469ba 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -2061,7 +2061,7 @@ resolved_shell_family(ShellPath, Depth, Default) -> -spec profile_sourced_command(string()) -> string(). profile_sourced_command(Command) -> - Profile = "$HOME/.profile", + Profile = "\"$HOME/.profile\"", "[ -f " ++ Profile ++ " ] && . " ++ Profile ++ " >/dev/null 2>&1; " ++ Command. -spec normalize_shell_string(string() | binary()) -> string(). diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index cdd16ea..6840a60 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -595,7 +595,7 @@ fallback_login_shell_prefers_compatible_shell_test() -> end. login_shell_args_adjust_for_sh_family_test() -> - ?assertEqual(["-c", "[ -f $HOME/.profile ] && . $HOME/.profile >/dev/null 2>&1; echo ok"], + ?assertEqual(["-c", "[ -f \"$HOME/.profile\" ] && . \"$HOME/.profile\" >/dev/null 2>&1; echo ok"], beam_agent_auth_core:login_shell_args("/bin/sh", "echo ok")), ?assertEqual(["-l", "-c", "echo ok"], beam_agent_auth_core:login_shell_args("/bin/zsh", "echo ok")). From 735d962bf7588e6f8fc8223070cc13d151df01fb Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 05:16:39 -0500 Subject: [PATCH 10/13] Clarify login-shell runner docstring --- src/core/beam_agent_auth_core.erl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index 9a469ba..d94261f 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -1575,8 +1575,9 @@ run_capture_cli(Program, Args, Timeout, RunOpts) when is_map(RunOpts) -> -doc(""" Execute a command through the user's login shell using the shell configured -in `SHELL`, preserving the same startup semantics as an interactive shell -invocation while still capturing output with BeamAgent's hardened runner. +in `SHELL`, preserving login-shell startup behavior as implemented by +BeamAgent's hardened runner while still capturing output. This does not +imply interactive-shell initialization such as sourcing `.bashrc`. """). -spec run_capture_login_shell(cli_program(), [string()], pos_integer()) -> {ok, [string()]} | {error, term()}. From d3fb12be2868df02caef128866ec7911229cc3a0 Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 05:34:23 -0500 Subject: [PATCH 11/13] Harden opencode discovery and auth probe status --- src/backends/opencode/opencode_client.erl | 7 +++- src/core/beam_agent_auth_core.erl | 3 +- .../opencode_model_discovery_tests.erl | 40 +++++++++++++++++++ test/core/beam_agent_auth_core_tests.erl | 31 ++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/backends/opencode/opencode_client.erl b/src/backends/opencode/opencode_client.erl index c1f6e6a..990229c 100644 --- a/src/backends/opencode/opencode_client.erl +++ b/src/backends/opencode/opencode_client.erl @@ -1064,7 +1064,12 @@ model_entry(ModelId) -> -spec run_model_cli(string(), [string(), ...]) -> {ok, [string()]} | {error, term()}. run_model_cli(Program, Args) -> - beam_agent_auth_core:run_capture_login_shell(Program, Args, 60000). + case beam_agent_auth_core:run_capture_cli(Program, Args, 60000) of + {error, {cli_not_found, _}} -> + beam_agent_auth_core:run_capture_login_shell(Program, Args, 60000); + Result -> + Result + end. -spec with_adapter_source(pid(), map()) -> session_view(). with_adapter_source(_Session, Result) -> diff --git a/src/core/beam_agent_auth_core.erl b/src/core/beam_agent_auth_core.erl index d94261f..73fc171 100644 --- a/src/core/beam_agent_auth_core.erl +++ b/src/core/beam_agent_auth_core.erl @@ -715,7 +715,8 @@ opencode_probe_failed_status(_Cli, {error, Reason}) -> <<"OpenCode server unreachable, OPENAI_API_KEY not set, " "and the OpenCode CLI probe failed.">>, source => <<"opencode auth list">>, - failure => list_to_binary(io_lib:format("~0p", [Reason]))}}}. + failure => cli_probe_failed, + reason => list_to_binary(io_lib:format("~0p", [Reason]))}}}. -spec opencode_credential_count([string() | binary()]) -> non_neg_integer(). opencode_credential_count(Lines) -> diff --git a/test/backends/opencode/opencode_model_discovery_tests.erl b/test/backends/opencode/opencode_model_discovery_tests.erl index 78f47ac..0e51028 100644 --- a/test/backends/opencode/opencode_model_discovery_tests.erl +++ b/test/backends/opencode/opencode_model_discovery_tests.erl @@ -34,6 +34,34 @@ discover_cli_models_uses_opencode_models_command_test() -> rm_rf(TmpDir) end. +discover_cli_models_with_explicit_cli_path_does_not_require_login_shell_test() -> + TmpDir = make_tmp_dir(), + PreviousPath = os:getenv("PATH"), + try + CliPath = write_fake_opencode(TmpDir), + os:putenv("PATH", ""), + with_env_value( + "SHELL", + "definitely-not-a-shell", + fun() -> + {ok, Models} = opencode_client:discover_cli_models(#{cli_path => CliPath}), + ?assertEqual( + [ + #{<<"modelId">> => <<"openai/gpt-5">>, + <<"name">> => <<"openai/gpt-5">>}, + #{<<"modelId">> => <<"anthropic/claude-sonnet-4-6">>, + <<"name">> => <<"anthropic/claude-sonnet-4-6">>} + ], + Models) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + write_fake_opencode(Dir) -> Path = filename:join(Dir, "opencode"), ok = file:write_file( @@ -71,3 +99,15 @@ rm_rf(Dir) -> {error, _} -> ok end. + +with_env_value(Name, Value, Fun) -> + Previous = os:getenv(Name), + os:putenv(Name, Value), + try + Fun() + after + case Previous of + false -> os:unsetenv(Name); + OldValue -> os:putenv(Name, OldValue) + end + end. diff --git a/test/core/beam_agent_auth_core_tests.erl b/test/core/beam_agent_auth_core_tests.erl index 6840a60..e00e166 100644 --- a/test/core/beam_agent_auth_core_tests.erl +++ b/test/core/beam_agent_auth_core_tests.erl @@ -513,6 +513,37 @@ opencode_status_reports_cli_probe_failures_distinctly_test() -> rm_rf(TmpDir) end. +opencode_status_reports_generic_probe_failures_with_stable_failure_code_test() -> + PreviousPath = os:getenv("PATH"), + try + os:putenv("PATH", ""), + with_env_unset( + "OPENAI_API_KEY", + fun() -> + with_env_value( + "SHELL", + "definitely-not-a-shell", + fun() -> + {ok, Status} = + beam_agent_auth_core:status( + opencode, + #{base_url => "http://localhost:1"}), + ?assertEqual(false, maps:get(authenticated, Status)), + ?assertEqual(cli, maps:get(method, Status)), + Details = maps:get(details, Status), + ?assertEqual(cli_probe_failed, maps:get(failure, Details)), + Reason = maps:get(reason, Details), + ?assert(is_binary(Reason)), + ?assertMatch({_, _}, binary:match(Reason, <<"shell_not_found">>)) + end) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end + end. + run_capture_cli_respects_cwd_option_test() -> TmpDir = make_tmp_dir(), try From 0933f31a78d3106287d2b89b24ffe2db2d363bff Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 05:51:35 -0500 Subject: [PATCH 12/13] Restore opencode shell parity for bare discovery --- src/backends/opencode/opencode_client.erl | 41 +++++++----- .../opencode_model_discovery_tests.erl | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/backends/opencode/opencode_client.erl b/src/backends/opencode/opencode_client.erl index 990229c..69089e8 100644 --- a/src/backends/opencode/opencode_client.erl +++ b/src/backends/opencode/opencode_client.erl @@ -184,10 +184,10 @@ {thread_realtime_append_text, 3}, {thread_realtime_stop, 2}, {review_start, 2}, - {with_adapter_source, 2}, - {maybe_include_thread_read, 4}, - {discover_cli_models, 1}, - {run_model_cli, 2}]}). + {with_adapter_source, 2}, + {maybe_include_thread_read, 4}, + {discover_cli_models, 1}, + {run_model_cli, 3}]}). -dialyzer({no_underspecs, [{send_control, 3}, {fork_session, 2}, @@ -1022,20 +1022,30 @@ cli_or_init_models(Session) -> extract_init_field(Session, models, models, []) end. --spec session_cli_opts(pid()) -> #{cli_path := string() | binary()}. +-spec session_cli_opts(pid()) -> #{cli_path := string() | binary(), + cli_path_explicit := boolean()}. session_cli_opts(Session) -> case session_info(Session) of {ok, Info} -> - #{cli_path => maps:get(cli_path, Info, "opencode")}; + case maps:find(cli_path, Info) of + {ok, CliPath} -> + #{cli_path => CliPath, + cli_path_explicit => true}; + error -> + #{cli_path => "opencode", + cli_path_explicit => false} + end; {error, _} -> - #{cli_path => "opencode"} + #{cli_path => "opencode", + cli_path_explicit => false} end. --spec discover_cli_models(#{cli_path := string() | binary()}) -> +-spec discover_cli_models(#{cli_path := string() | binary(), _ => _}) -> {ok, [discovered_model()]} | {error, term()}. discover_cli_models(Opts) when is_map(Opts) -> Cli = beam_agent_auth_core:resolve_cli(opencode, Opts), - case run_model_cli(Cli, ["models"]) of + ExplicitCliPath = maps:get(cli_path_explicit, Opts, maps:is_key(cli_path, Opts)), + case run_model_cli(Cli, ["models"], ExplicitCliPath) of {ok, Lines} -> {ok, parse_cli_model_lines(Lines)}; {error, _} = Err -> @@ -1061,15 +1071,12 @@ model_entry(ModelId) -> #{<<"modelId">> => ModelId, <<"name">> => ModelId}. --spec run_model_cli(string(), [string(), ...]) -> +-spec run_model_cli(string(), [string(), ...], boolean()) -> {ok, [string()]} | {error, term()}. -run_model_cli(Program, Args) -> - case beam_agent_auth_core:run_capture_cli(Program, Args, 60000) of - {error, {cli_not_found, _}} -> - beam_agent_auth_core:run_capture_login_shell(Program, Args, 60000); - Result -> - Result - end. +run_model_cli(Program, Args, true) -> + beam_agent_auth_core:run_capture_cli(Program, Args, 60000); +run_model_cli(Program, Args, false) -> + beam_agent_auth_core:run_capture_login_shell(Program, Args, 60000). -spec with_adapter_source(pid(), map()) -> session_view(). with_adapter_source(_Session, Result) -> diff --git a/test/backends/opencode/opencode_model_discovery_tests.erl b/test/backends/opencode/opencode_model_discovery_tests.erl index 0e51028..ebf6407 100644 --- a/test/backends/opencode/opencode_model_discovery_tests.erl +++ b/test/backends/opencode/opencode_model_discovery_tests.erl @@ -62,6 +62,41 @@ discover_cli_models_with_explicit_cli_path_does_not_require_login_shell_test() - rm_rf(TmpDir) end. +discover_cli_models_uses_login_shell_first_for_default_bare_command_test() -> + TmpDir = make_tmp_dir(), + ShellBinDir = filename:join(TmpDir, "shell-bin"), + PathBinDir = filename:join(TmpDir, "path-bin"), + PreviousPath = os:getenv("PATH"), + try + ok = filelib:ensure_dir(filename:join(ShellBinDir, "placeholder")), + ok = filelib:ensure_dir(filename:join(PathBinDir, "placeholder")), + _ShellCli = write_fake_opencode_named(ShellBinDir, "opencode", "shell-first/good-model"), + _PathCli = write_fake_opencode_named(PathBinDir, "opencode", "path-visible/wrong-model"), + ShellPath = write_fake_shell_with_path(TmpDir, ShellBinDir), + os:putenv("PATH", PathBinDir), + with_env_value( + "SHELL", + ShellPath, + fun() -> + {ok, Models} = + opencode_client:discover_cli_models( + #{cli_path => "opencode", + cli_path_explicit => false}), + ?assertEqual( + [ + #{<<"modelId">> => <<"shell-first/good-model">>, + <<"name">> => <<"shell-first/good-model">>} + ], + Models) + end) + after + case PreviousPath of + false -> os:unsetenv("PATH"); + Value -> os:putenv("PATH", Value) + end, + rm_rf(TmpDir) + end. + write_fake_opencode(Dir) -> Path = filename:join(Dir, "opencode"), ok = file:write_file( @@ -76,6 +111,38 @@ write_fake_opencode(Dir) -> ok = file:change_mode(Path, 8#755), Path. +write_fake_opencode_named(Dir, Name, ModelId) -> + Path = filename:join(Dir, Name), + ok = file:write_file( + Path, + iolist_to_binary( + ["#!/bin/sh\n", + "if [ \"$1\" = \"models\" ]; then\n", + " printf '%s\\n' '", ModelId, "'\n", + " exit 0\n", + "fi\n", + "exit 1\n"])), + ok = file:change_mode(Path, 8#755), + Path. + +write_fake_shell_with_path(Dir, ShellBinDir) -> + Path = filename:join(Dir, "fake-shell"), + ok = file:write_file( + Path, + <<"#!/bin/sh\n" + "PATH=\"", (list_to_binary(ShellBinDir))/binary, ":$PATH\"\n" + "export PATH\n" + "while [ $# -gt 0 ]; do\n" + " if [ \"$1\" = \"-c\" ]; then\n" + " shift\n" + " exec /bin/sh -c \"$1\"\n" + " fi\n" + " shift\n" + "done\n" + "exit 1\n">>), + ok = file:change_mode(Path, 8#755), + Path. + make_tmp_dir() -> Base = filename:basedir(user_cache, "beam_agent_test"), Unique = integer_to_list(erlang:unique_integer([positive])), From e703ad41ce976709c6c31344554c52b7477ad39a Mon Sep 17 00:00:00 2001 From: beardedeagle Date: Thu, 9 Apr 2026 06:05:51 -0500 Subject: [PATCH 13/13] Fix opencode parity typing contract --- src/backends/opencode/opencode_client.erl | 16 ++++++---------- .../opencode/opencode_model_discovery_tests.erl | 3 +-- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/backends/opencode/opencode_client.erl b/src/backends/opencode/opencode_client.erl index 69089e8..ce57847 100644 --- a/src/backends/opencode/opencode_client.erl +++ b/src/backends/opencode/opencode_client.erl @@ -1022,29 +1022,25 @@ cli_or_init_models(Session) -> extract_init_field(Session, models, models, []) end. --spec session_cli_opts(pid()) -> #{cli_path := string() | binary(), - cli_path_explicit := boolean()}. +-spec session_cli_opts(pid()) -> map(). session_cli_opts(Session) -> case session_info(Session) of {ok, Info} -> case maps:find(cli_path, Info) of {ok, CliPath} -> - #{cli_path => CliPath, - cli_path_explicit => true}; + #{cli_path => CliPath}; error -> - #{cli_path => "opencode", - cli_path_explicit => false} + #{} end; {error, _} -> - #{cli_path => "opencode", - cli_path_explicit => false} + #{} end. --spec discover_cli_models(#{cli_path := string() | binary(), _ => _}) -> +-spec discover_cli_models(map()) -> {ok, [discovered_model()]} | {error, term()}. discover_cli_models(Opts) when is_map(Opts) -> Cli = beam_agent_auth_core:resolve_cli(opencode, Opts), - ExplicitCliPath = maps:get(cli_path_explicit, Opts, maps:is_key(cli_path, Opts)), + ExplicitCliPath = maps:is_key(cli_path, Opts), case run_model_cli(Cli, ["models"], ExplicitCliPath) of {ok, Lines} -> {ok, parse_cli_model_lines(Lines)}; diff --git a/test/backends/opencode/opencode_model_discovery_tests.erl b/test/backends/opencode/opencode_model_discovery_tests.erl index ebf6407..9fac143 100644 --- a/test/backends/opencode/opencode_model_discovery_tests.erl +++ b/test/backends/opencode/opencode_model_discovery_tests.erl @@ -80,8 +80,7 @@ discover_cli_models_uses_login_shell_first_for_default_bare_command_test() -> fun() -> {ok, Models} = opencode_client:discover_cli_models( - #{cli_path => "opencode", - cli_path_explicit => false}), + #{}), ?assertEqual( [ #{<<"modelId">> => <<"shell-first/good-model">>,