diff --git a/CMakeLists.txt b/CMakeLists.txt index f55167e..02f67a1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ set(RCLI_SOURCES src/commands/cmd_info.cpp src/commands/cmd_auth.cpp src/commands/cmd_account.cpp + src/commands/cmd_opencode.cpp src/commands/cmd_backends.cpp src/commands/cmd_list.cpp src/commands/cmd_lora.cpp @@ -50,6 +51,7 @@ set(RCLI_SOURCES src/commands/cmd_bench.cpp src/account/console.cpp src/account/credentials.cpp + src/harness/opencode.cpp src/commands/engine_options.cpp src/commands/model_setup.cpp src/config/cli_paths.cpp diff --git a/README.md b/README.md index ef3dd7e..dca13a1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ console can be selected explicitly with `RCLI_CONSOLE_URL=http://localhost:` or `--console-url`; non-loopback HTTP origins are rejected. +With OpenCode installed, start an explicitly hosted coding session with a +console model id: + +```bash +rcli opencode --cloud --model +``` + +Pass OpenCode's own arguments after `--`, for example +`rcli opencode --cloud --model -- run`. RCLI supplies an +OpenAI-compatible `/v1` provider through +`OPENCODE_CONFIG_CONTENT` only while OpenCode runs. It never edits project or +user tool configuration and restores an existing environment value afterward. + The cloud session is separate from `rcli auth`, which configures the on-device SDK/control-plane connection. Cloud login does not require `RUNANYWHERE_API_KEY`. On macOS and Linux the session file is stored under a diff --git a/src/app.cpp b/src/app.cpp index ee82abb..e765543 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -71,6 +71,7 @@ void configure_app(CLI::App& app, GlobalOptions& options) { commands::register_version(app, options); commands::register_auth(app, options); commands::register_account(app, options); + commands::register_opencode(app, options); commands::register_telemetry(app, options); } diff --git a/src/commands/cmd_opencode.cpp b/src/commands/cmd_opencode.cpp new file mode 100644 index 0000000..5a728dc --- /dev/null +++ b/src/commands/cmd_opencode.cpp @@ -0,0 +1,38 @@ +#include +#include +#include + +#include "commands/commands.h" +#include "harness/opencode.h" + +namespace rcli::commands { +namespace { + +void Fail(int status) { + if (status != 0) { + throw CLI::RuntimeError(status); + } +} + +} // namespace + +void register_opencode(CLI::App& app, GlobalOptions& options) { + static_cast(options); + auto cloud = std::make_shared(false); + auto model = std::make_shared(); + auto arguments = std::make_shared>(); + + CLI::App* command = app.add_subcommand( + "opencode", "start OpenCode with an ephemeral RunAnywhere cloud provider"); + command->add_flag("--cloud", *cloud, "use the signed-in hosted inference endpoint")->required(); + command->add_option("-m,--model", *model, "hosted model id")->required(); + command + ->add_option("arguments", *arguments, + "arguments after `--` are passed directly to OpenCode") + ->allow_extra_args(); + command->positionals_at_end(true); + command->callback( + [cloud, model, arguments] { Fail(harness::LaunchOpenCodeCloud(*model, *arguments)); }); +} + +} // namespace rcli::commands diff --git a/src/commands/commands.h b/src/commands/commands.h index f496076..da44f32 100644 --- a/src/commands/commands.h +++ b/src/commands/commands.h @@ -60,6 +60,8 @@ void register_bench(CLI::App& app, GlobalOptions& options); void register_auth(CLI::App& app, GlobalOptions& options); // Browser-approved cloud session. Separate from SDK/device `auth` credentials. void register_account(CLI::App& app, GlobalOptions& options); +// Explicit hosted coding session. Does not bootstrap the local SDK or write tool config. +void register_opencode(CLI::App& app, GlobalOptions& options); void register_telemetry(CLI::App& app, GlobalOptions& options); /** diff --git a/src/harness/opencode.cpp b/src/harness/opencode.cpp new file mode 100644 index 0000000..1134e21 --- /dev/null +++ b/src/harness/opencode.cpp @@ -0,0 +1,225 @@ +#include "harness/opencode.h" + +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include + +#include +#endif + +#include "account/credentials.h" +#include "io/output.h" + +namespace rcli::harness { +namespace { + +constexpr const char* kOpenCodeConfigVariable = "OPENCODE_CONFIG_CONTENT"; + +long long EpochSeconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +bool ModelIsSafe(const std::string& model) { + if (model.empty() || model.size() > 512) { + return false; + } + for (const unsigned char byte : model) { + if (byte < 0x20 || byte == 0x7f) { + return false; + } + } + return true; +} + +bool SetEnvironment(const char* name, const std::string& value) { +#if defined(_WIN32) + return _putenv_s(name, value.c_str()) == 0; +#else + return setenv(name, value.c_str(), 1) == 0; +#endif +} + +bool UnsetEnvironment(const char* name) { +#if defined(_WIN32) + return _putenv_s(name, "") == 0; +#else + return unsetenv(name) == 0; +#endif +} + +class ScopedOpenCodeConfig { + public: + ScopedOpenCodeConfig() { + const char* previous = std::getenv(kOpenCodeConfigVariable); + if (previous != nullptr) { + had_previous_ = true; + previous_ = previous; + } + } + + ScopedOpenCodeConfig(const ScopedOpenCodeConfig&) = delete; + ScopedOpenCodeConfig& operator=(const ScopedOpenCodeConfig&) = delete; + + ~ScopedOpenCodeConfig() { + if (!active_) { + return; + } + if (had_previous_) { + static_cast(SetEnvironment(kOpenCodeConfigVariable, previous_)); + } else { + static_cast(UnsetEnvironment(kOpenCodeConfigVariable)); + } + } + + bool Activate(const std::string& value) { + active_ = SetEnvironment(kOpenCodeConfigVariable, value); + return active_; + } + + private: + std::string previous_; + bool had_previous_ = false; + bool active_ = false; +}; + +int Spawn(const std::string& executable, const std::vector& arguments) { + std::vector owned; + owned.reserve(arguments.size() + 1); + owned.push_back(executable); + owned.insert(owned.end(), arguments.begin(), arguments.end()); + + std::vector argv; + argv.reserve(owned.size() + 1); + for (std::string& value : owned) { + argv.push_back(value.data()); + } + argv.push_back(nullptr); + +#if defined(_WIN32) + const intptr_t status = _spawnvp(_P_WAIT, executable.c_str(), argv.data()); + if (status < 0) { + out::error_line("OpenCode was not found on PATH"); + return 127; + } + return static_cast(status); +#else + const pid_t child = fork(); + if (child < 0) { + out::error_line("could not start OpenCode"); + return 1; + } + if (child == 0) { + execvp(executable.c_str(), argv.data()); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno == EINTR) { + continue; + } + out::error_line("lost track of OpenCode"); + return 1; + } + if (!WIFEXITED(status)) { + return 1; + } + const int exit_code = WEXITSTATUS(status); + if (exit_code == 127) { + out::error_line("OpenCode was not found on PATH"); + } + return exit_code; +#endif +} + +bool RefreshCredentials(const account::ConsoleClient& console, account::Credentials* credentials, + std::string* error) { + if (credentials->refresh_token.empty()) { + if (error != nullptr) { + *error = "the cloud session cannot be refreshed; run `rcli login`"; + } + return false; + } + + account::Grant grant; + if (!console.Refresh(credentials->console_url, credentials->refresh_token, &grant, error)) { + return false; + } + credentials->access_token = grant.access_token; + if (!grant.refresh_token.empty()) { + credentials->refresh_token = grant.refresh_token; + } + if (!grant.email.empty()) { + credentials->email = grant.email; + } + credentials->expires_at = EpochSeconds() + (grant.expires_in > 0 ? grant.expires_in : 3600); + return account::Save(*credentials, error); +} + +} // namespace + +std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url, + const std::string& access_token) { + using Json = nlohmann::json; + const Json provider = { + {"npm", "@ai-sdk/openai-compatible"}, + {"name", "RunAnywhere"}, + {"options", {{"baseURL", base_url}, {"apiKey", access_token}}}, + {"models", {{model, {{"name", model}}}}}, + }; + return Json{{"provider", {{"runanywhere", provider}}}, {"model", "runanywhere/" + model}} + .dump(); +} + +int LaunchOpenCodeCloud(const std::string& model, const std::vector& arguments, + const account::ConsoleClient& console, const SpawnFunction& spawn) { + if (!ModelIsSafe(model)) { + out::error_line("a non-empty cloud model name without control characters is required"); + return 2; + } + + account::Credentials credentials; + std::string error; + if (!account::Load(&credentials, &error)) { + out::error_line(error); + return 1; + } + if (!credentials.signed_in()) { + out::error_line("not signed in - run `rcli login`"); + return 1; + } + if (credentials.access_token_expired(EpochSeconds()) && + !RefreshCredentials(console, &credentials, &error)) { + out::error_line(error); + return 1; + } + + const std::string base_url = credentials.console_url + "/v1"; + const std::string config = BuildOpenCodeCloudConfig(model, base_url, credentials.access_token); + ScopedOpenCodeConfig environment; + if (!environment.Activate(config)) { + out::error_line("could not set the temporary OpenCode configuration"); + return 1; + } + + out::status_line("launching OpenCode with the RunAnywhere cloud session"); + return spawn("opencode", arguments); +} + +int LaunchOpenCodeCloud(const std::string& model, const std::vector& arguments) { + const account::ConsoleClient console; + return LaunchOpenCodeCloud(model, arguments, console, Spawn); +} + +} // namespace rcli::harness diff --git a/src/harness/opencode.h b/src/harness/opencode.h new file mode 100644 index 0000000..42db3c4 --- /dev/null +++ b/src/harness/opencode.h @@ -0,0 +1,33 @@ +#ifndef RCLI_HARNESS_OPENCODE_H +#define RCLI_HARNESS_OPENCODE_H + +#include +#include +#include + +#include "account/console.h" + +namespace rcli::harness { + +using SpawnFunction = + std::function& arguments)>; + +/// OpenCode's complete, ephemeral provider configuration for a hosted model. +/// Exposed so the contract can be tested without launching a child process. +std::string BuildOpenCodeCloudConfig(const std::string& model, const std::string& base_url, + const std::string& access_token); + +/// Launch OpenCode against the signed-in RunAnywhere cloud session. +/// +/// Only OPENCODE_CONFIG_CONTENT is changed, only for the duration of the child. +/// No OpenCode or project configuration file is read or written. The default +/// overload starts `opencode` directly (never through a shell). +int LaunchOpenCodeCloud(const std::string& model, const std::vector& arguments); + +/// Test seam for the console refresh transport and child process. +int LaunchOpenCodeCloud(const std::string& model, const std::vector& arguments, + const account::ConsoleClient& console, const SpawnFunction& spawn); + +} // namespace rcli::harness + +#endif // RCLI_HARNESS_OPENCODE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 732d26d..98ca2d0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -13,6 +13,12 @@ target_link_libraries(test_rcli_account PRIVATE rcli_core) rcli_stage_windows_runtime_dlls(test_rcli_account) add_test(NAME rcli_account_tests COMMAND test_rcli_account --run-all) +add_executable(test_rcli_opencode test_rcli_opencode.cpp) +target_include_directories(test_rcli_opencode PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(test_rcli_opencode PRIVATE rcli_core) +rcli_stage_windows_runtime_dlls(test_rcli_opencode) +add_test(NAME rcli_opencode_tests COMMAND test_rcli_opencode --run-all) + find_package(Python3 REQUIRED COMPONENTS Interpreter) add_test( NAME rcli_account_cli_e2e diff --git a/tests/test_rcli_opencode.cpp b/tests/test_rcli_opencode.cpp new file mode 100644 index 0000000..b543c30 --- /dev/null +++ b/tests/test_rcli_opencode.cpp @@ -0,0 +1,225 @@ +#include "test_common.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "account/console.h" +#include "account/credentials.h" +#include "harness/opencode.h" + +namespace { + +namespace fs = std::filesystem; +using Json = nlohmann::json; + +class Environment { + public: + Environment(const char* name, const char* value) : name_(name) { + if (const char* previous = std::getenv(name)) { + had_previous_ = true; + previous_ = previous; + } + Set(value); + } + ~Environment() { Set(had_previous_ ? previous_.c_str() : nullptr); } + + private: + void Set(const char* value) { +#if defined(_WIN32) + _putenv_s(name_.c_str(), value != nullptr ? value : ""); +#else + value != nullptr ? setenv(name_.c_str(), value, 1) : unsetenv(name_.c_str()); +#endif + } + std::string name_; + std::string previous_; + bool had_previous_ = false; +}; + +class TemporaryDirectory { + public: + TemporaryDirectory() { + const auto nonce = std::chrono::steady_clock::now().time_since_epoch().count(); + path_ = fs::temp_directory_path() / ("rcli-opencode-test-" + std::to_string(nonce)); + fs::create_directories(path_); + } + ~TemporaryDirectory() { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + const fs::path& path() const { return path_; } + + private: + fs::path path_; +}; + +long long Now() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +bool Seed(const fs::path& profile, long long expires_at, std::string* error) { + Environment scoped_profile("RCLI_PROFILE_DIR", profile.string().c_str()); + rcli::account::Credentials credentials; + credentials.console_url = "https://console.runanywhere.ai"; + credentials.email = "developer@example.test"; + credentials.access_token = "old-access-token"; + credentials.refresh_token = "refresh-token"; + credentials.expires_at = expires_at; + return rcli::account::Save(credentials, error); +} + +TestResult test_ephemeral_config_and_passthrough() { + TestResult result; + result.test_name = "ephemeral_config_and_passthrough"; + TemporaryDirectory temporary; + Environment profile("RCLI_PROFILE_DIR", temporary.path().string().c_str()); + Environment existing("OPENCODE_CONFIG_CONTENT", "{\"keep\":true}"); + std::string error; + if (!Seed(temporary.path(), Now() + 3600, &error)) { + result.details = error; + return result; + } + + bool spawned = false; + const std::vector arguments = {"run", "--agent", "build", "two words"}; + const rcli::account::ConsoleClient console; + const int status = rcli::harness::LaunchOpenCodeCloud( + "frontier/model", arguments, console, + [&](const std::string& executable, const std::vector& received) { + spawned = true; + if (executable != "opencode" || received != arguments) { + return 91; + } + const char* raw = std::getenv("OPENCODE_CONFIG_CONTENT"); + if (raw == nullptr) { + return 92; + } + const Json config = Json::parse(raw); + const Json& provider = config.at("provider").at("runanywhere"); + if (config.at("model") != "runanywhere/frontier/model" || + provider.at("npm") != "@ai-sdk/openai-compatible" || + provider.at("options").at("baseURL") != "https://console.runanywhere.ai/v1" || + provider.at("options").at("apiKey") != "old-access-token" || + provider.at("models").at("frontier/model").at("name") != "frontier/model") { + return 93; + } + return 0; + }); + const char* restored = std::getenv("OPENCODE_CONFIG_CONTENT"); + if (status != 0 || !spawned || restored == nullptr || + std::string(restored) != "{\"keep\":true}") { + result.details = "launch did not preserve arguments and the existing environment"; + result.actual = std::to_string(status); + return result; + } + if (std::distance(fs::directory_iterator(temporary.path()), fs::directory_iterator{}) != 1) { + result.details = "launch wrote a tool or project configuration file"; + return result; + } + result.passed = true; + return result; +} + +TestResult test_refreshes_expired_session_without_sdk_bootstrap() { + TestResult result; + result.test_name = "refreshes_expired_session_without_sdk_bootstrap"; + TemporaryDirectory temporary; + Environment profile("RCLI_PROFILE_DIR", temporary.path().string().c_str()); + Environment api_key("RUNANYWHERE_API_KEY", nullptr); + Environment environment("RUNANYWHERE_ENVIRONMENT", nullptr); + Environment config("OPENCODE_CONFIG_CONTENT", nullptr); + std::string error; + if (!Seed(temporary.path(), Now() - 1, &error)) { + result.details = error; + return result; + } + + bool refreshed = false; + rcli::account::ConsoleClient console([&](const rcli::account::HttpRequest& request, + rcli::account::HttpResponse* response, std::string*) { + if (!request.url.ends_with("/auth/cli/refresh") || request.bearer_token.size() != 0 || + Json::parse(request.body).at("refresh_token") != "refresh-token") { + return false; + } + refreshed = true; + response->status = 200; + response->body = Json{{"access_token", "new-access-token"}, + {"refresh_token", "new-refresh-token"}, + {"expires_in", 7200}} + .dump(); + return true; + }); + + const int status = rcli::harness::LaunchOpenCodeCloud( + "hosted-model", {}, console, [&](const std::string&, const std::vector&) { + const Json value = Json::parse(std::getenv("OPENCODE_CONFIG_CONTENT")); + return value.at("provider").at("runanywhere").at("options").at("apiKey") == + "new-access-token" + ? 0 + : 94; + }); + if (status != 0 || !refreshed || std::getenv("OPENCODE_CONFIG_CONTENT") != nullptr) { + result.details = "expired cloud credentials were not refreshed ephemerally"; + result.actual = std::to_string(status); + return result; + } + + rcli::account::Credentials stored; + if (!rcli::account::Load(&stored, &error) || stored.access_token != "new-access-token" || + stored.refresh_token != "new-refresh-token" || stored.expires_at <= Now()) { + result.details = error.empty() ? "refreshed session was not stored" : error; + return result; + } + result.passed = true; + return result; +} + +TestResult test_restores_config_when_spawn_throws() { + TestResult result; + result.test_name = "restores_config_when_spawn_throws"; + TemporaryDirectory temporary; + Environment profile("RCLI_PROFILE_DIR", temporary.path().string().c_str()); + Environment config("OPENCODE_CONFIG_CONTENT", "original-value"); + std::string error; + if (!Seed(temporary.path(), Now() + 3600, &error)) { + result.details = error; + return result; + } + + bool threw = false; + try { + const rcli::account::ConsoleClient console; + static_cast(rcli::harness::LaunchOpenCodeCloud( + "hosted-model", {}, console, + [](const std::string&, const std::vector&) -> int { + throw std::runtime_error("synthetic spawn failure"); + })); + } catch (const std::runtime_error&) { + threw = true; + } + const char* restored = std::getenv("OPENCODE_CONFIG_CONTENT"); + if (!threw || restored == nullptr || std::string(restored) != "original-value") { + result.details = "temporary OpenCode config survived an exceptional child launch"; + return result; + } + result.passed = true; + return result; +} + +} // namespace + +int main(int argc, char** argv) { + TestSuite suite("rcli_opencode"); + suite.add("ephemeral_config_and_passthrough", test_ephemeral_config_and_passthrough); + suite.add("refreshes_expired_session_without_sdk_bootstrap", + test_refreshes_expired_session_without_sdk_bootstrap); + suite.add("restores_config_when_spawn_throws", test_restores_config_when_spawn_throws); + return suite.run(argc, argv); +}