mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 18:05:37 +08:00
ff3bc242cc
* fix(security): harden GrokBuild credential handling and Codex/Anthropic transforms against malformed input
Three robustness/security fixes in the upstream v3.18.0 code, each with a
regression test.
1) grok_config: remove the unconditional XAI_API_KEY fallback in
extract_credentials(). Credentials now come only from an explicit inline
api_key or the process env var named by env_key. Silently substituting a
different account's key (when the declared env_key var is unset) could
leak that key to whatever base_url the config points at.
2) deeplink/provider: merge_grokbuild_config no longer resolves env vars
into a plaintext api_key on import. A deeplink is untrusted input;
resolving+inlining would persist the victim's environment secret into the
imported provider's config.toml and ship it to the link's declared
base_url. env_key now stays an indirection (name), not a resolved secret.
3) proxy transforms: stop panicking on malformed upstream data.
- transform_codex_anthropic::anthropic_sse_to_message_value: only store a
`message`/`content_block` when it is an object; otherwise treat it as
empty, so the later `["content"]`/`["text"]`/`["signature"]` index
assignments can't panic on a scalar/array Value.
- streaming_codex_anthropic::responses_sse_events_from_anthropic_message:
bail out with a failed-event when the buffered body is a top-level JSON
array/scalar (a gateway that ignores stream:true), instead of
index-assigning into a non-object.
- mcp/grokbuild sync: normalize a non-table `mcp_servers` before
inserting, avoiding a toml_edit IndexMut panic on a user-edited
config.toml.
Panic findings verified with a minimal repro (serde_json index-assignment on
a non-object Value aborts). cargo test --lib: 2183 passed / 0 failed.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(mcp): normalize a non-table `mcp_servers` in Codex config before insert
Same class of bug as the GrokBuild MCP fix in the previous commit, but in the
much more widely used Codex path.
`sync_single_server_to_codex` guarded only with `contains_key("mcp_servers")`,
so a user-edited `~/.codex/config.toml` where the key exists but is *not* a
table (`mcp_servers = "x"` / `[]` / `42`) skipped the rebuild and then hit
`doc["mcp_servers"][id] = …`, which panics in toml_edit's `IndexMut`
(`.expect("index not found")`). The panic happens inside a Tauri command and
unwinds across the FFI boundary; in the provider-switch flow it also fires
after the DB/live write already committed, leaving a half-applied switch.
Extract the normalize-then-insert step into `upsert_mcp_server_table()` so the
previously panic-prone logic is unit-testable without touching the real
`~/.codex/config.toml`, and normalize any non-table value to an empty table
before inserting.
Audited the sibling MCP writers while here: claude.rs / gemini.rs /
hermes.rs / opencode.rs do not have this pattern (their `contains_key` uses are
read-only checks), and the other index-assignments in codex.rs operate on
freshly built `Table::new()` values, which are safe.
Tests: 2 new regression tests (malformed non-table values normalize and insert;
an existing valid table keeps its entries). cargo test --lib 2185 passed / 0
failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): reject path-traversal entries when extracting a skill repo archive
`SkillService::download_and_extract` built the output path from the raw ZIP
entry name (`file.name()`), stripped only the leading `<repo>-<branch>/`
component, and then `dest.join(relative_path)` → `fs::File::create`. A crafted
entry such as `repo-main/../../../evil.sh` therefore escaped the destination
directory (zip-slip): arbitrary file write outside the temp extraction dir.
The archive is third-party controlled: it is downloaded from
`https://github.com/<owner>/<name>/archive/refs/heads/<branch>.zip`, and a
skill repo (`owner`/`name`) can be added through an untrusted `ccswitch://`
deeplink (`deeplink/skill.rs`), so the attacker fully controls the archive
contents.
Fix: resolve each entry through `zip::read::ZipFile::enclosed_name()`, which
rejects `..` components and absolute paths, before stripping the archive's
root directory and joining onto `dest`. Unsafe entries are skipped with a
warning. This matches what the two sibling extractors in this codebase already
did correctly (`extract_local_zip`, `webdav_sync/archive.rs`) — this call site
was the one that had been missed.
The extraction loop is split out into `extract_repo_archive()` so the guard is
testable without network access.
Verified the test actually catches the bug: with the guard reverted to the old
`file.name()` behaviour the new test fails ("zip-slip entry must not escape
dest (temp root)"); with the guard in place it passes.
cargo test --lib: 2186 passed / 0 failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): strip all credentials from the shared Gemini common-config snippet
`extract_gemini_common_config` skipped only two hardcoded keys
(`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`) and copied every other `env`
entry into the shared snippet. But `GOOGLE_API_KEY` is a first-class Gemini
credential — `provider.rs` resolves it via
`first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"])` — so it was
never stripped.
That snippet is not inert: `apply_common_config_to_settings` (live.rs)
deep-merges it into the `env` of *every other* Gemini provider that uses the
common config, and the snippet is auto-extracted on startup, on import, and on
switch. Net effect: account A's key gets written into provider B and sent to
B's `GOOGLE_GEMINI_BASE_URL`, which may be a third-party relay. Anything else
the user put in `env` (`GOOGLE_APPLICATION_CREDENTIALS`, a proxy
`*_AUTH_TOKEN`, …) leaked the same way.
Fix: also skip `Self::is_sensitive_config_key(key)`, reusing the pattern
matcher the Claude extractor already relies on for exactly this reason (its
comment notes a fixed enumeration "will always miss the next `*_API_KEY`").
`GOOGLE_API_KEY` matches its `_API_KEY` suffix rule. Shareable non-secret
config (e.g. `GEMINI_TIMEOUT_MS`) is preserved.
Verified the test catches the bug: run against the old two-key filter it fails
with "credential GOOGLE_API_KEY must not leak into the shared Gemini snippet";
with the fix it passes.
cargo test --lib: 2187 passed / 0 failed; cargo clippy --lib -D warnings clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(security): validate skill directory from backups and sync-imported DB rows
The skill install pipeline sanitizes the install directory name, but two
entry points bypassed it entirely:
- restore_from_backup joined metadata.skill.directory (raw meta.json
content) into the SSOT dir with no validation, allowing a crafted
backup to copy attacker-controlled files outside the skills directory
and to persist the poisoned value into the database.
- Sync import (WebDAV/S3) loads the remote database dump verbatim, so a
malicious or compromised sync snapshot could plant a skills row whose
directory contains path traversal. Every later raw join then operated
on attacker-controlled paths — uninstall/remove_from_app would
remove_dir_all outside the managed dirs (arbitrary directory deletion),
and sync_to_app_dir would write/symlink outside them.
Add require_valid_directory() (built on the existing
sanitize_install_name) and enforce it at restore_from_backup,
sync_to_app_dir, remove_from_app, and uninstall. Regression tests cover
all three sinks plus the metadata path; each was verified against the
unguarded code first (disabling the guard makes the restore/uninstall
tests complete the traversal write/delete, failing the assertions).
Also fixes a pre-existing cargo fmt drift in mcp/grokbuild.rs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): reject path separators in sanitize_install_name on all platforms
The previous components()-based check was platform-dependent: on
Linux/macOS a backslash is not a path separator, so "a\b" parsed as a
single Normal component and was accepted as a valid install name — the
same value becomes a nested path when synced or restored onto Windows.
CI caught this on the Linux runner (the Windows runner passed).
Reject both '/' and '\' explicitly so the validation is
platform-independent; the components() check stays as the second layer
for dot segments, roots, and prefixes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(security): close the remaining skill-install attack chain
The zip-slip guard added earlier only covered half the problem, and the
repo coordinates that decide *which* archive gets downloaded were never
validated at all. Together those two gaps let an attacker choose both the
bytes and where they land.
1) zip-slip, second layer. `enclosed_name()` only guarantees the entry does
not escape the *archive's own* root, and it does not normalise the path —
`..` survives verbatim in the returned value. Stripping `root_name`
afterwards spends one level of that depth budget, so `repo-main/../evil`
still escaped `dest`. On Windows it is worse: `root_name` comes from
`split('/')` and may contain backslashes, which Windows treats as
separators, so one `strip_prefix` can eat N components. Re-check the
*actual* relative path for `ParentDir` right before `dest.join()`.
Verified by reverting the guard: the single-`..` case escapes without it.
(The existing test used two `..`, which `enclosed_name()` rejects on its
own — it covered the half that was already guarded.)
2) Repo coordinates are now validated. `download_repo` formats owner/name/
branch straight into
`https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,
and `deeplink/skill.rs` applied no validation whatsoever. URL parsing
resolves dot segments, so a branch of
`../../../releases/download/v1/evil` retargets the request at a *release
asset* — arbitrary attacker-uploaded bytes. That is what made (1)
reachable in practice: git itself will not let a tree contain `..`.
Note the trigger is below "install": repos are enabled by default, so
merely opening the Skills panel downloads and extracts.
`validate_repo_ref` whitelists all three fields. Branch names legally
contain `/` (`feature/x`), so the check is per segment rather than a
blanket separator ban; an empty branch keeps its existing "use the
default branch" sentinel meaning, same as `HEAD`. The main guard sits in
`download_repo` — the single convergence point for all four call paths —
because both `skill_repos` and `skills` can be overwritten wholesale by a
sync snapshot, which no insert-time check can prevent. Entry-point checks
(deeplink, `add_skill_repo`, agents lock) exist so bad values surface
immediately instead of silently failing on every later panel open.
`build_skill_doc_url` is covered too: it feeds `readme_url`, which the
frontend hands to `openExternal`.
3) Regressions from the previous commits in this branch, both fixed:
- `uninstall` ran the directory guard *before* `db.delete_skill`. That
method has exactly one call site and is not exposed as a command, so a
row with a dirty `directory` (pre-v3.11.0 installs, or anything
`import_from_apps` still creates today — it has no sanitiser) became
permanently undeletable from the UI. Now the guard skips the filesystem
work but still deletes the row.
- `sync_to_app` propagated with `?`, so one bad row aborted the entire
app's skill sync — and that runs on provider switch. Now it warns and
continues per entry.
- `require_valid_directory` returned `sanitize_install_name`'s normalised
value, which trims. A DB value of `" foo "` would then join as `"foo"`
and miss the real directory. It now validates without rewriting.
4) Guard applied to the sinks the earlier commits missed: `migrate_storage`
(rename + remove_dir_all), `update_skill` (delete + write attacker-named
paths), `import_from_apps` (both a sink and the source of dirty values —
`selection.directory` arrives raw over IPC), `resolve_uninstall_backup_source`
(copies any directory into the backup area, which the UI then lists),
`check_updates`, and `migrate_skills_to_ssot`.
5) Extraction limits. The archive bytes were fully attacker-controlled via
(2), and this path had no ceiling of any kind, unlike
`webdav_sync/archive.rs`. Added entry count, total extracted bytes, a
symlink-target cap (zip 2.4.2's `make_reader` does not truncate at the
declared `uncompressed_size`, so a symlink-flagged entry that inflates to
gigabytes was read straight into memory), per-directory charging (a
directory-only archive writes no content bytes but still consumes inodes),
and a download-body cap (`response.bytes()` buffered the whole archive
before any limit applied). Budget is charged on bytes actually read and
written, never on sizes declared in the archive header.
Symlink materialisation is charged to the same budget, and a target that
contains the link itself (`dir/link -> ..`) is now rejected: the existing
"must stay inside base" check passes for it, and the recursive copy then
re-copies its own output every level until PATH_MAX.
6) Temp directories are now RAII. `download_repo` and `extract_local_zip`
called `TempDir::keep()` immediately and relied on every exit path
remembering `remove_dir_all`. Several did not — including
`fetch_repo_skills`, the highest-frequency path of all. Returning the
guard makes the leak unrepresentable at the call sites.
* fix(config): make removals and edits survive user-authored config shapes
The previous commits hardened the *write* side of the MCP tables against a
non-table `mcp_servers`. The read/delete side kept using `as_table_mut`,
which returns None for an inline table (`mcp_servers = { foo = {...} }` —
valid TOML). Removal then silently did nothing: the UI reported success, the
entry stayed in the file, and Codex loaded it again on next start. That is
worse than the panic it mirrors, because users usually reach for the toggle
precisely when an MCP server is misbehaving. Both Codex and GrokBuild now use
`as_table_like_mut` on both sides.
Normalisation is no longer silent either. Replacing a user's hand-written
`mcp_servers = "x"` with an empty table destroys data, so all four sites that
do it now log a warning first.
`update_codex_toml_field` had the same asymmetry with a different symptom:
when `model_providers` or `[model_providers.<id>]` was an inline table,
`as_table_mut` returned None and execution fell through to the "write a
top-level field" fallback. The user's `base_url` edit landed at the wrong
level, Codex never read it, and nothing reported a problem.
`opencode_config` parses `~/.config/opencode/opencode.json` with json5 into a
bare Value and never checked the root's shape. A user file of `[]` or a
scalar made `set_provider`, `set_mcp_server` and `add_plugin` all panic on
index-assignment, inside a Tauri command, unwinding across the FFI boundary.
(A top-level `null` is fine — serde_json promotes it.) Rejecting a non-object
root at the single read site fixes all three. Rejecting rather than rebuilding
is deliberate: the file also holds the user's own `model` / `theme` settings,
so a silent rebuild would delete them. Same treatment for the `provider` and
`mcp` sections, whose non-object forms made writes silently no-op.
* fix(proxy): recover a malformed Anthropic content_block as text
Sanitising a non-object `content_block` to `{}` stops the panic but creates a
quieter failure: the final Responses conversion matches on the block's `type`
and silently drops anything it does not recognise, so a garbled block header
turned into a `completed` response with empty output. The client saw the model
say nothing, with no signal that data had been discarded.
The replacement now carries `type: "text"`. The deltas that follow a malformed
header are usually well-formed, so this recovers the common case; a tool-use
block still yields nothing, exactly as before. A warning is logged when the
substitution happens.
The regression test now asserts through `anthropic_response_to_responses`
rather than on the intermediate value — asserting on the intermediate alone
passes while the client still receives an empty response.
* fix(grokbuild): decouple base_url from credential resolution, reject env_key-only links
`resolve_usage_credentials` called `extract_credentials(...).unwrap_or_default()`,
so a missing credential blanked the base_url too — even though it is written
right there in the config. Removing the `XAI_API_KEY` fallback in the earlier
commit widened that considerably: a GUI process on macOS does not inherit the
shell environment, so an `env_key` that resolves fine in a terminal resolves to
nothing here. The result was a Base URL shown in the UI that differed from the
one actually used, `{{baseUrl}}` expanding to empty in usage scripts (turning
requests relative), and native balance queries reporting "API key is empty"
while hiding the real cause. The two values are now resolved independently, via
the existing `grok_config::extract_base_url`, matching how the Codex arm of the
same function already works.
A deeplink whose only credential is `env_key` is now rejected by name. It was
already unimportable — `build_grokbuild_settings` has no `env_key` slot — but it
failed with the generic "API key is required", which reads like a malformed link
and invites the obvious "just carry the name over" fix. Carrying it over is
exactly what must not happen: the forwarder and the usage query both resolve
`env_key` at request time, so the victim's environment secret would still reach
the link's `base_url`. Same leak, merely deferred. The message says so, and the
test sets the probe variable so that restoring the resolution turns it red.
* fix(security): scrub credentials already leaked into the Gemini shared snippet
Fixing the extractor only prevents new contamination. A Gemini snippet is never
re-extracted once it exists — startup auto-extract and post-import extraction
both require `snippet.is_none()`, and the on-switch rewrite only covers Claude
and Codex — so existing users keep injecting the leaked key into live config,
the proxy upstream, and the new-provider form, where another account's key is
plainly visible.
Removing it from the snippet alone would make things worse. Merge and strip
cancel out by *value equality*: on switch-away, `remove_common_config_from_settings`
deletes the injected keys using the snippet as its only record of what to look
for. Once the key is gone from the snippet, the residue left in live config is
backfilled verbatim into the victim provider's `settings_config`, turning a
transient leak into a permanent one. So the cleanup covers all four locations at
once, and step order is itself a safety property: every fallible step runs before
the irreversible one, and a failure returns an error so the next start retries
from an intact state.
Deletion is by key *and* value, never by key name alone, so a provider's own
same-named key with a different value survives. The `~/.gemini/.env` edit
preserves layout rather than round-tripping through
`parse_env_file`/`serialize_env_file`: that pair drops comments, blank lines and
unparseable lines, collapses duplicate keys and re-sorts the file. Acceptable
when re-projecting everything, destructive for a targeted removal the user never
asked for — in testing it reduced a six-line fixture to one line, taking the
user's own key with it.
An audit record is written before any provider row changes, listing key names and
affected provider ids but *no values*: `settings` is not in `SYNC_SKIP_TABLES`,
so it is uploaded by WebDAV/S3 sync, and these are precisely the credentials that
must be destroyed. Keeping values would trade one deletion for a plaintext copy
that spreads across devices, has no UI to reach it, and never expires. It is
written only when absent, so a partially-applied earlier run cannot overwrite the
original pre-cleanup state.
Consequence, intentional: some Gemini providers will now report a missing API key
and need one entered. That key was never theirs. (The victim's own value was
already overwritten at merge time and cannot be recovered — worth noting in the
release notes, along with a recommendation to rotate the leaked key.) The snippet
row is deleted rather than set to `{}` when nothing shareable remains, and the
`cleared` flag is deliberately not set — either would disable auto-extract
permanently and prevent the user's legitimate shared config from ever coming back.
The frontend snippet validator is aligned with the backend matcher. It had the
same two hardcoded key names, so a hand-edited snippet could put `GOOGLE_API_KEY`
straight back with no error.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
1173 lines
43 KiB
Rust
1173 lines
43 KiB
Rust
//! Provider import from deep link
|
||
//!
|
||
//! Handles importing provider configurations via ccswitch:// URLs.
|
||
|
||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||
use super::DeepLinkImportRequest;
|
||
use crate::error::AppError;
|
||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript};
|
||
use crate::services::ProviderService;
|
||
use crate::store::AppState;
|
||
use crate::AppType;
|
||
use serde_json::json;
|
||
use std::str::FromStr;
|
||
|
||
/// Import a provider from a deep link request
|
||
///
|
||
/// This function:
|
||
/// 1. Validates the request
|
||
/// 2. Merges config file if provided (v3.8+)
|
||
/// 3. Converts it to a Provider structure
|
||
/// 4. Delegates to ProviderService for actual import
|
||
/// 5. Optionally sets as current provider if enabled=true
|
||
pub fn import_provider_from_deeplink(
|
||
state: &AppState,
|
||
request: DeepLinkImportRequest,
|
||
) -> Result<String, AppError> {
|
||
// Verify this is a provider request
|
||
if request.resource != "provider" {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Expected provider resource, got '{}'",
|
||
request.resource
|
||
)));
|
||
}
|
||
|
||
// Step 1: Merge config file if provided (v3.8+)
|
||
let mut merged_request = parse_and_merge_config(&request)?;
|
||
|
||
// Extract required fields (now as Option)
|
||
let app_str = merged_request
|
||
.app
|
||
.clone()
|
||
.ok_or_else(|| AppError::InvalidInput("Missing 'app' field for provider".to_string()))?;
|
||
|
||
let api_key = merged_request.api_key.as_ref().ok_or_else(|| {
|
||
AppError::InvalidInput("API key is required (either in URL or config file)".to_string())
|
||
})?;
|
||
|
||
if api_key.is_empty() {
|
||
return Err(AppError::InvalidInput(
|
||
"API key cannot be empty".to_string(),
|
||
));
|
||
}
|
||
|
||
// Get endpoint: supports comma-separated multiple URLs (first is primary)
|
||
let endpoint_str = merged_request.endpoint.as_ref().ok_or_else(|| {
|
||
AppError::InvalidInput("Endpoint is required (either in URL or config file)".to_string())
|
||
})?;
|
||
|
||
// Parse endpoints: split by comma, first is primary
|
||
let all_endpoints: Vec<String> = endpoint_str
|
||
.split(',')
|
||
.map(|e| e.trim().to_string())
|
||
.filter(|e| !e.is_empty())
|
||
.collect();
|
||
|
||
let primary_endpoint = all_endpoints
|
||
.first()
|
||
.ok_or_else(|| AppError::InvalidInput("Endpoint cannot be empty".to_string()))?;
|
||
|
||
// Auto-infer homepage from endpoint if not provided
|
||
if merged_request
|
||
.homepage
|
||
.as_ref()
|
||
.is_none_or(|s| s.is_empty())
|
||
{
|
||
merged_request.homepage = infer_homepage_from_endpoint(primary_endpoint);
|
||
}
|
||
|
||
let homepage = merged_request.homepage.as_ref().ok_or_else(|| {
|
||
AppError::InvalidInput("Homepage is required (either in URL or config file)".to_string())
|
||
})?;
|
||
|
||
if homepage.is_empty() {
|
||
return Err(AppError::InvalidInput(
|
||
"Homepage cannot be empty".to_string(),
|
||
));
|
||
}
|
||
|
||
let name = merged_request
|
||
.name
|
||
.clone()
|
||
.ok_or_else(|| AppError::InvalidInput("Missing 'name' field for provider".to_string()))?;
|
||
|
||
// Parse app type
|
||
let app_type = AppType::from_str(&app_str)
|
||
.map_err(|_| AppError::InvalidInput(format!("Invalid app type: {app_str}")))?;
|
||
|
||
// Build provider configuration based on app type
|
||
let mut provider = build_provider_from_request(&app_type, &merged_request)?;
|
||
|
||
// Generate a unique ID for the provider using timestamp + sanitized name
|
||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||
let sanitized_name = name
|
||
.chars()
|
||
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
|
||
.collect::<String>()
|
||
.to_lowercase();
|
||
provider.id = format!("{sanitized_name}-{timestamp}");
|
||
|
||
let provider_id = provider.id.clone();
|
||
|
||
// Use ProviderService to add the provider
|
||
ProviderService::add(state, app_type.clone(), provider, true)?;
|
||
|
||
// Add extra endpoints as custom endpoints (skip first one as it's the primary)
|
||
for ep in all_endpoints.iter().skip(1) {
|
||
let normalized = ep.trim().trim_end_matches('/').to_string();
|
||
if !normalized.is_empty() {
|
||
if let Err(e) = ProviderService::add_custom_endpoint(
|
||
state,
|
||
app_type.clone(),
|
||
&provider_id,
|
||
normalized.clone(),
|
||
) {
|
||
log::warn!(
|
||
"Failed to add custom endpoint '{}': {e}",
|
||
crate::url_for_log(&normalized)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// If enabled=true, set as current provider
|
||
if merged_request.enabled.unwrap_or(false) {
|
||
ProviderService::switch(state, app_type.clone(), &provider_id)?;
|
||
log::info!("Provider '{provider_id}' set as current for {app_type:?}");
|
||
}
|
||
|
||
Ok(provider_id)
|
||
}
|
||
|
||
/// Build a Provider structure from a deep link request
|
||
pub(crate) fn build_provider_from_request(
|
||
app_type: &AppType,
|
||
request: &DeepLinkImportRequest,
|
||
) -> Result<Provider, AppError> {
|
||
let settings_config = match app_type {
|
||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||
AppType::Codex => build_codex_settings(request),
|
||
AppType::Gemini => build_gemini_settings(request),
|
||
AppType::GrokBuild => build_grokbuild_settings(request),
|
||
AppType::OpenCode => build_opencode_settings(request),
|
||
AppType::OpenClaw => build_additive_app_settings(request),
|
||
AppType::Hermes => build_hermes_settings(request),
|
||
};
|
||
|
||
// Build usage script configuration if provided
|
||
let mut meta = build_provider_meta(request)?;
|
||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||
meta.get_or_insert_with(ProviderMeta::default)
|
||
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
||
}
|
||
|
||
let provider = Provider {
|
||
id: String::new(), // Will be generated by caller
|
||
name: request.name.clone().unwrap_or_default(),
|
||
settings_config,
|
||
website_url: request.homepage.clone(),
|
||
category: None,
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: request.notes.clone(),
|
||
meta,
|
||
icon: request.icon.clone(),
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
};
|
||
|
||
Ok(provider)
|
||
}
|
||
|
||
/// Get primary endpoint from request (first one if comma-separated)
|
||
fn get_primary_endpoint(request: &DeepLinkImportRequest) -> String {
|
||
request
|
||
.endpoint
|
||
.as_ref()
|
||
.and_then(|ep| ep.split(',').next())
|
||
.map(|s| s.trim().to_string())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn normalize_deeplink_api_key(api_key: &str) -> String {
|
||
api_key.trim().to_string()
|
||
}
|
||
|
||
fn normalize_deeplink_base_url(base_url: &str) -> String {
|
||
base_url.trim().trim_end_matches('/').to_string()
|
||
}
|
||
|
||
fn usage_api_key_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||
let usage_api_key = normalize_deeplink_api_key(request.usage_api_key.as_deref()?);
|
||
if usage_api_key.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let provider_api_key = request
|
||
.api_key
|
||
.as_deref()
|
||
.map(normalize_deeplink_api_key)
|
||
.unwrap_or_default();
|
||
|
||
if !provider_api_key.is_empty() && usage_api_key == provider_api_key {
|
||
None
|
||
} else {
|
||
Some(usage_api_key)
|
||
}
|
||
}
|
||
|
||
fn usage_base_url_override(request: &DeepLinkImportRequest) -> Option<String> {
|
||
let usage_base_url = normalize_deeplink_base_url(request.usage_base_url.as_deref()?);
|
||
if usage_base_url.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let provider_base_url = normalize_deeplink_base_url(&get_primary_endpoint(request));
|
||
|
||
if !provider_base_url.is_empty() && usage_base_url == provider_base_url {
|
||
None
|
||
} else {
|
||
Some(usage_base_url)
|
||
}
|
||
}
|
||
|
||
/// Build provider meta with usage script configuration
|
||
fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<ProviderMeta>, AppError> {
|
||
// Check if any usage script fields are provided
|
||
if request.usage_script.is_none()
|
||
&& request.usage_enabled.is_none()
|
||
&& request.usage_api_key.is_none()
|
||
&& request.usage_base_url.is_none()
|
||
&& request.usage_access_token.is_none()
|
||
&& request.usage_user_id.is_none()
|
||
&& request.usage_auto_interval.is_none()
|
||
{
|
||
return Ok(None);
|
||
}
|
||
|
||
// Decode usage script code if provided
|
||
let code = if let Some(script_b64) = &request.usage_script {
|
||
let decoded = decode_base64_param("usage_script", script_b64)?;
|
||
String::from_utf8(decoded)
|
||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in usage_script: {e}")))?
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
// Determine enabled state: explicit param > has code > false
|
||
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
|
||
|
||
let usage_script = UsageScript {
|
||
enabled,
|
||
language: "javascript".to_string(),
|
||
code,
|
||
timeout: Some(10),
|
||
api_key: usage_api_key_override(request),
|
||
base_url: usage_base_url_override(request),
|
||
access_token: request.usage_access_token.clone(),
|
||
user_id: request.usage_user_id.clone(),
|
||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||
auto_query_interval: request.usage_auto_interval,
|
||
coding_plan_provider: None,
|
||
access_key_id: None,
|
||
secret_access_key: None,
|
||
team_organization_id: None,
|
||
team_project_id: None,
|
||
};
|
||
|
||
Ok(Some(ProviderMeta {
|
||
usage_script: Some(usage_script),
|
||
..Default::default()
|
||
}))
|
||
}
|
||
|
||
/// Build Claude settings configuration
|
||
///
|
||
/// Merges env from the inline config (if any) with the standard fields from URL params.
|
||
/// URL params take priority — they overwrite same-named fields from the config.
|
||
/// Non-standard env fields (e.g. `ANTHROPIC_CUSTOM_HEADERS`, `API_TIMEOUT_MS`,
|
||
/// `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS`, ...) are preserved as-is so that
|
||
/// providers requiring extra environment variables work after deeplink import.
|
||
fn build_claude_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
// Start from the full env block in the inline config (if present), so any
|
||
// custom env vars the user passed via `config=<base64-json>` survive the
|
||
// import. Falling back to an empty map keeps the previous behavior for
|
||
// deeplinks that don't carry a config field.
|
||
let mut env = extract_claude_config_env(request).unwrap_or_default();
|
||
|
||
// Now overwrite / fill in the standard fields from URL params. URL params
|
||
// are authoritative because they're what the deeplink builder put on the
|
||
// wire — for Claude these are: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL,
|
||
// ANTHROPIC_MODEL, and the haiku/sonnet/opus model aliases.
|
||
env.insert(
|
||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||
json!(request.api_key.clone().unwrap_or_default()),
|
||
);
|
||
env.insert(
|
||
"ANTHROPIC_BASE_URL".to_string(),
|
||
json!(get_primary_endpoint(request)),
|
||
);
|
||
|
||
// Add default model if provided
|
||
if let Some(model) = &request.model {
|
||
env.insert("ANTHROPIC_MODEL".to_string(), json!(model));
|
||
}
|
||
|
||
// Add Claude-specific model fields (v3.7.1+)
|
||
if let Some(haiku_model) = &request.haiku_model {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(),
|
||
json!(haiku_model),
|
||
);
|
||
}
|
||
if let Some(sonnet_model) = &request.sonnet_model {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(),
|
||
json!(sonnet_model),
|
||
);
|
||
}
|
||
if let Some(opus_model) = &request.opus_model {
|
||
env.insert(
|
||
"ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(),
|
||
json!(opus_model),
|
||
);
|
||
}
|
||
|
||
json!({ "env": env })
|
||
}
|
||
|
||
/// Decode and extract the `env` object from the deeplink's inline config payload.
|
||
///
|
||
/// Returns `None` when no config is attached, when the payload can't be
|
||
/// decoded/parsed, or when it doesn't contain a Claude-style `env` object.
|
||
/// This is a best-effort accessor — we deliberately don't surface parse
|
||
/// errors here because `parse_and_merge_config` will have already validated
|
||
/// the payload during the merge phase; any failure at this point just means
|
||
/// "fall back to URL-param-only behavior".
|
||
fn extract_claude_config_env(
|
||
request: &DeepLinkImportRequest,
|
||
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||
// Only the inline base64 config carries an env block. Remote config_url
|
||
// is not implemented yet (see parse_and_merge_config), so nothing else to
|
||
// try here.
|
||
let config_b64 = request.config.as_ref()?;
|
||
|
||
// Honor the declared format; default to JSON like parse_and_merge_config does.
|
||
let format = request.config_format.as_deref().unwrap_or("json");
|
||
if format != "json" {
|
||
// Claude config is always JSON in practice. TOML/other formats aren't
|
||
// expected on this app path, so don't try to handle them — safer to
|
||
// fall back than to risk silently producing the wrong shape.
|
||
return None;
|
||
}
|
||
|
||
// Decode the base64 payload. We re-decode here rather than threading the
|
||
// already-decoded value through every build_* function, because the call
|
||
// graph (parse_and_merge_config is pub and called separately for preview)
|
||
// makes signature changes invasive. Decode cost is negligible on this
|
||
// one-shot import path.
|
||
let decoded = decode_base64_param("config", config_b64).ok()?;
|
||
let json_str = std::str::from_utf8(&decoded).ok()?;
|
||
let value: serde_json::Value = serde_json::from_str(json_str).ok()?;
|
||
|
||
// Pull out the env object — same shape as Claude's own settings.json.
|
||
value.get("env").and_then(|v| v.as_object()).cloned()
|
||
}
|
||
|
||
/// Build Codex settings configuration
|
||
fn build_codex_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let provider_display_name = request
|
||
.name
|
||
.as_deref()
|
||
.unwrap_or("custom")
|
||
.chars()
|
||
.filter(|c| !c.is_control())
|
||
.collect::<String>()
|
||
.trim()
|
||
.to_string();
|
||
let provider_display_name = if provider_display_name.is_empty() {
|
||
"custom".to_string()
|
||
} else {
|
||
provider_display_name
|
||
};
|
||
|
||
// Model name: use deeplink model or default
|
||
let model_name = request
|
||
.model
|
||
.as_deref()
|
||
.unwrap_or("gpt-5-codex")
|
||
.to_string();
|
||
|
||
// Endpoint: normalize trailing slashes (use primary endpoint only)
|
||
let endpoint = get_primary_endpoint(request)
|
||
.trim()
|
||
.trim_end_matches('/')
|
||
.to_string();
|
||
|
||
let provider_display_name = toml_edit::Value::from(provider_display_name.as_str()).to_string();
|
||
let model_name = toml_edit::Value::from(model_name.as_str()).to_string();
|
||
let endpoint = toml_edit::Value::from(endpoint.as_str()).to_string();
|
||
|
||
// Build config.toml content
|
||
let config_toml = format!(
|
||
r#"model_provider = "custom"
|
||
model = {model_name}
|
||
model_reasoning_effort = "high"
|
||
disable_response_storage = true
|
||
|
||
[model_providers.custom]
|
||
name = {provider_display_name}
|
||
base_url = {endpoint}
|
||
wire_api = "responses"
|
||
requires_openai_auth = true
|
||
"#
|
||
);
|
||
|
||
json!({
|
||
"auth": {
|
||
"OPENAI_API_KEY": request.api_key,
|
||
},
|
||
"config": config_toml
|
||
})
|
||
}
|
||
|
||
/// Build Gemini settings configuration
|
||
fn build_gemini_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let mut env = serde_json::Map::new();
|
||
env.insert("GEMINI_API_KEY".to_string(), json!(request.api_key));
|
||
env.insert(
|
||
"GOOGLE_GEMINI_BASE_URL".to_string(),
|
||
json!(get_primary_endpoint(request)),
|
||
);
|
||
|
||
// Add model if provided
|
||
if let Some(model) = &request.model {
|
||
env.insert("GEMINI_MODEL".to_string(), json!(model));
|
||
}
|
||
|
||
json!({ "env": env })
|
||
}
|
||
|
||
fn build_grokbuild_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let model = request
|
||
.model
|
||
.as_deref()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or(crate::grok_config::DEFAULT_MODEL)
|
||
.trim();
|
||
let name = request
|
||
.name
|
||
.as_deref()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or("custom")
|
||
.trim();
|
||
let endpoint = get_primary_endpoint(request).trim().to_string();
|
||
let api_key = request.api_key.as_deref().unwrap_or("").trim();
|
||
|
||
let model_value = toml_edit::Value::from(model).to_string();
|
||
let name_value = toml_edit::Value::from(name).to_string();
|
||
let endpoint_value = toml_edit::Value::from(endpoint.as_str()).to_string();
|
||
let api_key_value = toml_edit::Value::from(api_key).to_string();
|
||
|
||
json!({
|
||
"config": format!(
|
||
"[models]\ndefault = {model_value}\n\n[model.{model_value}]\nmodel = {model_value}\nbase_url = {endpoint_value}\nname = {name_value}\napi_key = {api_key_value}\napi_backend = \"{}\"\ncontext_window = {}\n",
|
||
crate::grok_config::DEFAULT_API_BACKEND,
|
||
crate::grok_config::DEFAULT_CONTEXT_WINDOW,
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Build OpenCode settings configuration
|
||
fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let endpoint = get_primary_endpoint(request);
|
||
|
||
// Build options object
|
||
let mut options = serde_json::Map::new();
|
||
if !endpoint.is_empty() {
|
||
options.insert("baseURL".to_string(), json!(endpoint));
|
||
}
|
||
if let Some(api_key) = &request.api_key {
|
||
options.insert("apiKey".to_string(), json!(api_key));
|
||
}
|
||
|
||
// Build models object
|
||
let mut models = serde_json::Map::new();
|
||
if let Some(model) = &request.model {
|
||
models.insert(model.clone(), json!({ "name": model }));
|
||
}
|
||
|
||
// Default to openai-compatible npm package
|
||
json!({
|
||
"npm": "@ai-sdk/openai-compatible",
|
||
"options": options,
|
||
"models": models
|
||
})
|
||
}
|
||
|
||
/// Build settings for OpenClaw (camelCase live config).
|
||
/// Format: { baseUrl, apiKey, api, models }
|
||
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let endpoint = get_primary_endpoint(request);
|
||
|
||
let mut config = serde_json::Map::new();
|
||
|
||
if !endpoint.is_empty() {
|
||
config.insert("baseUrl".to_string(), json!(endpoint));
|
||
}
|
||
|
||
if let Some(api_key) = &request.api_key {
|
||
config.insert("apiKey".to_string(), json!(api_key));
|
||
}
|
||
|
||
config.insert("api".to_string(), json!("openai-completions"));
|
||
|
||
if let Some(model) = &request.model {
|
||
config.insert(
|
||
"models".to_string(),
|
||
json!([{ "id": model, "name": model }]),
|
||
);
|
||
}
|
||
|
||
json!(config)
|
||
}
|
||
|
||
/// Build Hermes provider settings (snake_case YAML-native fields).
|
||
///
|
||
/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode`
|
||
/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`).
|
||
/// Emitting camelCase here — as the OpenClaw path does — would poison the
|
||
/// YAML with unknown root fields the Hermes runtime ignores.
|
||
///
|
||
/// `api_mode` is always written explicitly. Deeplinks have no field to carry
|
||
/// it, so we default to `chat_completions` (the most widely compatible
|
||
/// protocol) and let the user adjust via the UI after import. We never rely
|
||
/// on Hermes' built-in URL heuristics, which only recognize a handful of
|
||
/// official endpoints.
|
||
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
|
||
let endpoint = get_primary_endpoint(request);
|
||
|
||
let mut config = serde_json::Map::new();
|
||
|
||
if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) {
|
||
config.insert("name".to_string(), json!(name));
|
||
}
|
||
|
||
if !endpoint.is_empty() {
|
||
config.insert("base_url".to_string(), json!(endpoint));
|
||
}
|
||
|
||
if let Some(api_key) = &request.api_key {
|
||
config.insert("api_key".to_string(), json!(api_key));
|
||
}
|
||
|
||
config.insert("api_mode".to_string(), json!("chat_completions"));
|
||
|
||
if let Some(model) = &request.model {
|
||
config.insert(
|
||
"models".to_string(),
|
||
json!([{ "id": model, "name": model }]),
|
||
);
|
||
}
|
||
|
||
json!(config)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Config Merge Logic
|
||
// =============================================================================
|
||
|
||
/// Parse and merge configuration from Base64 encoded config or remote URL
|
||
///
|
||
/// Priority: URL params > inline config > remote config
|
||
pub fn parse_and_merge_config(
|
||
request: &DeepLinkImportRequest,
|
||
) -> Result<DeepLinkImportRequest, AppError> {
|
||
// If no config provided, return original request
|
||
if request.config.is_none() && request.config_url.is_none() {
|
||
return Ok(request.clone());
|
||
}
|
||
|
||
// Step 1: Get config content
|
||
let config_content = if let Some(config_b64) = &request.config {
|
||
// Decode Base64 inline config
|
||
let decoded = decode_base64_param("config", config_b64)?;
|
||
String::from_utf8(decoded)
|
||
.map_err(|e| AppError::InvalidInput(format!("Invalid UTF-8 in config: {e}")))?
|
||
} else if let Some(_config_url) = &request.config_url {
|
||
// Fetch remote config (TODO: implement remote fetching in next phase)
|
||
return Err(AppError::InvalidInput(
|
||
"Remote config URL is not yet supported. Use inline config instead.".to_string(),
|
||
));
|
||
} else {
|
||
return Ok(request.clone());
|
||
};
|
||
|
||
// Step 2: Parse config based on format
|
||
let format = request.config_format.as_deref().unwrap_or("json");
|
||
let config_value: serde_json::Value = match format {
|
||
"json" => serde_json::from_str(&config_content)
|
||
.map_err(|e| AppError::InvalidInput(format!("Invalid JSON config: {e}")))?,
|
||
"toml" => {
|
||
let toml_value: toml::Value = toml::from_str(&config_content)
|
||
.map_err(|e| AppError::InvalidInput(format!("Invalid TOML config: {e}")))?;
|
||
// Convert TOML to JSON for uniform processing
|
||
serde_json::to_value(toml_value)
|
||
.map_err(|e| AppError::Message(format!("Failed to convert TOML to JSON: {e}")))?
|
||
}
|
||
_ => {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Unsupported config format: {format}"
|
||
)))
|
||
}
|
||
};
|
||
|
||
// Step 3: Extract values from config based on app type and merge with URL params
|
||
let mut merged = request.clone();
|
||
|
||
// MCP, Skill and other resource types don't need config merging
|
||
if request.resource != "provider" {
|
||
return Ok(merged);
|
||
}
|
||
|
||
match request.app.as_deref().unwrap_or("") {
|
||
"claude" => merge_claude_config(&mut merged, &config_value)?,
|
||
"codex" => merge_codex_config(&mut merged, &config_value)?,
|
||
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
|
||
"grokbuild" => merge_grokbuild_config(&mut merged, &config_value)?,
|
||
// Additive mode apps use JSON config directly; pass through as-is
|
||
"openclaw" | "opencode" | "hermes" => {
|
||
merge_additive_config(&mut merged, &config_value)?;
|
||
}
|
||
"" => {
|
||
// No app specified, skip merging
|
||
return Ok(merged);
|
||
}
|
||
_ => {
|
||
return Err(AppError::InvalidInput(format!(
|
||
"Invalid app type: {:?}",
|
||
request.app
|
||
)))
|
||
}
|
||
}
|
||
|
||
Ok(merged)
|
||
}
|
||
|
||
/// Merge Claude configuration from config file
|
||
fn merge_claude_config(
|
||
request: &mut DeepLinkImportRequest,
|
||
config: &serde_json::Value,
|
||
) -> Result<(), AppError> {
|
||
let env = config
|
||
.get("env")
|
||
.and_then(|v| v.as_object())
|
||
.ok_or_else(|| {
|
||
AppError::InvalidInput("Claude config must have 'env' object".to_string())
|
||
})?;
|
||
|
||
// Auto-fill API key if not provided in URL
|
||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) {
|
||
request.api_key = Some(token.to_string());
|
||
}
|
||
}
|
||
|
||
// Auto-fill endpoint if not provided in URL
|
||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||
request.endpoint = Some(base_url.to_string());
|
||
}
|
||
}
|
||
|
||
// Auto-fill homepage from endpoint if not provided
|
||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||
if request.homepage.is_none() {
|
||
request.homepage = Some("https://anthropic.com".to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-fill model fields (URL params take priority)
|
||
if request.model.is_none() {
|
||
request.model = env
|
||
.get("ANTHROPIC_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
}
|
||
if request.haiku_model.is_none() {
|
||
request.haiku_model = env
|
||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
}
|
||
if request.sonnet_model.is_none() {
|
||
request.sonnet_model = env
|
||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
}
|
||
if request.opus_model.is_none() {
|
||
request.opus_model = env
|
||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Merge Codex configuration from config file
|
||
fn merge_codex_config(
|
||
request: &mut DeepLinkImportRequest,
|
||
config: &serde_json::Value,
|
||
) -> Result<(), AppError> {
|
||
// Auto-fill API key from auth.OPENAI_API_KEY or Codex mobile-compatible bearer token.
|
||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||
let config_str = config.get("config").and_then(|v| v.as_str());
|
||
if let Some(api_key) =
|
||
crate::codex_config::extract_codex_api_key(config.get("auth"), config_str)
|
||
{
|
||
request.api_key = Some(api_key.to_string());
|
||
}
|
||
}
|
||
|
||
// Auto-fill endpoint and model from config string
|
||
if let Some(config_str) = config.get("config").and_then(|v| v.as_str()) {
|
||
// Parse TOML config string to extract base_url and model
|
||
if let Ok(toml_value) = toml::from_str::<toml::Value>(config_str) {
|
||
// Extract base_url from model_providers section
|
||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(base_url) = extract_codex_base_url(&toml_value) {
|
||
request.endpoint = Some(base_url);
|
||
}
|
||
}
|
||
|
||
// Extract model
|
||
if request.model.is_none() {
|
||
if let Some(model) = toml_value.get("model").and_then(|v| v.as_str()) {
|
||
request.model = Some(model.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-fill homepage from endpoint
|
||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||
if request.homepage.is_none() {
|
||
request.homepage = Some("https://openai.com".to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Merge Gemini configuration from config file
|
||
fn merge_gemini_config(
|
||
request: &mut DeepLinkImportRequest,
|
||
config: &serde_json::Value,
|
||
) -> Result<(), AppError> {
|
||
// Gemini uses flat env structure
|
||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(api_key) = config.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
|
||
request.api_key = Some(api_key.to_string());
|
||
}
|
||
}
|
||
|
||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(base_url) = config
|
||
.get("GOOGLE_GEMINI_BASE_URL")
|
||
.or_else(|| config.get("GEMINI_BASE_URL"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
request.endpoint = Some(base_url.to_string());
|
||
}
|
||
}
|
||
|
||
if request.model.is_none() {
|
||
request.model = config
|
||
.get("GEMINI_MODEL")
|
||
.and_then(|v| v.as_str())
|
||
.map(|s| s.to_string());
|
||
}
|
||
|
||
// Auto-fill homepage from endpoint
|
||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||
if request.homepage.is_none() {
|
||
request.homepage = Some("https://ai.google.dev".to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
fn merge_grokbuild_config(
|
||
request: &mut DeepLinkImportRequest,
|
||
config: &serde_json::Value,
|
||
) -> Result<(), AppError> {
|
||
let config_toml = if let Some(config_toml) = config.get("config").and_then(|v| v.as_str()) {
|
||
config_toml.to_string()
|
||
} else {
|
||
let toml_value: toml::Value = serde_json::from_value(config.clone()).map_err(|error| {
|
||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||
})?;
|
||
toml::to_string(&toml_value).map_err(|error| {
|
||
AppError::InvalidInput(format!("Invalid Grok Build config: {error}"))
|
||
})?
|
||
};
|
||
let model = crate::grok_config::extract_model_config(&config_toml).ok_or_else(|| {
|
||
AppError::InvalidInput("Invalid Grok Build config.toml model profile".to_string())
|
||
})?;
|
||
|
||
if request
|
||
.api_key
|
||
.as_ref()
|
||
.is_none_or(|value| value.is_empty())
|
||
{
|
||
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key`
|
||
// (or any process env var) into a plaintext value here: a deeplink is
|
||
// untrusted input, and resolving+inlining would silently persist the
|
||
// victim's environment secret into the imported provider's config.toml
|
||
// and ship it to whatever `base_url` the link declares. `env_key` is an
|
||
// indirection that must stay a name, not a resolved secret, on import.
|
||
request.api_key = model.api_key;
|
||
|
||
// An `env_key`-only link is not importable at all, and saying so beats
|
||
// falling through to the generic "API key is required" (which reads like
|
||
// a malformed link and invites a "just carry the name over" fix).
|
||
// Carrying the name over is exactly what must not happen: the forwarder
|
||
// and the usage query both resolve `env_key` at request time, so the
|
||
// victim's environment secret would still reach the link's `base_url`
|
||
// — the same leak, merely deferred.
|
||
if request
|
||
.api_key
|
||
.as_ref()
|
||
.is_none_or(|value| value.is_empty())
|
||
&& model.env_key.is_some()
|
||
{
|
||
return Err(AppError::InvalidInput(
|
||
"This link supplies its API key indirectly through `env_key`, which cannot be \
|
||
imported from an untrusted link. Add the provider manually and enter the key \
|
||
yourself."
|
||
.to_string(),
|
||
));
|
||
}
|
||
}
|
||
if request
|
||
.endpoint
|
||
.as_ref()
|
||
.is_none_or(|value| value.is_empty())
|
||
{
|
||
request.endpoint = Some(model.base_url);
|
||
}
|
||
if request.model.is_none() {
|
||
request.model = Some(model.model);
|
||
}
|
||
if request
|
||
.homepage
|
||
.as_ref()
|
||
.is_none_or(|value| value.is_empty())
|
||
{
|
||
if let Some(endpoint) = request.endpoint.as_deref() {
|
||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Merge configuration for additive mode apps (OpenClaw, OpenCode)
|
||
///
|
||
/// These apps use JSON config directly, so we only extract common fields
|
||
/// (api_key, endpoint, model) from the config if not already set in URL params.
|
||
fn merge_additive_config(
|
||
request: &mut DeepLinkImportRequest,
|
||
config: &serde_json::Value,
|
||
) -> Result<(), AppError> {
|
||
// Extract api_key from config if not provided in URL
|
||
if request.api_key.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(api_key) = config
|
||
.get("apiKey")
|
||
.or_else(|| config.get("api_key"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
request.api_key = Some(api_key.to_string());
|
||
}
|
||
}
|
||
|
||
// Extract endpoint from config if not provided in URL
|
||
if request.endpoint.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(base_url) = config
|
||
.get("baseUrl")
|
||
.or_else(|| config.get("base_url"))
|
||
.or_else(|| config.get("options").and_then(|o| o.get("baseURL")))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
request.endpoint = Some(base_url.to_string());
|
||
}
|
||
}
|
||
|
||
// Auto-fill homepage from endpoint
|
||
if request.homepage.as_ref().is_none_or(|s| s.is_empty()) {
|
||
if let Some(endpoint) = request.endpoint.as_ref().filter(|s| !s.is_empty()) {
|
||
request.homepage = infer_homepage_from_endpoint(endpoint);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Extract base_url from Codex TOML config
|
||
fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
|
||
// Try to find base_url in model_providers section
|
||
if let Some(providers) = toml_value.get("model_providers").and_then(|v| v.as_table()) {
|
||
for (_key, provider) in providers.iter() {
|
||
if let Some(base_url) = provider.get("base_url").and_then(|v| v.as_str()) {
|
||
return Some(base_url.to_string());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn hermes_request() -> DeepLinkImportRequest {
|
||
DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("hermes".to_string()),
|
||
name: Some("MyHermes".to_string()),
|
||
endpoint: Some("https://api.example.com/v1".to_string()),
|
||
api_key: Some("sk-test".to_string()),
|
||
model: Some("anthropic/claude-opus-4-8".to_string()),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// deeplink 同时声明 `api_key` 与 `env_key` 时,导入结果只保留用户可见的
|
||
/// `api_key`:既不能带上解析后的明文环境变量,也不能把 `env_key` 这个间接
|
||
/// 引用本身写进供应商配置。
|
||
///
|
||
/// 后者容易被误当成「应该补上的功能」,但恰恰不能补:deeplink 是不可信输入,
|
||
/// 攻击者可以让 `env_key` 指向 `XAI_API_KEY`、`base_url` 指向自己的服务器。
|
||
/// 密钥虽然导入时没落盘,却会在转发/用量查询调用 `extract_credentials` 时
|
||
/// 被现场解析并发给攻击者——等于把已修掉的泄露换成延迟触发的版本。
|
||
/// 手工建供应商的表单路径不受影响,那里的 env_key 是用户自己输入的。
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn grokbuild_deeplink_never_carries_env_key_or_resolved_secret() {
|
||
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||
|
||
let mut request = DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("grokbuild".to_string()),
|
||
name: Some("Attacker".to_string()),
|
||
..Default::default()
|
||
};
|
||
let config = serde_json::json!({
|
||
"config": concat!(
|
||
"[models]\ndefault = \"grok-env\"\n\n",
|
||
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||
"api_key = \"sk-declared-by-link\"\n",
|
||
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||
)
|
||
});
|
||
|
||
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
|
||
let settings = build_grokbuild_settings(&request);
|
||
let rendered = settings["config"].as_str().expect("config string");
|
||
|
||
assert!(
|
||
!rendered.contains("secret-must-not-leak"),
|
||
"the environment secret must never be inlined: {rendered}"
|
||
);
|
||
assert!(
|
||
!rendered.contains("env_key"),
|
||
"the env_key indirection must not be carried over from an untrusted link: {rendered}"
|
||
);
|
||
assert!(
|
||
rendered.contains("api_key = \"sk-declared-by-link\""),
|
||
"only the explicitly declared api_key should survive: {rendered}"
|
||
);
|
||
|
||
match original {
|
||
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||
}
|
||
}
|
||
|
||
/// `env_key` 独苗的链接必须在**公开入口**上被明确拒绝。
|
||
///
|
||
/// 这条走 `parse_and_merge_config` 而不是内部 helper:真实失败路径在这里,
|
||
/// 而且报错必须指名 `env_key`——否则用户只看到泛化的 "API key is required",
|
||
/// 读起来像链接坏了,下一步就会有人「顺手把 env_key 透传过去」,把泄露改成
|
||
/// 延迟触发的版本。
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn grokbuild_env_key_only_link_is_rejected_at_the_public_entry() {
|
||
use base64::prelude::*;
|
||
|
||
// 探针变量必须**真的设上**。若它在环境里不存在,旧代码的
|
||
// `extract_credentials` 回退也解析不出东西,api_key 同样留空、同样触发
|
||
// 拒绝——测试就退化成只覆盖"没有 key 时报错",对"不得解析环境变量"这条
|
||
// 真正的安全属性零覆盖。设上之后,一旦有人恢复回退解析,api_key 会变成
|
||
// 非空、拒绝不再触发,这条断言立刻变红。
|
||
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
|
||
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
|
||
|
||
let config_toml = concat!(
|
||
"[models]\ndefault = \"grok-env\"\n\n",
|
||
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
|
||
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
|
||
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
|
||
"api_backend = \"responses\"\ncontext_window = 500000\n",
|
||
);
|
||
let request = DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("grokbuild".to_string()),
|
||
name: Some("Attacker".to_string()),
|
||
config: Some(BASE64_STANDARD.encode(config_toml)),
|
||
config_format: Some("toml".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let result = parse_and_merge_config(&request);
|
||
|
||
match original {
|
||
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
|
||
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
|
||
}
|
||
|
||
let err = result.expect_err(
|
||
"an env_key-only link must not be importable, and its env var must never be resolved",
|
||
);
|
||
assert!(
|
||
err.to_string().contains("env_key"),
|
||
"the rejection must name env_key so it is not mistaken for a malformed link: {err}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn build_hermes_settings_emits_snake_case() {
|
||
let settings = build_hermes_settings(&hermes_request());
|
||
let obj = settings.as_object().expect("settings must be object");
|
||
|
||
assert_eq!(obj.get("name").unwrap(), "MyHermes");
|
||
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1");
|
||
assert_eq!(obj.get("api_key").unwrap(), "sk-test");
|
||
|
||
// camelCase and legacy fields must NOT be present
|
||
assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl");
|
||
assert!(obj.get("apiKey").is_none(), "no camelCase apiKey");
|
||
assert!(obj.get("api").is_none(), "no legacy 'api' field");
|
||
|
||
// models array with the deeplink model id
|
||
let models = obj.get("models").unwrap().as_array().unwrap();
|
||
assert_eq!(models.len(), 1);
|
||
assert_eq!(models[0]["id"], "anthropic/claude-opus-4-8");
|
||
}
|
||
|
||
#[test]
|
||
fn build_hermes_settings_writes_default_api_mode() {
|
||
let settings = build_hermes_settings(&hermes_request());
|
||
assert_eq!(
|
||
settings.as_object().unwrap().get("api_mode").unwrap(),
|
||
"chat_completions",
|
||
"api_mode must be written explicitly so Hermes never falls back to URL auto-detection"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn build_hermes_settings_skips_missing_optional_fields() {
|
||
let request = DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("hermes".to_string()),
|
||
name: Some("Minimal".to_string()),
|
||
endpoint: None,
|
||
api_key: None,
|
||
model: None,
|
||
..Default::default()
|
||
};
|
||
let settings = build_hermes_settings(&request);
|
||
let obj = settings.as_object().unwrap();
|
||
|
||
assert_eq!(obj.get("name").unwrap(), "Minimal");
|
||
assert!(obj.get("base_url").is_none());
|
||
assert!(obj.get("api_key").is_none());
|
||
assert!(obj.get("models").is_none());
|
||
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
|
||
}
|
||
|
||
#[test]
|
||
fn build_codex_settings_uses_custom_key_and_preserves_display_name() {
|
||
let request = DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("codex".to_string()),
|
||
name: Some("My \"Relay\"".to_string()),
|
||
endpoint: Some("https://api.example.com/v1/".to_string()),
|
||
api_key: Some("sk-test".to_string()),
|
||
model: Some("gpt-5-codex".to_string()),
|
||
..Default::default()
|
||
};
|
||
|
||
let settings = build_codex_settings(&request);
|
||
let config_text = settings
|
||
.get("config")
|
||
.and_then(|value| value.as_str())
|
||
.expect("config text");
|
||
let parsed: toml::Value = toml::from_str(config_text).expect("valid Codex config");
|
||
|
||
assert_eq!(
|
||
parsed
|
||
.get("model_provider")
|
||
.and_then(|value| value.as_str()),
|
||
Some("custom")
|
||
);
|
||
let custom_provider = parsed
|
||
.get("model_providers")
|
||
.and_then(|value| value.get("custom"))
|
||
.expect("custom model provider");
|
||
assert_eq!(
|
||
custom_provider.get("name").and_then(|value| value.as_str()),
|
||
Some("My \"Relay\"")
|
||
);
|
||
assert_eq!(
|
||
custom_provider
|
||
.get("base_url")
|
||
.and_then(|value| value.as_str()),
|
||
Some("https://api.example.com/v1")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn openclaw_still_uses_camel_case() {
|
||
// OpenClaw's live config natively uses camelCase; guard against a
|
||
// refactor accidentally flipping it to snake_case.
|
||
let request = DeepLinkImportRequest {
|
||
resource: "provider".to_string(),
|
||
app: Some("openclaw".to_string()),
|
||
name: Some("c".to_string()),
|
||
endpoint: Some("https://api.example.com".to_string()),
|
||
api_key: Some("k".to_string()),
|
||
..Default::default()
|
||
};
|
||
let settings = build_additive_app_settings(&request);
|
||
let obj = settings.as_object().unwrap();
|
||
assert!(obj.contains_key("baseUrl"));
|
||
assert!(obj.contains_key("apiKey"));
|
||
}
|
||
}
|