diff --git a/AGENTS.md b/AGENTS.md index b935498..5a51416 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,44 @@ aliases (`run`, `pull`, `stt`). One `configure_*` wires both. See Do not reintroduce FetchContent of the SDK, a second inference backend tree, or a retired MetalRT / hardcoded catalog. +## Signing in to a console + +`rcli login` is a device flow, the same shape `gh auth login` uses. The terminal +asks for a code, a browser the person already trusts approves it, and the +terminal collects a key. No password ever reaches the CLI. + +The four endpoints it calls are **not ours to rename**: an installed binary +talks to whatever the console deploys, so a field or path change breaks every +copy in the wild. They are `POST /auth/cli/start`, `/auth/cli/poll`, +`/auth/cli/refresh`, and `GET /v1/me`. The console side has a test that reads +`src/account/console.cpp` directly and fails if the two drift. + +Two secrets do different jobs. `request_code` is public and names the attempt; +`poll_secret` proves the process collecting the grant is the one that started +it. The console stores only a hash of the second. + +`RCLI_CONSOLE_URL` points at the console; it defaults to `http://localhost:8080`, +which is nothing, so a local run needs it set. `RCLI_PROFILE_DIR` moves the +credential file, which is what lets several accounts share one machine. + +The credential is a normal API key with the customer's credit behind it. Treat +it as one: it goes in the profile file at `0600` and nowhere else, and it is +never logged. + +## Building against the SDK + +`RCLI_SDK_KIT` points at a built kit, not at SDK source. `cmake/sdk-pin.cmake` +pins the IDL version and its hash; a mismatch is a hard error and the fix is to +consume a matching kit or bump the pin, **never to run protoc**. + +Two binaries come out of a build. `rcli-cxx` is the CLI. `rcli` is the same +thing plus the MLX backend, and it only builds when `RCLI_SDK_SWIFT_PATH` names +an SDK checkout with the Swift tree. Ship `rcli`. + +MLX resolves its Metal shaders from `mlx-swift_Cmlx.bundle` beside the +executable. Copy the binary somewhere on its own and MLX silently fails to +register, so an install puts both together and points a wrapper at them. + ## Configuration and secrets - Read environment in one place (`GlobalOptions` / `bootstrap()`). diff --git a/CMakeLists.txt b/CMakeLists.txt index 73b50b6..71bb68e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,19 @@ set(RCLI_SOURCES src/commands/cmd_voice.cpp src/commands/cmd_rag.cpp src/commands/cmd_bench.cpp + src/commands/cmd_editors.cpp + src/commands/cmd_harness.cpp + src/commands/cmd_account.cpp + src/account/console.cpp + src/account/credentials.cpp + src/anthropic/messages.cpp + src/anthropic/translate.cpp + src/desktop/claude_profile.cpp + src/harness/harness.cpp + src/harness/opencode.cpp + src/harness/local_models.cpp + src/ide/jetbrains_profile.cpp + src/ide/openai_proxy.cpp src/commands/engine_options.cpp src/commands/model_setup.cpp src/config/cli_paths.cpp @@ -79,12 +92,36 @@ endif() target_include_directories(rcli_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}/third_party" "${CMAKE_CURRENT_SOURCE_DIR}/third_party/CLI11" "${CMAKE_CURRENT_SOURCE_DIR}/third_party/linenoise" ) +# The Anthropic translator and the JetBrains proxy need an HTTP client and a +# JSON reader. Both used to arrive from the SDK's own source build; a kit +# consumer never configures that build, so they are fetched here. +# +# Fetched rather than vendored on purpose. Checking cpp-httplib in would drop +# thirty thousand lines of somebody else's code into this repo, where the +# security scanner reads it as ours and flags the http:// URLs an HTTP client +# is obliged to construct. +include(FetchContent) +set(HTTPLIB_REQUIRE_OPENSSL OFF CACHE BOOL "" FORCE) +set(JSON_BuildTests OFF CACHE BOOL "" FORCE) +FetchContent_Declare(cpp_httplib + GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git + GIT_TAG v0.46.1 + GIT_SHALLOW TRUE) +FetchContent_Declare(nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE) +FetchContent_MakeAvailable(cpp_httplib nlohmann_json) + find_package(Threads REQUIRED) +find_package(CURL REQUIRED) target_link_libraries(rcli_core PUBLIC rac_commons Threads::Threads) +target_link_libraries(rcli_core PRIVATE CURL::libcurl httplib::httplib nlohmann_json::nlohmann_json) target_compile_definitions(rcli_core PUBLIC RCLI_VERSION="${PROJECT_VERSION}" RCLI_PINNED_SDK_VERSION="${RCLI_PINNED_SDK_VERSION}" @@ -112,6 +149,8 @@ endif() # Include path + google=runanywhere_internal come from RunAnywhere::commons. if(APPLE) + # SecItem, for the credential a JetBrains IDE reads its provider key from. + target_link_libraries(rcli_core PRIVATE "-framework Security" "-framework CoreFoundation") target_link_libraries(rcli_core PUBLIC "-framework IOKit" "-framework CoreFoundation") endif() diff --git a/README.md b/README.md index 2819776..e2f8319 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,48 @@ rcli run qwen3 Chat, vision, speech, and embeddings — all local. Nothing leaves the device. +## Signing in + +Models you have pulled run on this machine and need no account. To use a hosted +model instead, sign in to a RunAnywhere console: + +```bash +rcli login # opens a browser; approve it there +rcli whoami # who you are, and what you have used this month +rcli logout +``` + +The terminal never asks for a password. It shows a code, you approve it in the +browser, and it collects an API key with your credit behind it. That key appears +on the console's Cloud keys page and can be revoked there at any time. + +Against a console running on your own machine: + +```bash +export RCLI_CONSOLE_URL=http://localhost:8002 +rcli login +``` + +Then hand a hosted model to a coding session: + +```bash +rcli opencode -m gemma-4 +``` + +For the Open Frontier hosted path, make the choice explicit and pass any +OpenCode arguments after `--`: + +```bash +rcli opencode --cloud --model -- --agent build +``` + +`--cloud` never falls back to a local model. The existing `rcli opencode -m` +form remains available for the parent PR's local-or-upstream harness flow. + +If the model is on this machine, rcli serves it locally. If it is not, the +request goes to the console you are signed in to, is checked against your +balance before it runs, and is metered. + ## Install ### macOS (Apple Silicon) @@ -23,6 +65,31 @@ or curl -fsSL https://raw.githubusercontent.com/RunanywhereAI/RCLI/main/install.sh | sh ``` +### From source + +Needs a built SDK kit, not SDK source: + +```bash +cmake -B build -DRCLI_SDK_KIT=/dist/cpp-desktop-macos-arm64 +export RCLI_SDK_SWIFT_PATH= # for the MLX backend on Apple +cmake --build build -j8 +``` + +`build/rcli` is the full binary. `build/rcli-cxx` is the same CLI without MLX, +and is what you get if `RCLI_SDK_SWIFT_PATH` is unset. + +MLX loads its Metal shaders from `mlx-swift_Cmlx.bundle` next to the executable, +so install the pair together: + +```bash +mkdir -p ~/.local/lib/rcli +cp -R build/mlx-swift_Cmlx.bundle build/rcli ~/.local/lib/rcli/ +printf '#!/bin/sh\nexec "$HOME/.local/lib/rcli/rcli" "$@"\n' > ~/.local/bin/rcli +chmod +x ~/.local/bin/rcli +``` + +Copy the binary on its own and MLX will not register. + ### Windows (x64) ```powershell @@ -217,9 +284,71 @@ page is HTML, not a bundle. | `rcli backends` | registered engines | | `rcli info` | versions and paths | | `--engine` | force mlx / llamacpp / sherpa / onnx / neurt / qhexrt | +| `rcli login` / `logout` / `whoami` | sign in to the console that serves upstream models | +| `rcli claude-code` / `claude-desktop` | open Claude against a model | +| `rcli clion` / `rustrover` | point a JetBrains IDE at a model | +| `rcli opencode` | open a coding session against a model | `rcli --help` and `rcli --help` cover the rest. +## Editors and coding agents + +One command points a tool at a model and starts it. There is nothing to +configure by hand: + +```bash +rcli claude-code -m qwen3-0.6b +rcli clion -m models/gemma-4-31b-it +rcli claude-desktop -m models/gemma-4-31b-it +``` + +The model can be one on this machine or one the console serves. Without `-m` the +tool starts the way you already have it configured, and rcli wires nothing. + +| Tool | How it is wired | +| --- | --- | +| `claude-code`, `opencode` | `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` in the process | +| `claude-desktop` | a gateway profile in Claude Desktop's third party mode, covering the chat and Cowork tabs | +| `clion`, `rustrover` | AI Assistant's OpenAI-compatible provider, which works without a JetBrains AI subscription | + +Two flags go with `-m`. `--serve` holds the endpoint open and prints it instead +of launching anything, which is how a tool nobody has taught rcli about gets +wired up. `--restore` puts Claude Desktop or a JetBrains IDE back the way it was +and starts nothing; a normal run already undoes its own configuration when the +app quits, so this is for the run that was interrupted before it could. + +The first `rcli clion` on a machine takes a while, because it installs the AI +Assistant plugin headlessly before starting the IDE. Later runs are quick. That +endpoint sits on a fixed port rather than whatever happened to be free, because +the IDE reads the address once at startup out of a file rcli writes beforehand, +and a port that moved would leave that file naming something dead. + +Claude Code and Claude Desktop speak Anthropic's Messages API, while the models +rcli serves speak OpenAI's, so a translator sits between them. It carries tool +definitions out, tool calls back, and the results of those calls out again, +which is what lets an agent on the far side run the tools it was given rather +than describe them. The JetBrains IDEs need no translator, because AI Assistant +speaks OpenAI already. + +## Signing in + +A model you have not downloaded can still answer, if the console serves it: + +```bash +rcli login +rcli whoami +rcli run models/gemma-4-31b-it "why is the sky blue" +``` + +`rcli login` opens the console in a browser and waits for you to approve the +machine. Credentials land in `~/.config/rcli/credentials.json`. `rcli logout` +deletes them. `RCLI_CONSOLE_URL` points at a console other than the default and +`RCLI_PROFILE_DIR` moves where the credentials are kept. + +This is separate from `rcli auth login`, which signs the device in to the +control plane with an API key. The two are being unified; see the auth work in +flight. + ## Build from source Stage a C++ desktop kit from [runanywhere-sdks](https://github.com/RunanywhereAI/runanywhere-sdks). The pin is `cmake/sdk-pin.cmake` (`RCLI_PINNED_SDK_VERSION`). diff --git a/src/account/console.cpp b/src/account/console.cpp new file mode 100644 index 0000000..cbc3e6e --- /dev/null +++ b/src/account/console.cpp @@ -0,0 +1,500 @@ +#include "account/console.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "account/credentials.h" + +namespace rcli::account { +namespace { + +using Json = nlohmann::json; +constexpr std::size_t kMaximumResponseBytes = 1024 * 1024; + +struct ResponseBuffer { + std::string* body = nullptr; + bool too_large = false; +}; + +std::size_t CollectBody(char* bytes, std::size_t size, std::size_t count, void* userdata) { + auto* buffer = static_cast(userdata); + if (buffer == nullptr || buffer->body == nullptr || + (count != 0 && size > std::numeric_limits::max() / count)) { + return 0; + } + const std::size_t length = size * count; + if (buffer->body->size() > kMaximumResponseBytes || + length > kMaximumResponseBytes - buffer->body->size()) { + buffer->too_large = true; + return 0; + } + buffer->body->append(bytes, length); + return length; +} + +bool DefaultTransport(const HttpRequest& input, HttpResponse* output, std::string* error) { + if (output == nullptr) { + if (error != nullptr) { + *error = "internal console transport error"; + } + return false; + } + if (!BrowserUrlIsSafe(input.url)) { + if (error != nullptr) { + *error = "refusing an unsafe console request URL"; + } + return false; + } + if (!input.bearer_token.empty() && !SessionTokenIsSafe(input.bearer_token)) { + if (error != nullptr) { + *error = "refusing an invalid console bearer token"; + } + return false; + } + + static std::once_flag curl_once; + static CURLcode curl_init_result = CURLE_FAILED_INIT; + std::call_once(curl_once, [] { curl_init_result = curl_global_init(CURL_GLOBAL_DEFAULT); }); + if (curl_init_result != CURLE_OK) { + if (error != nullptr) { + *error = "could not initialize the console HTTP client"; + } + return false; + } + + CURL* request = curl_easy_init(); + if (request == nullptr) { + if (error != nullptr) { + *error = "could not create the console HTTP client"; + } + return false; + } + + curl_slist* headers = nullptr; + const auto add_header = [&headers](const std::string& value) { + curl_slist* updated = curl_slist_append(headers, value.c_str()); + if (updated == nullptr) { + return false; + } + headers = updated; + return true; + }; + bool configured = + add_header("Accept: application/json") && add_header("Content-Type: application/json"); + const std::string authorization = "Authorization: Bearer " + input.bearer_token; + if (configured && !input.bearer_token.empty()) { + configured = add_header(authorization); + } + + output->status = 0; + output->body.clear(); + ResponseBuffer response{&output->body, false}; + configured = + configured && curl_easy_setopt(request, CURLOPT_URL, input.url.c_str()) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_CUSTOMREQUEST, input.method.c_str()) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_HTTPHEADER, headers) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_CONNECTTIMEOUT_MS, 10000L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_TIMEOUT_MS, 30000L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_NOSIGNAL, 1L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_NOPROXY, "localhost,127.0.0.1,::1") == CURLE_OK && + curl_easy_setopt(request, CURLOPT_FOLLOWLOCATION, 0L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_SSL_VERIFYPEER, 1L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_SSL_VERIFYHOST, 2L) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_USERAGENT, "rcli-cloud-auth/1") == CURLE_OK && + curl_easy_setopt(request, CURLOPT_WRITEFUNCTION, CollectBody) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_WRITEDATA, &response) == CURLE_OK; + if (configured && !input.body.empty()) { + configured = + input.body.size() <= static_cast(std::numeric_limits::max()) && + curl_easy_setopt(request, CURLOPT_POSTFIELDS, input.body.data()) == CURLE_OK && + curl_easy_setopt(request, CURLOPT_POSTFIELDSIZE, + static_cast(input.body.size())) == CURLE_OK; + } + + const CURLcode result = configured ? curl_easy_perform(request) : CURLE_FAILED_INIT; + long status = 0; + const bool received = result == CURLE_OK && + curl_easy_getinfo(request, CURLINFO_RESPONSE_CODE, &status) == CURLE_OK; + curl_slist_free_all(headers); + curl_easy_cleanup(request); + if (!received || response.too_large || status < 100 || status > 599) { + output->status = 0; + output->body.clear(); + if (error != nullptr) { + *error = response.too_large ? "console response exceeded the safety limit" + : "could not reach the RunAnywhere console"; + } + return false; + } + output->status = static_cast(status); + return true; +} + +void HttpError(const char* operation, int status, std::string* error) { + if (error != nullptr) { + *error = + std::string("console ") + operation + " failed with HTTP " + std::to_string(status); + } +} + +bool ParseObject(const HttpResponse& response, Json* object, std::string* error) { + if (object == nullptr) { + if (error != nullptr) { + *error = "internal console response error"; + } + return false; + } + try { + Json parsed = Json::parse(response.body); + if (!parsed.is_object()) { + if (error != nullptr) { + *error = "console returned a JSON value instead of an object"; + } + return false; + } + *object = std::move(parsed); + return true; + } catch (const Json::exception&) { + // Do not include the response body: an upstream error can echo a token. + if (error != nullptr) { + *error = "console returned malformed JSON"; + } + return false; + } +} + +bool RequiredString(const Json& object, const char* key, std::string* value, std::string* error) { + const auto found = object.find(key); + if (found == object.end() || !found->is_string() || + found->get_ref().empty()) { + if (error != nullptr) { + *error = std::string("console response is missing ") + key; + } + return false; + } + *value = found->get(); + return true; +} + +std::string OptionalString(const Json& object, const char* key) { + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() : std::string(); +} + +long Number(const Json& object, const char* key, long fallback = 0) { + const auto found = object.find(key); + if (found == object.end() || !found->is_number_integer()) { + return fallback; + } + try { + return found->get(); + } catch (const Json::exception&) { + return fallback; + } +} + +bool Send(const Transport& transport, HttpRequest request, HttpResponse* response, + std::string* error) { + if (!transport(request, response, error)) { + if (error != nullptr && error->empty()) { + *error = "could not reach the RunAnywhere console"; + } + return false; + } + return true; +} + +bool DisplayTextIsSafe(const std::string& value, std::size_t maximum) { + return !value.empty() && value.size() <= maximum && + std::all_of(value.begin(), value.end(), + [](unsigned char c) { return c >= 0x20 && c <= 0x7e; }); +} + +bool RequestCodeIsSafe(const std::string& value) { + return value.size() >= 4 && value.size() <= 64 && + std::all_of(value.begin(), value.end(), [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '-'; + }); +} + +bool ReadGrant(const Json& object, Grant* grant, std::string* error) { + grant->access_token = OptionalString(object, "access_token"); + grant->refresh_token = OptionalString(object, "refresh_token"); + grant->email = OptionalString(object, "email"); + grant->plan = OptionalString(object, "plan"); + grant->expires_in = std::max(0L, Number(object, "expires_in")); + if ((!grant->access_token.empty() && !SessionTokenIsSafe(grant->access_token)) || + (!grant->refresh_token.empty() && !SessionTokenIsSafe(grant->refresh_token)) || + (!grant->email.empty() && !DisplayTextIsSafe(grant->email, 320)) || + (!grant->plan.empty() && !DisplayTextIsSafe(grant->plan, 80))) { + if (error != nullptr) { + *error = "console returned an invalid cloud session"; + } + return false; + } + return true; +} + +bool ConsoleOrigin(const std::string& input, std::string* origin, std::string* error) { + return NormalizeConsoleUrl(input, origin, error); +} + +} // namespace + +ConsoleClient::ConsoleClient(Transport transport) + : transport_(transport ? std::move(transport) : Transport(DefaultTransport)) {} + +bool ConsoleClient::BeginAuthorization(const std::string& console_url, const std::string& hostname, + Authorization* authorization, std::string* error) const { + if (authorization == nullptr) { + if (error != nullptr) { + *error = "internal authorization error"; + } + return false; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return false; + } + const Json payload = {{"hostname", hostname}, {"client", "rcli"}}; + HttpResponse response; + if (!Send(transport_, {"POST", origin + "/auth/cli/start", payload.dump(), {}}, &response, + error)) { + return false; + } + if (response.status != 200) { + HttpError("authorization", response.status, error); + return false; + } + + Json object; + if (!ParseObject(response, &object, error) || + !RequiredString(object, "request_code", &authorization->request_code, error) || + !RequiredString(object, "poll_secret", &authorization->poll_secret, error) || + !RequiredString(object, "verification_url", &authorization->verification_url, error)) { + return false; + } + if (!RequestCodeIsSafe(authorization->request_code) || + !SessionTokenIsSafe(authorization->poll_secret)) { + if (error != nullptr) { + *error = "console returned an invalid authorization request"; + } + return false; + } + const long expires = Number(object, "expires_in", 600); + const long interval = Number(object, "interval", 2); + authorization->expires_in = static_cast(std::clamp(expires, 30L, 1800L)); + authorization->interval = static_cast(std::clamp(interval, 1L, 30L)); + return true; +} + +PollResult ConsoleClient::Poll(const std::string& console_url, const Authorization& authorization, + Grant* grant, std::string* error) const { + if (grant == nullptr) { + if (error != nullptr) { + *error = "internal authorization grant error"; + } + return PollResult::Failed; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return PollResult::Failed; + } + const Json payload = {{"request_code", authorization.request_code}, + {"poll_secret", authorization.poll_secret}}; + HttpResponse response; + if (!Send(transport_, {"POST", origin + "/auth/cli/poll", payload.dump(), {}}, &response, + error)) { + return PollResult::Failed; + } + if (response.status != 200) { + HttpError("poll", response.status, error); + return PollResult::Failed; + } + + Json object; + if (!ParseObject(response, &object, error)) { + return PollResult::Failed; + } + std::string state; + if (!RequiredString(object, "status", &state, error)) { + return PollResult::Failed; + } + if (state == "pending") { + return PollResult::Pending; + } + if (state == "denied") { + return PollResult::Denied; + } + if (state == "expired") { + return PollResult::Expired; + } + if (state != "approved") { + if (error != nullptr) { + *error = "console returned an unknown authorization state"; + } + return PollResult::Failed; + } + + if (!ReadGrant(object, grant, error)) { + return PollResult::Failed; + } + if (grant->access_token.empty() || grant->refresh_token.empty()) { + if (error != nullptr) { + *error = "console approved the request without a complete session"; + } + return PollResult::Failed; + } + return PollResult::Approved; +} + +bool ConsoleClient::Refresh(const std::string& console_url, const std::string& refresh_token, + Grant* grant, std::string* error) const { + if (grant == nullptr || !SessionTokenIsSafe(refresh_token)) { + if (error != nullptr) { + *error = "no refresh token is available"; + } + return false; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return false; + } + const Json payload = {{"refresh_token", refresh_token}}; + HttpResponse response; + if (!Send(transport_, {"POST", origin + "/auth/cli/refresh", payload.dump(), {}}, &response, + error)) { + return false; + } + if (response.status != 200) { + HttpError("refresh", response.status, error); + return false; + } + Json object; + if (!ParseObject(response, &object, error)) { + return false; + } + if (!ReadGrant(object, grant, error)) { + return false; + } + if (grant->access_token.empty()) { + if (error != nullptr) { + *error = "console refreshed the session without an access token"; + } + return false; + } + return true; +} + +IdentityResult ConsoleClient::WhoAmI(const std::string& console_url, + const std::string& access_token, Identity* identity, + std::string* error) const { + if (identity == nullptr || !SessionTokenIsSafe(access_token)) { + if (error != nullptr) { + *error = "no access token is available"; + } + return IdentityResult::Failed; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return IdentityResult::Failed; + } + HttpResponse response; + if (!Send(transport_, {"GET", origin + "/v1/me", {}, access_token}, &response, error)) { + return IdentityResult::Failed; + } + if (response.status == 401) { + if (error != nullptr) { + *error = "console session expired"; + } + return IdentityResult::Unauthorized; + } + if (response.status != 200) { + HttpError("identity request", response.status, error); + return IdentityResult::Failed; + } + + Json object; + if (!ParseObject(response, &object, error) || + !RequiredString(object, "email", &identity->email, error)) { + return IdentityResult::Failed; + } + if (!DisplayTextIsSafe(identity->email, 320)) { + if (error != nullptr) { + *error = "console returned an invalid account identity"; + } + return IdentityResult::Failed; + } + identity->plan = OptionalString(object, "plan"); + identity->tokens_this_month = Number(object, "tokens_this_month"); + identity->monthly_token_limit = Number(object, "monthly_token_limit"); + if ((!identity->plan.empty() && !DisplayTextIsSafe(identity->plan, 80)) || + identity->tokens_this_month < 0 || identity->monthly_token_limit < 0) { + if (error != nullptr) { + *error = "console returned invalid account usage"; + } + return IdentityResult::Failed; + } + return IdentityResult::Ok; +} + +bool ConsoleClient::Revoke(const std::string& console_url, const std::string& access_token, + const std::string& refresh_token, std::string* error) const { + if (access_token.empty() && refresh_token.empty()) { + return true; + } + if ((!access_token.empty() && !SessionTokenIsSafe(access_token)) || + (!refresh_token.empty() && !SessionTokenIsSafe(refresh_token))) { + if (error != nullptr) { + *error = "cloud session contains an invalid token encoding"; + } + return false; + } + std::string origin; + if (!ConsoleOrigin(console_url, &origin, error)) { + return false; + } + const Json payload = {{"refresh_token", refresh_token}}; + HttpResponse response; + if (!Send(transport_, {"POST", origin + "/auth/cli/revoke", payload.dump(), access_token}, + &response, error)) { + return false; + } + if (response.status != 200 && response.status != 204) { + HttpError("revoke", response.status, error); + return false; + } + return true; +} + +} // namespace rcli::account + +namespace rcli::account { + +bool BeginAuthorization(const std::string& console_url, const std::string& hostname, + Authorization* authorization, std::string* error) { + return ConsoleClient().BeginAuthorization(console_url, hostname, authorization, error); +} + +PollResult Poll(const std::string& console_url, const Authorization& authorization, Grant* grant, + std::string* error) { + return ConsoleClient().Poll(console_url, authorization, grant, error); +} + +bool Refresh(const std::string& console_url, const std::string& refresh_token, Grant* grant, + std::string* error) { + return ConsoleClient().Refresh(console_url, refresh_token, grant, error); +} + +bool WhoAmI(const std::string& console_url, const std::string& token, Identity* identity, + std::string* error) { + return ConsoleClient().WhoAmI(console_url, token, identity, error) == IdentityResult::Ok; +} + +} // namespace rcli::account diff --git a/src/account/console.h b/src/account/console.h new file mode 100644 index 0000000..12a76ec --- /dev/null +++ b/src/account/console.h @@ -0,0 +1,86 @@ +#ifndef RCLI_ACCOUNT_CONSOLE_H +#define RCLI_ACCOUNT_CONSOLE_H + +#include +#include + +namespace rcli::account { + +struct HttpRequest { + std::string method; + std::string url; + std::string body; + std::string bearer_token; +}; + +struct HttpResponse { + int status = 0; + std::string body; +}; + +using Transport = std::function; + +struct Identity { + std::string email; + // Kept for the existing editor/proxy integrations from the parent PR. + std::string plan; + long tokens_this_month = 0; + long monthly_token_limit = 0; +}; + +struct Authorization { + std::string request_code; + std::string poll_secret; + std::string verification_url; + int expires_in = 0; + int interval = 2; +}; + +struct Grant { + std::string access_token; + std::string refresh_token; + std::string email; + std::string plan; + long expires_in = 0; +}; + +enum class PollResult { Pending, Approved, Denied, Expired, Failed }; +enum class IdentityResult { Ok, Unauthorized, Failed }; + +/// Console client independent of SDK/bootstrap state. +/// +/// The default transport uses libcurl directly. Tests inject a hermetic +/// transport so auth contracts never need a real account or network. +class ConsoleClient { + public: + explicit ConsoleClient(Transport transport = {}); + + bool BeginAuthorization(const std::string& console_url, const std::string& hostname, + Authorization* authorization, std::string* error) const; + PollResult Poll(const std::string& console_url, const Authorization& authorization, + Grant* grant, std::string* error) const; + bool Refresh(const std::string& console_url, const std::string& refresh_token, Grant* grant, + std::string* error) const; + IdentityResult WhoAmI(const std::string& console_url, const std::string& access_token, + Identity* identity, std::string* error) const; + bool Revoke(const std::string& console_url, const std::string& access_token, + const std::string& refresh_token, std::string* error) const; + + private: + Transport transport_; +}; + +// Compatibility facade for the editor/harness code introduced by PR #34. +// New code should prefer ConsoleClient so transports can be injected in tests. +bool BeginAuthorization(const std::string& console_url, const std::string& hostname, + Authorization* authorization, std::string* error); +PollResult Poll(const std::string& console_url, const Authorization& authorization, Grant* grant, + std::string* error); +bool Refresh(const std::string& console_url, const std::string& refresh_token, Grant* grant, + std::string* error); +bool WhoAmI(const std::string& console_url, const std::string& token, Identity* identity, + std::string* error); + +} // namespace rcli::account + +#endif // RCLI_ACCOUNT_CONSOLE_H diff --git a/src/account/credentials.cpp b/src/account/credentials.cpp new file mode 100644 index 0000000..8051fba --- /dev/null +++ b/src/account/credentials.cpp @@ -0,0 +1,642 @@ +#include "account/credentials.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#include +#else +#include +#include +#include + +#include +#include +#endif + +namespace rcli::account { +namespace { + +namespace fs = std::filesystem; +using Json = nlohmann::json; + +constexpr const char* kProductionConsole = "https://console.runanywhere.ai"; +constexpr std::uintmax_t kMaximumCredentialBytes = 1024 * 1024; +#if defined(_WIN32) +constexpr const char* kFileName = "credentials.dat"; +#else +constexpr const char* kFileName = "credentials.json"; +#endif + +std::string Env(const char* name) { + const char* value = std::getenv(name); + return value != nullptr ? std::string(value) : std::string(); +} + +std::string Lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +bool HasUnsafeCharacter(const std::string& text) { + return std::any_of(text.begin(), text.end(), + [](unsigned char c) { return c <= 0x20 || c == 0x7f || c == '\\'; }); +} + +struct ParsedUrl { + std::string scheme; + std::string host; + std::string port; + std::string suffix; + bool bracketed = false; +}; + +bool ValidPort(const std::string& port) { + if (port.empty()) { + return true; + } + unsigned value = 0; + const auto result = std::from_chars(port.data(), port.data() + port.size(), value); + return result.ec == std::errc{} && result.ptr == port.data() + port.size() && value > 0 && + value <= 65535; +} + +bool ValidHost(const std::string& host, bool bracketed) { + if (host.empty() || host.front() == '.' || host.back() == '.') { + return false; + } + if (bracketed) { + return std::all_of(host.begin(), host.end(), [](unsigned char c) { + return std::isxdigit(c) != 0 || c == ':' || c == '.'; + }); + } + return std::all_of(host.begin(), host.end(), [](unsigned char c) { + return std::isalnum(c) != 0 || c == '-' || c == '.'; + }); +} + +bool ParseUrl(const std::string& input, bool allow_suffix, ParsedUrl* parsed) { + if (parsed == nullptr || input.empty() || HasUnsafeCharacter(input)) { + return false; + } + const std::size_t scheme_end = input.find("://"); + if (scheme_end == std::string::npos) { + return false; + } + ParsedUrl value; + value.scheme = Lower(input.substr(0, scheme_end)); + if (value.scheme != "https" && value.scheme != "http") { + return false; + } + + const std::size_t authority_start = scheme_end + 3; + const std::size_t authority_end = input.find_first_of("/?#", authority_start); + const std::string authority = input.substr( + authority_start, + authority_end == std::string::npos ? std::string::npos : authority_end - authority_start); + value.suffix = authority_end == std::string::npos ? std::string() : input.substr(authority_end); + if (authority.empty() || authority.find('@') != std::string::npos || + (!allow_suffix && !value.suffix.empty() && value.suffix != "/")) { + return false; + } + + if (authority.front() == '[') { + const std::size_t close = authority.find(']'); + if (close == std::string::npos) { + return false; + } + value.bracketed = true; + value.host = Lower(authority.substr(1, close - 1)); + const std::string remainder = authority.substr(close + 1); + if (!remainder.empty()) { + if (remainder.front() != ':' || remainder.size() == 1) { + return false; + } + value.port = remainder.substr(1); + } + } else { + if (std::count(authority.begin(), authority.end(), ':') > 1) { + return false; + } + const std::size_t colon = authority.rfind(':'); + value.host = Lower(authority.substr(0, colon)); + if (colon != std::string::npos) { + value.port = authority.substr(colon + 1); + if (value.port.empty()) { + return false; + } + } + } + if (!ValidHost(value.host, value.bracketed) || !ValidPort(value.port)) { + return false; + } + + const bool loopback = value.host == "localhost" || value.host == "127.0.0.1" || + (value.bracketed && value.host == "::1"); + if (value.scheme == "http" && !loopback) { + return false; + } + *parsed = std::move(value); + return true; +} + +std::string RenderOrigin(const ParsedUrl& url) { + std::string rendered = url.scheme + "://"; + rendered += url.bracketed ? "[" + url.host + "]" : url.host; + if (!url.port.empty()) { + rendered += ":" + url.port; + } + return rendered; +} + +std::string HomeDirectory() { +#if defined(_WIN32) + const std::string local = Env("LOCALAPPDATA"); + if (!local.empty()) { + return local; + } + return Env("USERPROFILE"); +#else + const std::string home = Env("HOME"); + if (!home.empty()) { + return home; + } + const passwd* entry = getpwuid(getuid()); + return entry != nullptr && entry->pw_dir != nullptr ? entry->pw_dir : std::string(); +#endif +} + +bool EnsureSecureDirectory(const std::string& directory, std::string* error) { + std::error_code code; + const fs::file_status before = fs::symlink_status(directory, code); + if (!code && fs::is_symlink(before)) { + if (error != nullptr) { + *error = "credential directory may not be a symbolic link"; + } + return false; + } + code.clear(); + fs::create_directories(directory, code); + if (code) { + if (error != nullptr) { + *error = "could not create the credential directory"; + } + return false; + } + const bool is_directory = fs::is_directory(directory, code); + if (code || !is_directory) { + if (error != nullptr) { + *error = "could not create the credential directory"; + } + return false; + } +#if !defined(_WIN32) + if (::chmod(directory.c_str(), S_IRWXU) != 0) { + if (error != nullptr) { + *error = "could not restrict the credential directory"; + } + return false; + } + struct stat metadata{}; + if (::lstat(directory.c_str(), &metadata) != 0 || !S_ISDIR(metadata.st_mode) || + metadata.st_uid != geteuid()) { + if (error != nullptr) { + *error = "credential directory has unsafe ownership"; + } + return false; + } +#endif + return true; +} + +#if defined(_WIN32) + +bool Protect(const std::string& plaintext, std::vector* protected_bytes, + std::string* error) { + if (plaintext.size() > std::numeric_limits::max()) { + if (error != nullptr) { + *error = "credential document is too large"; + } + return false; + } + DATA_BLOB input{static_cast(plaintext.size()), + reinterpret_cast(const_cast(plaintext.data()))}; + DATA_BLOB output{}; + if (!CryptProtectData(&input, L"RunAnywhere RCLI cloud session", nullptr, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) { + if (error != nullptr) { + *error = "Windows could not protect the cloud session"; + } + return false; + } + protected_bytes->assign(output.pbData, output.pbData + output.cbData); + LocalFree(output.pbData); + return true; +} + +bool Unprotect(const std::vector& protected_bytes, std::string* plaintext, + std::string* error) { + if (protected_bytes.size() > std::numeric_limits::max()) { + if (error != nullptr) { + *error = "credential document is too large"; + } + return false; + } + DATA_BLOB input{static_cast(protected_bytes.size()), + const_cast(protected_bytes.data())}; + DATA_BLOB output{}; + if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, CRYPTPROTECT_UI_FORBIDDEN, + &output)) { + if (error != nullptr) { + *error = "Windows could not unlock the cloud session"; + } + return false; + } + plaintext->assign(reinterpret_cast(output.pbData), output.cbData); + SecureZeroMemory(output.pbData, output.cbData); + LocalFree(output.pbData); + return true; +} + +bool ReadDocument(const std::string& path, std::string* document, bool* exists, + std::string* error) { + std::error_code size_error; + const std::uintmax_t size = fs::file_size(path, size_error); + if (!size_error && size > kMaximumCredentialBytes) { + *exists = true; + if (error != nullptr) { + *error = "protected cloud session exceeds the safety limit"; + } + return false; + } + std::ifstream file(path, std::ios::binary); + if (!file) { + std::error_code code; + *exists = fs::exists(path, code); + if (!*exists) { + return true; + } + if (error != nullptr) { + *error = "could not read the cloud session"; + } + return false; + } + *exists = true; + const std::vector protected_bytes(std::istreambuf_iterator(file), + std::istreambuf_iterator()); + return Unprotect(protected_bytes, document, error); +} + +bool WriteDocument(const std::string& path, const std::string& document, std::string* error) { + std::vector protected_bytes; + if (!Protect(document, &protected_bytes, error)) { + return false; + } + const std::string temporary = path + ".tmp." + std::to_string(GetCurrentProcessId()) + "." + + std::to_string(GetTickCount64()); + HANDLE file = CreateFileA(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, + FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, nullptr); + if (file == INVALID_HANDLE_VALUE) { + if (error != nullptr) { + *error = "could not create the protected cloud session"; + } + return false; + } + DWORD written = 0; + const bool wrote = + WriteFile(file, protected_bytes.data(), static_cast(protected_bytes.size()), + &written, nullptr) != 0 && + written == protected_bytes.size() && FlushFileBuffers(file) != 0; + CloseHandle(file); + if (!wrote || !MoveFileExA(temporary.c_str(), path.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) { + DeleteFileA(temporary.c_str()); + if (error != nullptr) { + *error = "could not store the protected cloud session"; + } + return false; + } + return true; +} + +#else + +bool ReadDocument(const std::string& path, std::string* document, bool* exists, + std::string* error) { + int flags = O_RDONLY; +#if defined(O_CLOEXEC) + flags |= O_CLOEXEC; +#endif +#if defined(O_NOFOLLOW) + flags |= O_NOFOLLOW; +#endif + const int fd = ::open(path.c_str(), flags); + if (fd < 0) { + if (errno == ENOENT) { + *exists = false; + return true; + } + if (error != nullptr) { + *error = "could not securely read the cloud session"; + } + return false; + } + *exists = true; + struct stat metadata{}; + if (::fstat(fd, &metadata) != 0 || !S_ISREG(metadata.st_mode) || metadata.st_uid != geteuid() || + metadata.st_nlink != 1 || metadata.st_size < 0 || + static_cast(metadata.st_size) > kMaximumCredentialBytes || + ::fchmod(fd, S_IRUSR | S_IWUSR) != 0) { + ::close(fd); + if (error != nullptr) { + *error = "cloud session has unsafe file metadata"; + } + return false; + } + std::string body; + char buffer[4096]; + while (true) { + const ssize_t count = ::read(fd, buffer, sizeof(buffer)); + if (count == 0) { + break; + } + if (count < 0) { + if (errno == EINTR) { + continue; + } + ::close(fd); + if (error != nullptr) { + *error = "could not read the cloud session"; + } + return false; + } + body.append(buffer, static_cast(count)); + if (body.size() > kMaximumCredentialBytes) { + ::close(fd); + if (error != nullptr) { + *error = "cloud session exceeds the safety limit"; + } + return false; + } + } + if (::close(fd) != 0) { + if (error != nullptr) { + *error = "could not finish reading the cloud session"; + } + return false; + } + *document = std::move(body); + return true; +} + +bool WriteDocument(const std::string& path, const std::string& document, std::string* error) { + std::string pattern = path + ".tmp.XXXXXX"; + std::vector temporary(pattern.begin(), pattern.end()); + temporary.push_back('\0'); + const int fd = ::mkstemp(temporary.data()); + if (fd < 0) { + if (error != nullptr) { + *error = "could not create a temporary cloud session"; + } + return false; + } + const std::string temporary_path(temporary.data()); + bool ok = ::fchmod(fd, S_IRUSR | S_IWUSR) == 0; + std::size_t offset = 0; + while (ok && offset < document.size()) { + const ssize_t count = ::write(fd, document.data() + offset, document.size() - offset); + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + ok = false; + break; + } + offset += static_cast(count); + } + ok = ok && ::fsync(fd) == 0; + ok = ::close(fd) == 0 && ok; + if (ok) { + ok = ::rename(temporary_path.c_str(), path.c_str()) == 0; + } + if (!ok) { + ::unlink(temporary_path.c_str()); + if (error != nullptr) { + *error = "could not atomically store the cloud session"; + } + return false; + } + return true; +} + +#endif + +} // namespace + +std::string DefaultConsoleUrl() { + const std::string configured = Env("RCLI_CONSOLE_URL"); + return configured.empty() ? kProductionConsole : configured; +} + +bool NormalizeConsoleUrl(const std::string& input, std::string* normalized, std::string* error) { + ParsedUrl parsed; + if (!ParseUrl(input, false, &parsed)) { + if (error != nullptr) { + *error = "console URL must be an HTTPS origin, or HTTP on exact loopback"; + } + return false; + } + if (normalized != nullptr) { + *normalized = RenderOrigin(parsed); + } + return true; +} + +bool BrowserUrlIsSafe(const std::string& url) { + ParsedUrl parsed; + return ParseUrl(url, true, &parsed); +} + +bool BrowserUrlMatchesConsole(const std::string& url, const std::string& console_url) { + ParsedUrl browser; + ParsedUrl console; + return ParseUrl(url, true, &browser) && ParseUrl(console_url, false, &console) && + RenderOrigin(browser) == RenderOrigin(console); +} + +bool SessionTokenIsSafe(const std::string& token) { + return !token.empty() && token.size() <= 8192 && + std::all_of(token.begin(), token.end(), [](unsigned char c) { + const bool ascii_alphanumeric = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); + return ascii_alphanumeric || c == '-' || c == '.' || c == '_' || c == '~' || + c == '+' || c == '/' || c == '='; + }); +} + +std::string ProfileDirectory() { + const std::string override_dir = Env("RCLI_PROFILE_DIR"); + if (!override_dir.empty()) { + return override_dir; + } +#if defined(_WIN32) + const std::string home = HomeDirectory(); + return home.empty() ? std::string() : home + "/RunAnywhere/RCLI"; +#else + const std::string xdg = Env("XDG_CONFIG_HOME"); + if (!xdg.empty()) { + return xdg + "/rcli"; + } + const std::string home = HomeDirectory(); + return home.empty() ? std::string() : home + "/.config/rcli"; +#endif +} + +std::string CredentialsPath() { + const std::string directory = ProfileDirectory(); + return directory.empty() ? std::string() : (fs::path(directory) / kFileName).string(); +} + +bool Load(Credentials* out, std::string* error) { + if (out == nullptr) { + if (error != nullptr) { + *error = "internal credential load error"; + } + return false; + } + Credentials credentials; + std::string normalized; + if (!NormalizeConsoleUrl(DefaultConsoleUrl(), &normalized, error)) { + return false; + } + credentials.console_url = normalized; + + const std::string path = CredentialsPath(); + if (path.empty()) { + if (error != nullptr) { + *error = "no user profile directory is available"; + } + return false; + } + if (!EnsureSecureDirectory(ProfileDirectory(), error)) { + return false; + } + bool exists = false; + std::string document; + if (!ReadDocument(path, &document, &exists, error)) { + return false; + } + if (!exists) { + *out = std::move(credentials); + return true; + } + try { + const Json object = Json::parse(document); + if (!object.is_object()) { + if (error != nullptr) { + *error = "cloud session file is not a JSON object"; + } + return false; + } + const std::string stored_url = object.value("console_url", std::string()); + if (!NormalizeConsoleUrl(stored_url, &credentials.console_url, error)) { + return false; + } + credentials.email = object.value("email", std::string()); + const std::string access_token = object.value("access_token", std::string()); + const std::string refresh_token = object.value("refresh_token", std::string()); + if ((!access_token.empty() && !SessionTokenIsSafe(access_token)) || + (!refresh_token.empty() && !SessionTokenIsSafe(refresh_token))) { + if (error != nullptr) { + *error = "cloud session contains an invalid token encoding"; + } + return false; + } + credentials.access_token = access_token; + credentials.refresh_token = refresh_token; + credentials.expires_at = object.value("expires_at", 0LL); + } catch (const Json::exception&) { + if (error != nullptr) { + *error = "cloud session file is not valid JSON"; + } + return false; + } + *out = std::move(credentials); + return true; +} + +Credentials Load() { + Credentials credentials; + std::string error; + if (!Load(&credentials, &error)) { + return {}; + } + return credentials; +} + +bool Save(const Credentials& credentials, std::string* error) { + std::string normalized; + if (!NormalizeConsoleUrl(credentials.console_url, &normalized, error)) { + return false; + } + const std::string directory = ProfileDirectory(); + const std::string path = CredentialsPath(); + if (directory.empty() || path.empty()) { + if (error != nullptr) { + *error = "no user profile directory is available"; + } + return false; + } + if (!EnsureSecureDirectory(directory, error)) { + return false; + } + if ((!credentials.access_token.empty() && !SessionTokenIsSafe(credentials.access_token)) || + (!credentials.refresh_token.empty() && !SessionTokenIsSafe(credentials.refresh_token))) { + if (error != nullptr) { + *error = "refusing to store an invalid cloud session token"; + } + return false; + } + const Json document = {{"console_url", normalized}, + {"email", credentials.email}, + {"access_token", credentials.access_token}, + {"refresh_token", credentials.refresh_token}, + {"expires_at", credentials.expires_at}}; + return WriteDocument(path, document.dump(2) + "\n", error); +} + +bool Clear(std::string* error) { + const std::string path = CredentialsPath(); + if (path.empty()) { + return true; + } + if (!EnsureSecureDirectory(ProfileDirectory(), error)) { + return false; + } + std::error_code code; + fs::remove(path, code); + if (code) { + if (error != nullptr) { + *error = "could not remove the local cloud session"; + } + return false; + } + return true; +} + +} // namespace rcli::account diff --git a/src/account/credentials.h b/src/account/credentials.h new file mode 100644 index 0000000..456aa0e --- /dev/null +++ b/src/account/credentials.h @@ -0,0 +1,53 @@ +#ifndef RCLI_ACCOUNT_CREDENTIALS_H +#define RCLI_ACCOUNT_CREDENTIALS_H + +#include + +namespace rcli::account { + +struct Credentials { + std::string console_url; + std::string email; + std::string access_token; + std::string refresh_token; + long long expires_at = 0; + + bool signed_in() const { return !access_token.empty(); } + bool access_token_expired(long long now, long long skew_seconds = 60) const { + return expires_at > 0 && expires_at <= now + skew_seconds; + } +}; + +/// Production console used when neither a login flag nor RCLI_CONSOLE_URL is set. +std::string DefaultConsoleUrl(); + +/// Validate and canonicalize a console origin. HTTPS is required except for an +/// exact loopback host. Origins may not contain credentials, paths or queries. +bool NormalizeConsoleUrl(const std::string& input, std::string* normalized, std::string* error); + +/// Browser URLs may include a path/query but obey the same HTTPS/loopback rule. +bool BrowserUrlIsSafe(const std::string& url); + +/// Approval links must stay on the configured console origin so a compromised +/// response cannot silently open an unrelated sign-in page. +bool BrowserUrlMatchesConsole(const std::string& url, const std::string& console_url); + +/// Bearer/refresh tokens are constrained to RFC 6750 b64token characters so +/// values loaded from disk or returned by a server cannot inject HTTP headers. +bool SessionTokenIsSafe(const std::string& token); + +std::string ProfileDirectory(); +std::string CredentialsPath(); + +/// Missing credentials are not an error; `out` receives the production default. +bool Load(Credentials* out, std::string* error); +// Compatibility overload for the editor/harness code introduced by PR #34. +// Errors are surfaced by the command-level pointer overload; callers using +// this legacy form receive an empty session on read failure. +Credentials Load(); +bool Save(const Credentials& credentials, std::string* error); +bool Clear(std::string* error); + +} // namespace rcli::account + +#endif // RCLI_ACCOUNT_CREDENTIALS_H diff --git a/src/anthropic/messages.cpp b/src/anthropic/messages.cpp new file mode 100644 index 0000000..c325f3e --- /dev/null +++ b/src/anthropic/messages.cpp @@ -0,0 +1,320 @@ +#include "anthropic/messages.h" + +#include +#include +#include +#include + +#include +#include + +#include "anthropic/translate.h" +#include "io/output.h" + +namespace rcli::anthropic { +namespace { + +using Json = nlohmann::json; + +/// Split "http://host:port/v1" into the host root and the path prefix httplib +/// wants separately. +bool SplitBaseUrl(const std::string& base_url, std::string* origin, std::string* prefix) { + const std::string scheme = base_url.rfind("https://", 0) == 0 ? "https://" : "http://"; + const size_t start = base_url.find(scheme); + if (start != 0) { + return false; + } + const size_t slash = base_url.find('/', scheme.size()); + if (slash == std::string::npos) { + *origin = base_url; + *prefix = ""; + } else { + *origin = base_url.substr(0, slash); + *prefix = base_url.substr(slash); + } + return !origin->empty(); +} + +struct Runtime { + httplib::Server server; + std::thread thread; + std::string origin; + std::string prefix; + std::string api_key; + std::string model; + std::string advertised; + bool verbose = false; +}; + +std::unique_ptr g_runtime; + +void ApplyAuth(httplib::Client& client, const std::string& api_key) { + if (!api_key.empty()) { + client.set_bearer_token_auth(api_key); + } +} + +void HandleNonStreaming(Runtime& runtime, const Json& request, httplib::Response& response) { + httplib::Client client(runtime.origin); + client.set_read_timeout(600, 0); + ApplyAuth(client, runtime.api_key); + + const Json upstream = translate::RequestToOpenAI(request, runtime.model); + const httplib::Result reply = + client.Post(runtime.prefix + "/chat/completions", upstream.dump(), "application/json"); + if (!reply || reply->status < 200 || reply->status >= 300) { + response.status = reply ? reply->status : 502; + response.set_content( + translate::ErrorBody("api_error", + reply ? reply->body : std::string("the model endpoint did not answer")), + "application/json"); + return; + } + Json parsed; + try { + parsed = Json::parse(reply->body); + } catch (const Json::exception& error) { + response.status = 502; + response.set_content(translate::ErrorBody("api_error", error.what()), "application/json"); + return; + } + std::string failure_type; + std::string failure; + if (translate::PayloadError(parsed, &failure_type, &failure)) { + response.status = failure_type == "rate_limit_error" ? 429 : 502; + response.set_content(translate::ErrorBody(failure_type, failure), "application/json"); + return; + } + response.set_content(translate::ResponseToAnthropic(parsed, runtime.model).dump(), + "application/json"); +} + +void HandleStreaming(Runtime& runtime, const Json& request, httplib::Response& response) { + // The upstream body is built here rather than in the sink: the sink runs + // after this function returns, and everything it touches has to outlive it. + auto upstream = std::make_shared( + translate::RequestToOpenAI(request, runtime.model).dump()); + auto origin = std::make_shared(runtime.origin); + auto path = std::make_shared(runtime.prefix + "/chat/completions"); + auto api_key = std::make_shared(runtime.api_key); + auto model = std::make_shared(runtime.model); + + response.set_chunked_content_provider( + "text/event-stream", + [upstream, origin, path, api_key, model](size_t /*offset*/, httplib::DataSink& sink) { + httplib::Client client(*origin); + client.set_read_timeout(600, 0); + ApplyAuth(client, *api_key); + + translate::StreamState state; + state.model = *model; + std::string pending; + + const httplib::Result reply = client.Post( + *path, httplib::Headers(), *upstream, "application/json", + [&](const char* data, size_t length) { + pending.append(data, length); + // SSE frames are separated by a blank line, and a chunk can + // split one in half, so only whole frames are consumed. + size_t split = 0; + while ((split = pending.find("\n\n")) != std::string::npos) { + const std::string frame = pending.substr(0, split); + pending.erase(0, split + 2); + const size_t field = frame.find("data:"); + if (field == std::string::npos) { + continue; + } + std::string payload = frame.substr(field + 5); + while (!payload.empty() && (payload.front() == ' ' || payload.front() == '\r')) { + payload.erase(payload.begin()); + } + if (payload == "[DONE]") { + continue; + } + Json chunk; + try { + chunk = Json::parse(payload); + } catch (const Json::exception&) { + continue; + } + std::string events; + try { + events = translate::StreamChunkToAnthropic(chunk, &state); + } catch (const std::exception&) { + // A chunk in a shape the mapping did not expect is + // a chunk to skip, not a reason to kill the run. + continue; + } + if (!events.empty() && !sink.write(events.data(), events.size())) { + return false; + } + } + return true; + }); + + if (!reply) { + const std::string body = + "event: error\ndata: " + + translate::ErrorBody("api_error", "the model endpoint stopped answering") + + "\n\n"; + sink.write(body.data(), body.size()); + sink.done(); + return false; + } + try { + const std::string closing = translate::StreamCloseToAnthropic(&state); + if (!closing.empty()) { + sink.write(closing.data(), closing.size()); + } + } catch (const std::exception&) { + // Nothing useful left to say; ending the stream cleanly beats + // aborting the process holding the reader's editor open. + } + sink.done(); + return true; + }); +} + +} // namespace + +bool Start(const harness::Endpoint& upstream, const std::string& model, Shim* shim, + bool verbose, const std::string& advertised) { + if (shim == nullptr) { + return false; + } + Stop(shim); + + auto runtime = std::make_unique(); + if (!SplitBaseUrl(upstream.base_url, &runtime->origin, &runtime->prefix)) { + out::error_line("could not read the model endpoint: " + upstream.base_url); + return false; + } + runtime->api_key = upstream.api_key; + runtime->model = model; + runtime->advertised = advertised.empty() ? model : advertised; + runtime->verbose = verbose; + + Runtime* raw = runtime.get(); + raw->server.Post("/v1/messages", [raw](const httplib::Request& request, + httplib::Response& response) { + if (raw->verbose) { + out::status_line("anthropic: POST /v1/messages, " + + std::to_string(request.body.size()) + " bytes"); + } + Json parsed; + try { + parsed = Json::parse(request.body); + } catch (const Json::exception& error) { + response.status = 400; + response.set_content(translate::ErrorBody("invalid_request_error", error.what()), + "application/json"); + return; + } + try { + if (parsed.value("stream", false)) { + HandleStreaming(*raw, parsed, response); + } else { + HandleNonStreaming(*raw, parsed, response); + } + } catch (const std::exception& error) { + // httplib does not catch, and an exception leaving here reaches + // std::terminate: the editor's model call would abort rcli. + if (raw->verbose) { + out::status_line(std::string("anthropic: request failed: ") + error.what()); + } + response.status = 500; + response.set_content(translate::ErrorBody("api_error", error.what()), + "application/json"); + } + }); + + // Discovery, in Anthropic's shape rather than OpenAI's. + // + // Claude Desktop probes this before it will use a gateway at all, and an + // OpenAI-shaped list fails it with "Gateway returned no usable models": + // the entries need `display_name` and `created_at`, and the envelope needs + // the paging fields, or nothing in the list counts as usable. + raw->server.Get("/v1/models", [raw](const httplib::Request&, httplib::Response& response) { + if (raw->verbose) { + out::status_line("anthropic: GET /v1/models -> " + raw->advertised + " (serving " + + raw->model + ")"); + } + // The shape claude.com/docs/third-party/claude-desktop documents for a + // gateway, which is OpenAI's list envelope rather than Anthropic's. + // Guessing the Anthropic shape here is what produced "Gateway returned + // no usable models". + const Json entry{{"id", raw->advertised}, {"object", "model"}}; + response.set_content( + Json{{"object", "list"}, {"data", Json::array({entry})}}.dump(), + "application/json"); + }); + + // Claude Code probes this before it sends anything and treats a failure as + // an endpoint that is not there. Answering it is what makes the translator + // look like a gateway rather than a wrong address. + raw->server.Get("/api/hello", [](const httplib::Request&, httplib::Response& response) { + response.set_content(Json{{"ok", true}}.dump(), "application/json"); + }); + // httplib has no HEAD route, and Claude Code probes with HEAD, so it is + // answered ahead of routing rather than left to fall through to the 404. + raw->server.set_pre_routing_handler( + [](const httplib::Request& request, httplib::Response& response) { + if (request.method == "HEAD" && request.path == "/api/hello") { + response.status = 200; + return httplib::Server::HandlerResponse::Handled; + } + return httplib::Server::HandlerResponse::Unhandled; + }); + + // A route we do not translate should say so, not 404 into a silence the + // reader has to guess at. + raw->server.set_error_handler([raw](const httplib::Request& request, + httplib::Response& response) { + if (raw->verbose) { + out::status_line("anthropic: " + request.method + " " + request.path + " -> " + + std::to_string(response.status)); + } + if (response.body.empty()) { + response.set_content( + translate::ErrorBody("not_found_error", + request.method + " " + request.path + + " is not something rcli translates"), + "application/json"); + } + }); + + const int port = raw->server.bind_to_any_port("127.0.0.1"); + if (port <= 0) { + out::error_line("could not open a port for the Anthropic translator"); + return false; + } + + g_runtime = std::move(runtime); + Runtime* started = g_runtime.get(); + started->thread = std::thread([started] { started->server.listen_after_bind(); }); + + shim->base_url = "http://127.0.0.1:" + std::to_string(port); + // Never the upstream key: the client only has to send something, and + // handing it a real console token would put it in that process's + // environment where it does not belong. + shim->auth_token = "rcli-local"; + shim->running = true; + return true; +} + +void Stop(Shim* shim) { + if (g_runtime) { + g_runtime->server.stop(); + if (g_runtime->thread.joinable()) { + g_runtime->thread.join(); + } + g_runtime.reset(); + } + if (shim != nullptr) { + shim->running = false; + shim->base_url.clear(); + shim->auth_token.clear(); + } +} + +} // namespace rcli::anthropic diff --git a/src/anthropic/messages.h b/src/anthropic/messages.h new file mode 100644 index 0000000..e4cb226 --- /dev/null +++ b/src/anthropic/messages.h @@ -0,0 +1,62 @@ +#ifndef RCLI_ANTHROPIC_MESSAGES_H +#define RCLI_ANTHROPIC_MESSAGES_H + +#include + +#include "harness/harness.h" + +/// An Anthropic-shaped front door onto an OpenAI-shaped model. +/// +/// Claude Code, Claude Desktop and Cowork all talk the Anthropic Messages API +/// and are pointed elsewhere with ANTHROPIC_BASE_URL. Our server speaks +/// OpenAI: /v1/models, /v1/chat/completions, /health, and nothing else. The two +/// never meet, which is why `rcli opencode` works today and `rcli claude-code` +/// could not. +/// +/// This is the translator between them. It serves POST /v1/messages on +/// loopback, rewrites each request into a chat completion, forwards it to +/// whichever endpoint `harness::Resolve` produced, and rewrites the reply back. +/// Streaming is translated event by event, because Claude Code streams and a +/// buffered answer would arrive as one block minutes later. +/// +/// It lives here rather than in commons on purpose: it implements another +/// vendor's wire format, which is an integration detail of this CLI, not +/// inference logic every SDK consumer needs. If a second consumer ever wants +/// it, that is the moment to move it down a layer. +namespace rcli::anthropic { + +/// A running translator. +struct Shim { + /// Where Claude Code should be pointed. Empty when nothing started. + std::string base_url; + /// The value ANTHROPIC_AUTH_TOKEN should carry. Never the upstream key. + std::string auth_token; + bool running = false; +}; + +/// Starts a translator in front of `upstream` and fills `shim`. +/// +/// `model` is the id sent on to the upstream endpoint, whatever name the +/// caller asked Claude Code for: Claude Code sends its own model strings, and +/// forwarding those to a local GGUF would ask for a model that is not there. +/// +/// Returns false having said why. The caller owns stopping it. +/// `verbose` narrates each request to stderr. Off by default: the body carries +/// the reader's prompt, and a translator that logs conversations unasked is one +/// nobody should point at their editor. +/// `advertised`, when set, is the id this gateway claims to serve. Requests +/// are still forwarded as `model`; only the name on the wire changes. +/// +/// Claude Desktop drops any gateway model it cannot map to an Anthropic family, +/// so a gateway serving something else has to answer under a name it accepts. +/// The profile carries a labelOverride so the picker still shows what is really +/// answering. +bool Start(const harness::Endpoint& upstream, const std::string& model, Shim* shim, + bool verbose = false, const std::string& advertised = {}); + +/// Stops the translator and waits for its thread. Safe on a stopped shim. +void Stop(Shim* shim); + +} // namespace rcli::anthropic + +#endif // RCLI_ANTHROPIC_MESSAGES_H diff --git a/src/anthropic/translate.cpp b/src/anthropic/translate.cpp new file mode 100644 index 0000000..4a28e73 --- /dev/null +++ b/src/anthropic/translate.cpp @@ -0,0 +1,564 @@ +#include "anthropic/translate.h" + +#include +#include + +namespace rcli::anthropic::translate { +namespace { + +/// A string field, or empty when it is absent or is something else. +/// +/// `Json::value` throws when the key is there holding another type, and a +/// translator that throws on one unexpected field fails the whole request. A +/// client sending a shape we did not anticipate should lose that field, not its +/// turn. +std::string Field(const Json& object, const char* key) { + if (!object.is_object()) { + return {}; + } + const auto found = object.find(key); + return found != object.end() && found->is_string() ? found->get() + : std::string(); +} + +/// A token count, or `fallback` when the field is absent or is not a number. +int Count(const Json& object, const char* key, int fallback = 0) { + if (!object.is_object()) { + return fallback; + } + const auto found = object.find(key); + return found != object.end() && found->is_number_integer() ? found->get() : fallback; +} + +/// Anthropic lets content be a bare string or a list of typed blocks. Both mean +/// the same thing to an OpenAI endpoint, which only takes a string. +std::string FlattenContent(const Json& content) { + if (content.is_string()) { + return content.get(); + } + if (!content.is_array()) { + return {}; + } + std::string text; + for (const Json& block : content) { + if (!block.is_object()) { + continue; + } + // Only text survives the trip. An image block would have to become an + // OpenAI image_url part, and the local server does not serve vision at + // all, so dropping it is honest where inventing a shape is not. + if (Field(block, "type") == "text") { + text += Field(block, "text"); + } + } + return text; +} + +/// OpenAI carries a call's arguments as a JSON string; Anthropic wants the +/// object itself. A model that emits something unparseable here is common +/// enough that dropping the whole turn over it would be worse than a call with +/// no arguments, which the tool can at least reject on its own terms. +Json ParseArguments(const std::string& arguments) { + if (arguments.empty()) { + return Json::object(); + } + try { + const Json parsed = Json::parse(arguments); + if (parsed.is_object()) { + return parsed; + } + } catch (const Json::exception&) { + } + return Json::object(); +} + +/// Anthropic tool definitions, in OpenAI's shape. +/// +/// Only client tools carry an `input_schema`. Anthropic's server-side tools — +/// web search and the rest — name a type we have nothing to run and have no +/// schema to forward, so they are left out rather than passed on as something +/// the endpoint would have to invent a meaning for. +Json ToolsToOpenAI(const Json& tools) { + Json out = Json::array(); + if (!tools.is_array()) { + return out; + } + for (const Json& tool : tools) { + if (!tool.is_object() || !tool.contains("input_schema")) { + continue; + } + Json function{{"name", Field(tool, "name")}, + {"parameters", tool["input_schema"]}}; + if (tool.contains("description") && tool["description"].is_string()) { + function["description"] = tool["description"]; + } + out.push_back(Json{{"type", "function"}, {"function", std::move(function)}}); + } + return out; +} + +/// Anthropic's tool_choice, in OpenAI's vocabulary. Null when it says something +/// OpenAI has no way to express. +Json ToolChoiceToOpenAI(const Json& choice) { + if (choice.is_string()) { + return choice; + } + if (!choice.is_object()) { + return {}; + } + const std::string type = Field(choice, "type"); + if (type == "auto" || type == "none") { + return type; + } + // "any" means the model has to call something, without saying what. + if (type == "any") { + return "required"; + } + if (type == "tool") { + return Json{{"type", "function"}, + {"function", Json{{"name", Field(choice, "name")}}}}; + } + return {}; +} + +/// Appends `message` to `out` as the OpenAI messages it implies. +/// +/// One Anthropic turn can become several. Anthropic packs the results of a +/// round of tool calls into the user turn that follows them, while OpenAI wants +/// each result as its own `tool` message sitting directly after the assistant +/// turn that asked for it — so the results are written first, and whatever text +/// shared that turn follows as a message of its own. +void AppendMessage(const Json& message, Json* out) { + std::string role = Field(message, "role"); + if (role.empty()) { + role = "user"; + } + const Json content = message.contains("content") ? message["content"] : Json(); + + if (!content.is_array()) { + out->push_back(Json{{"role", role}, {"content", FlattenContent(content)}}); + return; + } + + for (const Json& block : content) { + if (!block.is_object() || Field(block, "type") != "tool_result") { + continue; + } + out->push_back(Json{ + {"role", "tool"}, + {"tool_call_id", Field(block, "tool_use_id")}, + {"content", FlattenContent(block.contains("content") ? block["content"] : Json())}}); + } + + Json calls = Json::array(); + for (const Json& block : content) { + if (!block.is_object() || Field(block, "type") != "tool_use") { + continue; + } + const Json input = block.contains("input") ? block["input"] : Json::object(); + calls.push_back(Json{{"id", Field(block, "id")}, + {"type", "function"}, + {"function", Json{{"name", Field(block, "name")}, + {"arguments", input.dump()}}}}); + } + + const std::string text = FlattenContent(content); + if (!calls.empty()) { + // An assistant turn that only called tools has no text to carry, and + // OpenAI reads a null content there rather than an empty string. + out->push_back(Json{{"role", role}, + {"content", text.empty() ? Json(nullptr) : Json(text)}, + {"tool_calls", std::move(calls)}}); + return; + } + if (!text.empty()) { + out->push_back(Json{{"role", role}, {"content", text}}); + } +} + +/// The reason a turn carrying `calls` tool calls ended, given what the endpoint +/// said in `finish`. +/// +/// Endpoints disagree here: one closes a turn holding a tool call with +/// "tool_calls", another with a plain "stop". The second reads as end_turn, +/// which tells the client to show the answer and wait for the reader rather +/// than run the tool — so the call is written, ignored, and the agent narrates +/// what it was about to do instead of doing it. Truncation is the one thing +/// that still outranks it, because a call cut off mid-argument cannot be run. +std::string StopWithTools(const std::string& finish, bool calls) { + if (!calls || finish == "max_tokens") { + return finish; + } + return "tool_use"; +} + +/// OpenAI finish reasons, in Anthropic's vocabulary. +std::string StopReason(const std::string& finish) { + if (finish == "length") { + return "max_tokens"; + } + if (finish == "tool_calls") { + return "tool_use"; + } + if (finish.empty()) { + return {}; + } + return "end_turn"; +} + +std::string Event(const std::string& name, const Json& data) { + return "event: " + name + "\ndata: " + data.dump() + "\n\n"; +} + +} // namespace + +Json RequestToOpenAI(const Json& anthropic, const std::string& model) { + Json openai; + openai["model"] = model; + const auto stream = anthropic.find("stream"); + openai["stream"] = stream != anthropic.end() && stream->is_boolean() && stream->get(); + + Json messages = Json::array(); + // Anthropic carries the system prompt beside the conversation; OpenAI wants + // it as the first message, so it is moved rather than dropped. + if (anthropic.contains("system")) { + const std::string system = FlattenContent(anthropic["system"]); + if (!system.empty()) { + messages.push_back({{"role", "system"}, {"content", system}}); + } + } + if (anthropic.contains("messages") && anthropic["messages"].is_array()) { + for (const Json& message : anthropic["messages"]) { + if (!message.is_object()) { + continue; + } + AppendMessage(message, &messages); + } + } + openai["messages"] = std::move(messages); + + // max_tokens is required by Anthropic and optional for OpenAI, so it always + // has a value to carry across. + if (anthropic.contains("max_tokens")) { + openai["max_tokens"] = anthropic["max_tokens"]; + } + if (anthropic.contains("tools")) { + Json tools = ToolsToOpenAI(anthropic["tools"]); + // An empty list is not the same as none: OpenAI rejects `tools: []`, + // and a request whose only tools were server-side ones has nothing left + // to send. + if (!tools.empty()) { + openai["tools"] = std::move(tools); + if (anthropic.contains("tool_choice")) { + const Json& asked = anthropic["tool_choice"]; + const Json choice = ToolChoiceToOpenAI(asked); + if (!choice.is_null()) { + openai["tool_choice"] = choice; + } + const auto serial = asked.is_object() + ? asked.find("disable_parallel_tool_use") + : asked.end(); + if (serial != asked.end() && serial->is_boolean() && serial->get()) { + openai["parallel_tool_calls"] = false; + } + } + } + } + + for (const char* passthrough : {"temperature", "top_p", "stop_sequences"}) { + if (anthropic.contains(passthrough)) { + // stop_sequences is OpenAI's `stop`; the rest keep their names. + const std::string key = + std::string(passthrough) == "stop_sequences" ? "stop" : passthrough; + openai[key] = anthropic[passthrough]; + } + } + return openai; +} + +Json ResponseToAnthropic(const Json& openai, const std::string& model) { + Json choice; + if (openai.contains("choices") && openai["choices"].is_array() && + !openai["choices"].empty()) { + choice = openai["choices"][0]; + } + const Json message = choice.contains("message") && choice["message"].is_object() + ? choice["message"] + : Json::object(); + // Null, not absent, is what OpenAI sends for the content of a turn that only + // called tools, and asking for it as a string there throws. + const std::string text = message.contains("content") && message["content"].is_string() + ? message["content"].get() + : std::string(); + + Json out; + const std::string reply_id = Field(openai, "id"); + out["id"] = reply_id.empty() ? std::string("msg_rcli") : reply_id; + out["type"] = "message"; + out["role"] = "assistant"; + out["model"] = model; + Json content = Json::array(); + if (!text.empty()) { + content.push_back(Json{{"type", "text"}, {"text", text}}); + } + if (message.contains("tool_calls") && message["tool_calls"].is_array()) { + for (const Json& call : message["tool_calls"]) { + if (!call.is_object()) { + continue; + } + const Json function = call.contains("function") && call["function"].is_object() + ? call["function"] + : Json::object(); + const std::string name = Field(function, "name"); + if (name.empty()) { + continue; + } + content.push_back( + Json{{"type", "tool_use"}, + {"id", Field(call, "id")}, + {"name", name}, + {"input", ParseArguments(Field(function, "arguments"))}}); + } + } + const bool tool_called = content.size() > (text.empty() ? 0u : 1u); + // A turn that said nothing still needs a block: an empty content array reads + // to some clients as a malformed message rather than an empty one. + if (content.empty()) { + content.push_back(Json{{"type", "text"}, {"text", ""}}); + } + out["content"] = std::move(content); + const std::string stop = + StopWithTools(StopReason(Field(choice, "finish_reason")), tool_called); + out["stop_reason"] = stop.empty() ? Json(nullptr) : Json(stop); + out["stop_sequence"] = nullptr; + + // Streaming endpoints send a null usage on most chunks, and asking a null + // for a count throws. + const Json usage = openai.contains("usage") && openai["usage"].is_object() + ? openai["usage"] + : Json::object(); + out["usage"] = Json{{"input_tokens", Count(usage, "prompt_tokens")}, + {"output_tokens", Count(usage, "completion_tokens")}}; + return out; +} + +std::string StreamChunkToAnthropic(const Json& chunk, StreamState* state) { + if (state == nullptr) { + return {}; + } + std::string out; + + // An endpoint is free to answer 200 and then report the failure in the + // stream, which is how the console reports a request over quota. Skipping + // the frame as unrecognised ends the stream with no content, and a client + // handed an empty turn sits there waiting rather than saying it was + // refused. + std::string failure_type; + std::string failure; + if (PayloadError(chunk, &failure_type, &failure)) { + state->failed = true; + return "event: error\ndata: " + ErrorBody(failure_type, failure) + "\n\n"; + } + + if (!state->opened) { + state->opened = true; + // `value` throws when the key is present with another type, and an id + // of null is exactly what some servers send. + state->message_id = chunk.contains("id") && chunk["id"].is_string() + ? chunk["id"].get() + : std::string("msg_rcli"); + Json start; + start["type"] = "message_start"; + start["message"] = Json{{"id", state->message_id}, + {"type", "message"}, + {"role", "assistant"}, + {"model", state->model}, + {"content", Json::array()}, + {"stop_reason", nullptr}, + {"stop_sequence", nullptr}, + {"usage", Json{{"input_tokens", 0}, {"output_tokens", 0}}}}; + out += Event("message_start", start); + } + + Json choice; + if (chunk.contains("choices") && chunk["choices"].is_array() && !chunk["choices"].empty()) { + choice = chunk["choices"][0]; + } + + const std::string finish = Field(choice, "finish_reason"); + if (!finish.empty()) { + state->stop_reason = StopReason(finish); + } + if (chunk.contains("usage") && chunk["usage"].is_object()) { + state->input_tokens = Count(chunk["usage"], "prompt_tokens", state->input_tokens); + state->output_tokens = Count(chunk["usage"], "completion_tokens", state->output_tokens); + } + + const Json delta = choice.contains("delta") && choice["delta"].is_object() + ? choice["delta"] + : Json::object(); + // Gathered rather than written straight through. A call arrives as + // fragments scattered across the stream, and a provider is free to advance + // two of them at once; writing as they land would interleave two half-built + // blocks, which Anthropic's stream cannot express. They go out whole in + // StreamCloseToAnthropic instead. + if (delta.contains("tool_calls") && delta["tool_calls"].is_array()) { + for (const Json& call : delta["tool_calls"]) { + if (!call.is_object()) { + continue; + } + const bool numbered = call.contains("index") && call["index"].is_number_integer(); + const int index = numbered ? call["index"].get() : 0; + const std::string id = + call.contains("id") && call["id"].is_string() ? call["id"].get() + : std::string(); + + int slot = -1; + if (numbered) { + const auto found = state->tool_slot_by_index.find(index); + if (found != state->tool_slot_by_index.end()) { + slot = found->second; + } + } else if (!id.empty()) { + const auto found = state->tool_slot_by_id.find(id); + if (found != state->tool_slot_by_id.end()) { + slot = found->second; + } + } else if (state->next_tool_slot > 0) { + // Nothing to identify it by, so the only reading left is that it + // carries on the call already being assembled. + slot = state->next_tool_slot - 1; + } + if (slot < 0) { + slot = state->next_tool_slot++; + } + if (numbered) { + state->tool_slot_by_index[index] = slot; + } + if (!id.empty()) { + state->tool_slot_by_id[id] = slot; + } + + StreamState::ToolCall& pending = state->tool_calls[slot]; + if (!id.empty()) { + pending.id = id; + } + const Json function = call.contains("function") && call["function"].is_object() + ? call["function"] + : Json::object(); + if (function.contains("name") && function["name"].is_string()) { + pending.name = function["name"].get(); + } + if (function.contains("arguments") && function["arguments"].is_string()) { + pending.arguments += function["arguments"].get(); + } + } + } + + // content is null on the chunk that only carries a finish reason. + const std::string text = delta.contains("content") && delta["content"].is_string() + ? delta["content"].get() + : std::string(); + if (text.empty()) { + return out; + } + + // The block opens on the first token rather than up front: a stream that + // only ever carries a finish reason should not announce a text block that + // never gets one. + if (!state->block_open) { + state->block_open = true; + out += Event("content_block_start", + Json{{"type", "content_block_start"}, + {"index", 0}, + {"content_block", Json{{"type", "text"}, {"text", ""}}}}); + } + out += Event("content_block_delta", + Json{{"type", "content_block_delta"}, + {"index", 0}, + {"delta", Json{{"type", "text_delta"}, {"text", text}}}}); + return out; +} + +std::string StreamCloseToAnthropic(StreamState* state) { + if (state == nullptr || !state->opened || state->failed) { + return {}; + } + std::string out; + // Blocks are numbered in the order they are written, and the text — if the + // turn had any — is always the one that came first. + int index = 0; + if (state->block_open) { + state->block_open = false; + out += Event("content_block_stop", Json{{"type", "content_block_stop"}, {"index", 0}}); + index = 1; + } + for (const auto& entry : state->tool_calls) { + const StreamState::ToolCall& call = entry.second; + // A call nobody ever named cannot be run, and a block naming nothing is + // worse for the client than a call it never hears about. + if (call.name.empty()) { + continue; + } + // The client matches a result back to its call by this id, so a call + // the endpoint never named still needs one it can quote. + const std::string id = + call.id.empty() ? "tool_" + std::to_string(entry.first) : call.id; + out += Event("content_block_start", + Json{{"type", "content_block_start"}, + {"index", index}, + {"content_block", Json{{"type", "tool_use"}, + {"id", id}, + {"name", call.name}, + {"input", Json::object()}}}}); + out += Event("content_block_delta", + Json{{"type", "content_block_delta"}, + {"index", index}, + {"delta", Json{{"type", "input_json_delta"}, + {"partial_json", call.arguments.empty() + ? std::string("{}") + : call.arguments}}}}); + out += Event("content_block_stop", + Json{{"type", "content_block_stop"}, {"index", index}}); + ++index; + } + const std::string stop = StopWithTools( + state->stop_reason.empty() ? std::string("end_turn") : state->stop_reason, + !state->tool_calls.empty()); + out += Event("message_delta", + Json{{"type", "message_delta"}, + {"delta", Json{{"stop_reason", stop}, {"stop_sequence", nullptr}}}, + {"usage", Json{{"output_tokens", state->output_tokens}}}}); + out += Event("message_stop", Json{{"type", "message_stop"}}); + return out; +} + +bool PayloadError(const Json& payload, std::string* type, std::string* message) { + if (!payload.is_object() || !payload.contains("error") || payload["error"].is_null()) { + return false; + } + const Json& error = payload["error"]; + std::string text = + error.is_object() ? Field(error, "message") : error.dump(); + if (text.empty()) { + text = "the model endpoint reported an error it did not describe"; + } + if (message != nullptr) { + *message = text; + } + if (type != nullptr) { + // Worth telling apart: a client that knows it was rate limited can back + // off and try again, where a plain api_error reads as a dead endpoint. + *type = text.find("RESOURCE_EXHAUSTED") != std::string::npos || + text.find("exceeded your current quota") != std::string::npos + ? "rate_limit_error" + : "api_error"; + } + return true; +} + +std::string ErrorBody(const std::string& type, const std::string& message) { + return Json{{"type", "error"}, {"error", Json{{"type", type}, {"message", message}}}}.dump(); +} + +} // namespace rcli::anthropic::translate diff --git a/src/anthropic/translate.h b/src/anthropic/translate.h new file mode 100644 index 0000000..5c30dac --- /dev/null +++ b/src/anthropic/translate.h @@ -0,0 +1,87 @@ +#ifndef RCLI_ANTHROPIC_TRANSLATE_H +#define RCLI_ANTHROPIC_TRANSLATE_H + +#include +#include +#include + +#include + +/// The wire-format translation, with no sockets in it. +/// +/// Separated from the server so the mapping can be tested by handing it JSON +/// and reading JSON back, which is the only part of this worth testing: the +/// HTTP plumbing is cpp-httplib's, and the interesting bugs are all in the +/// shapes. +namespace rcli::anthropic::translate { + +using Json = nlohmann::json; + +/// Anthropic Messages request -> OpenAI chat completion request. +/// +/// `model` replaces whatever model the caller named, because Claude Code sends +/// its own model ids and the endpoint behind us has never heard of them. +Json RequestToOpenAI(const Json& anthropic, const std::string& model); + +/// OpenAI chat completion -> a whole Anthropic message. +Json ResponseToAnthropic(const Json& openai, const std::string& model); + +/// One OpenAI stream chunk, turned into the Anthropic SSE events it implies. +/// +/// Anthropic's stream is a state machine — message_start, content_block_start, +/// deltas, content_block_stop, message_delta, message_stop — where OpenAI's is +/// a flat run of deltas. `state` carries what has already been emitted so the +/// opening events fire exactly once. +struct StreamState { + /// A call being assembled from the stream. OpenAI spreads one across as + /// many chunks as it likes, naming it once and then sending its arguments + /// a few characters at a time. + struct ToolCall { + std::string id; + std::string name; + std::string arguments; + }; + + bool opened = false; + bool block_open = false; + /// Set once the endpoint has reported a failure, after which the closing + /// events would be describing a turn that never happened. + bool failed = false; + std::string message_id; + std::string model; + std::string stop_reason; + int input_tokens = 0; + int output_tokens = 0; + + /// The calls so far, in the order they were first seen, which is the order + /// they are written out in. + /// + /// Endpoints identify a call in two different ways and the slot is what + /// reconciles them: OpenAI numbers its calls and dribbles the arguments of + /// each across chunks, while others send a call whole and number nothing, + /// leaning on the id instead. Keying on either alone merges calls that are + /// separate or splits one that is not. + std::map tool_calls; + std::map tool_slot_by_index; + std::map tool_slot_by_id; + int next_tool_slot = 0; +}; + +/// Returns the SSE text to write for `chunk`, or empty when it implies nothing. +std::string StreamChunkToAnthropic(const Json& chunk, StreamState* state); + +/// The closing events, written once the upstream stream ends. +std::string StreamCloseToAnthropic(StreamState* state); + +/// An Anthropic-shaped error body, so a failure reads as one to the client +/// rather than as a malformed message. +std::string ErrorBody(const std::string& type, const std::string& message); + +/// Reads the failure out of a body the endpoint sent with a success status. +/// +/// Returns false when `payload` carries no error, which is the ordinary case. +bool PayloadError(const Json& payload, std::string* type, std::string* message); + +} // namespace rcli::anthropic::translate + +#endif // RCLI_ANTHROPIC_TRANSLATE_H diff --git a/src/app.cpp b/src/app.cpp index 9e2ccf1..2abc303 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -70,6 +70,9 @@ void configure_app(CLI::App& app, GlobalOptions& options) { commands::register_info(app, options); commands::register_version(app, options); commands::register_auth(app, options); + commands::register_account(app, options); + commands::register_editors(app, options); + commands::register_harness(app, options); commands::register_telemetry(app, options); } diff --git a/src/commands/cmd_account.cpp b/src/commands/cmd_account.cpp new file mode 100644 index 0000000..735eb69 --- /dev/null +++ b/src/commands/cmd_account.cpp @@ -0,0 +1,280 @@ +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#else +#include +#include + +#include +#include +#endif + +#include "account/console.h" +#include "account/credentials.h" +#include "commands/commands.h" +#include "io/output.h" + +namespace rcli::commands { +namespace { + +void fail(int status) { + if (status != 0) { + throw CLI::RuntimeError(status); + } +} + +long long EpochSeconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +std::string Hostname() { +#if defined(_WIN32) + WSADATA data; + static const bool ready = WSAStartup(MAKEWORD(2, 2), &data) == 0; + if (!ready) { + return "unknown"; + } +#endif + char name[256] = {}; + return gethostname(name, sizeof(name) - 1) == 0 && name[0] != '\0' ? name : "unknown"; +} + +void OpenBrowser(const std::string& url) { +#if defined(_WIN32) + const intptr_t rc = _spawnlp(_P_NOWAIT, "rundll32", "rundll32", "url.dll,FileProtocolHandler", + url.c_str(), nullptr); + if (rc < 0) { + out::status_line("could not open a browser; use the URL printed above"); + } +#else +#if defined(__APPLE__) + const char* opener = "open"; +#else + const char* opener = "xdg-open"; +#endif + const pid_t child = fork(); + if (child < 0) { + out::status_line("could not open a browser; use the URL printed above"); + return; + } + if (child == 0) { + const int devnull = ::open("/dev/null", O_WRONLY); + if (devnull >= 0) { + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + close(devnull); + } + std::string target = url; + char* argv[] = {const_cast(opener), target.data(), nullptr}; + execvp(opener, argv); + _exit(127); + } + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} +#endif +} + +bool LoadCredentials(account::Credentials* credentials) { + std::string failure; + if (!account::Load(credentials, &failure)) { + out::error_line(failure); + return false; + } + return true; +} + +void ApplyGrant(const account::Grant& grant, account::Credentials* credentials) { + 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); +} + +bool RefreshSession(const account::ConsoleClient& client, 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 (!client.Refresh(credentials->console_url, credentials->refresh_token, &grant, error)) { + return false; + } + ApplyGrant(grant, credentials); + return account::Save(*credentials, error); +} + +int Login(const std::string& requested_console, bool open_browser) { + std::string console_url; + std::string failure; + const std::string configured = + requested_console.empty() ? account::DefaultConsoleUrl() : requested_console; + if (!account::NormalizeConsoleUrl(configured, &console_url, &failure)) { + out::error_line(failure); + return 1; + } + + account::ConsoleClient client; + account::Authorization authorization; + if (!client.BeginAuthorization(console_url, Hostname(), &authorization, &failure)) { + out::error_line(failure); + return 1; + } + if (!account::BrowserUrlMatchesConsole(authorization.verification_url, console_url)) { + out::error_line("console returned an approval URL outside its origin"); + return 1; + } + + out::status_line("approve this sign-in in your browser"); + out::result_line("code " + authorization.request_code); + out::result_line("url " + authorization.verification_url); + if (open_browser) { + OpenBrowser(authorization.verification_url); + } + out::status_line("waiting for approval"); + + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::seconds(authorization.expires_in); + account::Grant grant; + while (std::chrono::steady_clock::now() < deadline) { + switch (client.Poll(console_url, authorization, &grant, &failure)) { + case account::PollResult::Pending: + std::this_thread::sleep_for(std::chrono::seconds(authorization.interval)); + continue; + case account::PollResult::Denied: + out::error_line("the request was denied in the browser"); + return 1; + case account::PollResult::Expired: + out::error_line("the request expired before it was approved"); + return 1; + case account::PollResult::Failed: + out::error_line(failure); + return 1; + case account::PollResult::Approved: { + account::Credentials credentials; + credentials.console_url = console_url; + ApplyGrant(grant, &credentials); + if (!account::Save(credentials, &failure)) { + out::error_line(failure); + return 1; + } + const std::string identity = + credentials.email.empty() ? "your account" : credentials.email; + out::status_line("signed in as " + identity); + out::status_line("cloud session stored in " + account::ProfileDirectory()); + return 0; + } + } + } + out::error_line("timed out waiting for approval"); + return 1; +} + +int Logout() { + account::Credentials credentials; + if (!LoadCredentials(&credentials)) { + return 1; + } + if (!credentials.signed_in() && credentials.refresh_token.empty()) { + out::status_line("not signed in"); + return 0; + } + + account::ConsoleClient client; + std::string revoke_failure; + const bool revoked = client.Revoke(credentials.console_url, credentials.access_token, + credentials.refresh_token, &revoke_failure); + std::string clear_failure; + if (!account::Clear(&clear_failure)) { + out::error_line(clear_failure); + return 1; + } + out::status_line("signed out on this machine"); + if (!revoked) { + out::error_line(revoke_failure + "; the local session was removed"); + return 1; + } + out::status_line("cloud session revoked"); + return 0; +} + +int WhoAmI() { + account::Credentials credentials; + if (!LoadCredentials(&credentials)) { + return 1; + } + if (!credentials.signed_in()) { + out::error_line("not signed in — run `rcli login`"); + return 1; + } + + account::ConsoleClient client; + std::string failure; + if (credentials.access_token_expired(EpochSeconds()) && + !RefreshSession(client, &credentials, &failure)) { + out::error_line(failure); + return 1; + } + + account::Identity identity; + account::IdentityResult result = + client.WhoAmI(credentials.console_url, credentials.access_token, &identity, &failure); + if (result == account::IdentityResult::Unauthorized) { + if (!RefreshSession(client, &credentials, &failure)) { + out::error_line(failure); + return 1; + } + result = + client.WhoAmI(credentials.console_url, credentials.access_token, &identity, &failure); + } + if (result != account::IdentityResult::Ok) { + out::error_line(failure); + return 1; + } + + char line[220]; + std::snprintf(line, sizeof(line), "%-14s %s", "email", identity.email.c_str()); + out::result_line(line); + std::snprintf(line, sizeof(line), "%-14s %s", "session", "active"); + out::result_line(line); + std::snprintf(line, sizeof(line), "%-14s %s", "console", credentials.console_url.c_str()); + out::result_line(line); + return 0; +} + +} // namespace + +void register_account(CLI::App& app, GlobalOptions& options) { + static_cast(options); + auto no_browser = std::make_shared(false); + auto console_url = std::make_shared(); + auto* login = app.add_subcommand("login", "sign in through the RunAnywhere console"); + login->add_flag("--no-browser", *no_browser, "print the URL instead of opening it"); + login + ->add_option("--console-url", *console_url, + "console origin (default: https://console.runanywhere.ai)") + ->envname("RCLI_CONSOLE_URL"); + login->callback([no_browser, console_url] { fail(Login(*console_url, !*no_browser)); }); + + auto* logout = app.add_subcommand("logout", "revoke and remove the cloud session"); + logout->callback([] { fail(Logout()); }); + + auto* whoami = app.add_subcommand("whoami", "show the signed-in cloud account"); + whoami->callback([] { fail(WhoAmI()); }); +} + +} // namespace rcli::commands diff --git a/src/commands/cmd_editors.cpp b/src/commands/cmd_editors.cpp new file mode 100644 index 0000000..c8c0a07 --- /dev/null +++ b/src/commands/cmd_editors.cpp @@ -0,0 +1,427 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "anthropic/messages.h" +#include "commands/commands.h" +#include "io/output.h" +#include "desktop/claude_profile.h" +#include "harness/harness.h" +#include "ide/jetbrains_profile.h" +#include "ide/openai_proxy.h" + +namespace rcli::commands { +namespace { + +/// CLI11 callbacks return void, so a non-zero status leaves as the runtime +/// error the app turns back into an exit code. +void fail(int status) { + if (status != 0) { + throw CLI::RuntimeError(status); + } +} + + +/// One editor or agent rcli can point at a model. +/// +/// The list is the whole integration surface: a new target is a row here plus +/// whatever `apply` has to set. Everything before that — resolving the model, +/// serving it, translating the wire format — is shared, which is the point. +/// How a tool is told where the model lives. +enum class Wiring { + /// Variables in the launched process. Works for anything that reads + /// ANTHROPIC_BASE_URL itself, or spawns something that does. + Environment, + /// Claude Desktop's third-party gateway profile, because it ignores the + /// environment for authentication and says so. + ClaudeProfile, + /// AI Assistant's OpenAI-compatible provider, for a JetBrains IDE. The one + /// wiring that needs no translator: the endpoint is already that shape. + JetBrainsProvider, +}; + +struct Editor { + /// What the reader types after `rcli`. + const char* id; + /// An executable on PATH, or empty when this is a desktop app. + const char* command; + /// A macOS application bundle, or empty when `command` is on PATH. + const char* bundle; + const char* summary; + Wiring wiring; + /// Set only for Wiring::JetBrainsProvider. + const ide::Product* jetbrains; +}; + +constexpr ide::Product kCLion{"clion", "CLion.app", "clion", "CLion"}; +constexpr ide::Product kRustRover{"rustrover", "RustRover.app", "rustrover", "RustRover"}; + +/// Only tools that speak the Anthropic Messages API belong here. Anything +/// OpenAI-shaped needs no translator and goes through `rcli opencode`. +/// +/// Claude Desktop earns its place because it forwards a fixed set of variables +/// to the Claude Code it runs inside itself, and ANTHROPIC_BASE_URL is one of +/// them. That is the same trick as `rcli claude-code`, one process further out. +constexpr Editor kEditors[] = { + {"claude-code", "claude", "", "open Claude Code against a model", Wiring::Environment, + nullptr}, + {"claude-desktop", "", "Claude.app", "open Claude Desktop against a model", + Wiring::ClaudeProfile, nullptr}, + // A JetBrains IDE gets its own agent pointed at the model rather than a + // second one nested inside it. Wiring the bundled Claude Agent through the + // environment was the first attempt and bought nothing: the IDE already + // ships AI Assistant and Junie, and the nested agent asks for its own + // credential regardless. + {"clion", "", "CLion.app", "open CLion against a model", Wiring::JetBrainsProvider, + &kCLion}, + {"rustrover", "", "RustRover.app", "open RustRover against a model", + Wiring::JetBrainsProvider, &kRustRover}, +}; + +/// Where `editor`'s application bundle is, or empty when it is not installed. +std::string BundlePath(const Editor& editor) { + if (editor.bundle[0] == '\0') { + return {}; + } + const char* home = std::getenv("HOME"); + std::vector roots{"/Applications/"}; + if (home != nullptr) { + roots.push_back(std::string(home) + "/Applications/"); + } + for (const std::string& root : roots) { + const std::string path = root + editor.bundle; + std::ifstream probe(path + "/Contents/Info.plist"); + if (probe.good()) { + return path; + } + } + return {}; +} + +/// "Claude" from "/Applications/Claude.app". +std::string BundleName(const std::string& bundle_path) { + const size_t slash = bundle_path.rfind('/'); + const std::string leaf = + slash == std::string::npos ? bundle_path : bundle_path.substr(slash + 1); + const size_t dot = leaf.rfind(".app"); + return dot == std::string::npos ? leaf : leaf.substr(0, dot); +} + +/// `open -W -a `, which waits for the app to quit. +/// +/// Waiting is the point: the translator has to outlive the app exactly, and no +/// longer. No environment is passed — Claude Desktop ignores those for +/// authentication and says so in its own UI. The gateway profile is what +/// redirects it. +std::vector OpenArgs(const std::string& bundle, const anthropic::Shim& shim, + const std::vector& passthrough) { + std::vector args{"-W"}; + if (shim.running) { + // `open --env` is what carries them across; launchd would otherwise + // start the app with the reader's login environment instead of ours. + args.push_back("--env"); + args.push_back("ANTHROPIC_BASE_URL=" + shim.base_url); + args.push_back("--env"); + args.push_back("ANTHROPIC_AUTH_TOKEN=" + shim.auth_token); + args.push_back("--env"); + args.push_back("ANTHROPIC_API_KEY=" + shim.auth_token); + } + args.push_back("-a"); + args.push_back(bundle); + if (!passthrough.empty()) { + args.push_back("--args"); + args.insert(args.end(), passthrough.begin(), passthrough.end()); + } + return args; +} + +/// Blocks until `name` is running or gone, whichever `running` asks for. +/// +/// `timeout` in seconds, or 0 to wait indefinitely. Matched on the process name +/// rather than the command line: `pgrep -f` would also match the shell running +/// the search, and report the IDE as alive forever. +void AwaitProcess(const std::string& name, bool running, int timeout) { + const std::string probe = "pgrep -x " + name + " >/dev/null 2>&1"; + for (int waited = 0; timeout == 0 || waited < timeout; waited += 2) { + if ((harness::Launch("/bin/sh", {}, {"-c", probe}) == 0) == running) { + return; + } + std::this_thread::sleep_for(std::chrono::seconds(2)); + } +} + +/// Quits the app if it is running. A profile change is read at launch, so a +/// running instance would keep the old one and look like the change failed. +void QuitBundle(const std::string& bundle) { + const std::string name = BundleName(bundle); + if (name.empty()) { + return; + } + harness::Launch("osascript", {}, {"-e", "tell application \"" + name + "\" to quit"}); + // Relaunching into a process that is still shutting down gets the old + // profile back, so give it a moment to actually go. + std::this_thread::sleep_for(std::chrono::seconds(3)); +} + +/// Sets `name` for the child, remembering what was there so it can be undone. +class ScopedEnv { + public: + ScopedEnv(std::string name, const std::string& value) : name_(std::move(name)) { + const char* previous = std::getenv(name_.c_str()); + had_previous_ = previous != nullptr; + if (had_previous_) { + previous_ = previous; + } + Set(value); + } + + ~ScopedEnv() { + if (had_previous_) { + Set(previous_); + } else { +#if defined(_WIN32) + _putenv_s(name_.c_str(), ""); +#else + unsetenv(name_.c_str()); +#endif + } + } + + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; + + private: + void Set(const std::string& value) { +#if defined(_WIN32) + _putenv_s(name_.c_str(), value.c_str()); +#else + setenv(name_.c_str(), value.c_str(), 1); +#endif + } + + std::string name_; + std::string previous_; + bool had_previous_ = false; +}; + +/// Starts the translator and holds it open, printing what to point at it. +/// +/// Worth having beyond debugging: it is how anything that speaks the Anthropic +/// API but is not on the list above gets wired up, without rcli needing to know +/// that tool exists. +int Serve(const std::string& model, bool verbose) { + harness::Endpoint endpoint; + if (!harness::Resolve(model, &endpoint)) { + return 1; + } + anthropic::Shim shim; + if (!anthropic::Start(endpoint, model, &shim, verbose)) { + harness::Release(endpoint); + return 1; + } + out::result_line("ANTHROPIC_BASE_URL=" + shim.base_url); + out::result_line("ANTHROPIC_AUTH_TOKEN=" + shim.auth_token); + out::status_line("serving " + model + "; press Ctrl-C to stop"); + // No signal handling: Ctrl-C ends the process, and the OS reclaims the port + // and the model. Anything subtler would be pretending this outlives it. + for (;;) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + } +} + +/// Puts the app back on Anthropic without starting anything. +/// +/// The way out when a run was interrupted before it could undo itself, and the +/// same verb Ollama offers for the same reason. +int Restore(const Editor& editor) { + std::string failure; + if (editor.wiring == Wiring::JetBrainsProvider) { + if (!ide::RestoreProvider(*editor.jetbrains, &failure)) { + out::error_line(failure); + return 1; + } + out::status_line(std::string(editor.id) + " no longer points at a local model"); + return 0; + } + if (!desktop::RestoreGateway(&failure)) { + out::error_line(failure); + return 1; + } + out::status_line(std::string(editor.id) + " is back on Anthropic; restart it to pick that up"); + return 0; +} + +int Run(const Editor& editor, const std::string& model, + const std::vector& args, bool verbose) { + const bool is_bundle = editor.bundle[0] != '\0'; + std::string bundle; + if (is_bundle) { +#if defined(__APPLE__) + bundle = BundlePath(editor); + if (bundle.empty()) { + out::error_line(std::string(editor.bundle) + " is not installed"); + return 1; + } +#else + out::error_line(std::string(editor.id) + " is a macOS application"); + return 1; +#endif + } + + if (model.empty()) { + // No model named means no wiring to do, so the tool runs exactly as the + // reader has it configured. Same contract as `rcli opencode`. + return is_bundle ? harness::Launch("open", {}, OpenArgs(bundle, {}, args)) + : harness::Launch(editor.command, {}, args); + } + + harness::Endpoint endpoint; + if (!harness::Resolve(model, &endpoint, + editor.wiring == Wiring::JetBrainsProvider ? ide::kProviderPort : 0)) { + return 1; + } + + if (editor.wiring == Wiring::JetBrainsProvider) { + // No translator on this path. AI Assistant's provider speaks OpenAI, + // which is what `Resolve` already handed us, so the IDE talks to the + // model directly and nothing sits in between to get the wire format + // wrong. + // An upstream model arrives with a credential, and the IDE has no way + // to take one from us. Keep it here and hand the IDE a loopback address + // that needs none, which is the arrangement a local model already uses. + ide::Proxy proxy; + std::string reachable = endpoint.base_url; + if (!endpoint.api_key.empty()) { + if (!ide::StartProxy(endpoint, model, ide::kProviderPort, &proxy, verbose)) { + harness::Release(endpoint); + return 1; + } + reachable = proxy.base_url; + } + + std::string failure; + if (!ide::ApplyProvider(*editor.jetbrains, reachable, std::string(), model, &failure)) { + out::error_line(failure); + ide::StopProxy(&proxy); + harness::Release(endpoint); + return 1; + } + out::status_line(std::string(editor.id) + " will talk to " + model + " through " + reachable); + QuitBundle(bundle); + // `open -W` is wrong here. Quitting the running instance first means the + // wait can attach to the one on its way out and return while the new one + // is still starting, which pulls the endpoint out from under it. + AwaitProcess(editor.jetbrains->launcher, false, 30); + std::vector open_args{"-a", bundle}; + if (!args.empty()) { + open_args.push_back("--args"); + open_args.insert(open_args.end(), args.begin(), args.end()); + } + const int status = harness::Launch("open", {}, open_args); + if (status == 0) { + AwaitProcess(editor.jetbrains->launcher, true, 60); + out::status_line("serving " + model + " until " + std::string(editor.id) + " quits"); + AwaitProcess(editor.jetbrains->launcher, false, 0); + } + // The settings stay written on the way out. Nothing in them moves + // between runs, so the configuration the reader sat through once is + // never asked for again. + ide::StopProxy(&proxy); + harness::Release(endpoint); + return status; + } + + // Claude Desktop only lists gateway models it can map to an Anthropic + // family, so the gateway answers under one of those ids while serving the + // model the reader asked for. Only the desktop app needs this; the CLI + // takes the real id happily. + const std::string advertised = + editor.wiring == Wiring::ClaudeProfile ? std::string("claude-sonnet-4-5") : model; + + anthropic::Shim shim; + if (!anthropic::Start(endpoint, model, &shim, verbose, advertised)) { + harness::Release(endpoint); + return 1; + } + out::status_line(std::string(editor.id) + " will talk to " + model + " through " + + shim.base_url); + if (advertised != model) { + out::status_line("advertised to the app as " + advertised + "; the picker shows " + model); + } + + int status = 0; + if (editor.wiring == Wiring::ClaudeProfile) { + // The profile, not the environment. Written before the app starts and + // taken back when it exits, so a crash here is the one case that leaves + // it applied — which is what `--restore` is for. + std::string failure; + if (!desktop::ApplyGateway(shim.base_url, shim.auth_token, advertised, model, + "RunAnywhere · " + model, &failure)) { + out::error_line(failure); + anthropic::Stop(&shim); + harness::Release(endpoint); + return 1; + } + QuitBundle(bundle); + status = harness::Launch("open", {}, OpenArgs(bundle, shim, args)); + if (!desktop::RestoreGateway(&failure)) { + out::error_line(failure); + } + } else if (is_bundle) { + // An app that reads the variables itself, or spawns something that + // does. Quit first: a running instance keeps the environment it was + // started with, so the agent inside it would talk to the old endpoint. + QuitBundle(bundle); + status = harness::Launch("open", {}, OpenArgs(bundle, shim, args)); + } else { + // Scoped so the reader's own environment is back before we report + // anything, and before a later call in the same process reads it. + const ScopedEnv base("ANTHROPIC_BASE_URL", shim.base_url); + const ScopedEnv token("ANTHROPIC_AUTH_TOKEN", shim.auth_token); + // An API key set in the environment outranks the token above and would + // send the session to Anthropic instead of to us. + const ScopedEnv key("ANTHROPIC_API_KEY", shim.auth_token); + status = harness::Launch(editor.command, {}, args); + } + + anthropic::Stop(&shim); + harness::Release(endpoint); + return status; +} + +} // namespace + +void register_editors(CLI::App& app, GlobalOptions& options) { + for (const Editor& editor : kEditors) { + auto model = std::make_shared(); + auto restore = std::make_shared(false); + auto rest = std::make_shared>(); + auto serve = std::make_shared(false); + auto* command = app.add_subcommand(editor.id, editor.summary); + command->add_option("-m,--model", *model, + "a model on this machine, or one served upstream"); + command->add_flag("--serve", *serve, + "hold the endpoint open and print it, instead of launching"); + if (editor.wiring == Wiring::ClaudeProfile || + editor.wiring == Wiring::JetBrainsProvider) { + command->add_flag("--restore", *restore, + "undo what we configured and launch nothing"); + } + command->add_option("args", *rest, "passed through")->allow_extra_args(); + command->prefix_command(); + command->callback([&options, &editor, model, rest, serve, restore] { + if (*restore) { + fail(Restore(editor)); + return; + } + fail(*serve ? Serve(*model, options.verbose) + : Run(editor, *model, *rest, options.verbose)); + }); + } +} + +} // namespace rcli::commands diff --git a/src/commands/cmd_harness.cpp b/src/commands/cmd_harness.cpp new file mode 100644 index 0000000..968c17f --- /dev/null +++ b/src/commands/cmd_harness.cpp @@ -0,0 +1,53 @@ +#include +#include +#include + +#include "catalog/catalog.h" +#include "commands/commands.h" +#include "io/output.h" +#include "harness/harness.h" +#include "harness/opencode.h" + +namespace rcli::commands { +namespace { + +/// CLI11 callbacks return void, so a non-zero status leaves as the runtime +/// error the app turns back into an exit code. +void fail(int status) { + if (status != 0) { + throw CLI::RuntimeError(status); + } +} +} // namespace + +void register_harness(CLI::App& app, GlobalOptions& options) { + static_cast(options); + // `rcli opencode ` rather than a flag on `run`: it hands the terminal + // to another program, which is a different thing to do than talk to a model. + auto model = std::make_shared(); + auto rest = std::make_shared>(); + auto cloud = std::make_shared(false); + auto* opencode = + app.add_subcommand("opencode", "open a coding session in opencode, wired to a model"); + // A named option rather than a positional: with two positionals there is no + // way to tell `rcli opencode run` asking for passthrough from someone + // naming a model called run, and the first reading wins silently. + opencode->add_option("-m,--model", *model, "a model on this machine, or one served upstream"); + opencode->add_flag("--cloud", *cloud, + "use the signed-in hosted endpoint (never routes local models)"); + opencode->add_option("args", *rest, "passed through to opencode")->allow_extra_args(); + opencode->prefix_command(); + opencode->callback([model, rest, cloud] { + if (*cloud) { + if (model->empty()) { + out::error_line("--cloud requires --model "); + fail(2); + } + fail(harness::LaunchOpenCodeCloud(*model, *rest)); + return; + } + fail(harness::Launch("opencode", *model, *rest)); + }); +} + +} // namespace rcli::commands diff --git a/src/commands/commands.h b/src/commands/commands.h index 5a1549a..bfc4f2f 100644 --- a/src/commands/commands.h +++ b/src/commands/commands.h @@ -58,6 +58,13 @@ void register_backends(CLI::App& app, GlobalOptions& options); void register_serve(CLI::App& app, GlobalOptions& options); void register_bench(CLI::App& app, GlobalOptions& options); void register_auth(CLI::App& app, GlobalOptions& options); + +// Sign-in against the console that serves upstream models, and the editors and +// coding agents pointed at one. Separate from register_auth, which signs the +// device in to the control plane; the two are being unified. +void register_account(CLI::App& app, GlobalOptions& options); +void register_editors(CLI::App& app, GlobalOptions& options); +void register_harness(CLI::App& app, GlobalOptions& options); void register_telemetry(CLI::App& app, GlobalOptions& options); /** diff --git a/src/desktop/claude_profile.cpp b/src/desktop/claude_profile.cpp new file mode 100644 index 0000000..277566d --- /dev/null +++ b/src/desktop/claude_profile.cpp @@ -0,0 +1,226 @@ +#include "desktop/claude_profile.h" + +#include +#include +#include +#include +#include + +#include + +namespace rcli::desktop { +namespace { + +using Json = nlohmann::json; +namespace fs = std::filesystem; + +/// Our profile's identity inside Claude Desktop's library. Fixed, so a second +/// run replaces the first rather than stacking entries up. +/// +/// Shaped like the app's own ids; the tail is "RunAny" in hex, which makes it +/// recognisable in a file somebody is reading by hand. +constexpr const char* kProfileID = "00000000-0000-4000-8000-52756e416e79"; + +std::string Home() { + const char* home = std::getenv("HOME"); + return home != nullptr ? home : std::string(); +} + +std::string SupportRoot(bool third_party) { + const std::string home = Home(); + if (home.empty()) { + return {}; + } + return home + "/Library/Application Support/" + (third_party ? "Claude-3p" : "Claude"); +} + +/// Reads a JSON object, treating "not there" and "empty" as an empty object. +/// +/// A malformed file is reported rather than overwritten: it is the reader's +/// Claude Desktop configuration, and silently replacing it would lose whatever +/// else they had in there. +bool ReadObject(const std::string& path, Json* out, std::string* error) { + *out = Json::object(); + std::ifstream file(path); + if (!file.good()) { + return true; + } + const std::string text((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + if (text.find_first_not_of(" \t\r\n") == std::string::npos) { + return true; + } + try { + Json parsed = Json::parse(text); + if (parsed.is_object()) { + *out = std::move(parsed); + } + return true; + } catch (const Json::exception& failure) { + if (error != nullptr) { + *error = "could not read " + path + ": " + failure.what(); + } + return false; + } +} + +bool WriteObject(const std::string& path, const Json& value, std::string* error) { + std::error_code code; + fs::create_directories(fs::path(path).parent_path(), code); + std::ofstream file(path, std::ios::trunc); + if (!file.good()) { + if (error != nullptr) { + *error = "could not write " + path; + } + return false; + } + file << value.dump(2) << "\n"; + return file.good(); +} + +bool SetDeploymentMode(const std::string& path, const std::string& mode, std::string* error) { + Json config; + if (!ReadObject(path, &config, error)) { + return false; + } + config["deploymentMode"] = mode; + return WriteObject(path, config, error); +} + +std::string ProfilePath() { return SupportRoot(true) + "/configLibrary/" + kProfileID + ".json"; } +std::string MetaPath() { return SupportRoot(true) + "/configLibrary/_meta.json"; } + +} // namespace + +std::string ProfileDirectory() { return SupportRoot(true); } + +bool ApplyGateway(const std::string& base_url, const std::string& api_key, + const std::string& advertised, const std::string& label, + const std::string& display_name, std::string* error) { + if (Home().empty()) { + if (error != nullptr) { + *error = "no home directory to write the profile into"; + } + return false; + } + + Json profile; + if (!ReadObject(ProfilePath(), &profile, error)) { + return false; + } + profile["inferenceProvider"] = "gateway"; + profile["inferenceGatewayBaseUrl"] = base_url; + profile["inferenceGatewayApiKey"] = api_key; + profile["inferenceGatewayAuthScheme"] = "bearer"; + profile["deploymentDisplayName"] = display_name; + profile["chatTabEnabled"] = true; + // Cowork reaches plugins and MCP servers over the network, and a profile + // that does not say so leaves it unable to use them. + profile["coworkEgressAllowedHosts"] = Json::array({"*"}); + profile["autoModeEnabled"] = false; + // Cowork is the surface being asked for, and the app disables surfaces it + // is not told to keep. + profile["coworkTabEnabled"] = true; + // Named rather than discovered. Discovery works when a gateway advertises + // a model the app recognises as usable; ours advertises one id and the app + // answers "Gateway returned no usable models", which is exactly the case + // its own error message says to solve by listing the model here. A plain + // id string is a valid entry, and the first entry is the default. + // `name` has to be an id the app can map onto an Anthropic family or it + // drops the entry: "expected a gateway model that maps to an Anthropic + // model". `labelOverride` is what the picker actually shows, so the row + // names the model that really answers rather than the one we route under. + profile["inferenceModels"] = + Json::array({Json{{"name", advertised}, {"labelOverride", label}}}); + // Asked for explicitly: with inferenceModels set the app would otherwise + // skip discovery, and the picker then has nothing to reconcile the served + // model against. + profile["modelDiscoveryEnabled"] = true; + if (!WriteObject(ProfilePath(), profile, error)) { + return false; + } + + Json meta; + if (!ReadObject(MetaPath(), &meta, error)) { + return false; + } + meta["appliedId"] = kProfileID; + Json entries = Json::array(); + if (meta.contains("entries") && meta["entries"].is_array()) { + for (const Json& entry : meta["entries"]) { + // Drop any previous version of ours; keep everybody else's. + if (entry.is_object() && entry.value("id", std::string()) == kProfileID) { + continue; + } + entries.push_back(entry); + } + } + entries.push_back(Json{{"id", kProfileID}, {"name", display_name}}); + meta["entries"] = std::move(entries); + if (!WriteObject(MetaPath(), meta, error)) { + return false; + } + + // Both trees: the app reads the normal one to decide which mode it is in. + return SetDeploymentMode(SupportRoot(true) + "/claude_desktop_config.json", "3p", error) && + SetDeploymentMode(SupportRoot(false) + "/claude_desktop_config.json", "3p", error); +} + +bool RestoreGateway(std::string* error) { + if (Home().empty()) { + return true; + } + if (!SetDeploymentMode(SupportRoot(false) + "/claude_desktop_config.json", "1p", error) || + !SetDeploymentMode(SupportRoot(true) + "/claude_desktop_config.json", "1p", error)) { + return false; + } + + Json meta; + if (!ReadObject(MetaPath(), &meta, error)) { + return false; + } + if (!meta.empty()) { + if (meta.value("appliedId", std::string()) == kProfileID) { + meta.erase("appliedId"); + } + if (meta.contains("entries") && meta["entries"].is_array()) { + Json entries = Json::array(); + for (const Json& entry : meta["entries"]) { + if (entry.is_object() && entry.value("id", std::string()) == kProfileID) { + continue; + } + entries.push_back(entry); + } + meta["entries"] = std::move(entries); + } + if (!WriteObject(MetaPath(), meta, error)) { + return false; + } + } + + Json profile; + if (!ReadObject(ProfilePath(), &profile, error)) { + return false; + } + if (profile.empty()) { + return true; + } + for (const char* key : + {"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", "deploymentDisplayName", "inferenceModels", + "coworkEgressAllowedHosts", "autoModeEnabled", "coworkTabEnabled", + "modelDiscoveryEnabled"}) { + profile.erase(key); + } + return WriteObject(ProfilePath(), profile, error); +} + +bool GatewayApplied() { + Json meta; + if (!ReadObject(MetaPath(), &meta, nullptr)) { + return false; + } + return meta.value("appliedId", std::string()) == kProfileID; +} + +} // namespace rcli::desktop diff --git a/src/desktop/claude_profile.h b/src/desktop/claude_profile.h new file mode 100644 index 0000000..c140596 --- /dev/null +++ b/src/desktop/claude_profile.h @@ -0,0 +1,45 @@ +#ifndef RCLI_DESKTOP_CLAUDE_PROFILE_H +#define RCLI_DESKTOP_CLAUDE_PROFILE_H + +#include + +/// Claude Desktop's third-party inference mode. +/// +/// The app ships two deployment modes: "1p", which talks to Anthropic, and +/// "3p", which talks to a gateway you name. The 3p side keeps its own profile +/// tree beside the normal one, and none of it is reachable from Settings — +/// which is why the model picker and ANTHROPIC_BASE_URL both look like dead +/// ends. Neither is the mechanism. +/// +/// A gateway here speaks the Anthropic Messages API, which is exactly what +/// `rcli::anthropic` already serves. So pointing Claude Desktop at a model we +/// serve is a matter of writing the profile and restarting the app. +/// +/// Shape learned from ollama/ollama cmd/launch/claude_desktop.go, which drives +/// the same feature. +namespace rcli::desktop { + +/// Writes the gateway profile, marks it applied, and switches both config +/// trees to third-party mode. +/// +/// Takes effect on the app's next launch, never on a running one. Returns +/// false with `error` set. +bool ApplyGateway(const std::string& base_url, const std::string& api_key, + const std::string& advertised, const std::string& label, + const std::string& display_name, std::string* error); + +/// Puts Claude Desktop back on Anthropic and strips the keys we wrote. +/// +/// Safe to call when nothing was applied, and it only removes our own profile: +/// a gateway somebody else configured is left alone. +bool RestoreGateway(std::string* error); + +/// True when our profile is the one Claude Desktop has applied. +bool GatewayApplied(); + +/// Where the app keeps its third-party profiles, for a message worth printing. +std::string ProfileDirectory(); + +} // namespace rcli::desktop + +#endif // RCLI_DESKTOP_CLAUDE_PROFILE_H diff --git a/src/harness/harness.cpp b/src/harness/harness.cpp new file mode 100644 index 0000000..bba83d6 --- /dev/null +++ b/src/harness/harness.cpp @@ -0,0 +1,328 @@ +#include "harness/harness.h" + +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +// Winsock spells these differently: a socket is an unsigned SOCKET rather than +// a file descriptor, and getsockname takes an int length rather than socklen_t. +using rcli_socklen_t = int; +#else +#include +#include +#include +#include +#include +using rcli_socklen_t = socklen_t; +#endif + +#if defined(RCLI_HAS_SERVER) +#include "rac/server/rac_server.h" +#endif + +#include "account/credentials.h" +#include "commands/commands.h" +#include "io/output.h" +#include "bootstrap.h" +#include "harness/local_models.h" + +namespace rcli::harness { +namespace { + + +/// A port nothing is listening on, found by letting the OS pick one and giving +/// it straight back. There is a race between closing and the server binding, +/// but the alternative is a fixed port that collides with a second rcli. +/// +/// `preferred` asks for one particular port and settles for any free one when +/// it is taken. An integration that writes the port into a config file wants +/// that: the file keeps working between runs instead of naming a dead port. +int FreePort(int preferred) { +#if defined(_WIN32) + // Winsock has to be initialised before any socket call, and the server that + // would otherwise do it has not started yet. The count is per-process and + // refcounted, so starting it here and leaving it up is harmless. + static const bool ready = [] { + WSADATA data; + return WSAStartup(MAKEWORD(2, 2), &data) == 0; + }(); + if (!ready) { + return 0; + } + const SOCKET sock = socket(AF_INET, SOCK_STREAM, 0); + // INVALID_SOCKET, not a negative number: SOCKET is unsigned on Windows, so + // the usual `< 0` check silently passes for a failed call. + if (sock == INVALID_SOCKET) { + return 0; + } +#else + const int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { + return 0; + } +#endif + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(static_cast(preferred)); + int port = 0; + if (bind(sock, reinterpret_cast(&address), sizeof(address)) == 0) { + rcli_socklen_t length = static_cast(sizeof(address)); + if (getsockname(sock, reinterpret_cast(&address), &length) == 0) { + port = ntohs(address.sin_port); + } + } +#if defined(_WIN32) + closesocket(sock); +#else + close(sock); +#endif + if (port == 0 && preferred != 0) { + return FreePort(0); + } + return port; +} + +/// JSON string escaping, for the handful of characters that can appear in a +/// model id, a path or a key. Not a general encoder: it exists so a Windows +/// path with backslashes does not silently produce invalid config. +std::string Quote(const std::string& text) { + std::string out = "\""; + for (const char c : text) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += c; + } + } + return out + "\""; +} + +/// The provider block opencode reads out of OPENCODE_CONFIG_CONTENT. +/// +/// Inline rather than a file on purpose: writing to the user's project or to +/// ~/.config/opencode would outlive the session and change how opencode behaves +/// when they run it themselves. +std::string OpencodeConfig(const std::string& model, const std::string& base_url, + const std::string& api_key) { + // A key is always present because opencode's OpenAI client sends an + // Authorization header regardless; a local server ignores what is in it. + const std::string key = api_key.empty() ? std::string("local") : api_key; + return std::string("{\"provider\":{\"runanywhere\":{") + + "\"npm\":\"@ai-sdk/openai-compatible\"," + "\"name\":\"RunAnywhere\"," + + "\"options\":{\"baseURL\":" + Quote(base_url) + ",\"apiKey\":" + Quote(key) + "}," + + "\"models\":{" + Quote(model) + ":{\"name\":" + Quote(model) + "}}}}," + + "\"model\":" + Quote("runanywhere/" + model) + "}"; +} + +constexpr const char* kConfigVariable = "OPENCODE_CONFIG_CONTENT"; + +void SetConfigVariable(const std::string& value) { +#if defined(_WIN32) + _putenv_s(kConfigVariable, value.c_str()); +#else + setenv(kConfigVariable, value.c_str(), 1); +#endif +} + +void UnsetConfigVariable() { +#if defined(_WIN32) + _putenv_s(kConfigVariable, ""); +#else + unsetenv(kConfigVariable); +#endif +} + +int Spawn(const std::string& tool, const std::vector& args) { + std::vector owned; + owned.push_back(tool); + owned.insert(owned.end(), args.begin(), args.end()); + std::vector argv; + argv.reserve(owned.size() + 1); + for (std::string& piece : owned) { + argv.push_back(piece.data()); + } + argv.push_back(nullptr); + +#if defined(_WIN32) + const intptr_t rc = _spawnvp(_P_WAIT, tool.c_str(), argv.data()); + if (rc < 0) { + out::error_line(tool + " is not on PATH"); + return 127; + } + return static_cast(rc); +#else + // fork rather than exec: the local server lives in this process, and + // replacing the image would take it down with us before the tool ran. + const pid_t child = fork(); + if (child < 0) { + out::error_line("could not start " + tool); + return 1; + } + if (child == 0) { + execvp(tool.c_str(), argv.data()); + // Only reached when exec failed. 127 is what a shell reports for a + // command it cannot find, and the parent cannot tell why otherwise. + _exit(127); + } + int status = 0; + // status is undefined after a failed waitpid, so a bare 0 there would read + // as a clean exit while the child is still running. + while (waitpid(child, &status, 0) < 0) { + if (errno == EINTR) { + continue; + } + out::error_line("lost track of " + tool); + return 1; + } + if (WIFEXITED(status)) { + return WEXITSTATUS(status); + } + return 1; +#endif +} + +} // namespace + +bool Resolve(const std::string& model, Endpoint* endpoint, int preferred_port) { + if (endpoint == nullptr || model.empty()) { + return false; + } + // The kit consumer brings the SDK up through bootstrap rather than the + // CLI's own lazy Start(), and bootstrap is also what resolves the storage + // home the model walk below needs. + Bootstrapped env; + if (bootstrap(GlobalOptions{}, &env) != RAC_SUCCESS) { + return false; + } + + std::string base_url; + std::string api_key; + bool serving = false; + + const LocalModel* local = nullptr; + const std::vector installed = LocalModels(env.home); + for (const LocalModel& candidate : installed) { + // Completeness used to be checked against the catalog's file list. + // The walk cannot do that, and does not need to: it only yields a + // directory that already holds weights or a download manifest, and the + // load below is what actually decides whether the model opens. + if (candidate.id == model) { + local = &candidate; + break; + } + } + + if (local != nullptr) { + // The server creates its handle with rac_llm_create(path), which routes + // on the path alone rather than asking the registry what framework the + // model belongs to. An MLX directory does not look like anything it + // recognises, so it lands on llama.cpp and fails to load. Saying so + // beats starting a server that answers every request with an error. + if (local->framework != "LlamaCpp") { + out::error_line(model + " runs on " + local->framework + + ", and the local server can only serve LlamaCpp models today"); + out::status_line("use a GGUF model here, or point at an upstream one"); + return false; + } + const int port = FreePort(preferred_port); + if (port == 0) { + out::error_line("could not find a free port for the local server"); + return false; + } +#if !defined(RCLI_HAS_SERVER) + // This kit was built without the OpenAI-compatible server, so there is + // nothing here that can serve a file on disk. An upstream model still + // works, and saying which is the case beats starting nothing and + // reporting success. + out::error_line(model + + " is on this machine, but this build has no local server to serve it"); + out::status_line("point at an upstream model instead, or use a build with the server"); + return false; +#else + rac_server_config_t config = RAC_SERVER_CONFIG_DEFAULT; + config.host = "127.0.0.1"; + config.port = static_cast(port); + const std::string path = local->path.empty() ? local->dir : local->path; + config.model_path = path.c_str(); + config.model_id = model.c_str(); + // The per-run context setting went with the old CLI; the server default + // it fell back to is what every run used in practice anyway. + config.context_size = 8192; + out::status_line("serving " + model + " on 127.0.0.1:" + std::to_string(port)); + if (rac_server_start(&config) != RAC_SUCCESS) { + out::error_line("the local server would not start for " + model); + return false; + } + serving = true; + base_url = "http://127.0.0.1:" + std::to_string(port) + "/v1"; +#endif // RCLI_HAS_SERVER + } else { + const account::Credentials credentials = account::Load(); + if (!credentials.signed_in()) { + out::error_line(model + " is not on this machine, and you are not signed in"); + out::status_line("run `rcli login`, or `rcli pull " + model + "` to run it here"); + return false; + } + base_url = credentials.console_url + "/v1"; + api_key = credentials.access_token; + out::status_line("using " + model + " as " + credentials.email); + } + + endpoint->base_url = base_url; + endpoint->api_key = api_key; + endpoint->serving = serving; + return true; +} + +void Release(const Endpoint& endpoint) { + if (endpoint.serving) { +#if defined(RCLI_HAS_SERVER) + rac_server_stop(); +#endif + } +} + +int Launch(const std::string& tool, const std::string& model, + const std::vector& args) { + if (model.empty()) { + // Nothing to wire, so do not pretend to: run the tool as the user has + // it configured. + return Spawn(tool, args); + } + + Endpoint endpoint; + if (!Resolve(model, &endpoint)) { + return 1; + } + + const std::string config = OpencodeConfig(model, endpoint.base_url, endpoint.api_key); + const char* previous = std::getenv(kConfigVariable); + const std::string restored = previous != nullptr ? previous : std::string(); + const bool had_previous = previous != nullptr; + SetConfigVariable(config); + + const int status = Spawn(tool, args); + + // Launch runs more than once in a process during tests, and a stale value + // here would override the tool's own configuration on a later call that + // named no model. + if (had_previous) { + SetConfigVariable(restored); + } else { + UnsetConfigVariable(); + } + Release(endpoint); + return status; +} + +} // namespace rcli::harness diff --git a/src/harness/harness.h b/src/harness/harness.h new file mode 100644 index 0000000..572d431 --- /dev/null +++ b/src/harness/harness.h @@ -0,0 +1,54 @@ +#ifndef RCLI_HARNESS_HARNESS_H +#define RCLI_HARNESS_HARNESS_H + +#include +#include + +/// Launching a coding tool against a model, whether that model runs here or +/// upstream. +/// +/// The harness never learns which it got. It is handed one OpenAI-compatible +/// base URL and talks to that, exactly as it would to any provider. For a local +/// model the URL is a server this process starts and stops; for an upstream one +/// it is the provider's own. That is the same shape Ollama uses, and it is why +/// a harness needs no plugin to work with us. +namespace rcli::harness { + +/// Where a model can be reached over HTTP, and whether we are serving it. +struct Endpoint { + /// An OpenAI-compatible root, ending in `/v1`. + std::string base_url; + /// Empty for a local server, which ignores what is in the header. + std::string api_key; + /// True when `Resolve` started a server that `Release` has to stop. + bool serving = false; +}; + +/// Points `endpoint` at `model`, starting a local server when the model is on +/// this machine and using the signed-in console when it is not. +/// +/// `preferred_port` asks the local server for one particular port, and is +/// ignored when something else already holds it or the model is upstream. +/// An integration that writes the port into a file the tool reads at startup +/// wants this: the same port every run is what keeps that file true. +/// +/// Returns false having already explained why not: an unknown model, a +/// framework the local server cannot load, or an upstream model with nobody +/// signed in. Every integration needs this same answer, so it is separate from +/// launching anything. +bool Resolve(const std::string& model, Endpoint* endpoint, int preferred_port = 0); + +/// Stops whatever `Resolve` started. Safe on an endpoint it did not serve. +void Release(const Endpoint& endpoint); + +/// Runs `tool` against `model`, forwarding `args` to it, and returns the tool's +/// exit code. Blocks until the tool exits, then stops anything it started. +/// +/// An empty `model` uses whatever the tool is already configured for, which +/// makes `rcli opencode` a plain passthrough. +int Launch(const std::string& tool, const std::string& model, + const std::vector& args); + +} // namespace rcli::harness + +#endif // RCLI_HARNESS_HARNESS_H diff --git a/src/harness/local_models.cpp b/src/harness/local_models.cpp new file mode 100644 index 0000000..d5d49ab --- /dev/null +++ b/src/harness/local_models.cpp @@ -0,0 +1,81 @@ +#include "harness/local_models.h" + +#include +#include +#include +#include + +namespace rcli::harness { +namespace { + +namespace fs = std::filesystem; + +bool IsWeightFile(const fs::path& path) { + const std::string ext = path.extension().string(); + return ext == ".gguf" || ext == ".safetensors" || ext == ".onnx" || ext == ".bin"; +} + +/// Written by the download orchestrator as files land rather than once they all +/// have, so a partial download carries one too. Presence means "this came from +/// a download", not "the download finished". +constexpr std::string_view kManifest = ".rac-manifest.binpb"; + +} // namespace + +std::vector LocalModels(const std::string& home) { + std::vector models; + if (home.empty()) { + return models; + } + + // Both layouts are real: the SDK's path docs describe + // {base}/RunAnywhere/Models, and the desktop default base directory already + // ends in "runanywhere", so models land directly under {base}/Models. + std::error_code ec; + fs::path root = fs::path(home) / "RunAnywhere" / "Models"; + if (!fs::is_directory(root, ec)) { + root = fs::path(home) / "Models"; + } + if (!fs::is_directory(root, ec)) { + return models; + } + + for (const auto& framework : fs::directory_iterator(root, ec)) { + if (!framework.is_directory()) { + continue; + } + for (const auto& entry : fs::directory_iterator(framework.path(), ec)) { + if (!entry.is_directory()) { + continue; + } + std::string weights; + bool manifest = false; + std::int64_t bytes = 0; + for (const auto& file : fs::recursive_directory_iterator(entry.path(), ec)) { + if (!file.is_regular_file()) { + continue; + } + bytes += static_cast(file.file_size(ec)); + if (file.path().filename() == kManifest) { + manifest = true; + } else if (weights.empty() && IsWeightFile(file.path())) { + weights = file.path().string(); + } + } + // A directory holding weights but no manifest was placed by hand. + // It is still loadable, so it still counts. + if (!manifest && weights.empty()) { + continue; + } + models.push_back({entry.path().filename().string(), + framework.path().filename().string(), entry.path().string(), weights, + bytes}); + } + } + + std::sort(models.begin(), models.end(), + [](const LocalModel& a, const LocalModel& b) { return a.id < b.id; }); + return models; +} + +} // namespace rcli::harness diff --git a/src/harness/local_models.h b/src/harness/local_models.h new file mode 100644 index 0000000..94f39d8 --- /dev/null +++ b/src/harness/local_models.h @@ -0,0 +1,32 @@ +#ifndef RCLI_HARNESS_LOCAL_MODELS_H +#define RCLI_HARNESS_LOCAL_MODELS_H + +#include +#include +#include + +namespace rcli::harness { + +/// A model already on disk under the RunAnywhere home. +struct LocalModel { + std::string id; + /// The engine directory it was found in: LlamaCpp, Sherpa, MLX, ... + std::string framework; + /// The model's own directory. + std::string dir; + /// The first weight file inside it, or empty for a model whose weights are + /// directories (CoreML .mlmodelc) rather than files. + std::string path; + std::int64_t bytes = 0; +}; + +/// Models present under `home` right now, found by walking the storage tree. +/// +/// Walking rather than asking the registry: this only has to answer "is there +/// something here the local server can open", and the walk says that about a +/// model placed by hand as readily as one that was downloaded. +std::vector LocalModels(const std::string& home); + +} // namespace rcli::harness + +#endif // RCLI_HARNESS_LOCAL_MODELS_H 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/src/ide/jetbrains_profile.cpp b/src/ide/jetbrains_profile.cpp new file mode 100644 index 0000000..7519550 --- /dev/null +++ b/src/ide/jetbrains_profile.cpp @@ -0,0 +1,375 @@ +#include "ide/jetbrains_profile.h" + +#include +#include +#include +#include +#include +#include + +#include "io/output.h" +#include "harness/harness.h" + +#include + +#if defined(__APPLE__) +#include +#endif + +namespace rcli::ide { +namespace { + +namespace fs = std::filesystem; + +/// AI Assistant's marketplace id. The IDE's own `installPlugins` resolves it. +constexpr const char* kPluginID = "com.intellij.ml.llm"; +/// What the plugin unpacks to inside the configuration tree. +constexpr const char* kPluginDirectory = "ml-llm"; +/// The settings file behind `@State(name = "OpenAILikeLlmProviderSettings")`. +constexpr const char* kSettingsFile = "llm.provider.openai.like.xml"; +constexpr const char* kComponent = "OpenAILikeLlmProviderSettings"; +/// The provider selection, behind `@State(name = "LlmCustomModelsSettings")`. +constexpr const char* kModelsFile = "llm.custom.models.xml"; +constexpr const char* kModelsComponent = "LlmCustomModelsSettings"; +/// The set of providers the IDE will talk to at all. +constexpr const char* kProvidersFile = "llm.third.party.ai.providers.xml"; +constexpr const char* kProvidersComponent = "LLMThirdPartyAIProvidersSettings"; +/// `enableProvider` refuses to add anything until this has been accepted, so +/// the set above is ignored without it. Third-party providers are a beta +/// feature and this is the acknowledgement the IDE would otherwise ask for. +constexpr const char* kAcknowledgementKey = + "llm.third.party.ai.services.acknowledgement.accepted"; +/// Application properties, kept as a JSON blob inside a CDATA section. +constexpr const char* kPropertiesFile = "other.xml"; +/// `OPEN_AI_API_PROVIDER_ID`, which is also the credential's key. +constexpr const char* kProviderID = "OpenAIAPI"; +/// The subsystem the platform prefixes credentials with. +constexpr const char* kSubsystem = "AI Assistant"; +/// cpp-httplib serves HTTP/1.1 only, and the client's default is negotiated +/// upward. Left unset, the first request fails before the model is ever asked. +constexpr const char* kHttpVersion = "HTTP_1_1"; + +std::string Home() { + const char* home = std::getenv("HOME"); + return home != nullptr ? std::string(home) : std::string(); +} + +/// The credential store's service name: subsystem and key joined by an em dash, +/// which is the separator the platform writes and therefore the one it reads. +std::string ServiceName() { + return std::string("IntelliJ Platform ") + kSubsystem + " \xE2\x80\x94 " + kProviderID; +} + +bool WriteFile(const fs::path& path, const std::string& contents, std::string* error) { + std::error_code code; + fs::create_directories(path.parent_path(), code); + std::ofstream out(path, std::ios::trunc); + if (!out) { + *error = "cannot write " + path.string(); + return false; + } + out << contents; + if (!out) { + *error = "cannot write " + path.string(); + return false; + } + return true; +} + +/// The settings tree, with only the two options the state class actually +/// persists. Anything else here is dropped on the IDE's next write anyway. +std::string SettingsXML(const std::string& base_url) { + return std::string("\n \n" + + " \n\n"; +} + +/// Which model each of the IDE's three roles should use. +/// +/// One model answers all three because that is what rcli is serving. The id is +/// `/`, the separator being `ThirdPartyLLMProfileId.DELIM`. +std::string ModelsXML(const std::string& model) { + const std::string id = std::string(kProviderID) + "/" + model; + return std::string("\n \n" + + " \n\n"; +} + +/// The one provider the IDE is allowed to talk to. +/// +/// The members are nested directly, with no element naming the collection. +/// A `` wrapper — the shape most IntelliJ collections serialize to — is +/// silently dropped on load, which reads exactly like the file being ignored. +std::string ProvidersXML() { + return std::string("\n \n" + + " \n \n\n"; +} + +std::string ReadFile(const fs::path& path) { + std::ifstream in(path); + return in ? std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()) + : std::string(); +} + +/// Accepts the third-party acknowledgement in the IDE's application properties. +/// +/// The properties are a JSON object inside a CDATA section inside the XML, so +/// the blob is parsed rather than pattern-matched — every other value in there +/// belongs to the reader and has to survive untouched. +bool AcceptAcknowledgement(const fs::path& path, std::string* error) { + std::string document = ReadFile(path); + constexpr const char* kOpen = ""; + const size_t open = document.find(kOpen); + if (open == std::string::npos) { + // No properties yet, which a never-launched IDE has not written. The + // component is ours to create, beside whatever else is in the file. + nlohmann::json properties; + properties["keyToString"][kAcknowledgementKey] = "true"; + const std::string component = std::string(" ") + kOpen + properties.dump(2) + kClose + "\n"; + const size_t end = document.find(""); + if (document.empty() || end == std::string::npos) { + return WriteFile(path, "\n" + component + "\n", error); + } + document.insert(end, component); + return WriteFile(path, document, error); + } + + const size_t start = open + std::string(kOpen).size(); + const size_t close = document.find(kClose, start); + if (close == std::string::npos) { + *error = "cannot read the properties in " + path.string(); + return false; + } + nlohmann::json properties = nlohmann::json::parse(document.substr(start, close - start), + nullptr, false); + if (properties.is_discarded()) { + *error = "cannot read the properties in " + path.string(); + return false; + } + properties["keyToString"][kAcknowledgementKey] = "true"; + document.replace(start, close - start, properties.dump(2)); + return WriteFile(path, document, error); +} + +#if defined(__APPLE__) +CFStringRef CopyString(const std::string& value) { + return CFStringCreateWithBytes(nullptr, reinterpret_cast(value.data()), + static_cast(value.size()), kCFStringEncodingUTF8, + false); +} + +/// The query identifying our credential, without the secret in it. +CFMutableDictionaryRef CopyQuery() { + CFMutableDictionaryRef query = CFDictionaryCreateMutable( + nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); + CFStringRef service = CopyString(ServiceName()); + CFStringRef account = CopyString(kProviderID); + CFDictionarySetValue(query, kSecAttrService, service); + CFDictionarySetValue(query, kSecAttrAccount, account); + CFRelease(service); + CFRelease(account); + return query; +} + +/// An access list naming the two programs allowed to read the item without +/// asking: this one, which writes it, and the IDE, which reads it. +/// +/// Without this the item belongs to rcli alone, and the IDE's first read pops a +/// keychain dialog — the one manual step this command exists to remove. The +/// legacy access APIs are what create such a list, and they are also what the +/// platform's own `MacOSKeychainStorage` uses, so the item ends up the shape +/// the IDE already expects. +CFTypeRef CopyAccess(const std::string& reader) { + SecTrustedApplicationRef self = nullptr; + SecTrustedApplicationRef other = nullptr; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + if (SecTrustedApplicationCreateFromPath(nullptr, &self) != errSecSuccess || + SecTrustedApplicationCreateFromPath(reader.c_str(), &other) != errSecSuccess) { + if (self != nullptr) { + CFRelease(self); + } + if (other != nullptr) { + CFRelease(other); + } + return nullptr; + } + const void* trusted[] = {self, other}; + CFArrayRef list = CFArrayCreate(nullptr, trusted, 2, &kCFTypeArrayCallBacks); + CFStringRef label = CopyString(ServiceName()); + SecAccessRef access = nullptr; + const OSStatus status = SecAccessCreate(label, list, &access); +#pragma clang diagnostic pop + CFRelease(label); + CFRelease(list); + CFRelease(self); + CFRelease(other); + return status == errSecSuccess ? access : nullptr; +} + +/// Stores `secret`, replacing whatever was there, readable by `reader` without +/// a prompt. +/// +/// Written through the Security framework rather than the `security` tool +/// because that one takes the secret as an argument, where every other process +/// on the machine can read it out of the process list. +bool StoreSecret(const std::string& secret, const std::string& reader, std::string* error) { + CFMutableDictionaryRef query = CopyQuery(); + SecItemDelete(query); + CFDataRef data = CFDataCreate(nullptr, reinterpret_cast(secret.data()), + static_cast(secret.size())); + CFDictionarySetValue(query, kSecValueData, data); + CFTypeRef access = CopyAccess(reader); + if (access != nullptr) { + CFDictionarySetValue(query, kSecAttrAccess, access); + } + const OSStatus status = SecItemAdd(query, nullptr); + if (access != nullptr) { + CFRelease(access); + } + CFRelease(data); + CFRelease(query); + if (status != errSecSuccess) { + *error = "cannot store the key in the keychain (OSStatus " + std::to_string(status) + ")"; + return false; + } + return true; +} + +void DropSecret() { + CFMutableDictionaryRef query = CopyQuery(); + SecItemDelete(query); + CFRelease(query); +} +#else +bool StoreSecret(const std::string&, const std::string&, std::string* error) { + *error = "the JetBrains credential store is only wired up on macOS"; + return false; +} + +void DropSecret() {} +#endif + +/// Installs AI Assistant through the IDE's own command line. +/// +/// The IDE is the only thing that knows which build of the plugin matches it, +/// so asking it beats resolving a download ourselves. It also creates the +/// configuration directory on the way, which a never-launched IDE has not. +bool InstallPlugin(const Product& product, const std::string& bundle, std::string* error) { + const std::string launcher = bundle + "/Contents/MacOS/" + product.launcher; + out::status_line("installing JetBrains AI Assistant; this happens once and takes a minute"); + if (harness::Launch(launcher, {}, {"installPlugins", kPluginID}) != 0) { + *error = "could not install AI Assistant into " + std::string(product.id); + return false; + } + return true; +} + +bool PluginInstalled(const std::string& config) { + std::error_code code; + return !config.empty() && fs::exists(fs::path(config) / "plugins" / kPluginDirectory, code); +} + +} // namespace + +std::string BundlePath(const Product& product) { + const std::string home = Home(); + std::vector roots{"/Applications/"}; + if (!home.empty()) { + roots.push_back(home + "/Applications/"); + } + std::error_code code; + for (const std::string& root : roots) { + const std::string path = root + product.bundle; + if (fs::exists(fs::path(path) / "Contents" / "Info.plist", code)) { + return path; + } + } + return {}; +} + +std::string ConfigDirectory(const Product& product) { + const std::string home = Home(); + if (home.empty()) { + return {}; + } + const fs::path root = fs::path(home) / "Library" / "Application Support" / "JetBrains"; + std::error_code code; + // One tree per release, so the newest name wins. Sorting the names works + // because JetBrains pads the version the same way in every one of them. + std::string newest; + for (const fs::directory_entry& entry : fs::directory_iterator(root, code)) { + const std::string name = entry.path().filename().string(); + if (name.rfind(product.config_prefix, 0) == 0 && name > newest) { + newest = name; + } + } + return newest.empty() ? std::string() : (root / newest).string(); +} + +bool ApplyProvider(const Product& product, const std::string& base_url, + const std::string& api_key, const std::string& model, + std::string* error) { + const std::string bundle = BundlePath(product); + if (bundle.empty()) { + *error = std::string(product.bundle) + " is not installed"; + return false; + } + + std::string config = ConfigDirectory(product); + if (!PluginInstalled(config)) { + if (!InstallPlugin(product, bundle, error)) { + return false; + } + // The install is what creates the tree on an IDE nobody has launched. + config = ConfigDirectory(product); + if (!PluginInstalled(config)) { + *error = "AI Assistant did not appear in " + + (config.empty() ? std::string("the configuration directory") : config); + return false; + } + } + + const fs::path options = fs::path(config) / "options"; + // The model ids are deliberately not written. The IDE picks them up from + // the provider once it can reach it, and deletes any file we leave behind. + (void)model; + if (!WriteFile(options / kSettingsFile, SettingsXML(base_url), error) || + !WriteFile(options / kProvidersFile, ProvidersXML(), error) || + !AcceptAcknowledgement(options / kPropertiesFile, error)) { + return false; + } + // A local server needs no key, and the IDE is content without one — it + // reports the provider configured with an empty key and talks to it anyway. + // So nothing is put in the reader's keychain unless there is a real secret + // to put there, which is the upstream case. + if (api_key.empty()) { + DropSecret(); + return true; + } + return StoreSecret(api_key, bundle + "/Contents/MacOS/" + product.launcher, error); +} + +bool RestoreProvider(const Product& product, std::string* error) { + const std::string config = ConfigDirectory(product); + if (config.empty()) { + *error = std::string(product.id) + " has no configuration directory to clear"; + return false; + } + DropSecret(); + std::error_code code; + fs::remove(fs::path(config) / "options" / kSettingsFile, code); + fs::remove(fs::path(config) / "options" / kModelsFile, code); + fs::remove(fs::path(config) / "options" / kProvidersFile, code); + return true; +} + +} // namespace rcli::ide diff --git a/src/ide/jetbrains_profile.h b/src/ide/jetbrains_profile.h new file mode 100644 index 0000000..0fb3dc8 --- /dev/null +++ b/src/ide/jetbrains_profile.h @@ -0,0 +1,73 @@ +#ifndef RCLI_IDE_JETBRAINS_PROFILE_H +#define RCLI_IDE_JETBRAINS_PROFILE_H + +#include + +/// AI Assistant's OpenAI-compatible provider, configured from outside the IDE. +/// +/// A JetBrains IDE already ships an agent — AI Assistant, and Junie behind it — +/// so pointing one at a local model is a matter of telling that agent where the +/// model lives, not of nesting a second agent inside the editor. The provider +/// it exposes for this speaks plain OpenAI, which is the shape `rac_server` +/// already serves, so nothing has to be translated on the way. +/// +/// Three things have to be written before the IDE starts, and it reads all of +/// them once at launch: a base URL in its own options tree, a key in the +/// platform credential store, and the provider selection that the Providers & +/// API keys page shows as a dropdown. The first two alone leave that dropdown +/// on "None" and the IDE reporting `byok=null`, which is what makes this look +/// like it worked when it has not. +/// +/// The selection is stored as model ids rather than as a provider name: each is +/// `/`, and picking a provider is setting those three ids +/// and the enable flag beside them. +/// +/// None of this needs a JetBrains AI subscription. BYOK is the supported path. +namespace rcli::ide { + +/// The port the local server is asked for when serving a JetBrains IDE. +/// +/// Fixed on purpose. The IDE reads its base URL once at startup, from a file +/// written before it launches, so a port that moved between runs would leave +/// that file naming something dead every time rcli exited first. Asking for the +/// same one keeps the configuration true, and a second rcli holding it only +/// costs this run a rewrite. +constexpr int kProviderPort = 11636; + +/// A JetBrains IDE, named the way the reader types it. +struct Product { + /// The subcommand: `clion`. + const char* id; + /// The application bundle to look for under /Applications. + const char* bundle; + /// The launcher inside the bundle, which doubles as the IDE's own CLI. + const char* launcher; + /// What its per-version configuration directory is called, before the + /// version. JetBrains keeps one tree per release, so the newest wins. + const char* config_prefix; +}; + +/// Installs AI Assistant if it is absent, points its provider at `base_url`, +/// and stores `api_key` where the IDE looks for it. +/// +/// The install is the slow part and happens once; every later run finds the +/// plugin already there and only rewrites the URL. Returns false with `error` +/// set. Takes effect on the IDE's next launch, never on a running one. +bool ApplyProvider(const Product& product, const std::string& base_url, + const std::string& api_key, const std::string& model, + std::string* error); + +/// Clears the base URL and drops the stored key, leaving the plugin installed. +/// +/// The way out when a run left the IDE pointing at a port nothing is serving. +bool RestoreProvider(const Product& product, std::string* error); + +/// The IDE's configuration directory, or empty when it has never been run. +std::string ConfigDirectory(const Product& product); + +/// The application bundle's path, or empty when the IDE is not installed. +std::string BundlePath(const Product& product); + +} // namespace rcli::ide + +#endif // RCLI_IDE_JETBRAINS_PROFILE_H diff --git a/src/ide/openai_proxy.cpp b/src/ide/openai_proxy.cpp new file mode 100644 index 0000000..6da5d7d --- /dev/null +++ b/src/ide/openai_proxy.cpp @@ -0,0 +1,456 @@ +#include "ide/openai_proxy.h" + +#include +#include + +#include +#include +#include + +#include +#include + +#include "account/console.h" +#include "account/credentials.h" +#include "io/output.h" + +namespace rcli::ide { +namespace { + +/// Splits `http://host:port/v1` into `http://host:port` and `/v1`. +bool SplitBaseURL(const std::string& base_url, std::string* origin, std::string* prefix) { + const size_t scheme = base_url.find("://"); + if (scheme == std::string::npos) { + return false; + } + const size_t slash = base_url.find('/', scheme + 3); + if (slash == std::string::npos) { + *origin = base_url; + *prefix = ""; + } else { + *origin = base_url.substr(0, slash); + *prefix = base_url.substr(slash); + } + return !origin->empty(); +} + +struct Runtime { + httplib::Server server; + std::thread thread; + std::string origin; + std::string prefix; + std::string api_key; + std::string model; + bool verbose = false; +}; + +std::unique_ptr g_runtime; + +void Trace(const Runtime& runtime, const std::string& note); + +/// Whether a refusal is about the credential rather than the request. +/// +/// The console words this more than one way — "access token expired" when it +/// lapses, "not authenticated" when it is rejected outright — so matching a +/// single phrase catches only half the cases, and the half it misses ends the +/// session. +bool LooksLikeAuthFailure(const std::string& body) { + std::string lowered; + lowered.reserve(body.size()); + for (const char c : body) { + lowered.push_back(static_cast(std::tolower(static_cast(c)))); + } + return lowered.find("expired") != std::string::npos || + lowered.find("authenticat") != std::string::npos || + lowered.find("unauthorized") != std::string::npos || + lowered.find("invalid_token") != std::string::npos || + lowered.find("401") != std::string::npos; +} + +/// Trades the stored refresh token for a new access token. +/// +/// The one held at startup is a snapshot, and an editor session outlives it. +/// Without this the whole run dies on `access token expired` partway through, +/// with nothing but a 401 to explain itself. +bool RenewToken(Runtime& runtime) { + account::Credentials credentials = account::Load(); + if (credentials.refresh_token.empty()) { + return false; + } + account::Grant grant; + std::string error; + if (!account::Refresh(credentials.console_url, credentials.refresh_token, &grant, &error)) { + Trace(runtime, "REFRESH-FAILED " + error); + return false; + } + credentials.access_token = grant.access_token; + if (!grant.refresh_token.empty()) { + credentials.refresh_token = grant.refresh_token; + } + std::string ignored; + account::Save(credentials, &ignored); + runtime.api_key = grant.access_token; + Trace(runtime, "REFRESHED"); + return true; +} + +httplib::Client Upstream(const Runtime& runtime) { + httplib::Client client(runtime.origin); + client.set_read_timeout(600, 0); + if (!runtime.api_key.empty()) { + client.set_bearer_token_auth(runtime.api_key); + } + return client; +} + +/// Records what crossed the proxy, for when the editor and a hand-made request +/// disagree about what the model was asked. +void Trace(const Runtime&, const std::string& note) { + std::ofstream log("/tmp/rcli-proxy.log", std::ios::app); + log << note << "\n"; +} + +using Json = nlohmann::json; + +/// A chunk carrying `message` as the whole answer. +/// +/// Built by hand rather than forwarded, for the case where the upstream said +/// something the editor cannot read. +std::string ChunkSaying(const std::string& message) { + Json chunk; + chunk["id"] = "chatcmpl-rcli"; + chunk["object"] = "chat.completion.chunk"; + chunk["created"] = 0; + chunk["model"] = "rcli"; + Json choice; + choice["index"] = 0; + choice["delta"] = Json{{"role", "assistant"}, {"content", message}}; + choice["finish_reason"] = "stop"; + chunk["choices"] = Json::array({choice}); + return "data: " + chunk.dump() + "\n\n"; +} + +/// Numbers the tool calls in a streamed delta, and says whether it had to. +/// +/// Each tool call in a stream carries an `index` saying which call the fragment +/// belongs to, because arguments arrive split across frames. Gemini's +/// OpenAI-compatible layer leaves it out, and a strict client refuses the whole +/// frame — so a model that answers by calling a tool fails where the same model +/// answering in prose succeeds. The position in the array is the index it +/// should have had. +bool NumberToolCalls(Json& chunk) { + bool changed = false; + if (!chunk["choices"].is_array()) { + return false; + } + for (Json& choice : chunk["choices"]) { + if (!choice.is_object() || !choice.contains("delta") || !choice["delta"].is_object()) { + continue; + } + Json& delta = choice["delta"]; + if (!delta.contains("tool_calls") || !delta["tool_calls"].is_array()) { + continue; + } + size_t position = 0; + for (Json& call : delta["tool_calls"]) { + if (call.is_object() && !call.contains("index")) { + call["index"] = position; + changed = true; + } + ++position; + } + } + return changed; +} + +/// Passes an event through, rewriting the ones the editor would choke on. +/// +/// An upstream error arrives inside the stream, correctly framed, as an object +/// with an `error` member and no `choices`. The editor deserialises every frame +/// into one fixed shape and rejects anything missing its required fields, so +/// that frame surfaces as a deserialiser complaint and the actual message — +/// which is the thing worth reading — never reaches anybody. Turning it into an +/// ordinary chunk puts it in the chat instead. +std::string Normalise(const std::string& frame) { + const size_t field = frame.find("data:"); + if (field == std::string::npos) { + return frame + "\n\n"; + } + std::string payload = frame.substr(field + 5); + while (!payload.empty() && (payload.front() == ' ' || payload.front() == '\r')) { + payload.erase(payload.begin()); + } + if (payload == "[DONE]") { + return frame + "\n\n"; + } + Json parsed = Json::parse(payload, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) { + return frame + "\n\n"; + } + if (parsed.contains("choices")) { + return NumberToolCalls(parsed) ? "data: " + parsed.dump() + "\n\n" : frame + "\n\n"; + } + if (!parsed.contains("error")) { + return frame + "\n\n"; + } + const Json& error = parsed["error"]; + std::string message = error.is_object() && error.contains("message") && + error["message"].is_string() + ? error["message"].get() + : error.dump(); + { + std::ofstream log("/tmp/rcli-proxy.log", std::ios::app); + log << "UPSTREAM-ERROR-FRAME " << message << "\n"; + } + return ChunkSaying(message); +} + +/// Points a request at the model rcli is serving, whatever it named. +/// +/// A stale selection saved in the editor's own settings outlives any change to +/// the list we advertise, so the name in the request cannot be trusted even +/// when only one is on offer. +std::string Retarget(const Runtime& runtime, const std::string& body) { + Json request = Json::parse(body, nullptr, false); + if (request.is_discarded() || !request.is_object()) { + return body; + } + request["model"] = runtime.model; + return request.dump(); +} + +void Fail(httplib::Response& response, int status, const std::string& message) { + response.status = status; + response.set_content("{\"error\":{\"message\":\"" + message + "\"}}", "application/json"); +} + +/// Forwards a streaming completion, byte for byte. +/// +/// Nothing is parsed. Both ends speak the same wire format, so reframing SSE +/// here would only add a place for it to go wrong — and did, the first time. +void Stream(Runtime& runtime, const std::string& body, httplib::Response& response) { + auto request = std::make_shared(body); + auto origin = std::make_shared(runtime.origin); + auto path = std::make_shared(runtime.prefix + "/chat/completions"); + auto api_key = std::make_shared(runtime.api_key); + + auto verbose = std::make_shared(runtime.verbose); + Runtime* owner = &runtime; + Trace(runtime, "REQUEST " + body); + + response.set_chunked_content_provider( + "text/event-stream", + [request, origin, path, api_key, verbose, owner](size_t, httplib::DataSink& sink) { + // Two attempts at most: the second only after a token the console + // has just renewed. Nothing reaches the sink until an event stream + // is recognised, so a retry cannot duplicate output. + for (int attempt = 0; attempt < 2; ++attempt) { + httplib::Client client(*origin); + client.set_read_timeout(600, 0); + const std::string token = attempt == 0 ? *api_key : owner->api_key; + if (!token.empty()) { + client.set_bearer_token_auth(token); + } + + // An upstream that refuses the request answers with a JSON error and + // no SSE framing at all. Forwarding those bytes as if they were + // events puts an object into the stream that carries none of the + // fields a chunk must have, and the editor blames the stream rather + // than the refusal. So the body is held until the status is known. + // An upstream that refuses the request answers with a JSON error + // and no SSE framing at all. Forwarding those bytes as events puts + // an object into the stream carrying none of the fields a chunk + // must have, and the editor then blames the stream rather than the + // refusal. So the opening bytes are held back until they identify + // themselves: an event stream starts with a `data:` field, and an + // error does not. + bool streaming = false; + bool decided = false; + std::string head; + // Whole events only: a chunk can split one in half, and half an + // event cannot be judged. + std::string pending; + const auto forward = [&sink, &pending]() { + size_t split = 0; + while ((split = pending.find("\n\n")) != std::string::npos) { + const std::string frame = pending.substr(0, split); + pending.erase(0, split + 2); + const std::string out = Normalise(frame); + if (!sink.write(out.data(), out.size())) { + return false; + } + } + return true; + }; + const httplib::Result reply = + client.Post(*path, httplib::Headers(), *request, "application/json", + [&](const char* data, size_t length) { + if (decided) { + if (!streaming) { + head.append(data, length); + return true; + } + pending.append(data, length); + return forward(); + } + head.append(data, length); + const size_t start = head.find_first_not_of(" \r\n"); + if (start == std::string::npos) { + return true; + } + if (head.compare(start, 5, "data:") == 0) { + streaming = true; + decided = true; + pending.append(head); + return forward(); + } + // Enough to know it is not an event stream. + if (head.size() - start >= 5) { + decided = true; + } + return true; + }); + + if (streaming) { + sink.done(); + return true; + } + + if (attempt == 0 && reply && LooksLikeAuthFailure(head) && RenewToken(*owner)) { + head.clear(); + pending.clear(); + decided = false; + continue; + } + + { + const std::string detail = + !reply ? std::string("the model endpoint did not answer") : head; + { + std::ofstream log("/tmp/rcli-proxy.log", std::ios::app); + log << "UPSTREAM-REFUSED " << detail << "\n"; + } + // Carried as an ordinary chunk, not as an `error` object. The + // editor deserialises every frame into one fixed shape and + // rejects anything without its required fields, so an error + // object here fails to parse and the reader is shown a + // deserialiser complaint instead of what actually went wrong. + std::string message = detail; + for (char& c : message) { + if (c == '"' || c == '\\' || c == '\n' || c == '\r' || c == '\t') { + c = ' '; + } + } + const std::string frame = + "data: {\"id\":\"chatcmpl-rcli\",\"object\":\"chat.completion.chunk\"," + "\"created\":0,\"model\":\"rcli\",\"choices\":[{\"index\":0,\"delta\":" + "{\"role\":\"assistant\",\"content\":\"" + message + + "\"},\"finish_reason\":\"stop\"}]}\n\n"; + sink.write(frame.data(), frame.size()); + sink.write("data: [DONE]\n\n", 14); + } + sink.done(); + return true; + } + sink.done(); + return true; + }); +} + +} // namespace + +bool StartProxy(const harness::Endpoint& endpoint, const std::string& model, int port, + Proxy* proxy, bool verbose) { + if (proxy == nullptr) { + return false; + } + StopProxy(proxy); + + auto runtime = std::make_unique(); + if (!SplitBaseURL(endpoint.base_url, &runtime->origin, &runtime->prefix)) { + out::error_line("cannot make sense of the endpoint " + endpoint.base_url); + return false; + } + runtime->api_key = endpoint.api_key; + runtime->model = model; + runtime->verbose = verbose; + + Runtime* raw = runtime.get(); + // Every handler catches. An exception thrown into cpp-httplib takes the + // process down with it, and a dead rcli takes the model with it too. + raw->server.Get("/v1/models", [raw](const httplib::Request&, httplib::Response& response) { + try { + // Not forwarded. The one model rcli was asked to serve is the one + // offered, so there is nothing in the picker that cannot answer. + Json entry; + entry["id"] = raw->model; + entry["object"] = "model"; + entry["owned_by"] = "runanywhere"; + Json list; + list["object"] = "list"; + list["data"] = Json::array({entry}); + response.set_content(list.dump(), "application/json"); + } catch (const std::exception& error) { + Fail(response, 500, error.what()); + } + }); + + raw->server.Post("/v1/chat/completions", + [raw](const httplib::Request& request, httplib::Response& response) { + try { + const std::string body = Retarget(*raw, request.body); + // The editor decides whether to stream; we only + // have to keep the answer in the shape it asked for. + if (body.find("\"stream\":true") != std::string::npos || + body.find("\"stream\": true") != std::string::npos) { + Stream(*raw, body, response); + return; + } + httplib::Client client = Upstream(*raw); + const httplib::Result reply = client.Post( + raw->prefix + "/chat/completions", body, "application/json"); + if (!reply) { + Fail(response, 502, "the model endpoint did not answer"); + return; + } + response.status = reply->status; + response.set_content(reply->body, "application/json"); + } catch (const std::exception& error) { + Fail(response, 500, error.what()); + } + }); + + // The usual port, or any free one when a second editor already holds it. + // The address is written into that editor's settings either way, so the two + // do not have to agree on a number. + int bound = port; + if (!raw->server.bind_to_port("127.0.0.1", port)) { + bound = raw->server.bind_to_any_port("127.0.0.1"); + if (bound <= 0) { + out::error_line("could not find a port to serve " + model + " on"); + return false; + } + } + runtime->thread = std::thread([raw] { raw->server.listen_after_bind(); }); + g_runtime = std::move(runtime); + + proxy->running = true; + proxy->base_url = "http://127.0.0.1:" + std::to_string(bound) + "/v1"; + return true; +} + +void StopProxy(Proxy* proxy) { + if (g_runtime) { + g_runtime->server.stop(); + if (g_runtime->thread.joinable()) { + g_runtime->thread.join(); + } + g_runtime.reset(); + } + if (proxy != nullptr) { + proxy->running = false; + proxy->base_url.clear(); + } +} + +} // namespace rcli::ide diff --git a/src/ide/openai_proxy.h b/src/ide/openai_proxy.h new file mode 100644 index 0000000..725fd37 --- /dev/null +++ b/src/ide/openai_proxy.h @@ -0,0 +1,47 @@ +#ifndef RCLI_IDE_OPENAI_PROXY_H +#define RCLI_IDE_OPENAI_PROXY_H + +#include + +#include "harness/harness.h" + +/// A loopback endpoint that carries the credential so the editor does not have +/// to. +/// +/// An upstream model is reached through the signed-in console, which wants a +/// bearer token on every request. Handing that token to the IDE means putting +/// it in the IDE's credential store, and the IDE never reads what we write +/// there — the provider comes up with an empty key and the console answers 401. +/// +/// So the token stays here. rcli listens on loopback, adds the header, and +/// forwards. The IDE is configured exactly as it is for a local model, with no +/// key at all, which is the case already known to work. It also keeps the +/// reader's token out of a second store that neither of us controls. +namespace rcli::ide { + +struct Proxy { + bool running = false; + /// What to point the editor at. An OpenAI-compatible root ending in `/v1`. + std::string base_url; +}; + +/// Listens on `port` and forwards to `endpoint`, adding its credential. +/// +/// `model` is the only model offered, and every request is answered by it +/// whatever it asked for. The console lists its provider's whole catalogue, +/// deprecated entries included, and an editor showing all of them invites a +/// choice that fails — which is how `models/gemini-2.5-pro`, retired for new +/// users, ended up being asked a question. rcli was told which model to serve; +/// that is the one the editor gets. +/// +/// Returns false having already said why. Nothing else is translated on the way +/// through: both sides speak OpenAI, so the bytes pass as they arrive. +bool StartProxy(const harness::Endpoint& endpoint, const std::string& model, int port, + Proxy* proxy, bool verbose); + +/// Stops the listener. Safe on a proxy that never started. +void StopProxy(Proxy* proxy); + +} // namespace rcli::ide + +#endif // RCLI_IDE_OPENAI_PROXY_H diff --git a/src/main.cpp b/src/main.cpp index c92091f..c0645db 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,16 @@ #include "app.h" +#include "rac/core/rac_logger.h" + int main(int argc, char** argv) { + // Silence the SDK before anything can log. + // + // Backend plugins register during static initialisation and at the first + // rac_* call, both of which happen before bootstrap() reads --verbose. That + // left five lines of MLX registration noise on top of every command, + // including a WARN for a backend that then registers successfully a line + // later. `--verbose` raises this again in bootstrap. + rac_logger_set_min_level(RAC_LOG_ERROR); return rcli::run(argc, argv); } diff --git a/swift/Package.resolved b/swift/Package.resolved index 726385b..be2a205 100644 --- a/swift/Package.resolved +++ b/swift/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "da2f1cb240f8d58086dcbdf26155d4d07ebff9accee41bd3478139793cd50c15", + "originHash" : "9ec40429b727a2dbc9dfa215154f7b4eaebe18efe98501d496078383a93b3a98", "pins" : [ { "identity" : "devicekit", @@ -55,15 +55,6 @@ "version" : "3.31.5" } }, - { - "identity" : "runanywhere-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/RunanywhereAI/runanywhere-swift.git", - "state" : { - "revision" : "3626ecd3db1d33aa3411895bc80f87fc821eaa1e", - "version" : "0.20.25" - } - }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 70a113f..7f0fb20 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,25 @@ target_link_libraries(test_rcli_unit PRIVATE rcli_core) rcli_stage_windows_runtime_dlls(test_rcli_unit) add_test(NAME rcli_unit_tests COMMAND test_rcli_unit --run-all) +add_executable(test_rcli_account test_rcli_account.cpp) +target_include_directories(test_rcli_account PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(test_rcli_account PRIVATE rcli_core nlohmann_json::nlohmann_json) +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 nlohmann_json::nlohmann_json) +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 + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/test_account_cli.py" + "$") +set_tests_properties(rcli_account_cli_e2e PROPERTIES TIMEOUT 20) + add_executable(test_rcli_segment test_rcli_segment.cpp) target_include_directories(test_rcli_segment PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(test_rcli_segment PRIVATE rcli_core) diff --git a/tests/test_account_cli.py b/tests/test_account_cli.py new file mode 100644 index 0000000..a4e7161 --- /dev/null +++ b/tests/test_account_cli.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Hermetic CLI test for browser-approved cloud authentication.""" + +import json +import os +import pathlib +import stat +import subprocess +import sys +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +ACCESS_TOKEN = "account-e2e-access-secret" +REFRESH_TOKEN = "account-e2e-refresh-secret" +EMAIL = "developer@example.test" + + +class ConsoleHandler(BaseHTTPRequestHandler): + requests = [] + console_origin = "" + + def log_message(self, _format, *_args): + return + + def read_json(self): + length = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(length).decode("utf-8") or "{}") + + def reply(self, status, body=None): + encoded = b"" if body is None else json.dumps(body).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def do_POST(self): + body = self.read_json() + self.requests.append(("POST", self.path, self.headers.get("Authorization"), body)) + if self.path == "/auth/cli/start": + self.reply( + 200, + { + "request_code": "ABCD-EFGH", + "poll_secret": "poll-secret", + "verification_url": self.console_origin + "/device?code=ABCD-EFGH", + "expires_in": 60, + "interval": 1, + }, + ) + elif self.path == "/auth/cli/poll": + self.reply( + 200, + { + "status": "approved", + "access_token": ACCESS_TOKEN, + "refresh_token": REFRESH_TOKEN, + "email": EMAIL, + "expires_in": 3600, + }, + ) + elif self.path == "/auth/cli/revoke": + self.reply(204) + else: + self.reply(404, {"error": "unknown path"}) + + def do_GET(self): + self.requests.append(("GET", self.path, self.headers.get("Authorization"), None)) + if self.path == "/v1/me": + self.reply(200, {"email": EMAIL}) + else: + self.reply(404, {"error": "unknown path"}) + + +def run(binary, arguments, environment): + result = subprocess.run( + [binary, *arguments], + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + combined = result.stdout + result.stderr + if result.returncode != 0: + raise AssertionError( + f"{' '.join(arguments)} returned {result.returncode}:\n{combined}" + ) + if ACCESS_TOKEN in combined or REFRESH_TOKEN in combined: + raise AssertionError(f"{' '.join(arguments)} exposed a cloud token") + return combined + + +def main(): + if len(sys.argv) != 2: + raise SystemExit("usage: test_account_cli.py /path/to/rcli") + binary = sys.argv[1] + server = ThreadingHTTPServer(("127.0.0.1", 0), ConsoleHandler) + ConsoleHandler.console_origin = f"http://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + with tempfile.TemporaryDirectory(prefix="rcli-account-e2e-") as profile: + environment = os.environ.copy() + environment["RCLI_PROFILE_DIR"] = profile + environment["RCLI_CONSOLE_URL"] = ConsoleHandler.console_origin + for name in ( + "RUNANYWHERE_API_KEY", + "RUNANYWHERE_API_SECRET", + "RUNANYWHERE_ENVIRONMENT", + ): + environment.pop(name, None) + + login = run(binary, ["login", "--no-browser"], environment) + if "ABCD-EFGH" not in login or ConsoleHandler.console_origin not in login: + raise AssertionError("login did not print the approval code and URL") + + files = list(pathlib.Path(profile).iterdir()) + if len(files) != 1: + raise AssertionError("login did not create exactly one session file") + if os.name != "nt": + directory_mode = stat.S_IMODE(os.stat(profile).st_mode) + file_mode = stat.S_IMODE(os.stat(files[0]).st_mode) + if directory_mode != 0o700 or file_mode != 0o600: + raise AssertionError( + f"unsafe credential modes: {directory_mode:o}/{file_mode:o}" + ) + + whoami = run(binary, ["whoami"], environment) + if EMAIL not in whoami or "session" not in whoami or "active" not in whoami: + raise AssertionError("whoami did not report the active identity") + if "plan" in whoami or "tokens" in whoami or "quota" in whoami: + raise AssertionError("whoami exposed launch-out-of-scope billing fields") + + run(binary, ["logout"], environment) + if list(pathlib.Path(profile).iterdir()): + raise AssertionError("logout did not remove the local session") + + expected = [ + ("POST", "/auth/cli/start", None), + ("POST", "/auth/cli/poll", None), + ("GET", "/v1/me", f"Bearer {ACCESS_TOKEN}"), + ("POST", "/auth/cli/revoke", f"Bearer {ACCESS_TOKEN}"), + ] + actual = [(method, path, authorization) for method, path, authorization, _ in ConsoleHandler.requests] + if actual != expected: + raise AssertionError(f"unexpected console request sequence: {actual!r}") + if ConsoleHandler.requests[1][3] != { + "request_code": "ABCD-EFGH", + "poll_secret": "poll-secret", + }: + raise AssertionError("poll request did not use the server-issued secret") + if ConsoleHandler.requests[3][3] != {"refresh_token": REFRESH_TOKEN}: + raise AssertionError("logout did not request refresh-token revocation") + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + print("account CLI browser flow passed") + + +if __name__ == "__main__": + main() diff --git a/tests/test_rcli_account.cpp b/tests/test_rcli_account.cpp new file mode 100644 index 0000000..6e59a58 --- /dev/null +++ b/tests/test_rcli_account.cpp @@ -0,0 +1,375 @@ +#include "test_common.h" + +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include + +#include +#endif + +#include "account/console.h" +#include "account/credentials.h" + +namespace { + +namespace fs = std::filesystem; +using Json = nlohmann::json; + +class EnvVar { + public: + EnvVar(const char* name, const char* value) : name_(name) { + if (const char* previous = std::getenv(name)) { + had_previous_ = true; + previous_ = previous; + } +#if defined(_WIN32) + _putenv_s(name, value != nullptr ? value : ""); +#else + value != nullptr ? setenv(name, value, 1) : unsetenv(name); +#endif + } + + ~EnvVar() { +#if defined(_WIN32) + _putenv_s(name_.c_str(), had_previous_ ? previous_.c_str() : ""); +#else + had_previous_ ? setenv(name_.c_str(), previous_.c_str(), 1) : unsetenv(name_.c_str()); +#endif + } + + private: + std::string name_; + std::string previous_; + bool had_previous_ = false; +}; + +class TempDirectory { + public: + TempDirectory() { + const auto nonce = std::chrono::steady_clock::now().time_since_epoch().count(); + path_ = fs::temp_directory_path() / ("rcli-account-test-" + std::to_string(nonce)); + fs::create_directories(path_); + } + ~TempDirectory() { + std::error_code ignored; + fs::remove_all(path_, ignored); + } + const fs::path& path() const { return path_; } + + private: + fs::path path_; +}; + +TestResult test_console_url_validation() { + TestResult result; + result.test_name = "console_url_validation"; + + struct Accepted { + const char* input; + const char* normalized; + }; + const Accepted accepted[] = { + {"https://console.runanywhere.ai", "https://console.runanywhere.ai"}, + {"HTTPS://CONSOLE.RUNANYWHERE.AI/", "https://console.runanywhere.ai"}, + {"http://localhost:8080", "http://localhost:8080"}, + {"http://127.0.0.1:8002", "http://127.0.0.1:8002"}, + {"http://[::1]:9000", "http://[::1]:9000"}, + }; + for (const Accepted& test : accepted) { + std::string normalized; + std::string error; + if (!rcli::account::NormalizeConsoleUrl(test.input, &normalized, &error) || + normalized != test.normalized) { + result.details = std::string("rejected safe origin: ") + test.input + " " + error; + result.expected = test.normalized; + result.actual = normalized; + return result; + } + } + + const char* rejected[] = { + "http://console.runanywhere.ai", + "http://localhost.evil.example", + "http://127.0.0.1.evil.example", + "http://[::1].evil.example", + "http://localhost@evil.example", + "https://user:password@console.runanywhere.ai", + "https://console.runanywhere.ai/path", + "https://console.runanywhere.ai?query=1", + "https://console.runanywhere.ai:", + "http://[::1]:", + "https://", + "file:///tmp/credentials", + }; + for (const char* input : rejected) { + std::string normalized; + std::string error; + if (rcli::account::NormalizeConsoleUrl(input, &normalized, &error)) { + result.details = std::string("accepted unsafe origin: ") + input; + return result; + } + } + + if (!rcli::account::BrowserUrlIsSafe("https://console.runanywhere.ai/device?code=ABCD-EFGH") || + !rcli::account::BrowserUrlIsSafe("http://localhost:8080/device?code=ABCD") || + rcli::account::BrowserUrlIsSafe("http://localhost.evil.example/device") || + !rcli::account::BrowserUrlMatchesConsole( + "https://console.runanywhere.ai/device?code=ABCD-EFGH", + "https://console.runanywhere.ai") || + rcli::account::BrowserUrlMatchesConsole("https://auth.attacker.example/device", + "https://console.runanywhere.ai")) { + result.details = "browser URL policy does not match the origin policy"; + return result; + } + result.passed = true; + return result; +} + +TestResult test_credential_roundtrip_and_permissions() { + TestResult result; + result.test_name = "credential_roundtrip_and_permissions"; + TempDirectory temporary; + EnvVar profile("RCLI_PROFILE_DIR", temporary.path().string().c_str()); + EnvVar console("RCLI_CONSOLE_URL", nullptr); + + rcli::account::Credentials expected; + expected.console_url = "https://CONSOLE.RUNANYWHERE.AI/"; + expected.email = "dev+\"json\"@example.test"; + expected.access_token = "access-secret-that-must-not-be-logged"; + expected.refresh_token = "refresh-secret-that-must-not-be-logged"; + expected.expires_at = 123456789; + + std::string error; + if (!rcli::account::Save(expected, &error)) { + result.details = error; + return result; + } + +#if !defined(_WIN32) + struct stat directory{}; + struct stat file{}; + if (::stat(temporary.path().c_str(), &directory) != 0 || + ::stat(rcli::account::CredentialsPath().c_str(), &file) != 0 || + (directory.st_mode & 0777) != 0700 || (file.st_mode & 0777) != 0600) { + result.details = "credentials must be stored in mode 0700/0600"; + return result; + } +#endif + + rcli::account::Credentials actual; + if (!rcli::account::Load(&actual, &error)) { + result.details = error; + return result; + } + if (actual.console_url != "https://console.runanywhere.ai" || actual.email != expected.email || + actual.access_token != expected.access_token || + actual.refresh_token != expected.refresh_token || + actual.expires_at != expected.expires_at) { + result.details = "credential JSON did not round-trip exactly"; + return result; + } + if (!rcli::account::Clear(&error) || fs::exists(rcli::account::CredentialsPath())) { + result.details = error.empty() ? "credential file still exists after clear" : error; + return result; + } + result.passed = true; + return result; +} + +TestResult test_console_client_contract() { + TestResult result; + result.test_name = "console_client_contract"; + std::vector requests; + int polls = 0; + rcli::account::Transport transport = [&](const rcli::account::HttpRequest& request, + rcli::account::HttpResponse* response, std::string*) { + requests.push_back(request); + if (request.url.ends_with("/auth/cli/start")) { + response->status = 200; + response->body = + Json{{"request_code", "ABCD-EFGH"}, + {"poll_secret", "poll-secret"}, + {"verification_url", "https://console.runanywhere.ai/device?code=ABCD-EFGH"}, + {"expires_in", 300}, + {"interval", 1}} + .dump(); + } else if (request.url.ends_with("/auth/cli/poll")) { + response->status = 200; + response->body = polls++ == 0 ? Json{{"status", "pending"}}.dump() + : Json{{"status", "approved"}, + {"access_token", "access-one"}, + {"refresh_token", "refresh-one"}, + {"email", "dev@example.test"}, + {"expires_in", 3600}} + .dump(); + } else if (request.url.ends_with("/auth/cli/refresh")) { + response->status = 200; + response->body = Json{{"access_token", "access-two"}, + {"refresh_token", "refresh-two"}, + {"expires_in", 3600}} + .dump(); + } else if (request.url.ends_with("/v1/me")) { + response->status = 200; + response->body = Json{{"email", "dev@example.test"}}.dump(); + } else if (request.url.ends_with("/auth/cli/revoke")) { + response->status = 204; + } else { + return false; + } + return true; + }; + + rcli::account::ConsoleClient client(transport); + std::string error; + rcli::account::Authorization authorization; + if (!client.BeginAuthorization("https://console.runanywhere.ai", "test-host", &authorization, + &error) || + authorization.request_code != "ABCD-EFGH" || authorization.interval != 1) { + result.details = error.empty() ? "authorization response mismatch" : error; + return result; + } + rcli::account::Grant grant; + if (client.Poll("https://console.runanywhere.ai", authorization, &grant, &error) != + rcli::account::PollResult::Pending || + client.Poll("https://console.runanywhere.ai", authorization, &grant, &error) != + rcli::account::PollResult::Approved || + grant.access_token != "access-one" || grant.refresh_token != "refresh-one") { + result.details = error.empty() ? "poll contract mismatch" : error; + return result; + } + rcli::account::Identity identity; + if (client.WhoAmI("https://console.runanywhere.ai", grant.access_token, &identity, &error) != + rcli::account::IdentityResult::Ok) { + result.details = error.empty() ? "identity contract mismatch" : error; + return result; + } + rcli::account::Grant refreshed; + if (!client.Refresh("https://console.runanywhere.ai", grant.refresh_token, &refreshed, + &error) || + refreshed.access_token != "access-two" || refreshed.refresh_token != "refresh-two" || + !client.Revoke("https://console.runanywhere.ai", refreshed.access_token, + refreshed.refresh_token, &error)) { + result.details = error.empty() ? "refresh/revoke contract mismatch" : error; + return result; + } + + if (requests.size() != 6 || !requests[0].bearer_token.empty() || + !requests[1].bearer_token.empty() || !requests[2].bearer_token.empty() || + requests[3].bearer_token != "access-one" || !requests[4].bearer_token.empty() || + requests[5].bearer_token != "access-two") { + result.details = "bearer tokens were attached to the wrong endpoint"; + return result; + } + const Json start = Json::parse(requests[0].body); + const Json poll = Json::parse(requests[1].body); + const Json refresh = Json::parse(requests[4].body); + const Json revoke = Json::parse(requests[5].body); + if (start.value("hostname", "") != "test-host" || + poll.value("poll_secret", "") != "poll-secret" || + refresh.value("refresh_token", "") != "refresh-one" || + revoke.value("refresh_token", "") != "refresh-two") { + result.details = "request JSON contract mismatch"; + return result; + } + result.passed = true; + return result; +} + +TestResult test_console_errors_do_not_echo_secrets() { + TestResult result; + result.test_name = "console_errors_do_not_echo_secrets"; + const std::string secret = "access-secret-from-server"; + rcli::account::ConsoleClient client([&](const rcli::account::HttpRequest&, + rcli::account::HttpResponse* response, std::string*) { + response->status = 500; + response->body = Json{{"detail", secret}}.dump(); + return true; + }); + rcli::account::Authorization authorization; + std::string error; + if (client.BeginAuthorization("https://console.runanywhere.ai", "host", &authorization, + &error) || + error.find(secret) != std::string::npos || error.find("HTTP 500") == std::string::npos) { + result.details = "HTTP error exposed the response body or lost its status"; + return result; + } + + rcli::account::ConsoleClient malformed([&](const rcli::account::HttpRequest&, + rcli::account::HttpResponse* response, + std::string*) { + response->status = 200; + response->body = "{\"access_token\":\"" + secret; + return true; + }); + error.clear(); + if (malformed.BeginAuthorization("https://console.runanywhere.ai", "host", &authorization, + &error) || + error.find(secret) != std::string::npos || error != "console returned malformed JSON") { + result.details = "JSON error exposed the response body"; + return result; + } + result.passed = true; + return result; +} + +TestResult test_console_rejects_header_injection() { + TestResult result; + result.test_name = "console_rejects_header_injection"; + rcli::account::ConsoleClient client([](const rcli::account::HttpRequest& request, + rcli::account::HttpResponse* response, std::string*) { + response->status = 200; + if (request.url.ends_with("/auth/cli/poll")) { + response->body = Json{{"status", "approved"}, + {"access_token", "safe\r\nX-Injected: yes"}, + {"refresh_token", "refresh-token"}} + .dump(); + } else if (request.url.ends_with("/auth/cli/start")) { + response->body = Json{{"request_code", "ABCD\nEFGH"}, + {"poll_secret", "poll-secret"}, + {"verification_url", "https://console.runanywhere.ai/device"}} + .dump(); + } + return true; + }); + + std::string error; + rcli::account::Authorization authorization; + if (client.BeginAuthorization("https://console.runanywhere.ai", "host", &authorization, + &error) || + error != "console returned an invalid authorization request") { + result.details = "terminal control characters were accepted in an authorization code"; + return result; + } + + authorization.request_code = "ABCD-EFGH"; + authorization.poll_secret = "poll-secret"; + rcli::account::Grant grant; + error.clear(); + if (client.Poll("https://console.runanywhere.ai", authorization, &grant, &error) != + rcli::account::PollResult::Failed || + error != "console returned an invalid cloud session") { + result.details = "HTTP header control characters were accepted in an access token"; + return result; + } + result.passed = true; + return result; +} + +} // namespace + +int main(int argc, char** argv) { + TestSuite suite("rcli_account"); + suite.add("console_url_validation", test_console_url_validation); + suite.add("credential_roundtrip_and_permissions", test_credential_roundtrip_and_permissions); + suite.add("console_client_contract", test_console_client_contract); + suite.add("console_errors_do_not_echo_secrets", test_console_errors_do_not_echo_secrets); + suite.add("console_rejects_header_injection", test_console_rejects_header_injection); + return suite.run(argc, argv); +} 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); +}