mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b314be449 | ||
|
|
3a3e594e6f |
+2
-20
@@ -4,7 +4,7 @@ DATABASE_POOL_SIZE=10
|
||||
|
||||
# LLM Provider
|
||||
# LLM_BACKEND=nearai # default
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, github_copilot, tinfoil, openai_codex
|
||||
# Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil
|
||||
# LLM_REQUEST_TIMEOUT_SECS=120 # Increase for local LLMs (Ollama, vLLM, LM Studio)
|
||||
|
||||
# === Anthropic Direct ===
|
||||
@@ -24,17 +24,6 @@ DATABASE_POOL_SIZE=10
|
||||
# LLM_USE_CODEX_AUTH=true
|
||||
# CODEX_AUTH_PATH=~/.codex/auth.json
|
||||
|
||||
# === GitHub Copilot ===
|
||||
# Uses the OAuth token from your Copilot IDE sign-in (for example
|
||||
# ~/.config/github-copilot/apps.json on Linux/macOS), or run `ironclaw onboard`
|
||||
# and choose the GitHub device login flow.
|
||||
# LLM_BACKEND=github_copilot
|
||||
# GITHUB_COPILOT_TOKEN=gho_...
|
||||
# GITHUB_COPILOT_MODEL=gpt-4o
|
||||
# IronClaw injects standard VS Code Copilot headers automatically.
|
||||
# Optional advanced headers for custom overrides:
|
||||
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
|
||||
|
||||
# === NEAR AI (Chat Completions API) ===
|
||||
# Two auth modes:
|
||||
# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run.
|
||||
@@ -42,7 +31,7 @@ DATABASE_POOL_SIZE=10
|
||||
# Base URL defaults to https://private.near.ai
|
||||
# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai.
|
||||
# Base URL defaults to https://cloud-api.near.ai
|
||||
NEARAI_MODEL=Qwen/Qwen3.5-122B-A10B
|
||||
NEARAI_MODEL=zai-org/GLM-5-FP8
|
||||
NEARAI_BASE_URL=https://private.near.ai
|
||||
NEARAI_AUTH_URL=https://private.near.ai
|
||||
# NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this
|
||||
@@ -103,13 +92,6 @@ NEARAI_AUTH_URL=https://private.near.ai
|
||||
# long = 1-hour TTL, 2.0× (200%) write surcharge
|
||||
# ANTHROPIC_CACHE_RETENTION=short
|
||||
|
||||
# === OpenAI Codex (ChatGPT subscription, OAuth) ===
|
||||
# LLM_BACKEND=openai_codex
|
||||
# OPENAI_CODEX_MODEL=gpt-5.3-codex # default
|
||||
# OPENAI_CODEX_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # override (rare)
|
||||
# OPENAI_CODEX_AUTH_URL=https://auth.openai.com # override (rare)
|
||||
# OPENAI_CODEX_API_URL=https://chatgpt.com/backend-api/codex # override (rare)
|
||||
|
||||
# For full provider setup guide see docs/LLM_PROVIDERS.md
|
||||
|
||||
# Channel Configuration
|
||||
|
||||
@@ -43,42 +43,12 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 1b. Does this PR touch high-risk state machine or resilience code? ---
|
||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
|
||||
|
||||
TOUCHES_HIGH_RISK=false
|
||||
HIGH_RISK_PATTERNS=(
|
||||
"src/context/state.rs"
|
||||
"src/agent/session.rs"
|
||||
"src/llm/circuit_breaker.rs"
|
||||
"src/llm/retry.rs"
|
||||
"src/llm/failover.rs"
|
||||
"src/agent/self_repair.rs"
|
||||
"src/agent/agentic_loop.rs"
|
||||
"src/tools/execute.rs"
|
||||
"crates/ironclaw_safety/src/"
|
||||
)
|
||||
|
||||
for pattern in "${HIGH_RISK_PATTERNS[@]}"; do
|
||||
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
|
||||
TOUCHES_HIGH_RISK=true
|
||||
echo "High-risk file matched: $pattern"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Skip only if NEITHER condition holds — no double-firing on fix PRs
|
||||
if [ "$IS_FIX" = false ] && [ "$TOUCHES_HIGH_RISK" = false ]; then
|
||||
echo "Not a fix PR and no high-risk files changed — skipping."
|
||||
if [ "$IS_FIX" = false ]; then
|
||||
echo "Not a fix PR — skipping regression test check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$IS_FIX" = true ]; then
|
||||
echo "Fix PR detected."
|
||||
fi
|
||||
if [ "$TOUCHES_HIGH_RISK" = true ]; then
|
||||
echo "High-risk state machine or resilience code modified."
|
||||
fi
|
||||
echo "Fix PR detected."
|
||||
|
||||
# --- 2. Skip label or commit message marker ---
|
||||
if grep -qF ',skip-regression-check,' <<< ",$PR_LABELS,"; then
|
||||
@@ -93,6 +63,8 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- 3. Exempt static-only / docs-only changes ---
|
||||
CHANGED_FILES=$(git diff --name-only "${BASE_REF}...${HEAD_REF}")
|
||||
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
echo "No changed files — skipping."
|
||||
exit 0
|
||||
@@ -138,12 +110,5 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- 5. No tests found ---
|
||||
if [ "$IS_FIX" = true ]; then
|
||||
echo "::warning::This PR looks like a bug fix but contains no test changes."
|
||||
fi
|
||||
if [ "$TOUCHES_HIGH_RISK" = true ]; then
|
||||
echo "::warning::This PR modifies high-risk state machine or resilience code but includes no test changes."
|
||||
fi
|
||||
echo "::warning::Please add tests exercising the changed behavior, or apply the 'skip-regression-check' label if not feasible."
|
||||
echo "::warning::This PR looks like a bug fix but contains no test changes. Every fix should include a regression test. Add a #[test] or #[tokio::test], or apply the 'skip-regression-check' label if not feasible."
|
||||
exit 1
|
||||
|
||||
|
||||
@@ -1,94 +1,6 @@
|
||||
# Agent Rules
|
||||
|
||||
## Purpose and Precedence
|
||||
## Feature Parity Update Policy
|
||||
|
||||
- `AGENTS.md` is the quick-start contract for coding agents. It is not the full architecture spec.
|
||||
- Read the relevant subsystem spec before changing a complex area. When a repo spec exists, treat it as authoritative.
|
||||
Start with these deeper docs as needed:
|
||||
- `CLAUDE.md`
|
||||
- `src/agent/CLAUDE.md`
|
||||
- `src/channels/web/CLAUDE.md`
|
||||
- `src/db/CLAUDE.md`
|
||||
- `src/llm/CLAUDE.md`
|
||||
- `src/setup/README.md`
|
||||
- `src/tools/README.md`
|
||||
- `src/workspace/README.md`
|
||||
- `src/NETWORK_SECURITY.md`
|
||||
- `tests/e2e/CLAUDE.md`
|
||||
|
||||
## Architecture Mental Model
|
||||
|
||||
- Channels normalize external input into `IncomingMessage`; `ChannelManager` merges all active channel streams.
|
||||
- `Agent` owns session/thread/turn handling, submission parsing, the LLM/tool loop, approvals, routines, and background runtime behavior.
|
||||
- `AppBuilder` is the composition root that wires database, secrets, LLMs, tools, workspace, extensions, skills, hooks, and cost controls before the agent starts.
|
||||
- The web gateway is a browser-facing API/UI layered on top of the same agent/session/tool systems, not a separate product path.
|
||||
|
||||
## Where to Work
|
||||
|
||||
- Agent/runtime behavior: `src/agent/`
|
||||
- Web gateway/API/SSE/WebSocket: `src/channels/web/`
|
||||
- Persistence and DB abstractions: `src/db/`
|
||||
- Setup/onboarding/configuration flow: `src/setup/`
|
||||
- LLM providers and routing: `src/llm/`
|
||||
- Workspace, memory, embeddings, search: `src/workspace/`
|
||||
- Extensions, tools, channels, MCP, WASM: `src/extensions/`, `src/tools/`, `src/channels/`
|
||||
|
||||
## Ownership and Composition Rules
|
||||
|
||||
- Keep `src/main.rs` and `src/app.rs` orchestration-focused. Do not move module-owned logic into entrypoints.
|
||||
- Module-specific initialization should live in the owning module behind a public factory/helper, not be reimplemented ad hoc.
|
||||
- Keep feature-flag branching inside the module that owns the abstraction whenever possible.
|
||||
- Prefer extending existing traits and registries over hardcoding one-off integration paths.
|
||||
|
||||
## Repo-Wide Coding Rules
|
||||
|
||||
- Avoid `.unwrap()` and `.expect()` in production; prefer proper error handling. They are fine in tests, and in production only for truly infallible invariants (e.g., literals/regexes) with a safety comment.
|
||||
- Keep clippy clean with zero warnings.
|
||||
- Prefer `crate::` imports for cross-module references.
|
||||
- Use strong types and enums over stringly-typed control flow when the shape is known.
|
||||
|
||||
## Database, Setup, and Config Rules
|
||||
|
||||
- New persistence behavior must support both PostgreSQL and libSQL.
|
||||
- Add new DB operations to the shared DB trait first, then implement both backends.
|
||||
- Treat bootstrap config, DB-backed settings, and encrypted secrets as distinct layers; do not collapse them casually.
|
||||
- If onboarding or setup behavior changes, update `src/setup/README.md` in the same branch.
|
||||
- Do not break config precedence, bootstrap env loading, DB-backed config reload, or post-secrets LLM re-resolution.
|
||||
|
||||
## Security and Runtime Invariants
|
||||
|
||||
- Review any change touching listeners, routes, auth, secrets, sandboxing, approvals, or outbound HTTP with a security mindset.
|
||||
- Do not weaken bearer-token auth, webhook auth, CORS/origin checks, body limits, rate limits, allowlists, or secret-handling guarantees.
|
||||
- Treat Docker containers and external services as untrusted.
|
||||
- Session/thread/turn state matters. Submission parsing happens before normal chat handling.
|
||||
- Skills are selected deterministically. Tool approval and auth flows are special paths and must not be mixed into normal chat history carelessly.
|
||||
- Persistent memory is the workspace system, not just transcript storage; preserve file-like semantics, chunking/search behavior, and identity/system-prompt loading.
|
||||
|
||||
## Tools, Channels, and Extensions
|
||||
|
||||
- Use a built-in Rust tool for core internal capabilities tightly coupled to the runtime.
|
||||
- Use WASM tools or WASM channels for sandboxed extensions and plugin-style integrations.
|
||||
- Use MCP for external server integrations when the capability belongs outside the main binary.
|
||||
- Preserve extension lifecycle expectations: install, authenticate/configure, activate, remove.
|
||||
|
||||
## Docs, Parity, and Testing
|
||||
|
||||
- If behavior changes, update the relevant docs/specs in the same branch.
|
||||
- If you change implementation status for any feature tracked in `FEATURE_PARITY.md`, update that file in the same branch.
|
||||
- Do not open a PR that changes feature behavior without checking `FEATURE_PARITY.md` for needed status updates (`❌`, `🚧`, `✅`, notes, and priorities).
|
||||
- Add the narrowest tests that validate the change: unit tests for local logic, integration tests for runtime/DB/routing behavior, and E2E or trace coverage for gateway, approvals, extensions, or other user-visible flows.
|
||||
|
||||
## Risk and Change Discipline
|
||||
|
||||
- Keep changes scoped; avoid broad refactors unless the task truly requires them.
|
||||
- Security, database schema, runtime, worker, CI, and secrets changes are high-risk. Call out rollback risks, compatibility concerns, and hidden side effects.
|
||||
- Preserve existing defaults unless the task explicitly changes them.
|
||||
- Avoid unrelated file churn and generated-file edits unless required.
|
||||
- Respect a dirty worktree and never revert user changes you did not make.
|
||||
|
||||
## Before Finishing
|
||||
|
||||
- Confirm whether behavior changes require updates to `FEATURE_PARITY.md`, specs, API docs, or `CHANGELOG.md`.
|
||||
- Run the most targeted tests/checks that cover the change.
|
||||
- Re-check security-sensitive paths when touching auth, secrets, network listeners, sandboxing, or approvals.
|
||||
- Keep the final diff scoped to the task.
|
||||
|
||||
@@ -158,8 +158,6 @@ src/
|
||||
│
|
||||
├── secrets/ # Secrets management (AES-256-GCM, OS keychain for master key)
|
||||
│
|
||||
├── profile.rs # Psychographic profile types, 9-dimension analysis framework
|
||||
│
|
||||
├── setup/ # 7-step onboarding wizard — see src/setup/README.md
|
||||
│
|
||||
├── skills/ # SKILL.md prompt extension system — see .claude/rules/skills.md
|
||||
|
||||
Generated
+7
-7
@@ -2339,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5575,7 +5575,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5624,7 +5624,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.10",
|
||||
"rustls-webpki 0.103.9",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -5696,9 +5696,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -6479,10 +6479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-2
@@ -242,7 +242,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||
| GitHub Copilot | ✅ | ✅ | - | Dedicated provider with OAuth token exchange (`GithubCopilotProvider`) |
|
||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||
| Perplexity | ✅ | ❌ | P3 | Freshness parameter for web_search |
|
||||
| MiniMax | ✅ | ❌ | P3 | Regional endpoint selection |
|
||||
@@ -466,7 +465,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Device pairing | ✅ | ❌ | |
|
||||
| Tailscale identity | ✅ | ❌ | |
|
||||
| Trusted-proxy auth | ✅ | ❌ | Header-based reverse proxy auth |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth plus hosted extension/MCP OAuth broker; external auth-proxy rollout still pending |
|
||||
| OAuth flows | ✅ | 🚧 | NEAR AI OAuth |
|
||||
| DM pairing verification | ✅ | ✅ | ironclaw pairing approve, host APIs |
|
||||
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||
| Per-group tool policies | ✅ | ❌ | |
|
||||
|
||||
@@ -168,7 +168,7 @@ written to `~/.ironclaw/.env` so they are available before the database connects
|
||||
### Alternative LLM Providers
|
||||
|
||||
IronClaw defaults to NEAR AI but supports many LLM providers out of the box.
|
||||
Built-in providers include **Anthropic**, **OpenAI**, **GitHub Copilot**, **Google Gemini**, **MiniMax**,
|
||||
Built-in providers include **Anthropic**, **OpenAI**, **Google Gemini**, **MiniMax**,
|
||||
**Mistral**, and **Ollama** (local). OpenAI-compatible services like **OpenRouter**
|
||||
(300+ models), **Together AI**, **Fireworks AI**, and self-hosted servers (**vLLM**,
|
||||
**LiteLLM**) are also supported.
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@ ironclaw onboard
|
||||
### 替代 LLM 提供商
|
||||
|
||||
IronClaw 默认使用 NEAR AI,但开箱即用地支持多种 LLM 提供商。
|
||||
内置提供商包括 **Anthropic**、**OpenAI**、**GitHub Copilot**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
|
||||
内置提供商包括 **Anthropic**、**OpenAI**、**Google Gemini**、**MiniMax**、**Mistral** 和 **Ollama**(本地部署)。同时也支持 OpenAI 兼容服务,如 **OpenRouter**(300+ 模型)、**Together AI**、**Fireworks AI** 以及自托管服务器(**vLLM**、**LiteLLM**)。
|
||||
|
||||
在向导中选择你的提供商,或直接设置环境变量:
|
||||
|
||||
|
||||
@@ -206,17 +206,9 @@ struct FeishuApiResponse<T> {
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
/// Tenant access token response (flat format).
|
||||
///
|
||||
/// Unlike most Feishu APIs that nest results under `data`, the
|
||||
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
|
||||
/// `tenant_access_token`, and `expire` at the top level.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TenantAccessTokenResponse {
|
||||
#[serde(default)]
|
||||
code: i32,
|
||||
#[serde(default)]
|
||||
msg: String,
|
||||
/// Tenant access token response.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct TenantAccessTokenData {
|
||||
tenant_access_token: String,
|
||||
expire: i64,
|
||||
}
|
||||
@@ -778,8 +770,9 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
|
||||
));
|
||||
}
|
||||
|
||||
let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
||||
let token_resp: FeishuApiResponse<TenantAccessTokenData> =
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
||||
|
||||
if token_resp.code != 0 {
|
||||
return Err(format!(
|
||||
@@ -788,33 +781,23 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
|
||||
));
|
||||
}
|
||||
|
||||
if token_resp.tenant_access_token.is_empty() {
|
||||
return Err("Token response missing tenant_access_token".to_string());
|
||||
}
|
||||
|
||||
if token_resp.expire <= 0 {
|
||||
return Err(format!(
|
||||
"Token response has invalid expire value: {}",
|
||||
token_resp.expire
|
||||
));
|
||||
}
|
||||
let data = token_resp
|
||||
.data
|
||||
.ok_or_else(|| "Token response missing data".to_string())?;
|
||||
|
||||
// Cache the token with expiry.
|
||||
let now = channel_host::now_millis();
|
||||
let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
|
||||
let expiry = now + (data.expire as u64) * 1000;
|
||||
|
||||
let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
|
||||
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token);
|
||||
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
|
||||
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Debug,
|
||||
&format!(
|
||||
"Tenant access token refreshed, expires in {}s",
|
||||
token_resp.expire
|
||||
),
|
||||
&format!("Tenant access token refreshed, expires in {}s", data.expire),
|
||||
);
|
||||
|
||||
Ok(token_resp.tenant_access_token)
|
||||
Ok(data.tenant_access_token)
|
||||
}
|
||||
Err(e) => Err(format!("Token exchange request failed: {}", e)),
|
||||
}
|
||||
@@ -836,60 +819,3 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
|
||||
body: body_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_flat_token_response() {
|
||||
let json = r#"{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"tenant_access_token": "t-abc123",
|
||||
"expire": 7200
|
||||
}"#;
|
||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
assert_eq!(resp.msg, "ok");
|
||||
assert_eq!(resp.tenant_access_token, "t-abc123");
|
||||
assert_eq!(resp.expire, 7200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_response_rejects_missing_token() {
|
||||
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
|
||||
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "should fail when tenant_access_token is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_response_rejects_missing_expire() {
|
||||
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
|
||||
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
||||
assert!(result.is_err(), "should fail when expire is missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_response_defaults_code_and_msg() {
|
||||
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
|
||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.code, 0);
|
||||
assert_eq!(resp.msg, "");
|
||||
assert_eq!(resp.tenant_access_token, "t-abc");
|
||||
assert_eq!(resp.expire, 3600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_error_response() {
|
||||
let json = r#"{
|
||||
"code": 10003,
|
||||
"msg": "invalid app_id",
|
||||
"tenant_access_token": "",
|
||||
"expire": 0
|
||||
}"#;
|
||||
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.code, 10003);
|
||||
assert!(resp.tenant_access_token.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,18 +243,6 @@ mod tests {
|
||||
assert!(wrapped.contains("Hello <world>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_for_llm_escapes_attr_chars() {
|
||||
let config = SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: true,
|
||||
};
|
||||
let safety = SafetyLayer::new(&config);
|
||||
|
||||
let wrapped = safety.wrap_for_llm("bad&\"<>name", "ok", false);
|
||||
assert!(wrapped.contains("name=\"bad&"<>name\"")); // safety: test assertion in #[cfg(test)] module
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_action_forces_sanitization_when_injection_check_disabled() {
|
||||
let config = SafetyConfig {
|
||||
|
||||
@@ -15,8 +15,6 @@ ignore = [
|
||||
"RUSTSEC-2026-0020",
|
||||
# wasmtime wasi:http/types.fields panic — mitigated by fuel limits
|
||||
"RUSTSEC-2026-0021",
|
||||
# rustls-webpki CRL distributionPoint matching — 0.102.8 pinned by libsql transitive dep
|
||||
"RUSTSEC-2026-0049",
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
@@ -17,7 +17,6 @@ configurations.
|
||||
| Yandex AI Studio | `yandex` | `YANDEX_API_KEY` | YandexGPT models |
|
||||
| MiniMax | `minimax` | `MINIMAX_API_KEY` | MiniMax-M2.7 models |
|
||||
| Cloudflare Workers AI | `cloudflare` | `CLOUDFLARE_API_KEY` | Access to Workers AI |
|
||||
| GitHub Copilot | `github_copilot` | `GITHUB_COPILOT_TOKEN` | Multi-models |
|
||||
| Ollama | `ollama` | No | Local inference |
|
||||
| AWS Bedrock | `bedrock` | AWS credentials | Native Converse API |
|
||||
| OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models |
|
||||
@@ -62,34 +61,6 @@ Popular models: `gpt-4o`, `gpt-4o-mini`, `o3-mini`
|
||||
|
||||
---
|
||||
|
||||
## GitHub Copilot
|
||||
|
||||
GitHub Copilot exposes chat endpoint at
|
||||
`https://api.githubcopilot.com`. IronClaw uses that endpoint directly through the
|
||||
built-in `github_copilot` provider.
|
||||
|
||||
```env
|
||||
LLM_BACKEND=github_copilot
|
||||
GITHUB_COPILOT_TOKEN=gho_...
|
||||
GITHUB_COPILOT_MODEL=gpt-4o
|
||||
# Optional advanced headers if your setup needs them:
|
||||
# GITHUB_COPILOT_EXTRA_HEADERS=Copilot-Integration-Id:vscode-chat
|
||||
```
|
||||
|
||||
`ironclaw onboard` can acquire this token for you using GitHub device login. If you
|
||||
already signed into Copilot through VS Code or a JetBrains IDE, you can also reuse
|
||||
the `oauth_token` stored in `~/.config/github-copilot/apps.json`. If you prefer,
|
||||
`LLM_BACKEND=github-copilot` also works as an alias.
|
||||
|
||||
Popular models vary by subscription, but `gpt-4o` is a safe default. IronClaw keeps
|
||||
model entry manual for this provider because GitHub Copilot model listing may require
|
||||
extra integration headers on some clients. IronClaw automatically injects the standard
|
||||
VS Code identity headers (`User-Agent`, `Editor-Version`, `Editor-Plugin-Version`,
|
||||
`Copilot-Integration-Id`) and lets you override them with
|
||||
`GITHUB_COPILOT_EXTRA_HEADERS`.
|
||||
|
||||
---
|
||||
|
||||
## Ollama (local)
|
||||
|
||||
Install Ollama from [ollama.com](https://ollama.com), pull a model, then:
|
||||
|
||||
@@ -77,29 +77,6 @@
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "github_copilot",
|
||||
"aliases": [
|
||||
"github-copilot",
|
||||
"githubcopilot",
|
||||
"copilot"
|
||||
],
|
||||
"protocol": "github_copilot",
|
||||
"default_base_url": "https://api.githubcopilot.com",
|
||||
"api_key_env": "GITHUB_COPILOT_TOKEN",
|
||||
"api_key_required": true,
|
||||
"model_env": "GITHUB_COPILOT_MODEL",
|
||||
"default_model": "gpt-4o",
|
||||
"extra_headers_env": "GITHUB_COPILOT_EXTRA_HEADERS",
|
||||
"description": "GitHub Copilot Chat API (OAuth token from IDE sign-in)",
|
||||
"setup": {
|
||||
"kind": "api_key",
|
||||
"secret_name": "llm_github_copilot_token",
|
||||
"key_url": "https://docs.github.com/en/copilot",
|
||||
"display_name": "GitHub Copilot",
|
||||
"can_list_models": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tinfoil",
|
||||
"aliases": [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram Channel",
|
||||
"kind": "channel",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.4",
|
||||
"wit_version": "0.3.0",
|
||||
"description": "Talk to your agent through a Telegram bot",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
---
|
||||
name: delegation
|
||||
version: 0.1.0
|
||||
description: Helps users delegate tasks, break them into steps, set deadlines, and track progress via routines and memory.
|
||||
activation:
|
||||
keywords:
|
||||
- delegate
|
||||
- hand off
|
||||
- assign task
|
||||
- help me with
|
||||
- take care of
|
||||
- remind me to
|
||||
- schedule
|
||||
- plan my
|
||||
- manage my
|
||||
- track this
|
||||
patterns:
|
||||
- "can you.*handle"
|
||||
- "I need (help|someone) to"
|
||||
- "take over"
|
||||
- "set up a reminder"
|
||||
- "follow up on"
|
||||
tags:
|
||||
- personal-assistant
|
||||
- task-management
|
||||
- delegation
|
||||
max_context_tokens: 1500
|
||||
---
|
||||
|
||||
# Task Delegation Assistant
|
||||
|
||||
When the user wants to delegate a task or get help managing something, follow this process:
|
||||
|
||||
## 1. Clarify the Task
|
||||
|
||||
Ask what needs to be done, by when, and any constraints. Get enough detail to act independently but don't over-interrogate. If the request is clear, skip straight to planning.
|
||||
|
||||
## 2. Break It Down
|
||||
|
||||
Decompose the task into concrete, actionable steps. Use `memory_write` to persist the task plan to a path like `tasks/{task-name}.md` with:
|
||||
- Clear description
|
||||
- Steps with checkboxes
|
||||
- Due date (if any)
|
||||
- Status: pending/in-progress/done
|
||||
|
||||
## 3. Set Up Tracking
|
||||
|
||||
If the task is recurring or has a deadline:
|
||||
- Create a routine using `routine_create` for scheduled check-ins
|
||||
- Add a heartbeat item if it needs daily monitoring
|
||||
- Set up an event-triggered routine if it depends on external input
|
||||
|
||||
## 4. Use Profile Context
|
||||
|
||||
Check `USER.md` for the user's preferences:
|
||||
- **Proactivity level**: High = check in frequently. Low = only report on completion.
|
||||
- **Communication style**: Match their preferred tone and detail level.
|
||||
- **Focus areas**: Prioritize tasks that align with their stated goals.
|
||||
|
||||
## 5. Execute or Queue
|
||||
|
||||
- If you can do it now (search, draft, organize, calculate), do it immediately.
|
||||
- If it requires waiting, external action, or follow-up, create a reminder routine.
|
||||
- If it requires tools you don't have, explain what's needed and suggest alternatives.
|
||||
|
||||
## 6. Report Back
|
||||
|
||||
Always confirm the plan with the user before starting execution. After completing, update the task file in memory and notify the user with a concise summary.
|
||||
|
||||
## Communication Guidelines
|
||||
|
||||
- Be direct and action-oriented
|
||||
- Confirm understanding before acting on ambiguous requests
|
||||
- When in doubt about autonomy level, ask once then remember the answer
|
||||
- Use `memory_write` to track delegation preferences for future reference
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: routine-advisor
|
||||
version: 0.1.0
|
||||
description: Suggests relevant cron routines based on user context, goals, and observed patterns
|
||||
activation:
|
||||
keywords:
|
||||
- every day
|
||||
- every morning
|
||||
- every week
|
||||
- routine
|
||||
- automate
|
||||
- remind me
|
||||
- check daily
|
||||
- monitor
|
||||
- recurring
|
||||
- schedule
|
||||
- habit
|
||||
- workflow
|
||||
- keep forgetting
|
||||
- always have to
|
||||
- repetitive
|
||||
- notifications
|
||||
- digest
|
||||
- summary
|
||||
- review daily
|
||||
- weekly review
|
||||
patterns:
|
||||
- "I (always|usually|often|regularly) (check|do|look at|review)"
|
||||
- "every (morning|evening|week|day|monday|friday)"
|
||||
- "I (wish|want) (I|it) (could|would) (automatically|auto)"
|
||||
- "is there a way to (auto|schedule|set up)"
|
||||
- "can you (check|monitor|watch|track).*for me"
|
||||
- "I keep (forgetting|missing|having to)"
|
||||
tags:
|
||||
- automation
|
||||
- scheduling
|
||||
- personal-assistant
|
||||
- productivity
|
||||
max_context_tokens: 1500
|
||||
---
|
||||
|
||||
# Routine Advisor
|
||||
|
||||
When the conversation suggests the user has a repeatable task or could benefit from automation, consider suggesting a routine.
|
||||
|
||||
## When to Suggest
|
||||
|
||||
Suggest a routine when you notice:
|
||||
- The user describes doing something repeatedly ("I check my PRs every morning")
|
||||
- The user mentions forgetting recurring tasks ("I keep forgetting to...")
|
||||
- The user asks you to do something that sounds periodic
|
||||
- You've learned enough about the user to propose a relevant automation
|
||||
- The user has installed extensions that enable new monitoring capabilities
|
||||
|
||||
## How to Suggest
|
||||
|
||||
Be specific and concrete. Not "Want me to set up a routine?" but rather: "I noticed you review PRs every morning. Want me to create a daily 9am routine that checks your open PRs and sends you a summary?"
|
||||
|
||||
Always include:
|
||||
1. What the routine would do (specific action)
|
||||
2. When it would run (specific schedule in plain language)
|
||||
3. How it would notify them (which channel they're on)
|
||||
|
||||
Wait for the user to confirm before creating.
|
||||
|
||||
## Pacing
|
||||
|
||||
- First 1-3 conversations: Do NOT suggest routines. Focus on helping and learning.
|
||||
- After learning 2-3 user patterns: Suggest your first routine. Keep it simple.
|
||||
- After 5+ conversations: Suggest more routines as patterns emerge.
|
||||
- Never suggest more than 1 routine per conversation unless the user is clearly interested.
|
||||
- If the user declines, wait at least 3 conversations before suggesting again.
|
||||
|
||||
## Creating Routines
|
||||
|
||||
Use the `routine_create` tool. Before creating, check `routine_list` to avoid duplicates.
|
||||
|
||||
Parameters:
|
||||
- `trigger_type`: Usually "cron" for scheduled tasks
|
||||
- `schedule`: Standard cron format. Common schedules:
|
||||
- Daily 9am: `0 9 * * *`
|
||||
- Weekday mornings: `0 9 * * MON-FRI`
|
||||
- Weekly Monday: `0 9 * * MON`
|
||||
- Every 2 hours during work: `0 9-17/2 * * MON-FRI`
|
||||
- Sunday evening: `0 18 * * SUN`
|
||||
- `action_type`: "lightweight" for simple checks, "full_job" for multi-step tasks
|
||||
- `prompt`: Clear, specific instruction for what the routine should do
|
||||
- `context_paths`: Workspace files to load as context (e.g., `["context/profile.json", "MEMORY.md"]`)
|
||||
|
||||
## Routine Ideas by User Type
|
||||
|
||||
**Developer:**
|
||||
- Daily PR review digest (check open PRs, summarize what needs attention)
|
||||
- CI/CD failure alerts (monitor build status)
|
||||
- Weekly dependency update check
|
||||
- Daily standup prep (summarize yesterday's work from daily logs)
|
||||
|
||||
**Professional:**
|
||||
- Morning briefing (today's priorities from memory + any pending tasks)
|
||||
- End-of-day summary (what was accomplished, what's pending)
|
||||
- Weekly goal review (check progress against stated goals)
|
||||
- Meeting prep reminders
|
||||
|
||||
**Health/Personal:**
|
||||
- Daily exercise or habit check-in
|
||||
- Weekly meal planning prompt
|
||||
- Monthly budget review reminder
|
||||
|
||||
**General:**
|
||||
- Daily news digest on topics of interest
|
||||
- Weekly reflection prompt (what went well, what to improve)
|
||||
- Periodic task/reminder check-in
|
||||
- Regular cleanup of stale tasks or notes
|
||||
- Weekly profile evolution (if the user has a profile in `context/profile.json`, suggest a Monday routine that reads the profile via `memory_read`, searches recent conversations for new patterns with `memory_search`, and updates the profile via `memory_write` if any fields should change with confidence > 0.6 — be conservative, only update with clear evidence)
|
||||
|
||||
## Awareness
|
||||
|
||||
Before suggesting, consider what tools and extensions are currently available. Only suggest routines the agent can actually execute. If a routine would need a tool that isn't installed, mention that too: "If you connect your calendar, I could also send you a morning briefing with today's meetings."
|
||||
+1
-1
@@ -113,7 +113,7 @@ Check-insert is done under a single write lock to prevent TOCTOU races. A cleanu
|
||||
4. Detects broken tools via `store.get_broken_tools(5)` (threshold: 5 failures). Requires `with_store()` to be called; returns empty without a store.
|
||||
5. Attempts to rebuild broken tools via `SoftwareBuilder`. Requires `with_builder()` to be called; returns `ManualRequired` without a builder.
|
||||
|
||||
The `stuck_threshold` duration is used for time-based detection of `InProgress` jobs that have been running longer than the threshold. When `detect_stuck_jobs()` finds such jobs, it transitions them to `Stuck` before returning them, enabling the normal `attempt_recovery()` path.
|
||||
Note: the `stuck_threshold` duration is stored but currently unused (marked `#[allow(dead_code)]`). Stuck detection relies on `JobState::Stuck` being set by the state machine, not wall-clock time comparison.
|
||||
|
||||
Repair results: `Success`, `Retry`, `Failed`, `ManualRequired`. `Retry` does NOT notify the user (to avoid spam).
|
||||
|
||||
|
||||
+22
-180
@@ -10,7 +10,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::StreamExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::context_monitor::ContextMonitor;
|
||||
use crate::agent::heartbeat::spawn_heartbeat;
|
||||
@@ -18,7 +17,7 @@ use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
|
||||
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
|
||||
use crate::agent::session_manager::SessionManager;
|
||||
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
|
||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
|
||||
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler};
|
||||
use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
|
||||
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
|
||||
use crate::context::ContextManager;
|
||||
@@ -32,13 +31,6 @@ use crate::skills::SkillRegistry;
|
||||
use crate::tools::ToolRegistry;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
/// Static greeting persisted to DB and broadcast on first launch.
|
||||
///
|
||||
/// Sent before the LLM is involved so the user sees something immediately.
|
||||
/// The conversational onboarding (profile building, channel setup) happens
|
||||
/// organically in the subsequent turns driven by BOOTSTRAP.md.
|
||||
const BOOTSTRAP_GREETING: &str = include_str!("../workspace/seeds/GREETING.md");
|
||||
|
||||
/// Collapse a tool output string into a single-line preview for display.
|
||||
pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
|
||||
let collapsed: String = output
|
||||
@@ -121,17 +113,6 @@ async fn resolve_routine_notification_target(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn chat_tool_execution_metadata(message: &IncomingMessage) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"notify_channel": message.channel,
|
||||
"notify_user": message
|
||||
.routing_target()
|
||||
.unwrap_or_else(|| message.user_id.clone()),
|
||||
"notify_thread_id": message.thread_id,
|
||||
"notify_metadata": message.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn should_fallback_routine_notification(error: &ChannelError) -> bool {
|
||||
!matches!(error, ChannelError::MissingRoutingTarget { .. })
|
||||
}
|
||||
@@ -165,8 +146,6 @@ pub struct AgentDeps {
|
||||
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
|
||||
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
|
||||
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
|
||||
/// Sandbox readiness state for full-job routine dispatch.
|
||||
pub sandbox_readiness: crate::agent::routine_engine::SandboxReadiness,
|
||||
/// Software builder for self-repair tool rebuilding.
|
||||
pub builder: Option<Arc<dyn crate::tools::SoftwareBuilder>>,
|
||||
}
|
||||
@@ -228,12 +207,9 @@ impl Agent {
|
||||
context_manager.clone(),
|
||||
deps.llm.clone(),
|
||||
deps.safety.clone(),
|
||||
SchedulerDeps {
|
||||
tools: deps.tools.clone(),
|
||||
extension_manager: deps.extension_manager.clone(),
|
||||
store: deps.store.clone(),
|
||||
hooks: deps.hooks.clone(),
|
||||
},
|
||||
deps.tools.clone(),
|
||||
deps.store.clone(),
|
||||
deps.hooks.clone(),
|
||||
);
|
||||
if let Some(ref tx) = deps.sse_tx {
|
||||
scheduler.set_sse_sender(tx.clone());
|
||||
@@ -362,32 +338,6 @@ impl Agent {
|
||||
|
||||
/// Run the agent main loop.
|
||||
pub async fn run(self) -> Result<(), Error> {
|
||||
// Proactive bootstrap: persist the static greeting to DB *before*
|
||||
// starting channels so the first web client sees it via history.
|
||||
let bootstrap_thread_id = if self
|
||||
.workspace()
|
||||
.is_some_and(|ws| ws.take_bootstrap_pending())
|
||||
{
|
||||
tracing::debug!(
|
||||
"Fresh workspace detected — persisting static bootstrap greeting to DB"
|
||||
);
|
||||
if let Some(store) = self.store() {
|
||||
let thread_id = store
|
||||
.get_or_create_assistant_conversation("default", "gateway")
|
||||
.await
|
||||
.ok();
|
||||
if let Some(id) = thread_id {
|
||||
self.persist_assistant_response(id, "gateway", "default", BOOTSTRAP_GREETING)
|
||||
.await;
|
||||
}
|
||||
thread_id
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start channels
|
||||
let mut message_stream = self.channels.start_all().await?;
|
||||
|
||||
@@ -604,10 +554,8 @@ impl Agent {
|
||||
Arc::clone(workspace),
|
||||
notify_tx,
|
||||
Some(self.scheduler.clone()),
|
||||
self.deps.extension_manager.clone(),
|
||||
self.tools().clone(),
|
||||
self.safety().clone(),
|
||||
self.deps.sandbox_readiness,
|
||||
));
|
||||
|
||||
// Register routine tools
|
||||
@@ -720,30 +668,6 @@ impl Agent {
|
||||
None
|
||||
};
|
||||
|
||||
// Bootstrap phase 2: register the thread in session manager and
|
||||
// broadcast the greeting via SSE for any clients already connected.
|
||||
// The greeting was already persisted to DB before start_all(), so
|
||||
// clients that connect after this point will see it via history.
|
||||
if let Some(id) = bootstrap_thread_id {
|
||||
// Use get_or_create_session (not resolve_thread) to avoid creating
|
||||
// an orphan thread. Then insert the DB-sourced thread directly.
|
||||
let session = self.session_manager.get_or_create_session("default").await;
|
||||
{
|
||||
use crate::agent::session::Thread;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(id, sess.id);
|
||||
sess.active_thread = Some(id);
|
||||
sess.threads.entry(id).or_insert(thread);
|
||||
}
|
||||
self.session_manager
|
||||
.register_thread("default", "gateway", id, session)
|
||||
.await;
|
||||
|
||||
let mut out = OutgoingResponse::text(BOOTSTRAP_GREETING.to_string());
|
||||
out.thread_id = Some(id.to_string());
|
||||
let _ = self.channels.broadcast("gateway", "default", out).await;
|
||||
}
|
||||
|
||||
// Main message loop
|
||||
tracing::debug!("Agent {} ready and listening", self.config.name);
|
||||
|
||||
@@ -937,6 +861,9 @@ impl Agent {
|
||||
}
|
||||
|
||||
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
|
||||
// Log at info level only for tracking without exposing PII (user_id can be a phone number)
|
||||
tracing::info!(message_id = %message.id, "Processing message");
|
||||
|
||||
// Log sensitive details at debug level for troubleshooting
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
@@ -1015,59 +942,19 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve session and thread. Approval submissions are allowed to
|
||||
// target an already-loaded owned thread by UUID across channels so the
|
||||
// web approval UI can approve work that originated from HTTP/other
|
||||
// owner-scoped channels.
|
||||
let approval_thread_uuid = if matches!(
|
||||
submission,
|
||||
Submission::ExecApproval { .. } | Submission::ApprovalResponse { .. }
|
||||
) {
|
||||
message
|
||||
.conversation_scope()
|
||||
.and_then(|thread_id| Uuid::parse_str(thread_id).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (session, thread_id) = if let Some(target_thread_id) = approval_thread_uuid {
|
||||
let session = self
|
||||
.session_manager
|
||||
.get_or_create_session(&message.user_id)
|
||||
.await;
|
||||
let mut sess = session.lock().await;
|
||||
if sess.threads.contains_key(&target_thread_id) {
|
||||
sess.active_thread = Some(target_thread_id);
|
||||
sess.last_active_at = chrono::Utc::now();
|
||||
drop(sess);
|
||||
self.session_manager
|
||||
.register_thread(
|
||||
&message.user_id,
|
||||
&message.channel,
|
||||
target_thread_id,
|
||||
Arc::clone(&session),
|
||||
)
|
||||
.await;
|
||||
(session, target_thread_id)
|
||||
} else {
|
||||
drop(sess);
|
||||
self.session_manager
|
||||
.resolve_thread(
|
||||
&message.user_id,
|
||||
&message.channel,
|
||||
message.conversation_scope(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
} else {
|
||||
self.session_manager
|
||||
.resolve_thread(
|
||||
&message.user_id,
|
||||
&message.channel,
|
||||
message.conversation_scope(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
// Resolve session and thread
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
"Resolving session and thread"
|
||||
);
|
||||
let (session, thread_id) = self
|
||||
.session_manager
|
||||
.resolve_thread(
|
||||
&message.user_id,
|
||||
&message.channel,
|
||||
message.conversation_scope(),
|
||||
)
|
||||
.await;
|
||||
tracing::debug!(
|
||||
message_id = %message.id,
|
||||
thread_id = %thread_id,
|
||||
@@ -1237,10 +1124,9 @@ impl Agent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
chat_tool_execution_metadata, resolve_routine_notification_user,
|
||||
should_fallback_routine_notification, truncate_for_preview,
|
||||
resolve_routine_notification_user, should_fallback_routine_notification,
|
||||
truncate_for_preview,
|
||||
};
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::error::ChannelError;
|
||||
|
||||
#[test]
|
||||
@@ -1336,50 +1222,6 @@ mod tests {
|
||||
assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tool_execution_metadata_prefers_message_routing_target() {
|
||||
let message = IncomingMessage::new("telegram", "owner-scope", "hello")
|
||||
.with_sender_id("telegram-user")
|
||||
.with_thread("thread-7")
|
||||
.with_metadata(serde_json::json!({
|
||||
"chat_id": 424242,
|
||||
"chat_type": "private",
|
||||
}));
|
||||
|
||||
let metadata = chat_tool_execution_metadata(&message);
|
||||
assert_eq!(
|
||||
metadata.get("notify_channel").and_then(|v| v.as_str()),
|
||||
Some("telegram")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_user").and_then(|v| v.as_str()),
|
||||
Some("424242")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_thread_id").and_then(|v| v.as_str()),
|
||||
Some("thread-7")
|
||||
); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tool_execution_metadata_falls_back_to_user_scope_without_route() {
|
||||
let message = IncomingMessage::new("gateway", "owner-scope", "hello").with_sender_id("");
|
||||
|
||||
let metadata = chat_tool_execution_metadata(&message);
|
||||
assert_eq!(
|
||||
metadata.get("notify_channel").and_then(|v| v.as_str()),
|
||||
Some("gateway")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_user").and_then(|v| v.as_str()),
|
||||
Some("owner-scope")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_thread_id"),
|
||||
Some(&serde_json::Value::Null)
|
||||
); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_routine_notifications_do_not_fallback_without_owner_route() {
|
||||
let error = ChannelError::MissingRoutingTarget {
|
||||
|
||||
+13
-43
@@ -29,7 +29,7 @@ pub(super) enum AgenticLoopResult {
|
||||
/// A tool requires approval before continuing.
|
||||
NeedApproval {
|
||||
/// The pending approval request to store.
|
||||
pending: Box<PendingApproval>,
|
||||
pending: PendingApproval,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -144,7 +144,12 @@ impl Agent {
|
||||
.with_requester_id(&message.sender_id);
|
||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
||||
job_ctx.user_timezone = user_tz.name().to_string();
|
||||
job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
|
||||
job_ctx.metadata = serde_json::json!({
|
||||
"notify_channel": message.channel,
|
||||
"notify_user": message.user_id,
|
||||
"notify_thread_id": message.thread_id,
|
||||
"notify_metadata": message.metadata,
|
||||
});
|
||||
|
||||
// Build system prompts once for this turn. Two variants: with tools
|
||||
// (normal iterations) and without (force_text final iteration).
|
||||
@@ -212,7 +217,9 @@ impl Agent {
|
||||
reason: format!("Exceeded maximum tool iterations ({max_tool_iterations})"),
|
||||
}
|
||||
.into()),
|
||||
LoopOutcome::NeedApproval(pending) => Ok(AgenticLoopResult::NeedApproval { pending }),
|
||||
LoopOutcome::NeedApproval(pending) => {
|
||||
Ok(AgenticLoopResult::NeedApproval { pending: *pending })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +482,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
usize,
|
||||
crate::llm::ToolCall,
|
||||
Arc<dyn crate::tools::Tool>,
|
||||
bool, // allow_always
|
||||
)> = None;
|
||||
|
||||
for (idx, original_tc) in tool_calls.iter().enumerate() {
|
||||
@@ -545,8 +551,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
&& let Some(tool) = tool_opt
|
||||
{
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let requirement = tool.requires_approval(&tc.arguments);
|
||||
let needs_approval = match requirement {
|
||||
let needs_approval = match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = self.session.lock().await;
|
||||
@@ -581,8 +586,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let allow_always = !matches!(requirement, ApprovalRequirement::Always);
|
||||
approval_needed = Some((idx, tc, tool, allow_always));
|
||||
approval_needed = Some((idx, tc, tool));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -883,7 +887,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
}
|
||||
|
||||
// Handle approval if a tool needed it
|
||||
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
|
||||
if let Some((approval_idx, tc, tool)) = approval_needed {
|
||||
let display_params = redact_params(&tc.arguments, tool.sensitive_params());
|
||||
let pending = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
@@ -895,7 +899,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
||||
context_messages: reason_ctx.messages.clone(),
|
||||
deferred_tool_calls: tool_calls[approval_idx + 1..].to_vec(),
|
||||
user_timezone: Some(self.user_tz.name().to_string()),
|
||||
allow_always,
|
||||
};
|
||||
|
||||
return Ok(Some(LoopOutcome::NeedApproval(Box::new(pending))));
|
||||
@@ -1194,7 +1197,6 @@ mod tests {
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
};
|
||||
|
||||
@@ -1363,35 +1365,6 @@ mod tests {
|
||||
assert!(always_needs, "Always must always require approval");
|
||||
}
|
||||
|
||||
/// Regression test: `allow_always` must be `false` for `Always` and
|
||||
/// `true` for `UnlessAutoApproved`, so the UI hides the "always" button
|
||||
/// for tools that truly cannot be auto-approved.
|
||||
#[test]
|
||||
fn test_allow_always_matches_approval_requirement() {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
|
||||
// Mirrors the expression used in dispatcher.rs and thread_ops.rs:
|
||||
// let allow_always = !matches!(requirement, ApprovalRequirement::Always);
|
||||
|
||||
// UnlessAutoApproved → allow_always = true
|
||||
let req = ApprovalRequirement::UnlessAutoApproved;
|
||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
||||
assert!(
|
||||
allow_always,
|
||||
"UnlessAutoApproved should set allow_always = true"
|
||||
);
|
||||
|
||||
// Always → allow_always = false
|
||||
let req = ApprovalRequirement::Always;
|
||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
||||
assert!(!allow_always, "Always should set allow_always = false");
|
||||
|
||||
// Never → allow_always = true (approval is never needed, but if it were, always would be ok)
|
||||
let req = ApprovalRequirement::Never;
|
||||
let allow_always = !matches!(req, ApprovalRequirement::Always);
|
||||
assert!(allow_always, "Never should set allow_always = true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
||||
// PendingApproval from before the deferred_tool_calls field was added
|
||||
@@ -1437,7 +1410,6 @@ mod tests {
|
||||
},
|
||||
],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&pending).expect("serialize");
|
||||
@@ -2066,7 +2038,6 @@ mod tests {
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
};
|
||||
|
||||
@@ -2186,7 +2157,6 @@ mod tests {
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
sandbox_readiness: crate::agent::routine_engine::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,15 +14,12 @@
|
||||
//! Agent Loop
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
/// Route context for forwarding job monitor events back to the user's channel.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -43,23 +40,10 @@ pub struct JobMonitorRoute {
|
||||
/// Tool use/result and status events are intentionally skipped (too noisy for
|
||||
/// the main agent's context window).
|
||||
pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
) -> JoinHandle<()> {
|
||||
spawn_job_monitor_with_context(job_id, event_rx, inject_tx, route, None)
|
||||
}
|
||||
|
||||
/// Like `spawn_job_monitor`, but also transitions the job's in-memory state
|
||||
/// when it receives a `JobResult` event. This ensures fire-and-forget sandbox
|
||||
/// jobs don't stay `InProgress` forever in the `ContextManager`.
|
||||
pub fn spawn_job_monitor_with_context(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
@@ -93,26 +77,6 @@ pub fn spawn_job_monitor_with_context(
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
// Transition in-memory state so the job frees its
|
||||
// max_jobs slot and query tools show the final state.
|
||||
if let Some(ref cm) = context_manager {
|
||||
let target = if status == "completed" {
|
||||
JobState::Completed
|
||||
} else {
|
||||
JobState::Failed
|
||||
};
|
||||
let reason = if status != "completed" {
|
||||
Some(format!("Container finished: {}", status))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = cm
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(target, reason);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut msg = IncomingMessage::new(
|
||||
route.channel.clone(),
|
||||
route.user_id.clone(),
|
||||
@@ -157,62 +121,6 @@ pub fn spawn_job_monitor_with_context(
|
||||
})
|
||||
}
|
||||
|
||||
/// Lightweight watcher that only transitions ContextManager state on job
|
||||
/// completion. Used when monitor routing metadata is absent (no channel to
|
||||
/// inject messages into) but we still need to free the `max_jobs` slot.
|
||||
pub fn spawn_completion_watcher(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
|
||||
let target = if status == "completed" {
|
||||
JobState::Completed
|
||||
} else {
|
||||
JobState::Failed
|
||||
};
|
||||
let reason = if status != "completed" {
|
||||
Some(format!("Container finished: {}", status))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(target, reason);
|
||||
})
|
||||
.await;
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
status = %status,
|
||||
"Completion watcher exiting (job finished)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
job_id = %short_id,
|
||||
skipped = n,
|
||||
"Completion watcher lagged"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Broadcast channel closed, stopping completion watcher"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -303,7 +211,6 @@ mod tests {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
@@ -386,139 +293,4 @@ mod tests {
|
||||
let msg = IncomingMessage::new("monitor", "system", "test").into_internal();
|
||||
assert!(msg.is_internal);
|
||||
}
|
||||
|
||||
// === Regression: fire-and-forget sandbox jobs must transition out of InProgress ===
|
||||
// Before this fix, spawn_job_monitor only forwarded SSE messages but never
|
||||
// updated ContextManager. Background sandbox jobs stayed InProgress forever,
|
||||
// permanently consuming a max_jobs slot.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_transitions_context_on_completion() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
job_id,
|
||||
event_tx.subscribe(),
|
||||
inject_tx,
|
||||
test_route(),
|
||||
Some(Arc::clone(&cm)),
|
||||
);
|
||||
|
||||
// Send completion event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Drain the injected message
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
|
||||
|
||||
// Wait for monitor to exit
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should exit")
|
||||
.expect("monitor should not panic");
|
||||
|
||||
// Job should now be Completed, not InProgress
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_transitions_context_on_failure() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
job_id,
|
||||
event_tx.subscribe(),
|
||||
inject_tx,
|
||||
test_route(),
|
||||
Some(Arc::clone(&cm)),
|
||||
);
|
||||
|
||||
// Send failure event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "failed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should exit")
|
||||
.expect("monitor should not panic");
|
||||
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Failed);
|
||||
}
|
||||
|
||||
// === Regression: completion watcher (no route metadata) ===
|
||||
// When monitor_route_from_ctx() returns None, spawn_completion_watcher
|
||||
// must still transition the job so the max_jobs slot is freed.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_completion_watcher_transitions_on_result() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
|
||||
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("watcher should exit")
|
||||
.expect("watcher should not panic");
|
||||
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor};
|
||||
pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat};
|
||||
pub use router::{MessageIntent, Router};
|
||||
pub use routine::{Routine, RoutineAction, RoutineRun, Trigger};
|
||||
pub use routine_engine::{RoutineEngine, SandboxReadiness};
|
||||
pub use scheduler::{Scheduler, SchedulerDeps};
|
||||
pub use routine_engine::RoutineEngine;
|
||||
pub use scheduler::Scheduler;
|
||||
pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob};
|
||||
pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState};
|
||||
pub use session_manager::SessionManager;
|
||||
|
||||
+27
-139
@@ -79,13 +79,6 @@ pub enum Trigger {
|
||||
#[serde(default)]
|
||||
filters: std::collections::HashMap<String, String>,
|
||||
},
|
||||
/// Fire on incoming webhook POST to /api/webhooks/{path}.
|
||||
Webhook {
|
||||
/// Optional webhook path suffix (defaults to routine id).
|
||||
path: Option<String>,
|
||||
/// Optional shared secret for HMAC validation.
|
||||
secret: Option<String>,
|
||||
},
|
||||
/// Only fires via tool call or CLI.
|
||||
Manual,
|
||||
}
|
||||
@@ -97,7 +90,6 @@ impl Trigger {
|
||||
Trigger::Cron { .. } => "cron",
|
||||
Trigger::Event { .. } => "event",
|
||||
Trigger::SystemEvent { .. } => "system_event",
|
||||
Trigger::Webhook { .. } => "webhook",
|
||||
Trigger::Manual => "manual",
|
||||
}
|
||||
}
|
||||
@@ -179,17 +171,6 @@ impl Trigger {
|
||||
filters,
|
||||
})
|
||||
}
|
||||
"webhook" => {
|
||||
let path = config
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let secret = config
|
||||
.get("secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Ok(Trigger::Webhook { path, secret })
|
||||
}
|
||||
"manual" => Ok(Trigger::Manual),
|
||||
other => Err(RoutineError::UnknownTriggerType {
|
||||
trigger_type: other.to_string(),
|
||||
@@ -217,10 +198,6 @@ impl Trigger {
|
||||
"event_type": event_type,
|
||||
"filters": filters,
|
||||
}),
|
||||
Trigger::Webhook { path, secret } => serde_json::json!({
|
||||
"path": path,
|
||||
"secret": secret,
|
||||
}),
|
||||
Trigger::Manual => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
@@ -258,6 +235,11 @@ pub enum RoutineAction {
|
||||
/// Max reasoning iterations (default: 10).
|
||||
#[serde(default = "default_max_iterations")]
|
||||
max_iterations: u32,
|
||||
/// Tool names pre-authorized for `Always`-approval tools (e.g. destructive
|
||||
/// shell commands, cross-channel messaging). `UnlessAutoApproved` tools are
|
||||
/// automatically permitted in routine jobs without listing them here.
|
||||
#[serde(default)]
|
||||
tool_permissions: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -282,6 +264,19 @@ fn clamp_max_tool_rounds(value: u64) -> u32 {
|
||||
value.clamp(1, MAX_TOOL_ROUNDS_LIMIT as u64) as u32
|
||||
}
|
||||
|
||||
/// Parse a `tool_permissions` JSON array into a `Vec<String>`.
|
||||
pub fn parse_tool_permissions(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
.get("tool_permissions")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl RoutineAction {
|
||||
/// The string tag stored in the DB action_type column.
|
||||
pub fn type_tag(&self) -> &'static str {
|
||||
@@ -356,10 +351,12 @@ impl RoutineAction {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(default_max_iterations() as u64)
|
||||
as u32;
|
||||
let tool_permissions = parse_tool_permissions(&config);
|
||||
Ok(RoutineAction::FullJob {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
})
|
||||
}
|
||||
other => Err(RoutineError::UnknownActionType {
|
||||
@@ -388,10 +385,12 @@ impl RoutineAction {
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
} => serde_json::json!({
|
||||
"title": title,
|
||||
"description": description,
|
||||
"max_iterations": max_iterations,
|
||||
"tool_permissions": tool_permissions,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -517,36 +516,16 @@ pub fn content_hash(content: &str) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
|
||||
///
|
||||
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
|
||||
/// Standard cron uses 5 fields: `min hour day-of-month month day-of-week`.
|
||||
/// This function auto-expands:
|
||||
/// - 5-field → prepend `0` (seconds) and append `*` (year)
|
||||
/// - 6-field → append `*` (year)
|
||||
/// - 7-field → pass through unchanged
|
||||
pub fn normalize_cron_expression(schedule: &str) -> String {
|
||||
let trimmed = schedule.trim();
|
||||
let fields: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
match fields.len() {
|
||||
5 => format!("0 {} *", trimmed),
|
||||
6 => format!("{} *", trimmed),
|
||||
_ => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a cron expression and compute the next fire time from now.
|
||||
///
|
||||
/// Accepts standard 5-field, 6-field, or 7-field cron expressions (auto-normalized).
|
||||
/// When `timezone` is provided and valid, the schedule is evaluated in that
|
||||
/// timezone and the result is converted back to UTC. Otherwise UTC is used.
|
||||
pub fn next_cron_fire(
|
||||
schedule: &str,
|
||||
timezone: Option<&str>,
|
||||
) -> Result<Option<DateTime<Utc>>, RoutineError> {
|
||||
let normalized = normalize_cron_expression(schedule);
|
||||
let cron_schedule =
|
||||
cron::Schedule::from_str(&normalized).map_err(|e| RoutineError::InvalidCron {
|
||||
cron::Schedule::from_str(schedule).map_err(|e| RoutineError::InvalidCron {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if let Some(tz) = timezone.and_then(crate::timezone::parse_timezone) {
|
||||
@@ -726,7 +705,7 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
||||
mod tests {
|
||||
use crate::agent::routine::{
|
||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
||||
describe_cron, next_cron_fire, normalize_cron_expression,
|
||||
describe_cron, next_cron_fire,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -793,47 +772,13 @@ mod tests {
|
||||
title: "Deploy review".to_string(),
|
||||
description: "Review and deploy pending changes".to_string(),
|
||||
max_iterations: 5,
|
||||
tool_permissions: vec!["shell".to_string()],
|
||||
};
|
||||
let json = action.to_config_json();
|
||||
let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job");
|
||||
assert!(
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. }
|
||||
if title == "Deploy review"
|
||||
&& max_iterations == 5)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_full_job_ignores_legacy_permission_fields() {
|
||||
let parsed = RoutineAction::from_db(
|
||||
"full_job",
|
||||
serde_json::json!({
|
||||
"title": "Deploy review",
|
||||
"description": "Review and deploy pending changes",
|
||||
"max_iterations": 5,
|
||||
"tool_permissions": ["shell"],
|
||||
"permission_mode": "inherit_owner"
|
||||
}),
|
||||
)
|
||||
.expect("parse full_job");
|
||||
assert!(matches!(
|
||||
parsed,
|
||||
RoutineAction::FullJob {
|
||||
ref title,
|
||||
ref description,
|
||||
max_iterations,
|
||||
..
|
||||
} if title == "Deploy review"
|
||||
&& description == "Review and deploy pending changes"
|
||||
&& max_iterations == 5
|
||||
));
|
||||
assert_eq!(
|
||||
parsed.to_config_json(),
|
||||
serde_json::json!({
|
||||
"title": "Deploy review",
|
||||
"description": "Review and deploy pending changes",
|
||||
"max_iterations": 5,
|
||||
})
|
||||
matches!(parsed, RoutineAction::FullJob { title, max_iterations, tool_permissions, .. }
|
||||
if title == "Deploy review" && max_iterations == 5 && tool_permissions == vec!["shell".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -985,66 +930,9 @@ mod tests {
|
||||
.type_tag(),
|
||||
"system_event"
|
||||
);
|
||||
assert_eq!(
|
||||
Trigger::Webhook {
|
||||
path: None,
|
||||
secret: None,
|
||||
}
|
||||
.type_tag(),
|
||||
"webhook"
|
||||
);
|
||||
assert_eq!(Trigger::Manual.type_tag(), "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cron_5_field() {
|
||||
// Standard cron: min hour dom month dow
|
||||
assert_eq!(normalize_cron_expression("0 9 * * 1"), "0 0 9 * * 1 *");
|
||||
assert_eq!(
|
||||
normalize_cron_expression("0 9 * * MON-FRI"),
|
||||
"0 0 9 * * MON-FRI *"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cron_6_field() {
|
||||
// 6-field: sec min hour dom month dow
|
||||
assert_eq!(
|
||||
normalize_cron_expression("0 0 9 * * MON-FRI"),
|
||||
"0 0 9 * * MON-FRI *"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cron_7_field_passthrough() {
|
||||
// Already 7-field: no change
|
||||
assert_eq!(
|
||||
normalize_cron_expression("0 0 9 * * MON-FRI *"),
|
||||
"0 0 9 * * MON-FRI *"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_5_field_accepted() {
|
||||
// Standard 5-field cron should now work through normalization
|
||||
let result = next_cron_fire("0 9 * * 1", None);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"5-field cron should be accepted: {result:?}"
|
||||
);
|
||||
assert!(result.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_next_cron_fire_5_field_with_timezone() {
|
||||
let result = next_cron_fire("0 9 * * MON-FRI", Some("America/New_York"));
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"5-field cron with timezone should be accepted: {result:?}"
|
||||
);
|
||||
assert!(result.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_lightweight_backward_compat_no_use_tools() {
|
||||
// Simulate old DB record without use_tools field
|
||||
|
||||
+67
-320
@@ -29,13 +29,11 @@ use crate::config::RoutineConfig;
|
||||
use crate::context::{JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::RoutineError;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::llm::{
|
||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
||||
};
|
||||
use crate::tools::{
|
||||
ToolError, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_message,
|
||||
prepare_tool_params,
|
||||
ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, prepare_tool_params,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
use ironclaw_safety::SafetyLayer;
|
||||
@@ -45,17 +43,6 @@ enum EventMatcher {
|
||||
System { routine: Routine },
|
||||
}
|
||||
|
||||
/// Distinguishes why sandbox is unavailable so error messages are accurate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SandboxReadiness {
|
||||
/// Docker is available and sandbox is enabled.
|
||||
Available,
|
||||
/// User explicitly disabled sandboxing (SANDBOX_ENABLED=false).
|
||||
DisabledByConfig,
|
||||
/// Sandbox is enabled but Docker is not running or not installed.
|
||||
DockerUnavailable,
|
||||
}
|
||||
|
||||
/// The routine execution engine.
|
||||
pub struct RoutineEngine {
|
||||
config: RoutineConfig,
|
||||
@@ -70,14 +57,10 @@ pub struct RoutineEngine {
|
||||
event_cache: Arc<RwLock<Vec<EventMatcher>>>,
|
||||
/// Scheduler for dispatching jobs (FullJob mode).
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
/// Owner-scoped extension activation state for autonomous tool resolution.
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
/// Tool registry for lightweight routine tool execution.
|
||||
tools: Arc<ToolRegistry>,
|
||||
/// Safety layer for tool output sanitization.
|
||||
safety: Arc<SafetyLayer>,
|
||||
/// Sandbox readiness state for full-job dispatch.
|
||||
sandbox_readiness: SandboxReadiness,
|
||||
/// Timestamp when this engine instance was created. Used by
|
||||
/// `sync_dispatched_runs` to distinguish orphaned runs (from a previous
|
||||
/// process) from actively-watched runs (from this process).
|
||||
@@ -93,10 +76,8 @@ impl RoutineEngine {
|
||||
workspace: Arc<Workspace>,
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
sandbox_readiness: SandboxReadiness,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -107,10 +88,8 @@ impl RoutineEngine {
|
||||
running_count: Arc::new(AtomicUsize::new(0)),
|
||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||
scheduler,
|
||||
extension_manager,
|
||||
tools,
|
||||
safety,
|
||||
sandbox_readiness,
|
||||
boot_time: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -707,95 +686,8 @@ impl RoutineEngine {
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
extension_manager: self.extension_manager.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
sandbox_readiness: self.sandbox_readiness,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
execute_routine(engine, routine, run).await;
|
||||
});
|
||||
|
||||
Ok(run_id)
|
||||
}
|
||||
|
||||
/// Fire a routine from a webhook trigger.
|
||||
///
|
||||
/// Similar to `fire_manual` but records the trigger as `"webhook"` with the
|
||||
/// webhook path as detail. Skips ownership check (auth is via webhook secret).
|
||||
/// Enforces enabled check, cooldown, and concurrent run limit.
|
||||
pub async fn fire_webhook(
|
||||
&self,
|
||||
routine_id: Uuid,
|
||||
webhook_path: &str,
|
||||
) -> Result<Uuid, RoutineError> {
|
||||
let routine = self
|
||||
.store
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.map_err(|e| RoutineError::Database {
|
||||
reason: e.to_string(),
|
||||
})?
|
||||
.ok_or(RoutineError::NotFound { id: routine_id })?;
|
||||
|
||||
if !routine.enabled {
|
||||
return Err(RoutineError::Disabled {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.check_cooldown(&routine) {
|
||||
return Err(RoutineError::Cooldown {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.check_concurrent(&routine).await {
|
||||
return Err(RoutineError::MaxConcurrent {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if self.running_count.load(Ordering::Relaxed) >= self.config.max_concurrent_routines {
|
||||
return Err(RoutineError::MaxConcurrent {
|
||||
name: routine.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let run_id = Uuid::new_v4();
|
||||
let run = RoutineRun {
|
||||
id: run_id,
|
||||
routine_id: routine.id,
|
||||
trigger_type: "webhook".to_string(),
|
||||
trigger_detail: Some(webhook_path.to_string()),
|
||||
started_at: Utc::now(),
|
||||
completed_at: None,
|
||||
status: RunStatus::Running,
|
||||
result_summary: None,
|
||||
tokens_used: None,
|
||||
job_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
if let Err(e) = self.store.create_routine_run(&run).await {
|
||||
return Err(RoutineError::Database {
|
||||
reason: format!("failed to create run record: {e}"),
|
||||
});
|
||||
}
|
||||
|
||||
let engine = EngineContext {
|
||||
config: self.config.clone(),
|
||||
store: self.store.clone(),
|
||||
llm: self.llm.clone(),
|
||||
workspace: self.workspace.clone(),
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
extension_manager: self.extension_manager.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
sandbox_readiness: self.sandbox_readiness,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -829,10 +721,8 @@ impl RoutineEngine {
|
||||
notify_tx: self.notify_tx.clone(),
|
||||
running_count: self.running_count.clone(),
|
||||
scheduler: self.scheduler.clone(),
|
||||
extension_manager: self.extension_manager.clone(),
|
||||
tools: self.tools.clone(),
|
||||
safety: self.safety.clone(),
|
||||
sandbox_readiness: self.sandbox_readiness,
|
||||
};
|
||||
|
||||
// Record the run in DB, then spawn execution
|
||||
@@ -967,10 +857,8 @@ struct EngineContext {
|
||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||
running_count: Arc<AtomicUsize>,
|
||||
scheduler: Option<Arc<Scheduler>>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
sandbox_readiness: SandboxReadiness,
|
||||
}
|
||||
|
||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||
@@ -1001,13 +889,18 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
||||
title,
|
||||
description,
|
||||
max_iterations,
|
||||
tool_permissions,
|
||||
} => {
|
||||
let execution = FullJobExecutionConfig {
|
||||
execute_full_job(
|
||||
&ctx,
|
||||
&routine,
|
||||
&run,
|
||||
title,
|
||||
description,
|
||||
max_iterations: *max_iterations,
|
||||
};
|
||||
execute_full_job(&ctx, &routine, &run, &execution).await
|
||||
*max_iterations,
|
||||
tool_permissions,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1133,36 +1026,15 @@ fn sanitize_routine_name(name: &str) -> String {
|
||||
/// non-active state (not Pending/InProgress/Stuck). Returns the final
|
||||
/// `RunStatus` mapped from the job outcome. This keeps the routine run
|
||||
/// active for the full job lifetime so concurrency guardrails apply.
|
||||
struct FullJobExecutionConfig<'a> {
|
||||
title: &'a str,
|
||||
description: &'a str,
|
||||
max_iterations: u32,
|
||||
}
|
||||
|
||||
async fn execute_full_job(
|
||||
ctx: &EngineContext,
|
||||
routine: &Routine,
|
||||
run: &RoutineRun,
|
||||
execution: &FullJobExecutionConfig<'_>,
|
||||
title: &str,
|
||||
description: &str,
|
||||
max_iterations: u32,
|
||||
tool_permissions: &[String],
|
||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||
match ctx.sandbox_readiness {
|
||||
SandboxReadiness::Available => {}
|
||||
SandboxReadiness::DisabledByConfig => {
|
||||
return Err(RoutineError::JobDispatchFailed {
|
||||
reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \
|
||||
Full-job routines require sandbox."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
SandboxReadiness::DockerUnavailable => {
|
||||
return Err(RoutineError::JobDispatchFailed {
|
||||
reason: "Sandbox is enabled but Docker is not available. \
|
||||
Install Docker or set SANDBOX_ENABLED=false."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let scheduler = ctx
|
||||
.scheduler
|
||||
.as_ref()
|
||||
@@ -1170,10 +1042,8 @@ async fn execute_full_job(
|
||||
reason: "scheduler not available".to_string(),
|
||||
})?;
|
||||
|
||||
let mut metadata = serde_json::json!({
|
||||
"max_iterations": execution.max_iterations,
|
||||
"owner_id": routine.user_id
|
||||
});
|
||||
let mut metadata =
|
||||
serde_json::json!({ "max_iterations": max_iterations, "owner_id": routine.user_id });
|
||||
// Carry the routine's notify config in job metadata so the message tool
|
||||
// can resolve channel/target per-job without global state mutation.
|
||||
if let Some(channel) = &routine.notify.channel {
|
||||
@@ -1181,12 +1051,17 @@ async fn execute_full_job(
|
||||
}
|
||||
metadata["notify_user"] = serde_json::json!(&routine.notify.user);
|
||||
|
||||
// Build approval context: UnlessAutoApproved tools are auto-approved for routines;
|
||||
// Always tools require explicit listing in tool_permissions.
|
||||
let approval_context = ApprovalContext::autonomous_with_tools(tool_permissions.iter().cloned());
|
||||
|
||||
let job_id = scheduler
|
||||
.dispatch_job(
|
||||
.dispatch_job_with_context(
|
||||
&routine.user_id,
|
||||
execution.title,
|
||||
execution.description,
|
||||
title,
|
||||
description,
|
||||
Some(metadata),
|
||||
approval_context,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| RoutineError::JobDispatchFailed {
|
||||
@@ -1207,7 +1082,7 @@ async fn execute_full_job(
|
||||
tracing::info!(
|
||||
routine = %routine.name,
|
||||
job_id = %job_id,
|
||||
max_iterations = execution.max_iterations,
|
||||
max_iterations = max_iterations,
|
||||
"Dispatched full job for routine, watching for completion"
|
||||
);
|
||||
|
||||
@@ -1475,9 +1350,6 @@ async fn execute_lightweight_with_tools(
|
||||
description: routine.name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let allowed_tools =
|
||||
autonomous_allowed_tool_names(&ctx.tools, ctx.extension_manager.as_ref(), &routine.user_id)
|
||||
.await;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
@@ -1512,11 +1384,8 @@ async fn execute_lightweight_with_tools(
|
||||
// Tool-enabled iteration
|
||||
let tool_defs = ctx
|
||||
.tools
|
||||
.tool_definitions()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|tool| allowed_tools.contains(&tool.name))
|
||||
.collect();
|
||||
.tool_definitions_excluding(ROUTINE_TOOL_DENYLIST)
|
||||
.await;
|
||||
|
||||
let request_messages = snapshot_messages_for_tool_iteration(&messages);
|
||||
let request = ToolCompletionRequest::new(request_messages, tool_defs)
|
||||
@@ -1551,7 +1420,7 @@ async fn execute_lightweight_with_tools(
|
||||
|
||||
// Execute tools sequentially
|
||||
for tc in response.tool_calls {
|
||||
let result = execute_routine_tool(ctx, &job_ctx, &allowed_tools, &tc).await;
|
||||
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
|
||||
|
||||
// Sanitize and wrap result (including errors)
|
||||
let result_content = match result {
|
||||
@@ -1620,16 +1489,31 @@ fn snapshot_messages_for_tool_iteration(messages: &[ChatMessage]) -> Vec<ChatMes
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Tools that must never be callable from lightweight routines.
|
||||
///
|
||||
/// These tools pose autonomy-escalation risks: a routine could self-replicate,
|
||||
/// modify its own triggers/prompts, delete other routines, or restart the agent.
|
||||
const ROUTINE_TOOL_DENYLIST: &[&str] = &[
|
||||
"routine_create",
|
||||
"routine_update",
|
||||
"routine_delete",
|
||||
"routine_fire",
|
||||
"restart",
|
||||
];
|
||||
|
||||
/// Execute a single tool for a lightweight routine.
|
||||
async fn execute_routine_tool(
|
||||
ctx: &EngineContext,
|
||||
job_ctx: &JobContext,
|
||||
allowed_tools: &std::collections::HashSet<String>,
|
||||
tc: &ToolCall,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if !allowed_tools.contains(&tc.name) {
|
||||
let message = autonomous_unavailable_message(&tc.name, &job_ctx.user_id);
|
||||
return Err(message.into());
|
||||
// Block tools that pose autonomy-escalation risks
|
||||
if ROUTINE_TOOL_DENYLIST.contains(&tc.name.as_str()) {
|
||||
return Err(format!(
|
||||
"Tool '{}' is not available in lightweight routines",
|
||||
tc.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
// Check if tool exists
|
||||
@@ -1640,6 +1524,22 @@ async fn execute_routine_tool(
|
||||
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
|
||||
let normalized_params = prepare_tool_params(tool.as_ref(), &tc.arguments);
|
||||
|
||||
// Check approval requirement: only allow Never tools in lightweight routines.
|
||||
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
|
||||
// Lightweight routines can be triggered by external events and may process untrusted data,
|
||||
// making them vulnerable to prompt injection that could trick the LLM into calling
|
||||
// sensitive tools. Blocking these tools entirely is the safest approach.
|
||||
match tool.requires_approval(&normalized_params) {
|
||||
ApprovalRequirement::Never => {}
|
||||
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
|
||||
return Err(format!(
|
||||
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
|
||||
tc.name
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = ctx
|
||||
.safety
|
||||
@@ -1780,7 +1680,6 @@ pub fn spawn_cron_ticker(
|
||||
// never races with FullJobWatcher instances from this process.
|
||||
engine.sync_dispatched_runs().await;
|
||||
engine.check_cron_triggers().await;
|
||||
engine.sync_dispatched_runs().await;
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1794,56 +1693,6 @@ fn truncate(s: &str, max: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a summary string from job transitions before using in notifications.
|
||||
///
|
||||
/// `last_reason` comes from untrusted container code, so we:
|
||||
/// 1. Strip control characters (except newline) to prevent terminal injection
|
||||
/// 2. Strip HTML tags to prevent injection in web-rendered notifications
|
||||
/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
|
||||
/// 4. Truncate to 500 chars to prevent oversized notifications
|
||||
#[cfg(test)]
|
||||
fn sanitize_summary(s: &str) -> String {
|
||||
// Strip control characters (keep newline for now, collapse later)
|
||||
let no_control: String = s
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || *c == '\n')
|
||||
.collect();
|
||||
|
||||
// Strip HTML tags (e.g. <script>, <img>, <a href=...>)
|
||||
let no_html = strip_html_tags(&no_control);
|
||||
|
||||
// Collapse whitespace: multiple spaces/newlines become a single space
|
||||
let collapsed: String = no_html.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
|
||||
// Truncate to reasonable length
|
||||
if collapsed.len() <= 500 {
|
||||
collapsed
|
||||
} else {
|
||||
// Find a safe char boundary for truncation
|
||||
let mut end = 500;
|
||||
while !collapsed.is_char_boundary(end) && end > 0 {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}...", &collapsed[..end])
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove HTML/XML tags from a string.
|
||||
#[cfg(test)]
|
||||
fn strip_html_tags(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut in_tag = false;
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => in_tag = false,
|
||||
_ if !in_tag => result.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||
@@ -2055,8 +1904,8 @@ mod tests {
|
||||
];
|
||||
for tool in &denylisted {
|
||||
assert!(
|
||||
crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should be in AUTONOMOUS_TOOL_DENYLIST",
|
||||
super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should be in ROUTINE_TOOL_DENYLIST",
|
||||
tool
|
||||
);
|
||||
}
|
||||
@@ -2067,8 +1916,8 @@ mod tests {
|
||||
let allowed = vec!["echo", "time", "json", "http", "memory_search", "shell"];
|
||||
for tool in &allowed {
|
||||
assert!(
|
||||
!crate::tools::AUTONOMOUS_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should NOT be in AUTONOMOUS_TOOL_DENYLIST",
|
||||
!super::ROUTINE_TOOL_DENYLIST.contains(tool),
|
||||
"Tool '{}' should NOT be in ROUTINE_TOOL_DENYLIST",
|
||||
tool
|
||||
);
|
||||
}
|
||||
@@ -2125,62 +1974,6 @@ mod tests {
|
||||
assert_eq!(snapshot[2].content, "b"); // safety: test-only no-panics CI false positive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_running_status_does_not_notify() {
|
||||
let config = NotifyConfig {
|
||||
on_success: true,
|
||||
on_failure: true,
|
||||
on_attention: true,
|
||||
..Default::default()
|
||||
};
|
||||
let should_notify = match RunStatus::Running {
|
||||
RunStatus::Ok => config.on_success,
|
||||
RunStatus::Attention => config.on_attention,
|
||||
RunStatus::Failed => config.on_failure,
|
||||
RunStatus::Running => false,
|
||||
};
|
||||
assert!(!should_notify);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_job_dispatch_returns_running_status() {
|
||||
assert_eq!(RunStatus::Running.to_string(), "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_readiness_disabled_by_config_error() {
|
||||
use super::SandboxReadiness;
|
||||
|
||||
let readiness = SandboxReadiness::DisabledByConfig;
|
||||
assert_ne!(readiness, SandboxReadiness::Available);
|
||||
|
||||
let err = crate::error::RoutineError::JobDispatchFailed {
|
||||
reason: "Sandboxing is disabled (SANDBOX_ENABLED=false). \
|
||||
Full-job routines require sandbox."
|
||||
.to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("SANDBOX_ENABLED=false"));
|
||||
assert!(msg.contains("require sandbox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_readiness_docker_unavailable_error() {
|
||||
use super::SandboxReadiness;
|
||||
|
||||
let readiness = SandboxReadiness::DockerUnavailable;
|
||||
assert_ne!(readiness, SandboxReadiness::Available);
|
||||
|
||||
let err = crate::error::RoutineError::JobDispatchFailed {
|
||||
reason: "Sandbox is enabled but Docker is not available. \
|
||||
Install Docker or set SANDBOX_ENABLED=false."
|
||||
.to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Docker is not available"));
|
||||
assert!(msg.contains("SANDBOX_ENABLED"));
|
||||
}
|
||||
|
||||
/// Regression test for #1317: FullJobWatcher maps terminal job states correctly.
|
||||
#[test]
|
||||
fn test_full_job_watcher_state_mapping() {
|
||||
@@ -2262,50 +2055,4 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_summary_strips_control_chars() {
|
||||
use super::sanitize_summary;
|
||||
|
||||
// Preserves normal text
|
||||
assert_eq!(sanitize_summary("Job completed"), "Job completed");
|
||||
|
||||
// Strips control characters and collapses whitespace
|
||||
assert_eq!(
|
||||
sanitize_summary("line1\nline2\x00\x1b[31mred"),
|
||||
"line1 line2[31mred"
|
||||
);
|
||||
|
||||
// Truncates long strings
|
||||
let long = "x".repeat(600);
|
||||
let result = sanitize_summary(&long);
|
||||
assert!(result.len() <= 503); // 500 + "..."
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_summary_strips_html() {
|
||||
use super::sanitize_summary;
|
||||
|
||||
assert_eq!(
|
||||
sanitize_summary("Hello <script>alert('xss')</script> world"),
|
||||
"Hello alert('xss') world"
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_summary("<b>bold</b> and <a href=\"evil\">link</a>"),
|
||||
"bold and link"
|
||||
);
|
||||
assert_eq!(sanitize_summary("<img src=x onerror=alert(1)>"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_summary_multibyte_truncation() {
|
||||
use super::sanitize_summary;
|
||||
|
||||
// Ensure truncation doesn't panic on multi-byte chars near the boundary
|
||||
let s = "a".repeat(498) + "\u{1F600}\u{1F600}"; // 498 + two 4-byte emoji
|
||||
let result = sanitize_summary(&s);
|
||||
assert!(result.len() <= 503);
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-58
@@ -14,14 +14,10 @@ use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::{
|
||||
ApprovalContext, ToolRegistry, autonomous_allowed_tool_names, autonomous_unavailable_error,
|
||||
prepare_tool_params,
|
||||
};
|
||||
use crate::tools::{ApprovalContext, ToolRegistry, prepare_tool_params};
|
||||
use crate::worker::job::{Worker, WorkerDeps};
|
||||
|
||||
/// Message to send to a worker.
|
||||
@@ -49,14 +45,6 @@ struct ScheduledSubtask {
|
||||
handle: JoinHandle<Result<TaskOutput, Error>>,
|
||||
}
|
||||
|
||||
/// Shared scheduler-owned dependencies that are forwarded into autonomous runs.
|
||||
pub struct SchedulerDeps {
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
/// Schedules and manages parallel job execution.
|
||||
pub struct Scheduler {
|
||||
config: AgentConfig,
|
||||
@@ -64,7 +52,6 @@ pub struct Scheduler {
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
extension_manager: Option<Arc<ExtensionManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// SSE broadcast sender for live job event streaming.
|
||||
@@ -84,17 +71,18 @@ impl Scheduler {
|
||||
context_manager: Arc<ContextManager>,
|
||||
llm: Arc<dyn LlmProvider>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
deps: SchedulerDeps,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
context_manager,
|
||||
llm,
|
||||
safety,
|
||||
tools: deps.tools,
|
||||
extension_manager: deps.extension_manager,
|
||||
store: deps.store,
|
||||
hooks: deps.hooks,
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
sse_tx: None,
|
||||
http_interceptor: None,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -132,21 +120,14 @@ impl Scheduler {
|
||||
description: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
let approval_context = self.autonomous_approval_context(user_id).await;
|
||||
self.dispatch_job_inner(
|
||||
user_id,
|
||||
title,
|
||||
description,
|
||||
metadata,
|
||||
Some(approval_context),
|
||||
)
|
||||
.await
|
||||
self.dispatch_job_inner(user_id, title, description, metadata, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dispatch a job with an explicit approval context for autonomous execution.
|
||||
///
|
||||
/// Same as `dispatch_job`, but the worker will use the given `ApprovalContext`
|
||||
/// to determine the explicit autonomous allowlist for that job.
|
||||
/// to determine which tools are pre-approved (instead of blocking all non-`Never` tools).
|
||||
pub async fn dispatch_job_with_context(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -235,13 +216,6 @@ impl Scheduler {
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
async fn autonomous_approval_context(&self, user_id: &str) -> ApprovalContext {
|
||||
ApprovalContext::autonomous_with_tools(
|
||||
autonomous_allowed_tool_names(&self.tools, self.extension_manager.as_ref(), user_id)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
/// Schedule a job for execution.
|
||||
pub async fn schedule(&self, job_id: Uuid) -> Result<(), JobError> {
|
||||
self.schedule_with_context(job_id, None).await
|
||||
@@ -544,7 +518,10 @@ impl Scheduler {
|
||||
let blocked =
|
||||
ApprovalContext::is_blocked_or_default(&approval_context, tool_name, requirement);
|
||||
if blocked {
|
||||
return Err(autonomous_unavailable_error(tool_name, &job_ctx.user_id).into());
|
||||
return Err(crate::error::ToolError::AuthRequired {
|
||||
name: tool_name.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
// Delegate to shared tool execution pipeline
|
||||
@@ -799,18 +776,7 @@ mod tests {
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
let hooks = Arc::new(HookRegistry::default());
|
||||
|
||||
Scheduler::new(
|
||||
config,
|
||||
cm,
|
||||
llm,
|
||||
safety,
|
||||
SchedulerDeps {
|
||||
tools,
|
||||
extension_manager: None,
|
||||
store: None,
|
||||
hooks,
|
||||
},
|
||||
)
|
||||
Scheduler::new(config, cm, llm, safety, tools, None, hooks)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1037,14 +1003,12 @@ mod tests {
|
||||
async fn test_execute_tool_task_autonomous_unblocks_soft() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous execution only allows tools explicitly in scope.
|
||||
// Autonomous context auto-approves UnlessAutoApproved
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
cm.clone(),
|
||||
safety.clone(),
|
||||
Some(ApprovalContext::autonomous_with_tools([
|
||||
"soft_gate".to_string()
|
||||
])),
|
||||
Some(ApprovalContext::autonomous()),
|
||||
job_id,
|
||||
"soft_gate",
|
||||
serde_json::json!({}),
|
||||
@@ -1076,11 +1040,8 @@ mod tests {
|
||||
async fn test_execute_tool_task_autonomous_with_permissions() {
|
||||
let (tools, cm, safety, job_id) = setup_tools_and_job().await;
|
||||
|
||||
// Autonomous context with explicit permission for both tools.
|
||||
let ctx = ApprovalContext::autonomous_with_tools([
|
||||
"soft_gate".to_string(),
|
||||
"hard_gate".to_string(),
|
||||
]);
|
||||
// Autonomous context with explicit permission for hard_gate
|
||||
let ctx = ApprovalContext::autonomous_with_tools(["hard_gate".to_string()]);
|
||||
|
||||
let result = Scheduler::execute_tool_task(
|
||||
tools.clone(),
|
||||
|
||||
+41
-137
@@ -66,7 +66,6 @@ pub trait SelfRepair: Send + Sync {
|
||||
/// Default self-repair implementation.
|
||||
pub struct DefaultSelfRepair {
|
||||
context_manager: Arc<ContextManager>,
|
||||
/// Jobs in `InProgress` longer than this are treated as stuck.
|
||||
stuck_threshold: Duration,
|
||||
max_repair_attempts: u32,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
@@ -112,58 +111,15 @@ impl DefaultSelfRepair {
|
||||
#[async_trait]
|
||||
impl SelfRepair for DefaultSelfRepair {
|
||||
async fn detect_stuck_jobs(&self) -> Vec<StuckJob> {
|
||||
let stuck_ids = self
|
||||
.context_manager
|
||||
.find_stuck_jobs_with_threshold(Some(self.stuck_threshold))
|
||||
.await;
|
||||
let stuck_ids = self.context_manager.find_stuck_jobs().await;
|
||||
let mut stuck_jobs = Vec::new();
|
||||
|
||||
for job_id in stuck_ids {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& matches!(ctx.state, JobState::Stuck | JobState::InProgress)
|
||||
&& ctx.state == JobState::Stuck
|
||||
{
|
||||
// InProgress jobs detected by threshold need to be transitioned
|
||||
// to Stuck before they can be repaired (attempt_recovery requires
|
||||
// Stuck state). These jobs already passed the threshold check in
|
||||
// find_stuck_jobs_with_threshold, so skip the duration filter below.
|
||||
let just_transitioned = ctx.state == JobState::InProgress;
|
||||
if just_transitioned {
|
||||
let reason = "exceeded stuck_threshold";
|
||||
let transition = self
|
||||
.context_manager
|
||||
.update_context(job_id, |ctx| ctx.mark_stuck(reason))
|
||||
.await;
|
||||
match transition {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
job = %job_id,
|
||||
"Failed to mark InProgress job as Stuck: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
job = %job_id,
|
||||
"Failed to transition InProgress job to Stuck: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch context after potential InProgress->Stuck transition
|
||||
// so that stuck_since picks up the new transition timestamp.
|
||||
let ctx = match self.context_manager.get_context(job_id).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Use the timestamp of the most recent Stuck transition, not started_at.
|
||||
// A job that ran for hours before becoming stuck should not immediately
|
||||
// exceed the threshold — we measure from when it actually became stuck.
|
||||
// Measure stuck_duration from the most recent Stuck transition,
|
||||
// not from started_at (which reflects when the job first ran).
|
||||
let stuck_since = ctx
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -178,10 +134,8 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Only report already-Stuck jobs that have been stuck long enough.
|
||||
// Jobs just transitioned from InProgress skip this check — they
|
||||
// were already vetted by find_stuck_jobs_with_threshold.
|
||||
if !just_transitioned && stuck_duration < self.stuck_threshold {
|
||||
// Only report jobs that have been stuck long enough
|
||||
if stuck_duration < self.stuck_threshold {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -209,17 +163,10 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
});
|
||||
}
|
||||
|
||||
// Try to recover the job.
|
||||
// If the job is still InProgress (detected via stuck_threshold), transition
|
||||
// it to Stuck first so that attempt_recovery() can move it back to InProgress.
|
||||
// Try to recover the job
|
||||
let result = self
|
||||
.context_manager
|
||||
.update_context(job.job_id, |ctx| {
|
||||
if ctx.state == JobState::InProgress {
|
||||
ctx.transition_to(JobState::Stuck, Some("exceeded stuck_threshold".into()))?;
|
||||
}
|
||||
ctx.attempt_recovery()
|
||||
})
|
||||
.update_context(job.job_id, |ctx| ctx.attempt_recovery())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -542,82 +489,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_and_repair_in_progress_job_via_threshold() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let job_id = cm.create_job("Long running", "desc").await.unwrap();
|
||||
|
||||
// Transition to InProgress.
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Backdate started_at to simulate a job running for 10 minutes.
|
||||
cm.update_context(job_id, |ctx| {
|
||||
ctx.started_at = Some(Utc::now() - chrono::Duration::seconds(600));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Use a 5-minute threshold so the 10-minute job is detected.
|
||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(300), 3);
|
||||
|
||||
// detect_stuck_jobs should find it and transition InProgress -> Stuck.
|
||||
let stuck = repair.detect_stuck_jobs().await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0].job_id, job_id);
|
||||
|
||||
// After detection the job should now be in Stuck state.
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Stuck);
|
||||
|
||||
// Repair should recover it: Stuck -> InProgress.
|
||||
let result = repair.repair_stuck_job(&stuck[0]).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::Success { .. }),
|
||||
"Expected Success, got: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Job should be back to InProgress after recovery.
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_broken_tools_returns_empty_without_store() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
// No store configured, should return empty.
|
||||
let broken = repair.detect_broken_tools().await;
|
||||
assert!(broken.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_broken_tool_returns_manual_without_builder() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
let broken = BrokenTool {
|
||||
name: "test-tool".to_string(),
|
||||
failure_count: 10,
|
||||
last_error: Some("crash".to_string()),
|
||||
first_failure: Utc::now(),
|
||||
last_failure: Utc::now(),
|
||||
last_build_result: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_broken_tool(&broken).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired without builder, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_stuck_jobs_filters_by_threshold() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
@@ -710,6 +581,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_broken_tools_returns_empty_without_store() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
// No store configured, should return empty.
|
||||
let broken = repair.detect_broken_tools().await;
|
||||
assert!(broken.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_broken_tool_returns_manual_without_builder() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
let broken = BrokenTool {
|
||||
name: "test-tool".to_string(),
|
||||
failure_count: 10,
|
||||
last_error: Some("crash".to_string()),
|
||||
first_failure: Utc::now(),
|
||||
last_failure: Utc::now(),
|
||||
last_build_result: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_broken_tool(&broken).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired without builder, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/// Mock SoftwareBuilder that returns a successful build result.
|
||||
struct MockBuilder {
|
||||
build_count: std::sync::atomic::AtomicU32,
|
||||
|
||||
@@ -188,15 +188,6 @@ pub struct PendingApproval {
|
||||
/// through the approval flow even if the approval message lacks timezone.
|
||||
#[serde(default)]
|
||||
pub user_timezone: Option<String>,
|
||||
/// Whether the "always" auto-approve option should be offered to the user.
|
||||
/// `false` when the tool returned `ApprovalRequirement::Always` (e.g.
|
||||
/// destructive shell commands), meaning every invocation must be confirmed.
|
||||
#[serde(default = "default_true")]
|
||||
pub allow_always: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A conversation thread within a session.
|
||||
@@ -1115,7 +1106,6 @@ mod tests {
|
||||
context_messages: vec![ChatMessage::user("do it")],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
|
||||
thread.await_approval(approval);
|
||||
@@ -1142,7 +1132,6 @@ mod tests {
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
|
||||
thread.await_approval(approval);
|
||||
|
||||
@@ -772,33 +772,6 @@ mod tests {
|
||||
assert_ne!(resolved, tid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_then_resolve_same_uuid_on_second_channel_reuses_thread() {
|
||||
use crate::agent::session::{Session, Thread};
|
||||
|
||||
let manager = SessionManager::new();
|
||||
let tid = Uuid::new_v4();
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
manager
|
||||
.register_thread("user-cross", "http", tid, Arc::clone(&session))
|
||||
.await;
|
||||
manager
|
||||
.register_thread("user-cross", "gateway", tid, Arc::clone(&session))
|
||||
.await;
|
||||
|
||||
let (_, resolved) = manager
|
||||
.resolve_thread("user-cross", "gateway", Some(&tid.to_string()))
|
||||
.await;
|
||||
assert_eq!(resolved, tid);
|
||||
}
|
||||
|
||||
// === QA Plan P3 - 4.2: Concurrent session stress tests ===
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -382,8 +382,6 @@ pub enum SubmissionResult {
|
||||
description: String,
|
||||
/// Parameters being passed.
|
||||
parameters: serde_json::Value,
|
||||
/// Whether "always" auto-approve should be offered to the user.
|
||||
allow_always: bool,
|
||||
},
|
||||
|
||||
/// Successfully processed (for control commands).
|
||||
|
||||
+65
-199
@@ -506,8 +506,7 @@ impl Agent {
|
||||
let tool_name = pending.tool_name.clone();
|
||||
let description = pending.description.clone();
|
||||
let parameters = pending.display_parameters.clone();
|
||||
let allow_always = pending.allow_always;
|
||||
thread.await_approval(*pending);
|
||||
thread.await_approval(pending);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
@@ -517,7 +516,6 @@ impl Agent {
|
||||
tool_name: tool_name.clone(),
|
||||
description: description.clone(),
|
||||
parameters: parameters.clone(),
|
||||
allow_always,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -527,7 +525,6 @@ impl Agent {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -868,9 +865,7 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Get pending approval for this thread.
|
||||
// Hold the session lock for the entire check-take-verify-mutate sequence
|
||||
// to eliminate the TOCTOU race window (fixes #1486).
|
||||
// Get pending approval for this thread
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess
|
||||
@@ -888,52 +883,59 @@ impl Agent {
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
|
||||
let pending = match thread.take_pending_approval() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID while still holding the lock
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Restore the pending approval atomically without releasing the lock
|
||||
thread.await_approval(pending);
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
|
||||
// If approved with "always", set auto-approve while we still have the lock.
|
||||
// Drop the thread borrow before calling sess methods to satisfy the borrow checker.
|
||||
if approved && always {
|
||||
let tool_name = pending.tool_name.clone();
|
||||
sess.auto_approve_tool(&tool_name);
|
||||
tracing::info!("Auto-approved tool '{}' for session {}", tool_name, sess.id);
|
||||
}
|
||||
|
||||
// Set thread state to Processing for approved requests.
|
||||
// Re-borrow the thread since the previous borrow was dropped.
|
||||
if approved && let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
pending
|
||||
thread.take_pending_approval()
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
%thread_id,
|
||||
"Ignoring stale approval: no pending approval found"
|
||||
);
|
||||
return Ok(SubmissionResult::ok_with_message(""));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify request ID if provided
|
||||
if let Some(req_id) = request_id
|
||||
&& req_id != pending.request_id
|
||||
{
|
||||
// Put it back and return error
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.await_approval(pending);
|
||||
}
|
||||
return Ok(SubmissionResult::error(
|
||||
"Request ID mismatch. Use the correct request ID.",
|
||||
));
|
||||
}
|
||||
|
||||
if approved {
|
||||
// If always, add to auto-approved set
|
||||
if always {
|
||||
let mut sess = session.lock().await;
|
||||
sess.auto_approve_tool(&pending.tool_name);
|
||||
tracing::info!(
|
||||
"Auto-approved tool '{}' for session {}",
|
||||
pending.tool_name,
|
||||
sess.id
|
||||
);
|
||||
}
|
||||
|
||||
// Reset thread state to processing
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the approved tool and continue the loop
|
||||
let mut job_ctx =
|
||||
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
|
||||
.with_requester_id(&message.sender_id);
|
||||
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
|
||||
job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
|
||||
// Prefer a valid timezone from the approval message, fall back to the
|
||||
// resolved timezone stored when the approval was originally requested.
|
||||
let tz_candidate = message
|
||||
@@ -1067,31 +1069,28 @@ impl Agent {
|
||||
usize,
|
||||
crate::llm::ToolCall,
|
||||
Arc<dyn crate::tools::Tool>,
|
||||
bool, // allow_always
|
||||
)> = None;
|
||||
|
||||
for (idx, tc) in deferred_tool_calls.iter().enumerate() {
|
||||
if let Some(tool) = self.tools().get(&tc.name).await {
|
||||
// Match dispatcher.rs: when auto_approve_tools is true, skip
|
||||
// all approval checks (including ApprovalRequirement::Always).
|
||||
let (needs_approval, allow_always) = if self.config.auto_approve_tools {
|
||||
(false, true)
|
||||
let needs_approval = if self.config.auto_approve_tools {
|
||||
false
|
||||
} else {
|
||||
use crate::tools::ApprovalRequirement;
|
||||
let requirement = tool.requires_approval(&tc.arguments);
|
||||
let needs = match requirement {
|
||||
match tool.requires_approval(&tc.arguments) {
|
||||
ApprovalRequirement::Never => false,
|
||||
ApprovalRequirement::UnlessAutoApproved => {
|
||||
let sess = session.lock().await;
|
||||
!sess.is_tool_auto_approved(&tc.name)
|
||||
}
|
||||
ApprovalRequirement::Always => true,
|
||||
};
|
||||
(needs, !matches!(requirement, ApprovalRequirement::Always))
|
||||
}
|
||||
};
|
||||
|
||||
if needs_approval {
|
||||
approval_needed = Some((idx, tc.clone(), tool, allow_always));
|
||||
approval_needed = Some((idx, tc.clone(), tool));
|
||||
break; // remaining tools stay deferred
|
||||
}
|
||||
}
|
||||
@@ -1299,7 +1298,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
// Handle approval if a tool needed it
|
||||
if let Some((approval_idx, tc, tool, allow_always)) = approval_needed {
|
||||
if let Some((approval_idx, tc, tool)) = approval_needed {
|
||||
let new_pending = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
tool_name: tc.name.clone(),
|
||||
@@ -1311,7 +1310,6 @@ impl Agent {
|
||||
deferred_tool_calls: deferred_tool_calls[approval_idx + 1..].to_vec(),
|
||||
// Carry forward the resolved timezone from the original pending approval
|
||||
user_timezone: pending.user_timezone.clone(),
|
||||
allow_always,
|
||||
};
|
||||
|
||||
let request_id = new_pending.request_id;
|
||||
@@ -1335,7 +1333,6 @@ impl Agent {
|
||||
tool_name: tool_name.clone(),
|
||||
description: description.clone(),
|
||||
parameters: parameters.clone(),
|
||||
allow_always,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -1346,7 +1343,6 @@ impl Agent {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1415,8 +1411,7 @@ impl Agent {
|
||||
let tool_name = new_pending.tool_name.clone();
|
||||
let description = new_pending.description.clone();
|
||||
let parameters = new_pending.display_parameters.clone();
|
||||
let allow_always = new_pending.allow_always;
|
||||
thread.await_approval(*new_pending);
|
||||
thread.await_approval(new_pending);
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
@@ -1426,7 +1421,6 @@ impl Agent {
|
||||
tool_name: tool_name.clone(),
|
||||
description: description.clone(),
|
||||
parameters: parameters.clone(),
|
||||
allow_always,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
@@ -1436,7 +1430,6 @@ impl Agent {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1454,25 +1447,17 @@ impl Agent {
|
||||
);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
match sess.threads.get_mut(&thread_id) {
|
||||
Some(thread) => {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
"Thread disappeared during approval rejection"
|
||||
);
|
||||
}
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
// User message already persisted at turn start; save rejection response
|
||||
self.persist_assistant_response(
|
||||
thread_id,
|
||||
&message.channel,
|
||||
&message.user_id,
|
||||
&rejection,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1964,7 +1949,6 @@ mod tests {
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
@@ -2014,124 +1998,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that request_id mismatch atomically
|
||||
/// restores the pending approval (no TOCTOU window where it could be lost).
|
||||
#[test]
|
||||
fn test_request_id_mismatch_preserves_pending_state() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let correct_request_id = Uuid::new_v4();
|
||||
let wrong_request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id: correct_request_id,
|
||||
tool_name: "shell".to_string(),
|
||||
parameters: serde_json::json!({"command": "rm -rf /"}),
|
||||
display_parameters: serde_json::json!({"command": "[REDACTED]"}),
|
||||
description: "Execute dangerous command".to_string(),
|
||||
tool_call_id: "call_42".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: false,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Verify initial state
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
assert!(session.threads[&thread_id].pending_approval.is_some());
|
||||
|
||||
// Simulate the atomic check-take-verify sequence from the fixed code:
|
||||
// take the approval, then verify request_id, and restore on mismatch.
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
let taken = thread.take_pending_approval().unwrap();
|
||||
assert!(thread.pending_approval.is_none(), "take should clear it");
|
||||
|
||||
// Wrong request_id -- restore atomically (within same "lock scope" in production)
|
||||
assert_ne!(wrong_request_id, taken.request_id);
|
||||
thread.await_approval(taken);
|
||||
|
||||
// Verify the pending approval is fully restored
|
||||
assert_eq!(
|
||||
session.threads[&thread_id].state,
|
||||
ThreadState::AwaitingApproval
|
||||
);
|
||||
let restored = session.threads[&thread_id]
|
||||
.pending_approval
|
||||
.as_ref()
|
||||
.expect("pending approval must be restored after request_id mismatch");
|
||||
assert_eq!(restored.request_id, correct_request_id);
|
||||
assert_eq!(restored.tool_name, "shell");
|
||||
assert_eq!(restored.tool_call_id, "call_42");
|
||||
}
|
||||
|
||||
/// Regression test for #1486: verify that approval with "always" flag
|
||||
/// correctly transitions thread state and registers auto-approve in a
|
||||
/// single lock scope.
|
||||
#[test]
|
||||
fn test_approval_with_always_sets_auto_approve_and_processing() {
|
||||
use crate::agent::session::{PendingApproval, Session, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let request_id = Uuid::new_v4();
|
||||
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let pending = PendingApproval {
|
||||
request_id,
|
||||
tool_name: "web_fetch".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
display_parameters: serde_json::json!({"url": "https://example.com"}),
|
||||
description: "Fetch a web page".to_string(),
|
||||
tool_call_id: "call_99".to_string(),
|
||||
context_messages: vec![],
|
||||
deferred_tool_calls: vec![],
|
||||
user_timezone: None,
|
||||
allow_always: true,
|
||||
};
|
||||
thread.await_approval(pending);
|
||||
|
||||
let mut session = Session::new("test-user");
|
||||
session.threads.insert(thread_id, thread);
|
||||
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition
|
||||
// Simulate the combined lock scope: take + verify + auto_approve + state transition.
|
||||
// Use separate scopes to mirror the borrow pattern in production code.
|
||||
let taken = {
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.take_pending_approval().unwrap()
|
||||
};
|
||||
|
||||
// Request ID matches
|
||||
assert_eq!(taken.request_id, request_id);
|
||||
|
||||
// Auto-approve the tool (thread borrow dropped, so session is accessible)
|
||||
session.auto_approve_tool(&taken.tool_name);
|
||||
|
||||
// Re-borrow thread and set state to Processing
|
||||
{
|
||||
let thread = session.threads.get_mut(&thread_id).unwrap();
|
||||
thread.state = ThreadState::Processing;
|
||||
}
|
||||
|
||||
// Verify both mutations happened atomically
|
||||
assert!(session.is_tool_auto_approved("web_fetch"));
|
||||
assert_eq!(session.threads[&thread_id].state, ThreadState::Processing);
|
||||
// Pending approval should be consumed (not restored)
|
||||
assert!(session.threads[&thread_id].pending_approval.is_none());
|
||||
}
|
||||
|
||||
// Helper function to extract the approval message without needing a full Agent instance
|
||||
fn extract_approval_message(
|
||||
session: &crate::agent::session::Session,
|
||||
|
||||
+8
-64
@@ -25,7 +25,7 @@ use crate::tools::ToolRegistry;
|
||||
use crate::tools::mcp::{McpProcessManager, McpSessionManager};
|
||||
use crate::tools::wasm::SharedCredentialRegistry;
|
||||
use crate::tools::wasm::WasmToolRuntime;
|
||||
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, Workspace};
|
||||
use crate::workspace::{EmbeddingProvider, Workspace};
|
||||
|
||||
/// Fully initialized application components, ready for channel wiring
|
||||
/// and agent construction.
|
||||
@@ -312,23 +312,12 @@ impl AppBuilder {
|
||||
.create_provider(&self.config.llm.nearai.base_url, self.session.clone());
|
||||
|
||||
// Register memory tools if database is available
|
||||
let workspace_user_id = self
|
||||
.config
|
||||
.channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|gw| gw.user_id.as_str())
|
||||
.unwrap_or("default");
|
||||
let workspace = if let Some(ref db) = self.db {
|
||||
let emb_cache_config = EmbeddingCacheConfig {
|
||||
max_entries: self.config.embeddings.cache_size,
|
||||
};
|
||||
let mut ws = Workspace::new_with_db(workspace_user_id, db.clone())
|
||||
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
|
||||
.with_search_config(&self.config.search);
|
||||
if let Some(ref emb) = embeddings {
|
||||
ws = ws.with_embeddings_cached(emb.clone(), emb_cache_config);
|
||||
ws = ws.with_embeddings(emb.clone());
|
||||
}
|
||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||
let ws = Arc::new(ws);
|
||||
tools.register_memory_tools(Arc::clone(&ws));
|
||||
Some(ws)
|
||||
@@ -536,7 +525,7 @@ impl AppBuilder {
|
||||
server_name,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -553,10 +542,6 @@ impl AppBuilder {
|
||||
tool_count,
|
||||
server_name
|
||||
);
|
||||
return Some((
|
||||
server_name,
|
||||
Arc::new(client),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
@@ -587,27 +572,14 @@ impl AppBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
}
|
||||
|
||||
let mut startup_clients = Vec::new();
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok(Some(client_pair)) => {
|
||||
startup_clients.push(client_pair);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
if e.is_panic() {
|
||||
tracing::error!("MCP server loading task panicked: {}", e);
|
||||
} else {
|
||||
tracing::warn!("MCP server loading task failed: {}", e);
|
||||
}
|
||||
}
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("MCP server loading task panicked: {}", e);
|
||||
}
|
||||
}
|
||||
return startup_clients;
|
||||
}
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
@@ -625,12 +597,10 @@ impl AppBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let (dev_loaded_tool_names, startup_mcp_clients) =
|
||||
tokio::join!(wasm_tools_future, mcp_servers_future);
|
||||
let (dev_loaded_tool_names, _) = tokio::join!(wasm_tools_future, mcp_servers_future);
|
||||
|
||||
// Load registry catalog entries for extension discovery
|
||||
let mut catalog_entries = match crate::registry::RegistryCatalog::load_or_embedded() {
|
||||
@@ -692,17 +662,6 @@ impl AppBuilder {
|
||||
));
|
||||
tools.register_extension_tools(Arc::clone(&manager));
|
||||
tracing::debug!("Extension manager initialized with in-chat discovery tools");
|
||||
|
||||
if !startup_mcp_clients.is_empty() {
|
||||
tracing::info!(
|
||||
count = startup_mcp_clients.len(),
|
||||
"Injecting startup MCP clients into extension manager"
|
||||
);
|
||||
for (name, client) in startup_mcp_clients {
|
||||
manager.inject_mcp_client(name, client).await;
|
||||
}
|
||||
}
|
||||
|
||||
Some(manager)
|
||||
};
|
||||
|
||||
@@ -732,11 +691,7 @@ impl AppBuilder {
|
||||
// Post-init validation: if a non-nearai backend was selected but
|
||||
// credentials were never resolved (deferred resolution found no keys),
|
||||
// fail early with a clear error instead of a confusing runtime failure.
|
||||
if self.config.llm.backend != "nearai"
|
||||
&& self.config.llm.backend != "bedrock"
|
||||
&& self.config.llm.backend != "openai_codex"
|
||||
&& self.config.llm.provider.is_none()
|
||||
{
|
||||
if self.config.llm.backend != "nearai" && self.config.llm.provider.is_none() {
|
||||
let backend = &self.config.llm.backend;
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND={backend} is configured but no credentials were found. \
|
||||
@@ -765,17 +720,6 @@ impl AppBuilder {
|
||||
dev_loaded_tool_names,
|
||||
) = self.init_extensions(&tools, &hooks).await?;
|
||||
|
||||
// Load bootstrap-completed flag from settings so that existing users
|
||||
// who already completed onboarding don't re-get bootstrap injection.
|
||||
if let Some(ref ws) = workspace {
|
||||
let toml_path = crate::settings::Settings::default_toml_path();
|
||||
if let Ok(Some(settings)) = crate::settings::Settings::load_toml(&toml_path)
|
||||
&& settings.profile_onboarding_completed
|
||||
{
|
||||
ws.mark_bootstrap_completed();
|
||||
}
|
||||
}
|
||||
|
||||
// Seed workspace and backfill embeddings
|
||||
if let Some(ref ws) = workspace {
|
||||
// Import workspace files from disk FIRST if WORKSPACE_IMPORT_DIR is set.
|
||||
|
||||
@@ -305,11 +305,6 @@ pub enum StatusUpdate {
|
||||
tool_name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
/// When `true`, the UI should offer an "always" option that auto-approves
|
||||
/// future calls to this tool for the rest of the session. When `false`
|
||||
/// (i.e. `ApprovalRequirement::Always`), the tool must be approved every
|
||||
/// time and the "always" button should be hidden.
|
||||
allow_always: bool,
|
||||
},
|
||||
/// Extension needs user authentication (token or OAuth).
|
||||
AuthRequired {
|
||||
|
||||
@@ -239,11 +239,6 @@ impl ChannelManager {
|
||||
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||
self.channels.read().await.get(name).cloned()
|
||||
}
|
||||
|
||||
/// Remove a channel from the manager.
|
||||
pub async fn remove(&self, name: &str) -> Option<Arc<dyn Channel>> {
|
||||
self.channels.write().await.remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelManager {
|
||||
|
||||
+383
-193
@@ -1,16 +1,16 @@
|
||||
//! Channel trait implementation for channel-relay webhook callbacks.
|
||||
//! Channel trait implementation for channel-relay SSE streams.
|
||||
//!
|
||||
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
|
||||
//! (pushed through an mpsc channel by the webhook handler), converts them
|
||||
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
|
||||
//! proxy API (Slack).
|
||||
//! `RelayChannel` connects to a channel-relay service via SSE, converts
|
||||
//! incoming events to `IncomingMessage`s, and sends responses via the
|
||||
//! relay's provider-specific proxy API (Slack).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
|
||||
use crate::channels::relay::client::{ChannelEvent, RelayClient};
|
||||
use crate::channels::relay::client::{RelayClient, RelayError};
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
@@ -39,34 +39,44 @@ impl RelayProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Channel implementation that receives events from channel-relay via webhook callbacks.
|
||||
/// Channel implementation that connects to a channel-relay SSE stream.
|
||||
pub struct RelayChannel {
|
||||
client: RelayClient,
|
||||
provider: RelayProvider,
|
||||
stream_token: Arc<RwLock<String>>,
|
||||
team_id: String,
|
||||
instance_id: String,
|
||||
/// Sender side of the event channel — shared with the webhook handler.
|
||||
event_tx: mpsc::Sender<ChannelEvent>,
|
||||
/// Receiver side — taken once by `start()`.
|
||||
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
|
||||
user_id: String,
|
||||
/// SSE stream long-poll timeout in seconds.
|
||||
stream_timeout_secs: u64,
|
||||
/// Initial exponential backoff in milliseconds.
|
||||
backoff_initial_ms: u64,
|
||||
/// Maximum exponential backoff in milliseconds.
|
||||
backoff_max_ms: u64,
|
||||
/// Handle to the reconnect task for clean shutdown.
|
||||
reconnect_handle: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
||||
/// Handle to the SSE parser task for clean shutdown.
|
||||
parser_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
/// Maximum consecutive reconnect failures before giving up.
|
||||
max_consecutive_failures: u64,
|
||||
}
|
||||
|
||||
impl RelayChannel {
|
||||
/// Create a new relay channel for Slack (default provider).
|
||||
pub fn new(
|
||||
client: RelayClient,
|
||||
stream_token: String,
|
||||
team_id: String,
|
||||
instance_id: String,
|
||||
event_tx: mpsc::Sender<ChannelEvent>,
|
||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
||||
user_id: String,
|
||||
) -> Self {
|
||||
Self::new_with_provider(
|
||||
client,
|
||||
RelayProvider::Slack,
|
||||
stream_token,
|
||||
team_id,
|
||||
instance_id,
|
||||
event_tx,
|
||||
event_rx,
|
||||
user_id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,24 +84,44 @@ impl RelayChannel {
|
||||
pub fn new_with_provider(
|
||||
client: RelayClient,
|
||||
provider: RelayProvider,
|
||||
stream_token: String,
|
||||
team_id: String,
|
||||
instance_id: String,
|
||||
event_tx: mpsc::Sender<ChannelEvent>,
|
||||
event_rx: mpsc::Receiver<ChannelEvent>,
|
||||
user_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
provider,
|
||||
stream_token: Arc::new(RwLock::new(stream_token)),
|
||||
team_id,
|
||||
instance_id,
|
||||
event_tx,
|
||||
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
|
||||
user_id,
|
||||
stream_timeout_secs: 86400,
|
||||
backoff_initial_ms: 1000,
|
||||
backoff_max_ms: 60000,
|
||||
reconnect_handle: RwLock::new(None),
|
||||
parser_handle: Arc::new(RwLock::new(None)),
|
||||
max_consecutive_failures: 50,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a clone of the event sender for wiring into the webhook endpoint.
|
||||
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
|
||||
self.event_tx.clone()
|
||||
/// Set backoff/timeout parameters from relay config values.
|
||||
pub fn with_timeouts(
|
||||
mut self,
|
||||
stream_timeout_secs: u64,
|
||||
backoff_initial_ms: u64,
|
||||
backoff_max_ms: u64,
|
||||
) -> Self {
|
||||
self.stream_timeout_secs = stream_timeout_secs;
|
||||
self.backoff_initial_ms = backoff_initial_ms;
|
||||
self.backoff_max_ms = backoff_max_ms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the maximum number of consecutive reconnect failures before giving up.
|
||||
pub fn with_max_failures(mut self, max: u64) -> Self {
|
||||
self.max_consecutive_failures = max;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build a provider-appropriate proxy body for sending a message.
|
||||
@@ -121,9 +151,15 @@ impl RelayChannel {
|
||||
team_id: &str,
|
||||
method: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
|
||||
) -> Result<serde_json::Value, RelayError> {
|
||||
self.client
|
||||
.proxy_provider(self.provider.as_str(), team_id, method, body)
|
||||
.proxy_provider(
|
||||
self.provider.as_str(),
|
||||
team_id,
|
||||
method,
|
||||
body,
|
||||
Some(&self.instance_id),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -136,83 +172,205 @@ impl Channel for RelayChannel {
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let channel_name = self.name().to_string();
|
||||
let token = self.stream_token.read().await.clone();
|
||||
let (stream, initial_parser_handle) = self
|
||||
.client
|
||||
.connect_stream(&token, self.stream_timeout_secs)
|
||||
.await
|
||||
.map_err(|e| ChannelError::StartupFailed {
|
||||
name: channel_name.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Take the receiver (can only start once)
|
||||
let mut event_rx =
|
||||
self.event_rx
|
||||
.lock()
|
||||
.await
|
||||
.take()
|
||||
.ok_or_else(|| ChannelError::StartupFailed {
|
||||
name: channel_name.clone(),
|
||||
reason: "RelayChannel already started".to_string(),
|
||||
})?;
|
||||
*self.parser_handle.write().await = Some(initial_parser_handle);
|
||||
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
|
||||
// Spawn the stream reader + reconnect task
|
||||
let client = self.client.clone();
|
||||
let stream_token = Arc::clone(&self.stream_token);
|
||||
let instance_id = self.instance_id.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
let team_id = self.team_id.clone();
|
||||
let stream_timeout_secs = self.stream_timeout_secs;
|
||||
let backoff_initial_ms = self.backoff_initial_ms;
|
||||
let backoff_max_ms = self.backoff_max_ms;
|
||||
let max_consecutive_failures = self.max_consecutive_failures;
|
||||
let parser_handle = Arc::clone(&self.parser_handle);
|
||||
let provider_str = self.provider.as_str().to_string();
|
||||
let relay_name = channel_name.clone();
|
||||
|
||||
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
// Validate required fields
|
||||
if event.sender_id.is_empty()
|
||||
|| event.channel_id.is_empty()
|
||||
|| event.provider_scope.is_empty()
|
||||
{
|
||||
tracing::debug!(
|
||||
let handle = tokio::spawn(async move {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut current_stream = stream;
|
||||
let mut backoff_ms = backoff_initial_ms;
|
||||
let mut consecutive_failures: u64 = 0;
|
||||
|
||||
loop {
|
||||
// Read events from the current stream
|
||||
while let Some(event) = current_stream.next().await {
|
||||
// Reset backoff and failure count on successful event
|
||||
backoff_ms = backoff_initial_ms;
|
||||
consecutive_failures = 0;
|
||||
|
||||
// Validate required fields
|
||||
if event.sender_id.is_empty()
|
||||
|| event.channel_id.is_empty()
|
||||
|| event.provider_scope.is_empty()
|
||||
{
|
||||
tracing::debug!(
|
||||
event_type = %event.event_type,
|
||||
sender_id = %event.sender_id,
|
||||
channel_id = %event.channel_id,
|
||||
"Relay: skipping event with missing required fields"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-message events
|
||||
if !event.is_message() {
|
||||
tracing::debug!(
|
||||
event_type = %event.event_type,
|
||||
"Relay: skipping non-message event"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
event_type = %event.event_type,
|
||||
sender_id = %event.sender_id,
|
||||
channel_id = %event.channel_id,
|
||||
"Relay: skipping event with missing required fields"
|
||||
sender = %event.sender_id,
|
||||
channel = %event.channel_id,
|
||||
provider = %provider_str,
|
||||
"Relay: received message from {}", provider_str
|
||||
);
|
||||
continue;
|
||||
|
||||
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
|
||||
.with_user_name(event.display_name())
|
||||
.with_metadata(serde_json::json!({
|
||||
"team_id": event.team_id(),
|
||||
"channel_id": event.channel_id,
|
||||
"sender_id": event.sender_id,
|
||||
"sender_name": event.display_name(),
|
||||
"event_type": event.event_type,
|
||||
"thread_id": event.thread_id,
|
||||
"provider": event.provider,
|
||||
}));
|
||||
|
||||
let msg = if let Some(ref thread_id) = event.thread_id {
|
||||
msg.with_thread(thread_id)
|
||||
} else {
|
||||
msg.with_thread(&event.channel_id)
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
tracing::info!("Relay channel receiver dropped, stopping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip non-message events
|
||||
if !event.is_message() {
|
||||
tracing::debug!(
|
||||
event_type = %event.event_type,
|
||||
"Relay: skipping non-message event"
|
||||
// Stream ended, attempt reconnect with backoff
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= max_consecutive_failures {
|
||||
tracing::error!(
|
||||
channel = %relay_name,
|
||||
failures = consecutive_failures,
|
||||
"Relay channel giving up after {} consecutive failures",
|
||||
consecutive_failures
|
||||
);
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
event_type = %event.event_type,
|
||||
sender = %event.sender_id,
|
||||
channel = %event.channel_id,
|
||||
provider = %provider_str,
|
||||
"Relay: received message from {}", provider_str
|
||||
tracing::warn!(
|
||||
backoff_ms = backoff_ms,
|
||||
failures = consecutive_failures,
|
||||
"Relay SSE stream ended, reconnecting..."
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
|
||||
backoff_ms = (backoff_ms * 2).min(backoff_max_ms);
|
||||
|
||||
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
|
||||
.with_user_name(event.display_name())
|
||||
.with_metadata(serde_json::json!({
|
||||
"team_id": event.team_id(),
|
||||
"channel_id": event.channel_id,
|
||||
"sender_id": event.sender_id,
|
||||
"sender_name": event.display_name(),
|
||||
"event_type": event.event_type,
|
||||
"thread_id": event.thread_id,
|
||||
"provider": event.provider,
|
||||
}));
|
||||
// Try to reconnect
|
||||
let token = stream_token.read().await.clone();
|
||||
match client.connect_stream(&token, stream_timeout_secs).await {
|
||||
Ok((new_stream, new_parser)) => {
|
||||
tracing::info!("Relay SSE stream reconnected");
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
// Abort old parser before replacing
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
old.abort();
|
||||
}
|
||||
*parser_handle.write().await = Some(new_parser);
|
||||
}
|
||||
Err(RelayError::TokenExpired) => {
|
||||
// Attempt token renewal
|
||||
tracing::info!("Relay stream token expired, renewing...");
|
||||
match client.renew_token(&instance_id, &user_id).await {
|
||||
Ok(new_token) => {
|
||||
*stream_token.write().await = new_token.clone();
|
||||
match client.connect_stream(&new_token, stream_timeout_secs).await {
|
||||
Ok((new_stream, new_parser)) => {
|
||||
tracing::info!(
|
||||
"Relay SSE stream reconnected with new token"
|
||||
);
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
old.abort();
|
||||
}
|
||||
*parser_handle.write().await = Some(new_parser);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Failed to reconnect after token renewal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Failed to renew relay stream token"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to reconnect relay SSE stream");
|
||||
}
|
||||
}
|
||||
|
||||
let msg = if let Some(ref thread_id) = event.thread_id {
|
||||
msg.with_thread(thread_id)
|
||||
} else {
|
||||
msg.with_thread(&event.channel_id)
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
tracing::info!("Relay channel receiver dropped, stopping");
|
||||
return;
|
||||
// Check if the team is still valid (skip when team_id is unknown,
|
||||
// e.g. when no DB store was available at activation time)
|
||||
if !team_id.is_empty() {
|
||||
match client.list_connections(&instance_id).await {
|
||||
Ok(conns) => {
|
||||
let has_team =
|
||||
conns.iter().any(|c| c.team_id == team_id && c.connected);
|
||||
if !has_team {
|
||||
tracing::warn!(
|
||||
team_id = %team_id,
|
||||
"Team no longer connected, stopping relay channel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Could not verify team connection, will retry next iteration"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Relay event channel closed");
|
||||
});
|
||||
|
||||
*self.reconnect_handle.write().await = Some(handle);
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
@@ -265,7 +423,6 @@ impl Channel for RelayChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always: _,
|
||||
} = status
|
||||
else {
|
||||
return Ok(());
|
||||
@@ -293,24 +450,28 @@ impl Channel for RelayChannel {
|
||||
name: self.name().to_string(),
|
||||
reason: "Missing channel_id for approval buttons".into(),
|
||||
})?;
|
||||
let sender_id = metadata
|
||||
.get("sender_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ChannelError::SendFailed {
|
||||
name: self.name().to_string(),
|
||||
reason: "Missing sender_id for approval buttons".into(),
|
||||
})?;
|
||||
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
|
||||
let team_id = metadata
|
||||
.get("team_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&self.team_id);
|
||||
|
||||
// Register server-side approval record and get opaque token.
|
||||
// The button value contains ONLY the token — no routing fields.
|
||||
let approval_token = self
|
||||
.client
|
||||
.create_approval(team_id, channel_id, thread_id, &request_id)
|
||||
.await
|
||||
.map_err(|e| ChannelError::SendFailed {
|
||||
name: self.name().to_string(),
|
||||
reason: format!("Failed to register approval: {e}"),
|
||||
})?;
|
||||
// Button value payload (Slack limits button values to 2000 chars;
|
||||
// safe with typical UUIDs but documented here as a constraint)
|
||||
let value_payload = serde_json::json!({
|
||||
"approval_token": approval_token,
|
||||
"instance_id": self.instance_id,
|
||||
"team_id": team_id,
|
||||
"channel_id": channel_id,
|
||||
"thread_ts": thread_id,
|
||||
"request_id": request_id,
|
||||
"sender_id": sender_id,
|
||||
});
|
||||
let value_str = value_payload.to_string();
|
||||
|
||||
@@ -421,8 +582,12 @@ impl Channel for RelayChannel {
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<(), ChannelError> {
|
||||
// Relay cleanup is driven by the extension manager dropping the shared
|
||||
// sender and removing the channel from the channel manager.
|
||||
if let Some(handle) = self.reconnect_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
if let Some(handle) = self.parser_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -440,20 +605,27 @@ mod tests {
|
||||
.expect("client")
|
||||
}
|
||||
|
||||
fn make_channel() -> RelayChannel {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_channel_name() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_context_extracts_metadata() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"sender_name": "bob",
|
||||
@@ -468,6 +640,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn metadata_shape_includes_event_type_and_sender_name() {
|
||||
// Regression: metadata JSON must include event_type and sender_name
|
||||
// for downstream routing (DM vs channel) and conversation_context().
|
||||
let metadata = serde_json::json!({
|
||||
"team_id": "T123",
|
||||
"channel_id": "C456",
|
||||
@@ -477,19 +651,43 @@ mod tests {
|
||||
"thread_id": null,
|
||||
"provider": "slack",
|
||||
});
|
||||
// event_type must be present for DM-vs-channel routing
|
||||
assert_eq!(
|
||||
metadata.get("event_type").and_then(|v| v.as_str()),
|
||||
Some("direct_message")
|
||||
);
|
||||
// sender_name must be present for conversation_context
|
||||
assert_eq!(
|
||||
metadata.get("sender_name").and_then(|v| v.as_str()),
|
||||
Some("alice")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_timeouts_sets_values() {
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
)
|
||||
.with_timeouts(43200, 2000, 120000);
|
||||
|
||||
assert_eq!(channel.stream_timeout_secs, 43200);
|
||||
assert_eq!(channel.backoff_initial_ms, 2000);
|
||||
assert_eq!(channel.backoff_max_ms, 120000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_send_body_slack() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
|
||||
assert_eq!(method, "chat.postMessage");
|
||||
assert_eq!(body["channel"], "C456");
|
||||
@@ -497,95 +695,72 @@ mod tests {
|
||||
assert_eq!(body["thread_ts"], "1234567.890");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_processes_events() {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let channel =
|
||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
||||
|
||||
let mut stream = channel.start().await.unwrap();
|
||||
|
||||
// Send an event
|
||||
tx.send(ChannelEvent {
|
||||
id: "1".into(),
|
||||
event_type: "message".into(),
|
||||
provider: "slack".into(),
|
||||
provider_scope: "T123".into(),
|
||||
channel_id: "C456".into(),
|
||||
sender_id: "U789".into(),
|
||||
sender_name: Some("alice".into()),
|
||||
content: Some("hello".into()),
|
||||
thread_id: None,
|
||||
raw: serde_json::Value::Null,
|
||||
timestamp: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.content, "hello");
|
||||
assert_eq!(msg.user_id, "U789");
|
||||
#[test]
|
||||
fn parser_handle_is_shared_arc() {
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
// parser_handle should be an Arc — cloning should give a second reference
|
||||
let handle_clone = Arc::clone(&channel.parser_handle);
|
||||
// Both point to the same allocation
|
||||
assert!(Arc::ptr_eq(&channel.parser_handle, &handle_clone));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_skips_non_message_events() {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let channel =
|
||||
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
||||
#[test]
|
||||
fn with_max_failures_sets_value() {
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
)
|
||||
.with_max_failures(10);
|
||||
|
||||
let mut stream = channel.start().await.unwrap();
|
||||
assert_eq!(channel.max_consecutive_failures, 10);
|
||||
}
|
||||
|
||||
// Send a non-message event (should be skipped)
|
||||
tx.send(ChannelEvent {
|
||||
id: "1".into(),
|
||||
event_type: "reaction".into(),
|
||||
provider: "slack".into(),
|
||||
provider_scope: "T123".into(),
|
||||
channel_id: "C456".into(),
|
||||
sender_id: "U789".into(),
|
||||
sender_name: None,
|
||||
content: None,
|
||||
thread_id: None,
|
||||
raw: serde_json::Value::Null,
|
||||
timestamp: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
#[test]
|
||||
fn default_max_failures_is_50() {
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
assert_eq!(channel.max_consecutive_failures, 50);
|
||||
}
|
||||
|
||||
// Send a real message
|
||||
tx.send(ChannelEvent {
|
||||
id: "2".into(),
|
||||
event_type: "message".into(),
|
||||
provider: "slack".into(),
|
||||
provider_scope: "T123".into(),
|
||||
channel_id: "C456".into(),
|
||||
sender_id: "U789".into(),
|
||||
sender_name: None,
|
||||
content: Some("real message".into()),
|
||||
thread_id: None,
|
||||
raw: serde_json::Value::Null,
|
||||
timestamp: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(msg.content, "real message");
|
||||
#[test]
|
||||
fn empty_team_id_accepted_at_construction() {
|
||||
// Regression: empty team_id (when no DB store is available) must not
|
||||
// prevent channel construction or cause immediate shutdown.
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
String::new(), // empty team_id
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
assert_eq!(channel.team_id, "");
|
||||
// The reconnect loop now skips team validation when team_id is empty,
|
||||
// so the channel remains alive.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_status_non_approval_is_noop() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
let metadata = serde_json::json!({});
|
||||
let result = channel
|
||||
.send_status(
|
||||
@@ -600,7 +775,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_status_approval_non_dm_skips() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
let metadata = serde_json::json!({
|
||||
"event_type": "message",
|
||||
"channel_id": "C456",
|
||||
@@ -613,7 +794,6 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -624,7 +804,13 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_status_approval_dm_missing_channel_id_errors() {
|
||||
let channel = make_channel();
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
let metadata = serde_json::json!({
|
||||
"event_type": "direct_message",
|
||||
"sender_id": "U789",
|
||||
@@ -636,7 +822,6 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -650,8 +835,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
|
||||
let channel = make_channel();
|
||||
async fn test_send_status_approval_dm_missing_sender_id_errors() {
|
||||
let channel = RelayChannel::new(
|
||||
test_client(),
|
||||
"token".into(),
|
||||
"T123".into(),
|
||||
"inst1".into(),
|
||||
"user1".into(),
|
||||
);
|
||||
let metadata = serde_json::json!({
|
||||
"event_type": "direct_message",
|
||||
"channel_id": "C456",
|
||||
@@ -663,7 +854,6 @@ mod tests {
|
||||
tool_name: "shell".into(),
|
||||
description: "run command".into(),
|
||||
parameters: serde_json::json!({}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -671,8 +861,8 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
!err.contains("sender_id"),
|
||||
"sender_id should not be required anymore, got: {err}"
|
||||
err.contains("sender_id"),
|
||||
"expected sender_id error, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+206
-91
@@ -1,10 +1,15 @@
|
||||
//! HTTP client for the channel-relay service.
|
||||
//!
|
||||
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
|
||||
//! approvals, signing-secret fetch, and Slack API proxy.
|
||||
//! SSE streaming, token renewal, and Slack API proxy.
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use futures::Stream;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Known relay event types.
|
||||
pub mod event_types {
|
||||
@@ -13,7 +18,7 @@ pub mod event_types {
|
||||
pub const MENTION: &str = "mention";
|
||||
}
|
||||
|
||||
/// A parsed event from the channel-relay webhook callback.
|
||||
/// A parsed SSE event from the channel-relay stream.
|
||||
///
|
||||
/// Field names match the channel-relay `ChannelEvent` struct exactly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -118,19 +123,21 @@ impl RelayClient {
|
||||
///
|
||||
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
|
||||
/// returns the `Location` header (Slack OAuth URL) without following it.
|
||||
/// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
|
||||
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
||||
/// for validating the callback — no URLs.
|
||||
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
||||
let mut query: Vec<(&str, &str)> = vec![];
|
||||
if let Some(nonce) = state_nonce {
|
||||
query.push(("state_nonce", nonce));
|
||||
}
|
||||
pub async fn initiate_oauth(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
user_id: &str,
|
||||
callback_url: &str,
|
||||
) -> Result<String, RelayError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/oauth/slack/auth", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.header("X-API-Key", self.api_key.expose_secret())
|
||||
.query(&[
|
||||
("instance_id", instance_id),
|
||||
("user_id", user_id),
|
||||
("callback", callback_url),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
@@ -166,69 +173,104 @@ impl RelayClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a pending approval and return the opaque approval token.
|
||||
/// Connect to the SSE event stream.
|
||||
///
|
||||
/// Calls `POST /approvals` with the target team/channel/request identifiers.
|
||||
/// The returned token is embedded in Slack button values instead of routing fields.
|
||||
/// The relay derives the authorized approver from the connection's authed_user_id.
|
||||
pub async fn create_approval(
|
||||
/// Returns a stream of parsed `ChannelEvent`s and the `JoinHandle` of the
|
||||
/// background SSE parser task. The caller is responsible for reconnection
|
||||
/// logic on stream end/error and for aborting the handle on shutdown.
|
||||
pub async fn connect_stream(
|
||||
&self,
|
||||
team_id: &str,
|
||||
channel_id: &str,
|
||||
thread_ts: Option<&str>,
|
||||
request_id: &str,
|
||||
) -> Result<String, RelayError> {
|
||||
let mut body = serde_json::json!({
|
||||
"team_id": team_id,
|
||||
"channel_id": channel_id,
|
||||
"request_id": request_id,
|
||||
});
|
||||
if let Some(ts) = thread_ts {
|
||||
body["thread_ts"] = serde_json::Value::String(ts.to_string());
|
||||
}
|
||||
|
||||
stream_token: &str,
|
||||
stream_timeout_secs: u64,
|
||||
) -> Result<(ChannelEventStream, tokio::task::JoinHandle<()>), RelayError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/approvals", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.json(&body)
|
||||
.get(format!("{}/stream", self.base_url))
|
||||
.query(&[("token", stream_token)])
|
||||
.timeout(std::time::Duration::from_secs(stream_timeout_secs))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(RelayError::TokenExpired);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
status: status.as_u16(),
|
||||
message: body,
|
||||
});
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp
|
||||
// Spawn a background task that reads the SSE stream and sends parsed events
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let byte_stream = resp.bytes_stream();
|
||||
let handle = tokio::spawn(parse_sse_stream(byte_stream, tx));
|
||||
|
||||
Ok((ChannelEventStream { rx }, handle))
|
||||
}
|
||||
|
||||
/// Renew an expired stream token.
|
||||
///
|
||||
/// Calls `POST /stream/renew` with API key auth, returns a new stream token.
|
||||
pub async fn renew_token(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<String, RelayError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/stream/renew", self.base_url))
|
||||
.header("X-API-Key", self.api_key.expose_secret())
|
||||
.json(&serde_json::json!({
|
||||
"instance_id": instance_id,
|
||||
"user_id": user_id,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(RelayError::Api {
|
||||
status: status.as_u16(),
|
||||
message: body,
|
||||
});
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
||||
|
||||
result
|
||||
.get("approval_token")
|
||||
body.get("stream_token")
|
||||
.or_else(|| body.get("token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
|
||||
.ok_or_else(|| RelayError::Protocol("Response missing stream_token field".to_string()))
|
||||
}
|
||||
|
||||
/// Proxy an API call through channel-relay for any provider.
|
||||
///
|
||||
/// Calls `POST /proxy/{provider}/{method}?team_id=X&instance_id=Y` with the given JSON body.
|
||||
pub async fn proxy_provider(
|
||||
&self,
|
||||
provider: &str,
|
||||
team_id: &str,
|
||||
method: &str,
|
||||
body: serde_json::Value,
|
||||
instance_id: Option<&str>,
|
||||
) -> Result<serde_json::Value, RelayError> {
|
||||
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||
let mut query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
||||
if let Some(iid) = instance_id {
|
||||
query.push(("instance_id", iid));
|
||||
}
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/proxy/{}/{}", self.base_url, provider, method))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.header("X-API-Key", self.api_key.expose_secret())
|
||||
.query(&query)
|
||||
.json(&body)
|
||||
.send()
|
||||
@@ -249,58 +291,12 @@ impl RelayClient {
|
||||
.map_err(|e| RelayError::Protocol(e.to_string()))
|
||||
}
|
||||
|
||||
/// Fetch the per-instance callback signing secret from channel-relay.
|
||||
///
|
||||
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
|
||||
/// 32-byte secret. Called once at activation time; the result is cached in the
|
||||
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
||||
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/relay/signing-secret", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.query(&[("team_id", team_id)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| RelayError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(RelayError::Api {
|
||||
status,
|
||||
message: body,
|
||||
});
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
||||
|
||||
body.get("signing_secret")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
|
||||
.and_then(|raw| {
|
||||
let decoded = hex::decode(raw).map_err(|e| {
|
||||
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
|
||||
})?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(RelayError::Protocol(format!(
|
||||
"invalid signing_secret length: expected 32 bytes, got {}",
|
||||
decoded.len()
|
||||
)));
|
||||
}
|
||||
Ok(decoded)
|
||||
})
|
||||
}
|
||||
|
||||
/// List active connections for an instance.
|
||||
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/connections", self.base_url))
|
||||
.bearer_auth(self.api_key.expose_secret())
|
||||
.header("X-API-Key", self.api_key.expose_secret())
|
||||
.query(&[("instance_id", instance_id)])
|
||||
.send()
|
||||
.await
|
||||
@@ -321,6 +317,91 @@ impl RelayClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Async stream of parsed channel events from SSE.
|
||||
pub struct ChannelEventStream {
|
||||
rx: mpsc::Receiver<ChannelEvent>,
|
||||
}
|
||||
|
||||
impl Stream for ChannelEventStream {
|
||||
type Item = ChannelEvent;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
self.rx.poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse SSE format from a reqwest bytes stream.
|
||||
///
|
||||
/// SSE format:
|
||||
/// ```text
|
||||
/// event: message
|
||||
/// data: {"key": "value"}
|
||||
///
|
||||
/// ```
|
||||
/// Blank line terminates an event.
|
||||
async fn parse_sse_stream(
|
||||
byte_stream: impl futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send + 'static,
|
||||
tx: mpsc::Sender<ChannelEvent>,
|
||||
) {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut buffer = Vec::<u8>::new();
|
||||
let mut event_type = String::new();
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
let mut byte_stream = std::pin::pin!(byte_stream);
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "SSE stream chunk error");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
// Process complete lines (decode UTF-8 only on full lines to avoid
|
||||
// corruption when multi-byte characters span chunk boundaries)
|
||||
while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
|
||||
let line = String::from_utf8_lossy(&buffer[..newline_pos])
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
buffer.drain(..=newline_pos);
|
||||
|
||||
if line.is_empty() {
|
||||
// Blank line = end of event
|
||||
if !data_lines.is_empty() {
|
||||
let data = data_lines.join("\n");
|
||||
if let Ok(mut event) = serde_json::from_str::<ChannelEvent>(&data) {
|
||||
if event.event_type.is_empty() && !event_type.is_empty() {
|
||||
event.event_type = event_type.clone();
|
||||
}
|
||||
if tx.send(event).await.is_err() {
|
||||
return; // receiver dropped
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
event_type = %event_type,
|
||||
data_len = data.len(),
|
||||
"Failed to parse SSE event data as ChannelEvent"
|
||||
);
|
||||
}
|
||||
}
|
||||
event_type.clear();
|
||||
data_lines.clear();
|
||||
} else if let Some(value) = line.strip_prefix("event:") {
|
||||
event_type = value.trim().to_string();
|
||||
} else if let Some(value) = line.strip_prefix("data:") {
|
||||
data_lines.push(value.trim().to_string());
|
||||
}
|
||||
// Ignore other fields (id:, retry:, comments)
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("SSE stream ended");
|
||||
}
|
||||
|
||||
/// Errors from relay client operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RelayError {
|
||||
@@ -332,6 +413,9 @@ pub enum RelayError {
|
||||
|
||||
#[error("Protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
#[error("Stream token expired")]
|
||||
TokenExpired,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -410,6 +494,9 @@ mod tests {
|
||||
message: "unauthorized".into(),
|
||||
};
|
||||
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
|
||||
|
||||
let err = RelayError::TokenExpired;
|
||||
assert_eq!(err.to_string(), "Stream token expired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -431,4 +518,32 @@ mod tests {
|
||||
assert!(make(event_types::DIRECT_MESSAGE).is_message());
|
||||
assert!(make(event_types::MENTION).is_message());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_sse_handles_multibyte_utf8_across_chunks() {
|
||||
// The crab emoji (🦀) is 4 bytes: [0xF0, 0x9F, 0xA6, 0x80].
|
||||
// Split it across two chunks to verify no U+FFFD corruption.
|
||||
let event_json = r#"{"event_type":"message","content":"hello 🦀 world","provider_scope":"T1","channel_id":"C1","sender_id":"U1"}"#;
|
||||
let full = format!("event: message\ndata: {}\n\n", event_json);
|
||||
let bytes = full.as_bytes();
|
||||
|
||||
// Find the crab emoji and split mid-character
|
||||
let crab_pos = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
|
||||
.expect("crab emoji not found");
|
||||
let split_at = crab_pos + 2; // split in the middle of the 4-byte emoji
|
||||
|
||||
let chunk1 = bytes::Bytes::copy_from_slice(&bytes[..split_at]);
|
||||
let chunk2 = bytes::Bytes::copy_from_slice(&bytes[split_at..]);
|
||||
|
||||
let chunks: Vec<Result<bytes::Bytes, reqwest::Error>> = vec![Ok(chunk1), Ok(chunk2)];
|
||||
let stream = futures::stream::iter(chunks);
|
||||
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
parse_sse_stream(stream, tx).await;
|
||||
|
||||
let event = rx.recv().await.expect("should receive event");
|
||||
assert_eq!(event.text(), "hello 🦀 world");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! Channel-relay integration for connecting to external messaging platforms
|
||||
//! (Slack) via the channel-relay service.
|
||||
//!
|
||||
//! The relay service handles OAuth, credential storage, and webhook ingestion.
|
||||
//! IronClaw receives events via webhook callbacks and sends messages via the
|
||||
//! relay's proxy API.
|
||||
//! The relay service handles OAuth, credential storage, webhook ingestion,
|
||||
//! and SSE event streaming. IronClaw consumes the SSE stream and sends
|
||||
//! messages via the relay's proxy API.
|
||||
|
||||
pub mod channel;
|
||||
pub mod client;
|
||||
pub mod webhook;
|
||||
|
||||
pub use channel::{DEFAULT_RELAY_NAME, RelayChannel};
|
||||
pub use client::RelayClient;
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Shared relay webhook signature verification helpers.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify a relay callback HMAC signature.
|
||||
pub fn verify_relay_signature(
|
||||
secret: &[u8],
|
||||
timestamp: &str,
|
||||
body: &[u8],
|
||||
signature: &str,
|
||||
) -> bool {
|
||||
verify_signature(secret, timestamp, body, signature)
|
||||
}
|
||||
|
||||
fn verify_signature(secret: &[u8], timestamp: &str, body: &[u8], signature: &str) -> bool {
|
||||
let mut mac = match HmacSha256::new_from_slice(secret) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return false,
|
||||
};
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(body);
|
||||
let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
subtle::ConstantTimeEq::ct_eq(expected.as_bytes(), signature.as_bytes()).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_signature(secret: &[u8], timestamp: &str, body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
|
||||
mac.update(timestamp.as_bytes());
|
||||
mac.update(b".");
|
||||
mac.update(body);
|
||||
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_valid_signature() {
|
||||
let secret = b"test-secret";
|
||||
let body = b"hello";
|
||||
let ts = "1234567890";
|
||||
let sig = make_signature(secret, ts, body);
|
||||
assert!(verify_signature(secret, ts, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_wrong_secret_fails() {
|
||||
let body = b"hello";
|
||||
let ts = "1234567890";
|
||||
let sig = make_signature(b"correct", ts, body);
|
||||
assert!(!verify_signature(b"wrong", ts, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_tampered_body_fails() {
|
||||
let secret = b"secret";
|
||||
let ts = "1234567890";
|
||||
let sig = make_signature(secret, ts, b"original");
|
||||
assert!(!verify_signature(secret, ts, b"tampered", &sig));
|
||||
}
|
||||
}
|
||||
@@ -539,7 +539,6 @@ impl Channel for ReplChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => {
|
||||
let term_width = crossterm::terminal::size()
|
||||
.map(|(w, _)| w as usize)
|
||||
@@ -583,13 +582,9 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
|
||||
eprintln!(" \u{2502}");
|
||||
if allow_always {
|
||||
eprintln!(
|
||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||
);
|
||||
} else {
|
||||
eprintln!(" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[31mno\x1b[0m (n)");
|
||||
}
|
||||
eprintln!(
|
||||
" \u{2502} \x1b[32myes\x1b[0m (y) / \x1b[34malways\x1b[0m (a) / \x1b[31mno\x1b[0m (n)"
|
||||
);
|
||||
eprintln!(" {bot_border}");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
+3
-11
@@ -915,28 +915,20 @@ impl Channel for SignalChannel {
|
||||
tool_name,
|
||||
description: _,
|
||||
parameters,
|
||||
allow_always,
|
||||
} = &status
|
||||
&& let Some(target_str) = metadata.get("signal_target").and_then(|v| v.as_str())
|
||||
{
|
||||
let params_json = serde_json::to_string_pretty(parameters).unwrap_or_default();
|
||||
let always_line = if *allow_always {
|
||||
format!(
|
||||
"\n• `always` or `a` - Approve and auto-approve future {} requests",
|
||||
tool_name
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let message = format!(
|
||||
"⚠️ *Approval Required*\n\n\
|
||||
*Request ID:* `{}`\n\
|
||||
*Tool:* {}\n\
|
||||
*Parameters:*\n```\n{}\n```\n\n\
|
||||
Reply with:\n\
|
||||
• `yes` or `y` - Approve this request{}\n\
|
||||
• `yes` or `y` - Approve this request\n\
|
||||
• `always` or `a` - Approve and auto-approve future {} requests\n\
|
||||
• `no` or `n` - Deny",
|
||||
request_id, tool_name, params_json, always_line
|
||||
request_id, tool_name, params_json, tool_name
|
||||
);
|
||||
self.send_status_message(target_str, &message).await;
|
||||
}
|
||||
|
||||
@@ -2043,7 +2043,6 @@ impl WasmChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
..
|
||||
} => {
|
||||
// WASM channels (Telegram, Slack, etc.) cannot render
|
||||
@@ -2082,11 +2081,6 @@ impl WasmChannel {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let reply_hint = if *allow_always {
|
||||
"Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||
} else {
|
||||
"Reply \"yes\" to approve or \"no\" to deny."
|
||||
};
|
||||
let prompt = format!(
|
||||
"Approval needed: {tool_name}\n\
|
||||
{description}\n\
|
||||
@@ -2094,7 +2088,7 @@ impl WasmChannel {
|
||||
Parameters:\n\
|
||||
{params_preview}\n\
|
||||
\n\
|
||||
{reply_hint}"
|
||||
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||
);
|
||||
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
@@ -2987,23 +2981,15 @@ fn status_to_wit(
|
||||
request_id,
|
||||
tool_name,
|
||||
description,
|
||||
allow_always,
|
||||
..
|
||||
} => {
|
||||
let reply_hint = if *allow_always {
|
||||
"yes (or /approve), no (or /deny), or always (or /always)"
|
||||
} else {
|
||||
"yes (or /approve) or no (or /deny)"
|
||||
};
|
||||
wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: format!(
|
||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: {}.",
|
||||
tool_name, description, request_id, reply_hint
|
||||
),
|
||||
metadata_json,
|
||||
}
|
||||
}
|
||||
} => wit_channel::StatusUpdate {
|
||||
status: wit_channel::StatusType::ApprovalNeeded,
|
||||
message: format!(
|
||||
"Approval needed for tool '{}'. {}\nRequest ID: {}\nReply with: yes (or /approve), no (or /deny), or always (or /always).",
|
||||
tool_name, description, request_id
|
||||
),
|
||||
metadata_json,
|
||||
},
|
||||
StatusUpdate::JobStarted {
|
||||
job_id,
|
||||
title,
|
||||
@@ -3314,7 +3300,6 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::Channel;
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
@@ -3402,16 +3387,6 @@ mod tests {
|
||||
assert!(channel.health_check().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_delegates_to_call_on_broadcast() {
|
||||
let channel = create_test_channel();
|
||||
// With `component: None`, call_on_broadcast short-circuits to Ok(()).
|
||||
let result = channel
|
||||
.broadcast("146032821", OutgoingResponse::text("hello"))
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_poll_no_wasm_returns_empty() {
|
||||
// When there's no WASM module (None component), execute_poll
|
||||
@@ -3695,7 +3670,6 @@ mod tests {
|
||||
tool_name: "http_request".into(),
|
||||
description: "Fetch weather".into(),
|
||||
parameters: serde_json::json!({"url": "https://wttr.in"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -4157,7 +4131,6 @@ mod tests {
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
@@ -4183,7 +4156,6 @@ mod tests {
|
||||
tool_name: "http_request".to_string(),
|
||||
description: "Fetch weather data".to_string(),
|
||||
parameters: serde_json::json!({"url": "https://api.weather.test"}),
|
||||
allow_always: true,
|
||||
},
|
||||
&metadata,
|
||||
)
|
||||
|
||||
@@ -123,8 +123,25 @@ pub async fn memory_read_handler(
|
||||
}))
|
||||
}
|
||||
|
||||
// memory_write_handler lives in server.rs (layer-aware version with append,
|
||||
// privacy redirect, and proper error status codes).
|
||||
pub async fn memory_write_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Json(req): Json<MemoryWriteRequest>,
|
||||
) -> Result<Json<MemoryWriteResponse>, (StatusCode, String)> {
|
||||
let workspace = state.workspace.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
workspace
|
||||
.write(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn memory_search_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
|
||||
@@ -26,4 +26,3 @@ pub mod routines;
|
||||
pub mod settings;
|
||||
#[allow(dead_code)]
|
||||
pub mod static_files;
|
||||
pub mod webhooks;
|
||||
|
||||
@@ -303,9 +303,7 @@ fn routine_error_status(err: &RoutineError) -> StatusCode {
|
||||
match err {
|
||||
RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
|
||||
RoutineError::Disabled { .. }
|
||||
| RoutineError::Cooldown { .. }
|
||||
| RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
||||
RoutineError::Disabled { .. } | RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
//! Public webhook trigger endpoint for routine webhook triggers.
|
||||
//!
|
||||
//! `POST /api/webhooks/{path}` — matches the path against routines with
|
||||
//! `Trigger::Webhook { path, secret }`, validates the secret via constant-time
|
||||
//! comparison, and fires the matching routine through the `RoutineEngine`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::agent::routine::Trigger;
|
||||
use crate::channels::web::server::GatewayState;
|
||||
|
||||
/// Validate the webhook secret for a routine.
|
||||
///
|
||||
/// Returns `Ok(())` if the routine has a configured secret and the provided
|
||||
/// secret matches via constant-time comparison. Returns an appropriate HTTP
|
||||
/// error if the secret is missing (403) or invalid (401).
|
||||
fn validate_webhook_secret(
|
||||
trigger: &Trigger,
|
||||
provided_secret: &str,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
// Require webhook secret — routines without a secret cannot be triggered via webhook
|
||||
let expected_secret = match trigger {
|
||||
Trigger::Webhook {
|
||||
secret: Some(s), ..
|
||||
} => s,
|
||||
_ => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
"Webhook secret not configured for this routine. \
|
||||
Set a secret with: ironclaw routine update <id> --webhook-secret <secret>"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !bool::from(provided_secret.as_bytes().ct_eq(expected_secret.as_bytes())) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid webhook secret".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming webhook POST to `/api/webhooks/{path}`.
|
||||
///
|
||||
/// This endpoint is **public** (no gateway auth token required) but protected
|
||||
/// by the per-routine webhook secret sent via the `X-Webhook-Secret` header.
|
||||
pub async fn webhook_trigger_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(path): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// Rate limit check
|
||||
if !state.webhook_rate_limiter.check() {
|
||||
return Err((
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Rate limit exceeded. Try again shortly.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Targeted query instead of loading all routines
|
||||
let routine = store
|
||||
.get_webhook_routine_by_path(&path)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
"No routine matches this webhook path".to_string(),
|
||||
))?;
|
||||
|
||||
let provided_secret = headers
|
||||
.get("x-webhook-secret")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
validate_webhook_secret(&routine.trigger, provided_secret)?;
|
||||
|
||||
// Fire through the RoutineEngine so guardrails, run tracking,
|
||||
// notifications, and FullJob dispatch all work correctly.
|
||||
let engine = {
|
||||
let guard = state.routine_engine.read().await;
|
||||
guard.as_ref().cloned().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Routine engine not available".to_string(),
|
||||
))?
|
||||
};
|
||||
|
||||
let run_id = engine.fire_webhook(routine.id, &path).await.map_err(|e| {
|
||||
let status = match &e {
|
||||
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
crate::error::RoutineError::Disabled { .. }
|
||||
| crate::error::RoutineError::Cooldown { .. }
|
||||
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "triggered",
|
||||
"routine_id": routine.id,
|
||||
"routine_name": routine.name,
|
||||
"run_id": run_id,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Routines with `secret: None` must be rejected with 403.
|
||||
#[test]
|
||||
fn test_validate_rejects_missing_secret() {
|
||||
let trigger = Trigger::Webhook {
|
||||
path: Some("my-hook".to_string()),
|
||||
secret: None,
|
||||
};
|
||||
let result = validate_webhook_secret(&trigger, "any-secret");
|
||||
let (status, msg) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert!(
|
||||
msg.contains("not configured"),
|
||||
"Error should tell user to configure a secret, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Non-webhook triggers must be rejected with 403.
|
||||
#[test]
|
||||
fn test_validate_rejects_non_webhook_trigger() {
|
||||
let trigger = Trigger::Manual;
|
||||
let result = validate_webhook_secret(&trigger, "any-secret");
|
||||
let (status, _) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// Correct secret passes validation.
|
||||
#[test]
|
||||
fn test_validate_accepts_correct_secret() {
|
||||
let trigger = Trigger::Webhook {
|
||||
path: Some("my-hook".to_string()),
|
||||
secret: Some("s3cret-token".to_string()),
|
||||
};
|
||||
assert!(validate_webhook_secret(&trigger, "s3cret-token").is_ok());
|
||||
}
|
||||
|
||||
/// Wrong secret returns 401.
|
||||
#[test]
|
||||
fn test_validate_rejects_wrong_secret() {
|
||||
let trigger = Trigger::Webhook {
|
||||
path: Some("my-hook".to_string()),
|
||||
secret: Some("correct-secret".to_string()),
|
||||
};
|
||||
let result = validate_webhook_secret(&trigger, "wrong-secret");
|
||||
let (status, msg) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert!(msg.contains("Invalid"), "Expected 'Invalid' in: {msg}");
|
||||
}
|
||||
|
||||
/// Empty provided secret returns 401 (not a false positive).
|
||||
#[test]
|
||||
fn test_validate_rejects_empty_provided_secret() {
|
||||
let trigger = Trigger::Webhook {
|
||||
path: Some("my-hook".to_string()),
|
||||
secret: Some("real-secret".to_string()),
|
||||
};
|
||||
let result = validate_webhook_secret(&trigger, "");
|
||||
let (status, _) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
/// Constant-time comparison: secrets of different lengths are still rejected
|
||||
/// (not short-circuited in a way that leaks length info).
|
||||
#[test]
|
||||
fn test_validate_rejects_different_length_secret() {
|
||||
let trigger = Trigger::Webhook {
|
||||
path: None,
|
||||
secret: Some("short".to_string()),
|
||||
};
|
||||
let result = validate_webhook_secret(&trigger, "a-much-longer-secret-value");
|
||||
let (status, _) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,6 @@ impl GatewayChannel {
|
||||
skill_catalog: None,
|
||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
@@ -137,7 +136,6 @@ impl GatewayChannel {
|
||||
skill_catalog: self.state.skill_catalog.clone(),
|
||||
chat_rate_limiter: server::RateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||
registry_entries: self.state.registry_entries.clone(),
|
||||
cost_guard: self.state.cost_guard.clone(),
|
||||
routine_engine: Arc::clone(&self.state.routine_engine),
|
||||
@@ -376,7 +374,6 @@ impl Channel for GatewayChannel {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
allow_always,
|
||||
} => SseEvent::ApprovalNeeded {
|
||||
request_id,
|
||||
tool_name,
|
||||
@@ -384,7 +381,6 @@ impl Channel for GatewayChannel {
|
||||
parameters: serde_json::to_string_pretty(¶meters)
|
||||
.unwrap_or_else(|_| parameters.to_string()),
|
||||
thread_id,
|
||||
allow_always,
|
||||
},
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name,
|
||||
|
||||
+243
-300
@@ -19,7 +19,6 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_stream::StreamExt;
|
||||
use tower_http::cors::{AllowHeaders, CorsLayer};
|
||||
@@ -36,10 +35,7 @@ use crate::channels::web::handlers::jobs::{
|
||||
jobs_events_handler, jobs_list_handler, jobs_prompt_handler, jobs_restart_handler,
|
||||
jobs_summary_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::routines::{
|
||||
routines_delete_handler, routines_detail_handler, routines_list_handler,
|
||||
routines_summary_handler, routines_toggle_handler, routines_trigger_handler,
|
||||
};
|
||||
use crate::channels::web::handlers::routines::{routines_delete_handler, routines_toggle_handler};
|
||||
use crate::channels::web::handlers::skills::{
|
||||
skills_install_handler, skills_list_handler, skills_remove_handler, skills_search_handler,
|
||||
};
|
||||
@@ -67,16 +63,6 @@ pub type PromptQueue = Arc<
|
||||
pub type RoutineEngineSlot =
|
||||
Arc<tokio::sync::RwLock<Option<Arc<crate::agent::routine_engine::RoutineEngine>>>>;
|
||||
|
||||
fn redact_oauth_state_for_logs(state: &str) -> String {
|
||||
let digest = Sha256::digest(state.as_bytes());
|
||||
let mut short_hash = String::with_capacity(12);
|
||||
for byte in &digest[..6] {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut short_hash, "{byte:02x}");
|
||||
}
|
||||
format!("sha256:{short_hash}:len={}", state.len())
|
||||
}
|
||||
|
||||
/// Simple sliding-window rate limiter.
|
||||
///
|
||||
/// Tracks the number of requests in the current window. Resets when the window expires.
|
||||
@@ -190,8 +176,6 @@ pub struct GatewayState {
|
||||
pub chat_rate_limiter: RateLimiter,
|
||||
/// Rate limiter for OAuth callback endpoints (10 requests per 60 seconds).
|
||||
pub oauth_rate_limiter: RateLimiter,
|
||||
/// Rate limiter for webhook trigger endpoints (10 requests per 60 seconds).
|
||||
pub webhook_rate_limiter: RateLimiter,
|
||||
/// Registry catalog entries for the available extensions API.
|
||||
/// Populated at startup from `registry/` manifests, independent of extension manager.
|
||||
pub registry_entries: Vec<crate::extensions::RegistryEntry>,
|
||||
@@ -234,11 +218,6 @@ pub async fn start_server(
|
||||
.route(
|
||||
"/oauth/slack/callback",
|
||||
get(slack_relay_oauth_callback_handler),
|
||||
)
|
||||
.route("/relay/events", post(relay_events_handler))
|
||||
.route(
|
||||
"/api/webhooks/{path}",
|
||||
post(crate::channels::web::handlers::webhooks::webhook_trigger_handler),
|
||||
);
|
||||
|
||||
// Protected routes (require auth)
|
||||
@@ -350,7 +329,6 @@ pub async fn start_server(
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/theme-init.js", get(theme_init_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/i18n/index.js", get(i18n_index_handler))
|
||||
.route("/i18n/en.js", get(i18n_en_handler))
|
||||
@@ -472,16 +450,6 @@ async fn js_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn theme_init_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/theme-init.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn favicon_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
@@ -597,35 +565,22 @@ async fn oauth_callback_handler(
|
||||
}
|
||||
};
|
||||
|
||||
let decoded_state = match oauth_defaults::decode_hosted_oauth_state(&state_param) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => {
|
||||
let redacted_state = redact_oauth_state_for_logs(&state_param);
|
||||
tracing::warn!(
|
||||
state = %redacted_state,
|
||||
error = %error,
|
||||
"OAuth callback received with malformed state"
|
||||
);
|
||||
clear_auth_mode(&state).await;
|
||||
return oauth_error_page("IronClaw");
|
||||
}
|
||||
};
|
||||
let lookup_key = decoded_state.flow_id.clone();
|
||||
// Strip instance prefix from state for registry lookup.
|
||||
// Platform nginx sends `state=instance:nonce` but flows are keyed by nonce only.
|
||||
let lookup_key = oauth_defaults::strip_instance_prefix(&state_param);
|
||||
|
||||
let flow = ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.write()
|
||||
.await
|
||||
.remove(&lookup_key);
|
||||
.remove(lookup_key);
|
||||
|
||||
let flow = match flow {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
let redacted_state = redact_oauth_state_for_logs(&state_param);
|
||||
let redacted_lookup_key = redact_oauth_state_for_logs(&lookup_key);
|
||||
tracing::warn!(
|
||||
state = %redacted_state,
|
||||
lookup_key = %redacted_lookup_key,
|
||||
state = %state_param,
|
||||
lookup_key = %lookup_key,
|
||||
"OAuth callback received with unknown or expired state"
|
||||
);
|
||||
clear_auth_mode(&state).await;
|
||||
@@ -652,29 +607,33 @@ async fn oauth_callback_handler(
|
||||
}
|
||||
|
||||
// Exchange the authorization code for tokens.
|
||||
// Use the platform exchange proxy when configured, otherwise call the
|
||||
// provider's token URL directly.
|
||||
let exchange_proxy_url = oauth_defaults::exchange_proxy_url();
|
||||
// Use the platform exchange proxy when configured (keeps client_secret off container),
|
||||
// otherwise call the provider's token URL directly.
|
||||
let exchange_proxy_url = std::env::var("IRONCLAW_OAUTH_EXCHANGE_URL").ok();
|
||||
|
||||
let result: Result<(), String> = async {
|
||||
let token_response = if let Some(proxy_url) = &exchange_proxy_url {
|
||||
let token_response = if let (Some(proxy_url), None) = (&exchange_proxy_url, &flow.resource)
|
||||
{
|
||||
// Use the platform exchange proxy when configured and no resource
|
||||
// parameter is needed. The proxy holds client_secret server-side so
|
||||
// the container never sees it. MCP flows (resource.is_some()) bypass
|
||||
// the proxy because it doesn't forward the RFC 8707 resource param.
|
||||
let gateway_token = flow.gateway_token.as_deref().unwrap_or_default();
|
||||
oauth_defaults::exchange_via_proxy(oauth_defaults::ProxyTokenExchangeRequest {
|
||||
oauth_defaults::exchange_via_proxy(
|
||||
proxy_url,
|
||||
gateway_token,
|
||||
token_url: &flow.token_url,
|
||||
client_id: &flow.client_id,
|
||||
client_secret: flow.client_secret.as_deref(),
|
||||
code: &code,
|
||||
redirect_uri: &flow.redirect_uri,
|
||||
code_verifier: flow.code_verifier.as_deref(),
|
||||
access_token_field: &flow.access_token_field,
|
||||
extra_token_params: &flow.token_exchange_extra_params,
|
||||
})
|
||||
&code,
|
||||
&flow.redirect_uri,
|
||||
flow.code_verifier.as_deref(),
|
||||
&flow.access_token_field,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
oauth_defaults::exchange_oauth_code_with_params(
|
||||
// Direct token exchange: uses exchange_oauth_code_with_resource so MCP
|
||||
// flows can include the RFC 8707 `resource` parameter to scope the
|
||||
// issued token to the specific MCP server.
|
||||
oauth_defaults::exchange_oauth_code_with_resource(
|
||||
&flow.token_url,
|
||||
&flow.client_id,
|
||||
flow.client_secret.as_deref(),
|
||||
@@ -682,7 +641,7 @@ async fn oauth_callback_handler(
|
||||
&flow.redirect_uri,
|
||||
flow.code_verifier.as_deref(),
|
||||
&flow.access_token_field,
|
||||
&flow.token_exchange_extra_params,
|
||||
flow.resource.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -709,8 +668,10 @@ async fn oauth_callback_handler(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Persist the client_id for flows that need it after the session ends
|
||||
// (for example DCR-based MCP refresh).
|
||||
// For MCP OAuth flows (identified by resource field), persist the
|
||||
// client_id so token refresh works without re-authentication.
|
||||
// The CLI flow stores this in authorize_mcp_server(); the gateway
|
||||
// callback must do the same.
|
||||
if let Some(ref client_id_secret) = flow.client_id_secret_name {
|
||||
let params = crate::secrets::CreateSecretParams::new(client_id_secret, &flow.client_id)
|
||||
.with_provider(flow.provider.as_ref().cloned().unwrap_or_default());
|
||||
@@ -791,103 +752,11 @@ async fn oauth_callback_handler(
|
||||
axum::response::Html(html).into_response()
|
||||
}
|
||||
|
||||
/// Webhook endpoint for receiving relay events from channel-relay.
|
||||
///
|
||||
/// PUBLIC route — authenticated via HMAC signature (X-Relay-Signature header).
|
||||
async fn relay_events_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
headers: axum::http::HeaderMap,
|
||||
body: axum::body::Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let ext_mgr = match state.extension_manager.as_ref() {
|
||||
Some(mgr) => mgr,
|
||||
None => {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let signing_secret = match ext_mgr.relay_signing_secret() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "relay not configured").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify signature
|
||||
let signature = match headers
|
||||
.get("x-relay-signature")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
return (StatusCode::UNAUTHORIZED, "missing signature").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let timestamp = match headers
|
||||
.get("x-relay-timestamp")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
return (StatusCode::UNAUTHORIZED, "missing timestamp").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Check timestamp freshness (5 min window)
|
||||
let ts: i64 = match timestamp.parse() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_REQUEST, "malformed timestamp").into_response();
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if (now - ts).abs() > 300 {
|
||||
return (StatusCode::UNAUTHORIZED, "stale timestamp").into_response();
|
||||
}
|
||||
|
||||
// Verify HMAC: sha256(secret, timestamp + "." + body)
|
||||
if !crate::channels::relay::webhook::verify_relay_signature(
|
||||
&signing_secret,
|
||||
×tamp,
|
||||
&body,
|
||||
&signature,
|
||||
) {
|
||||
return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
|
||||
}
|
||||
|
||||
// Parse event
|
||||
let event: crate::channels::relay::client::ChannelEvent = match serde_json::from_slice(&body) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "relay callback invalid JSON");
|
||||
return (StatusCode::BAD_REQUEST, "invalid JSON").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Push to relay channel
|
||||
let event_tx_guard = ext_mgr.relay_event_tx();
|
||||
let event_tx = event_tx_guard.lock().await;
|
||||
match event_tx.as_ref() {
|
||||
Some(tx) => {
|
||||
if let Err(e) = tx.try_send(event) {
|
||||
tracing::warn!(error = %e, "relay event channel full or closed");
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "event queue full").into_response();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE, "relay channel not active").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
/// OAuth callback for Slack via channel-relay.
|
||||
///
|
||||
/// This is a PUBLIC route (no Bearer token required) because channel-relay
|
||||
/// redirects the user's browser here after Slack OAuth completes.
|
||||
/// Query params: `provider`, `team_id`.
|
||||
/// Query params: `stream_token`, `provider`, `team_id`.
|
||||
async fn slack_relay_oauth_callback_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
@@ -904,6 +773,27 @@ async fn slack_relay_oauth_callback_handler(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Validate stream_token: required, non-empty, max 2048 bytes
|
||||
let stream_token = match params.get("stream_token") {
|
||||
Some(t) if !t.is_empty() && t.len() <= 2048 => t.clone(),
|
||||
Some(t) if t.len() > 2048 => {
|
||||
return axum::response::Html(
|
||||
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||
.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {
|
||||
return axum::response::Html(
|
||||
"<html><body style='font-family: system-ui; text-align: center; padding: 60px;'>\
|
||||
<h2>Error</h2><p>Invalid callback parameters.</p></body></html>"
|
||||
.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Validate team_id format: empty or T followed by alphanumeric (max 20 chars)
|
||||
let team_id = params.get("team_id").cloned().unwrap_or_default();
|
||||
if !team_id.is_empty() {
|
||||
@@ -989,16 +879,30 @@ async fn slack_relay_oauth_callback_handler(
|
||||
let _ = ext_mgr.secrets().delete(&state.user_id, &state_key).await;
|
||||
|
||||
let result: Result<(), String> = async {
|
||||
let store = state.store.as_ref().ok_or_else(|| {
|
||||
"Relay activation requires persistent settings storage; no-db mode is unsupported."
|
||||
.to_string()
|
||||
})?;
|
||||
// Store the stream token as a secret
|
||||
let token_key = format!("relay:{}:stream_token", DEFAULT_RELAY_NAME);
|
||||
let _ = ext_mgr.secrets().delete(&state.user_id, &token_key).await;
|
||||
ext_mgr
|
||||
.secrets()
|
||||
.create(
|
||||
&state.user_id,
|
||||
crate::secrets::CreateSecretParams {
|
||||
name: token_key,
|
||||
value: secrecy::SecretString::from(stream_token),
|
||||
provider: Some(provider.clone()),
|
||||
expires_at: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to store stream token: {}", e))?;
|
||||
|
||||
// Store team_id in settings
|
||||
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
||||
let _ = store
|
||||
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
|
||||
.await;
|
||||
if let Some(ref store) = state.store {
|
||||
let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME);
|
||||
let _ = store
|
||||
.set_setting(&state.user_id, &team_id_key, &serde_json::json!(team_id))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Activate the relay channel
|
||||
ext_mgr
|
||||
@@ -1822,53 +1726,14 @@ async fn memory_write_handler(
|
||||
"Workspace not available".to_string(),
|
||||
))?;
|
||||
|
||||
// Route through layer-aware methods when a layer is specified
|
||||
if let Some(ref layer_name) = req.layer {
|
||||
let result = if req.append {
|
||||
workspace
|
||||
.append_to_layer(layer_name, &req.path, &req.content, req.force)
|
||||
.await
|
||||
} else {
|
||||
workspace
|
||||
.write_to_layer(layer_name, &req.path, &req.content, req.force)
|
||||
.await
|
||||
}
|
||||
.map_err(|e| {
|
||||
use crate::error::WorkspaceError;
|
||||
let status = match &e {
|
||||
WorkspaceError::LayerNotFound { .. } => StatusCode::BAD_REQUEST,
|
||||
WorkspaceError::LayerReadOnly { .. } => StatusCode::FORBIDDEN,
|
||||
WorkspaceError::PrivacyRedirectFailed => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, e.to_string())
|
||||
})?;
|
||||
return Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
redirected: Some(result.redirected),
|
||||
actual_layer: Some(result.actual_layer),
|
||||
}));
|
||||
}
|
||||
|
||||
// Non-layer path: honor the append field
|
||||
if req.append {
|
||||
workspace
|
||||
.append(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
} else {
|
||||
workspace
|
||||
.write(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
}
|
||||
workspace
|
||||
.write(&req.path, &req.content)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(MemoryWriteResponse {
|
||||
path: req.path,
|
||||
status: "written",
|
||||
redirected: None,
|
||||
actual_layer: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2450,6 +2315,164 @@ async fn pairing_approve_handler(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routines handlers ---
|
||||
|
||||
async fn routines_list_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineListResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
||||
|
||||
Ok(Json(RoutineListResponse { routines: items }))
|
||||
}
|
||||
|
||||
async fn routines_summary_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
) -> Result<Json<RoutineSummaryResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routines = store
|
||||
.list_all_routines()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let total = routines.len() as u64;
|
||||
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
|
||||
let disabled = total - enabled;
|
||||
let failing = routines
|
||||
.iter()
|
||||
.filter(|r| r.consecutive_failures > 0)
|
||||
.count() as u64;
|
||||
|
||||
let today_start = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.map(|dt| dt.and_utc());
|
||||
let runs_today = if let Some(start) = today_start {
|
||||
routines
|
||||
.iter()
|
||||
.filter(|r| r.last_run_at.is_some_and(|ts| ts >= start))
|
||||
.count() as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(Json(RoutineSummaryResponse {
|
||||
total,
|
||||
enabled,
|
||||
disabled,
|
||||
failing,
|
||||
runs_today,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn routines_detail_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<RoutineDetailResponse>, (StatusCode, String)> {
|
||||
let store = state.store.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Database not available".to_string(),
|
||||
))?;
|
||||
|
||||
let routine_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||
|
||||
let routine = store
|
||||
.get_routine(routine_id)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?;
|
||||
|
||||
let runs = store
|
||||
.list_routine_runs(routine_id, 20)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let recent_runs: Vec<RoutineRunInfo> = runs
|
||||
.iter()
|
||||
.map(|run| RoutineRunInfo {
|
||||
id: run.id,
|
||||
trigger_type: run.trigger_type.clone(),
|
||||
started_at: run.started_at.to_rfc3339(),
|
||||
completed_at: run.completed_at.map(|dt| dt.to_rfc3339()),
|
||||
status: format!("{:?}", run.status),
|
||||
result_summary: run.result_summary.clone(),
|
||||
tokens_used: run.tokens_used,
|
||||
job_id: run.job_id,
|
||||
})
|
||||
.collect();
|
||||
let routine_info = RoutineInfo::from_routine(&routine);
|
||||
|
||||
Ok(Json(RoutineDetailResponse {
|
||||
id: routine.id,
|
||||
name: routine.name.clone(),
|
||||
description: routine.description.clone(),
|
||||
enabled: routine.enabled,
|
||||
trigger_type: routine_info.trigger_type,
|
||||
trigger_raw: routine_info.trigger_raw,
|
||||
trigger_summary: routine_info.trigger_summary,
|
||||
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
|
||||
action: serde_json::to_value(&routine.action).unwrap_or_default(),
|
||||
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),
|
||||
notify: serde_json::to_value(&routine.notify).unwrap_or_default(),
|
||||
last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()),
|
||||
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
|
||||
run_count: routine.run_count,
|
||||
consecutive_failures: routine.consecutive_failures,
|
||||
created_at: routine.created_at.to_rfc3339(),
|
||||
recent_runs,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn routines_trigger_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let engine = {
|
||||
let guard = state.routine_engine.read().await;
|
||||
guard.as_ref().cloned().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Routine engine not available".to_string(),
|
||||
))?
|
||||
};
|
||||
|
||||
let routine_id = Uuid::parse_str(&id)
|
||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?;
|
||||
|
||||
let run_id = engine
|
||||
.fire_manual(routine_id, Some(&state.user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let status = match &e {
|
||||
crate::error::RoutineError::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
crate::error::RoutineError::NotAuthorized { .. } => StatusCode::FORBIDDEN,
|
||||
crate::error::RoutineError::Disabled { .. }
|
||||
| crate::error::RoutineError::MaxConcurrent { .. } => StatusCode::CONFLICT,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "triggered",
|
||||
"routine_id": routine_id,
|
||||
"run_id": run_id,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn routines_runs_handler(
|
||||
State(state): State<Arc<GatewayState>>,
|
||||
Path(id): Path<String>,
|
||||
@@ -2879,7 +2902,6 @@ mod tests {
|
||||
scheduler: None,
|
||||
chat_rate_limiter: RateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||
registry_entries: vec![],
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
@@ -3231,7 +3253,7 @@ mod tests {
|
||||
secrets,
|
||||
sse_sender: None,
|
||||
gateway_token: None,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
resource: None,
|
||||
client_id_secret_name: None,
|
||||
created_at,
|
||||
};
|
||||
@@ -3299,7 +3321,7 @@ mod tests {
|
||||
secrets,
|
||||
sse_sender: Some(sender),
|
||||
gateway_token: None,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
resource: None,
|
||||
client_id_secret_name: None,
|
||||
created_at,
|
||||
};
|
||||
@@ -3402,7 +3424,7 @@ mod tests {
|
||||
secrets,
|
||||
sse_sender: None,
|
||||
gateway_token: None,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
resource: None,
|
||||
client_id_secret_name: None,
|
||||
// Expired — handler will reject after lookup (no network I/O)
|
||||
created_at,
|
||||
@@ -3454,85 +3476,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_oauth_callback_accepts_versioned_hosted_state() {
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
||||
Arc::new(crate::secrets::InMemorySecretsStore::new(Arc::new(
|
||||
crate::secrets::SecretsCrypto::new(secrecy::SecretString::from(
|
||||
TEST_GATEWAY_CRYPTO_KEY.to_string(),
|
||||
))
|
||||
.expect("crypto"),
|
||||
)));
|
||||
let (ext_mgr, _wasm_tools_dir, _wasm_channels_dir) = test_ext_mgr(secrets.clone());
|
||||
|
||||
let Some(created_at) = expired_flow_created_at() else {
|
||||
eprintln!("Skipping versioned OAuth state test: monotonic uptime below expiry window");
|
||||
return;
|
||||
};
|
||||
let flow = crate::cli::oauth_defaults::PendingOAuthFlow {
|
||||
extension_name: "test_tool".to_string(),
|
||||
display_name: "Test Tool".to_string(),
|
||||
token_url: "https://example.com/token".to_string(),
|
||||
client_id: "client123".to_string(),
|
||||
client_secret: None,
|
||||
redirect_uri: "https://example.com/oauth/callback".to_string(),
|
||||
code_verifier: None,
|
||||
access_token_field: "access_token".to_string(),
|
||||
secret_name: "test_token".to_string(),
|
||||
provider: None,
|
||||
validation_endpoint: None,
|
||||
scopes: vec![],
|
||||
user_id: "test".to_string(),
|
||||
secrets,
|
||||
sse_sender: None,
|
||||
gateway_token: None,
|
||||
token_exchange_extra_params: std::collections::HashMap::new(),
|
||||
client_id_secret_name: None,
|
||||
created_at,
|
||||
};
|
||||
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.write()
|
||||
.await
|
||||
.insert("test_nonce".to_string(), flow);
|
||||
|
||||
let state = test_gateway_state(Some(ext_mgr.clone()));
|
||||
let app = test_oauth_router(state);
|
||||
let versioned_state =
|
||||
crate::cli::oauth_defaults::encode_hosted_oauth_state("test_nonce", Some("myinstance"));
|
||||
|
||||
let req = axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/oauth/callback?code=fake_code&state={}",
|
||||
urlencoding::encode(&versioned_state)
|
||||
))
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
let resp = ServiceExt::<axum::http::Request<Body>>::oneshot(app, req)
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body = axum::body::to_bytes(resp.into_body(), 1024 * 64)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8_lossy(&body);
|
||||
assert!(html.contains("Authorization Failed"));
|
||||
assert!(
|
||||
ext_mgr
|
||||
.pending_oauth_flows()
|
||||
.read()
|
||||
.await
|
||||
.get("test_nonce")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// --- Slack relay OAuth CSRF tests ---
|
||||
|
||||
fn test_relay_oauth_router(state: Arc<GatewayState>) -> Router {
|
||||
@@ -3590,7 +3533,7 @@ mod tests {
|
||||
|
||||
// Callback without state param should be rejected
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/oauth/slack/callback?team_id=T123&provider=slack")
|
||||
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
@@ -3634,7 +3577,7 @@ mod tests {
|
||||
|
||||
// Callback with wrong state param
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/oauth/slack/callback?team_id=T123&provider=slack&state=wrong-nonce")
|
||||
.uri("/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state=wrong-nonce")
|
||||
.body(Body::empty())
|
||||
.expect("request");
|
||||
|
||||
@@ -3682,7 +3625,7 @@ mod tests {
|
||||
// we just verify it doesn't return a CSRF error.
|
||||
let req = axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/oauth/slack/callback?team_id=T123&provider=slack&state={}",
|
||||
"/oauth/slack/callback?stream_token=tok123&team_id=T123&provider=slack&state={}",
|
||||
nonce
|
||||
))
|
||||
.body(Body::empty())
|
||||
|
||||
@@ -1,69 +1,5 @@
|
||||
// IronClaw Web Gateway - Client
|
||||
|
||||
// --- Theme Management (dark / light / system) ---
|
||||
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
const VALID_THEME_MODES = { dark: true, light: true, system: true };
|
||||
|
||||
function getThemeMode() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
|
||||
}
|
||||
|
||||
function resolveTheme(mode) {
|
||||
return mode === 'system' ? getSystemTheme() : mode;
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
const resolved = resolveTheme(mode);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
|
||||
const announce = document.getElementById('theme-announce');
|
||||
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const cycle = { dark: 'light', light: 'system', system: 'dark' };
|
||||
const current = getThemeMode();
|
||||
const next = cycle[current] || 'dark';
|
||||
localStorage.setItem('ironclaw-theme', next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
|
||||
// but we call again here to ensure tooltip is set after DOM is ready).
|
||||
applyTheme(getThemeMode());
|
||||
|
||||
// Delay enabling theme transition to avoid flash on initial load.
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() {
|
||||
document.body.classList.add('theme-transition');
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for OS theme changes — only re-apply when in 'system' mode.
|
||||
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSchemeChange = function() {
|
||||
if (getThemeMode() === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener('change', onSchemeChange);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(onSchemeChange);
|
||||
}
|
||||
|
||||
// Bind theme toggle button (CSP-compliant — no inline onclick).
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
|
||||
let token = '';
|
||||
let eventSource = null;
|
||||
let logEventSource = null;
|
||||
@@ -164,30 +100,6 @@ document.getElementById('token-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') authenticate();
|
||||
});
|
||||
|
||||
// --- Static element event bindings (CSP-compliant, no inline handlers) ---
|
||||
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
|
||||
document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
|
||||
document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
|
||||
document.getElementById('language-btn').addEventListener('click', () => toggleLanguageMenu());
|
||||
// Language option clicks handled by delegated data-action="switch-language" handler.
|
||||
document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
|
||||
document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
|
||||
document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
|
||||
document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
|
||||
document.getElementById('send-btn').addEventListener('click', () => sendMessage());
|
||||
document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
|
||||
document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
|
||||
document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
|
||||
document.getElementById('logs-server-level').addEventListener('change', function() { setServerLogLevel(this.value); });
|
||||
document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
|
||||
document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
|
||||
document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
|
||||
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
|
||||
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
|
||||
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
||||
|
||||
// Auto-authenticate from URL param or saved session
|
||||
(function autoAuth() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -1226,19 +1138,18 @@ function showApproval(data) {
|
||||
approveBtn.textContent = I18n.t('approval.approve');
|
||||
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
||||
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
|
||||
const denyBtn = document.createElement('button');
|
||||
denyBtn.className = 'deny';
|
||||
denyBtn.textContent = I18n.t('approval.deny');
|
||||
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
||||
|
||||
actions.appendChild(approveBtn);
|
||||
if (data.allow_always !== false) {
|
||||
const alwaysBtn = document.createElement('button');
|
||||
alwaysBtn.className = 'always';
|
||||
alwaysBtn.textContent = I18n.t('approval.always');
|
||||
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
||||
actions.appendChild(alwaysBtn);
|
||||
}
|
||||
actions.appendChild(alwaysBtn);
|
||||
actions.appendChild(denyBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
@@ -3942,6 +3853,7 @@ function renderRoutineDetail(routine) {
|
||||
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
|
||||
}
|
||||
|
||||
// Action config
|
||||
html += '<div class="job-description"><h3>Action</h3>'
|
||||
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
|
||||
|
||||
@@ -4960,14 +4872,7 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
inputWrap.style.gap = '8px';
|
||||
|
||||
var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
|
||||
function formatSettingValue(raw) {
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
if (raw === null || raw === undefined) return '';
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
var activeValueText = formatSettingValue(activeValue);
|
||||
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||
var placeholderText = activeValue ? I18n.t('settings.envValue', { value: activeValue }) : (def.placeholder || I18n.t('settings.envDefault'));
|
||||
|
||||
if (def.type === 'boolean') {
|
||||
var boolSel = document.createElement('select');
|
||||
@@ -5039,26 +4944,6 @@ function renderStructuredSettingsRow(def, value, activeValue) {
|
||||
};
|
||||
})(def.key, numInp));
|
||||
inputWrap.appendChild(numInp);
|
||||
} else if (def.type === 'list') {
|
||||
var listInp = document.createElement('input');
|
||||
listInp.type = 'text';
|
||||
listInp.className = 'settings-input';
|
||||
listInp.setAttribute('aria-label', ariaLabel);
|
||||
var listValue = '';
|
||||
if (Array.isArray(value)) listValue = value.join(', ');
|
||||
else if (typeof value === 'string') listValue = value;
|
||||
listInp.value = listValue;
|
||||
if (!listValue) listInp.placeholder = placeholderText;
|
||||
listInp.addEventListener('change', (function(k, el) {
|
||||
return function() {
|
||||
if (el.value.trim() === '') return saveSetting(k, null);
|
||||
var items = el.value.split(/[\n,]/).map(function(item) {
|
||||
return item.trim();
|
||||
}).filter(Boolean);
|
||||
saveSetting(k, items);
|
||||
};
|
||||
})(def.key, listInp));
|
||||
inputWrap.appendChild(listInp);
|
||||
} else {
|
||||
var textInp = document.createElement('input');
|
||||
textInp.type = 'text';
|
||||
|
||||
@@ -24,12 +24,6 @@ I18n.register('en', {
|
||||
'restart.progressSubtitle': 'Please wait for the process to restart...',
|
||||
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
|
||||
|
||||
// Theme
|
||||
'theme.tooltipDark': 'Theme: Dark (click for Light)',
|
||||
'theme.tooltipLight': 'Theme: Light (click for System)',
|
||||
'theme.tooltipSystem': 'Theme: System (click for Dark)',
|
||||
'theme.announce': 'Theme: {mode}',
|
||||
|
||||
// Tabs
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
|
||||
@@ -24,12 +24,6 @@ I18n.register('zh-CN', {
|
||||
'restart.progressSubtitle': '请等待进程重启...',
|
||||
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
|
||||
|
||||
// 主题
|
||||
'theme.tooltipDark': '主题:深色(点击切换浅色)',
|
||||
'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
|
||||
'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
|
||||
'theme.announce': '主题:{mode}',
|
||||
|
||||
// 标签页
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Auth Screen -->
|
||||
@@ -110,18 +109,6 @@
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
@@ -148,17 +135,19 @@
|
||||
<!-- Chat Tab -->
|
||||
<div class="tab-panel active" id="tab-chat">
|
||||
<div class="thread-sidebar" id="thread-sidebar">
|
||||
<div class="thread-sidebar-header">
|
||||
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="assistant-item" id="assistant-thread">
|
||||
<span class="assistant-label" id="assistant-label" data-i18n="chat.assistant">Assistant</span>
|
||||
<span class="assistant-meta" id="assistant-meta"></span>
|
||||
</div>
|
||||
<div class="threads-section-header">
|
||||
<span data-i18n="chat.conversations">Conversations</span>
|
||||
<div class="spacer"></div>
|
||||
<button class="thread-new-btn" id="thread-new-btn" data-i18n="chat.newThread" data-i18n-attr="title"
|
||||
title="New thread (Ctrl/Cmd+N)">+</button>
|
||||
<button class="thread-toggle-btn" id="thread-toggle-btn" data-i18n="chat.toggleSidebar"
|
||||
data-i18n-attr="title" title="Toggle sidebar">«</button>
|
||||
</div>
|
||||
<div class="thread-list" id="thread-list"></div>
|
||||
</div>
|
||||
|
||||
+134
-295
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
||||
// Prevent FOUC: apply saved theme before first paint.
|
||||
// This script must be loaded synchronously in <head> (no defer/async).
|
||||
(function() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
|
||||
let resolved = mode;
|
||||
if (mode === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
})();
|
||||
@@ -83,7 +83,6 @@ impl TestGatewayBuilder {
|
||||
scheduler: None,
|
||||
chat_rate_limiter: RateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
|
||||
@@ -177,8 +177,6 @@ pub enum SseEvent {
|
||||
parameters: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
/// Whether the "always" auto-approve option should be shown.
|
||||
allow_always: bool,
|
||||
},
|
||||
#[serde(rename = "auth_required")]
|
||||
AuthRequired {
|
||||
@@ -232,8 +230,6 @@ pub enum SseEvent {
|
||||
status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
fallback_deliverable: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// An image was generated by a tool.
|
||||
@@ -302,30 +298,12 @@ pub struct MemoryReadResponse {
|
||||
pub struct MemoryWriteRequest {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
/// Optional layer to write to. When present, uses `write_to_layer()`
|
||||
/// which enables privacy classification and redirect.
|
||||
pub layer: Option<String>,
|
||||
/// When true and a layer is specified, appends to existing content
|
||||
/// instead of replacing it.
|
||||
#[serde(default)]
|
||||
pub append: bool,
|
||||
/// Skip privacy classification and write directly to the specified layer.
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MemoryWriteResponse {
|
||||
pub path: String,
|
||||
pub status: &'static str,
|
||||
/// Whether the write was redirected to a different layer (e.g., sensitive
|
||||
/// content redirected from shared to private).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirected: Option<bool>,
|
||||
/// The layer the content was actually written to (may differ from requested
|
||||
/// layer if privacy redirect occurred).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actual_layer: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -832,14 +810,6 @@ impl RoutineInfo {
|
||||
String::new(),
|
||||
format!("event: {}.{}", source, event_type),
|
||||
),
|
||||
crate::agent::routine::Trigger::Webhook { path, .. } => {
|
||||
let p = path.as_deref().unwrap_or("default");
|
||||
(
|
||||
"webhook".to_string(),
|
||||
String::new(),
|
||||
format!("webhook: /api/webhooks/{}", p),
|
||||
)
|
||||
}
|
||||
crate::agent::routine::Trigger::Manual => (
|
||||
"manual".to_string(),
|
||||
String::new(),
|
||||
@@ -1110,7 +1080,6 @@ mod tests {
|
||||
description: "Run ls".to_string(),
|
||||
parameters: "{}".to_string(),
|
||||
thread_id: Some("t1".to_string()),
|
||||
allow_always: true,
|
||||
};
|
||||
let ws = WsServerMessage::from_sse_event(&sse);
|
||||
match ws {
|
||||
|
||||
@@ -517,7 +517,6 @@ mod tests {
|
||||
skill_catalog: None,
|
||||
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
|
||||
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
webhook_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
|
||||
registry_entries: Vec::new(),
|
||||
cost_guard: None,
|
||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
|
||||
+3
-59
@@ -33,7 +33,7 @@ pub async fn run_doctor_command() -> anyhow::Result<()> {
|
||||
|
||||
check(
|
||||
"NEAR AI session",
|
||||
check_nearai_session(&settings).await,
|
||||
check_nearai_session().await,
|
||||
&mut passed,
|
||||
&mut failed,
|
||||
&mut skipped,
|
||||
@@ -215,22 +215,7 @@ fn check_settings_file() -> CheckResult {
|
||||
|
||||
// ── NEAR AI session ─────────────────────────────────────────
|
||||
|
||||
async fn check_nearai_session(settings: &Settings) -> CheckResult {
|
||||
// Skip entirely when the configured backend is not NEAR AI.
|
||||
let llm_config = match crate::config::LlmConfig::resolve(settings) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
// check_llm_config will report the full error; just skip here.
|
||||
return CheckResult::Skip(format!("LLM config error: {e}"));
|
||||
}
|
||||
};
|
||||
if llm_config.backend != "nearai" {
|
||||
return CheckResult::Skip(format!(
|
||||
"not using NEAR AI backend (backend={})",
|
||||
llm_config.backend
|
||||
));
|
||||
}
|
||||
|
||||
async fn check_nearai_session() -> CheckResult {
|
||||
// Check if session file exists
|
||||
let session_path = crate::config::llm::default_session_path();
|
||||
if !session_path.exists() {
|
||||
@@ -635,53 +620,12 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_nearai_session_does_not_panic() {
|
||||
let settings = Settings::default();
|
||||
let result = check_nearai_session(&settings).await;
|
||||
let result = check_nearai_session().await;
|
||||
match result {
|
||||
CheckResult::Pass(_) | CheckResult::Fail(_) | CheckResult::Skip(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_nearai_session_skips_for_non_nearai_backend() {
|
||||
struct EnvGuard(&'static str, Option<String>);
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
match &self.1 {
|
||||
Some(val) => std::env::set_var(self.0, val),
|
||||
None => std::env::remove_var(self.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _mutex = crate::config::helpers::ENV_MUTEX.lock().expect("env mutex");
|
||||
let prev = std::env::var("LLM_BACKEND").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "anthropic");
|
||||
}
|
||||
let _env_guard = EnvGuard("LLM_BACKEND", prev);
|
||||
|
||||
let settings = Settings::default();
|
||||
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
|
||||
let result = rt.block_on(check_nearai_session(&settings));
|
||||
match result {
|
||||
CheckResult::Skip(msg) => {
|
||||
assert!(
|
||||
msg.contains("backend=anthropic"),
|
||||
"expected backend name in skip message, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected Skip for non-nearai backend, got: {}",
|
||||
format_result(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_settings_file_handles_missing() {
|
||||
// Settings::default_path() might or might not exist, but must not panic
|
||||
|
||||
+3
-5
@@ -7,18 +7,17 @@ use std::sync::Arc;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::workspace::{EmbeddingCacheConfig, EmbeddingProvider, SearchConfig, Workspace};
|
||||
use crate::workspace::{EmbeddingProvider, SearchConfig, Workspace};
|
||||
|
||||
/// Run a memory command using the Database trait (works with any backend).
|
||||
pub async fn run_memory_command_with_db(
|
||||
cmd: MemoryCommand,
|
||||
db: std::sync::Arc<dyn crate::db::Database>,
|
||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||
cache_config: EmbeddingCacheConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut workspace = Workspace::new_with_db("default", db);
|
||||
if let Some(emb) = embeddings {
|
||||
workspace = workspace.with_embeddings_cached(emb, cache_config);
|
||||
workspace = workspace.with_embeddings(emb);
|
||||
}
|
||||
|
||||
match cmd {
|
||||
@@ -86,11 +85,10 @@ pub async fn run_memory_command(
|
||||
cmd: MemoryCommand,
|
||||
pool: deadpool_postgres::Pool,
|
||||
embeddings: Option<Arc<dyn EmbeddingProvider>>,
|
||||
cache_config: EmbeddingCacheConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut workspace = Workspace::new("default", pool);
|
||||
if let Some(emb) = embeddings {
|
||||
workspace = workspace.with_embeddings_cached(emb, cache_config);
|
||||
workspace = workspace.with_embeddings(emb);
|
||||
}
|
||||
|
||||
match cmd {
|
||||
|
||||
+1
-15
@@ -239,17 +239,6 @@ pub enum Command {
|
||||
)]
|
||||
Import(ImportCommand),
|
||||
|
||||
/// Authenticate with a provider (re-login)
|
||||
#[command(
|
||||
about = "Authenticate with a provider",
|
||||
long_about = "Re-authenticate with an LLM provider.\nExample: ironclaw login --openai-codex"
|
||||
)]
|
||||
Login {
|
||||
/// Authenticate with OpenAI Codex (ChatGPT subscription)
|
||||
#[arg(long)]
|
||||
openai_codex: bool,
|
||||
},
|
||||
|
||||
/// Run as a sandboxed worker inside a Docker container (internal use).
|
||||
/// This is invoked automatically by the orchestrator, not by users directly.
|
||||
#[command(hide = true)]
|
||||
@@ -347,10 +336,7 @@ pub async fn run_memory_command(mem_cmd: &MemoryCommand) -> anyhow::Result<()> {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let cache_config = crate::workspace::EmbeddingCacheConfig {
|
||||
max_entries: config.embeddings.cache_size,
|
||||
};
|
||||
run_memory_command_with_db(mem_cmd.clone(), db, embeddings, cache_config).await
|
||||
run_memory_command_with_db(mem_cmd.clone(), db, embeddings).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+73
-263
@@ -5,10 +5,17 @@
|
||||
//!
|
||||
//! # Built-in Credentials
|
||||
//!
|
||||
//! Some providers ship with built-in OAuth credentials so users don't need to
|
||||
//! register their own OAuth app just to get started. Today this module only
|
||||
//! includes built-in defaults for Google-family tools, and those defaults can
|
||||
//! be overridden by provider-specific environment variables when needed.
|
||||
//! Many CLI tools (gcloud, rclone, gdrive) ship with default OAuth credentials
|
||||
//! so users don't need to register their own OAuth app. Google explicitly
|
||||
//! documents that client_secret for "Desktop App" / "Installed App" types
|
||||
//! is NOT actually secret.
|
||||
//!
|
||||
//! Default credentials are hardcoded below. They can be overridden at:
|
||||
//!
|
||||
//! - **Compile time**: Set IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET
|
||||
//! env vars before building to replace the hardcoded defaults.
|
||||
//! - **Runtime**: Users can set GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET
|
||||
//! env vars, which take priority over built-in defaults.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -16,7 +23,6 @@ use std::time::Duration;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -54,14 +60,6 @@ pub fn builtin_credentials(secret_name: &str) -> Option<OAuthCredentials> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the compile-time override env var name, if this provider supports one.
|
||||
pub fn builtin_client_id_override_env(secret_name: &str) -> Option<&'static str> {
|
||||
match secret_name {
|
||||
"google_oauth_token" => Some("IRONCLAW_GOOGLE_CLIENT_ID"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared callback server ──────────────────────────────────────────────
|
||||
|
||||
// Core OAuth callback infrastructure is defined in `crate::llm::oauth_helpers`
|
||||
@@ -175,8 +173,9 @@ pub async fn exchange_oauth_code(
|
||||
code_verifier: Option<&str>,
|
||||
access_token_field: &str,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
let extra_token_params = HashMap::new();
|
||||
exchange_oauth_code_with_params(
|
||||
// Delegates to exchange_oauth_code_with_resource with resource=None.
|
||||
// Non-MCP OAuth flows don't need the RFC 8707 resource parameter.
|
||||
exchange_oauth_code_with_resource(
|
||||
token_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
@@ -184,14 +183,16 @@ pub async fn exchange_oauth_code(
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
access_token_field,
|
||||
&extra_token_params,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Exchange an OAuth authorization code for tokens with generic extra form parameters.
|
||||
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
|
||||
///
|
||||
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn exchange_oauth_code_with_params(
|
||||
pub async fn exchange_oauth_code_with_resource(
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
@@ -199,7 +200,7 @@ pub async fn exchange_oauth_code_with_params(
|
||||
redirect_uri: &str,
|
||||
code_verifier: Option<&str>,
|
||||
access_token_field: &str,
|
||||
extra_token_params: &HashMap<String, String>,
|
||||
resource: Option<&str>,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut token_params = vec![
|
||||
@@ -212,8 +213,10 @@ pub async fn exchange_oauth_code_with_params(
|
||||
token_params.push(("code_verifier", verifier.to_string()));
|
||||
}
|
||||
|
||||
for (key, value) in extra_token_params {
|
||||
token_params.push((key.as_str(), value.clone()));
|
||||
// RFC 8707: include the `resource` parameter so the authorization server
|
||||
// scopes the issued token to the specific MCP server (protected resource).
|
||||
if let Some(resource) = resource {
|
||||
token_params.push(("resource", resource.to_string()));
|
||||
}
|
||||
|
||||
let mut request = client.post(token_url);
|
||||
@@ -273,37 +276,6 @@ pub async fn exchange_oauth_code_with_params(
|
||||
})
|
||||
}
|
||||
|
||||
/// Exchange an OAuth authorization code for tokens, with optional RFC 8707 `resource` parameter.
|
||||
///
|
||||
/// The `resource` parameter scopes the issued token to a specific server (used by MCP OAuth).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn exchange_oauth_code_with_resource(
|
||||
token_url: &str,
|
||||
client_id: &str,
|
||||
client_secret: Option<&str>,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: Option<&str>,
|
||||
access_token_field: &str,
|
||||
resource: Option<&str>,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
let mut extra_token_params = HashMap::new();
|
||||
if let Some(resource) = resource {
|
||||
extra_token_params.insert("resource".to_string(), resource.to_string());
|
||||
}
|
||||
exchange_oauth_code_with_params(
|
||||
token_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
access_token_field,
|
||||
&extra_token_params,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Store OAuth tokens (access + refresh) in the secrets store.
|
||||
///
|
||||
/// Also stores the granted scopes as `{secret_name}_scopes` so that scope
|
||||
@@ -451,9 +423,9 @@ pub struct PendingOAuthFlow {
|
||||
pub sse_sender: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
|
||||
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
||||
pub gateway_token: Option<String>,
|
||||
/// Additional form params for the token exchange request.
|
||||
/// Used for provider-specific requirements such as RFC 8707 `resource`.
|
||||
pub token_exchange_extra_params: HashMap<String, String>,
|
||||
/// RFC 8707 resource parameter (MCP OAuth only).
|
||||
/// Sent during token exchange to scope the token to a specific MCP server.
|
||||
pub resource: Option<String>,
|
||||
/// Secret name for persisting the client ID (MCP OAuth only).
|
||||
/// Needed so token refresh can find the client_id after the session ends.
|
||||
pub client_id_secret_name: Option<String>,
|
||||
@@ -487,7 +459,9 @@ pub fn new_pending_oauth_registry() -> PendingOAuthRegistry {
|
||||
/// URL, meaning the user's browser will redirect to a hosted gateway rather than
|
||||
/// localhost.
|
||||
pub fn use_gateway_callback() -> bool {
|
||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|raw| {
|
||||
url::Url::parse(&raw)
|
||||
.ok()
|
||||
@@ -498,13 +472,6 @@ pub fn use_gateway_callback() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Returns the configured OAuth token-exchange proxy URL, if any.
|
||||
pub fn exchange_proxy_url() -> Option<String> {
|
||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_EXCHANGE_URL")
|
||||
.map(|url| url.trim().to_string())
|
||||
.filter(|url| !url.is_empty())
|
||||
}
|
||||
|
||||
/// Maximum age for pending OAuth flows (5 minutes, matching TCP listener timeout).
|
||||
pub const OAUTH_FLOW_EXPIRY: Duration = Duration::from_secs(300);
|
||||
|
||||
@@ -519,117 +486,23 @@ pub async fn sweep_expired_flows(registry: &PendingOAuthRegistry) {
|
||||
|
||||
// ── Platform routing helpers ────────────────────────────────────────
|
||||
|
||||
const HOSTED_STATE_PREFIX: &str = "ic2";
|
||||
const HOSTED_STATE_CHECKSUM_BYTES: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DecodedHostedOAuthState {
|
||||
pub flow_id: String,
|
||||
pub instance_name: Option<String>,
|
||||
pub is_legacy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HostedOAuthStatePayload {
|
||||
flow_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
instance_name: Option<String>,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
fn current_instance_name() -> Option<String> {
|
||||
crate::config::helpers::env_or_override("IRONCLAW_INSTANCE_NAME")
|
||||
.or_else(|| crate::config::helpers::env_or_override("OPENCLAW_INSTANCE_NAME"))
|
||||
.filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
fn hosted_state_checksum(payload_bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(payload_bytes);
|
||||
URL_SAFE_NO_PAD.encode(&digest[..HOSTED_STATE_CHECKSUM_BYTES])
|
||||
}
|
||||
|
||||
/// Build a versioned hosted OAuth state envelope.
|
||||
/// Prepend instance name to CSRF state for platform routing.
|
||||
///
|
||||
/// The encoded value is opaque to providers and can be decoded by both
|
||||
/// IronClaw and the external auth proxy for routing and callback lookup.
|
||||
pub fn encode_hosted_oauth_state(flow_id: &str, instance_name: Option<&str>) -> String {
|
||||
let payload = HostedOAuthStatePayload {
|
||||
flow_id: flow_id.to_string(),
|
||||
instance_name: instance_name
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string),
|
||||
issued_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
let payload_json = match serde_json::to_vec(&payload) {
|
||||
Ok(payload_json) => payload_json,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, flow_id, "Failed to serialize hosted OAuth state payload");
|
||||
return payload.flow_id;
|
||||
}
|
||||
};
|
||||
let payload = URL_SAFE_NO_PAD.encode(&payload_json);
|
||||
let checksum = hosted_state_checksum(&payload_json);
|
||||
format!("{HOSTED_STATE_PREFIX}.{payload}.{checksum}")
|
||||
}
|
||||
|
||||
/// Decode hosted OAuth state in either the new versioned format or the
|
||||
/// legacy `instance:nonce`/`nonce` forms.
|
||||
pub fn decode_hosted_oauth_state(state: &str) -> Result<DecodedHostedOAuthState, String> {
|
||||
if let Some(rest) = state.strip_prefix(&format!("{HOSTED_STATE_PREFIX}."))
|
||||
&& let Some((payload_b64, checksum)) = rest.rsplit_once('.')
|
||||
&& let Ok(payload_json) = URL_SAFE_NO_PAD.decode(payload_b64)
|
||||
{
|
||||
let expected_checksum = hosted_state_checksum(&payload_json);
|
||||
if checksum != expected_checksum {
|
||||
return Err("Hosted OAuth state checksum mismatch".to_string());
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_slice::<HostedOAuthStatePayload>(&payload_json)
|
||||
&& !payload.flow_id.trim().is_empty()
|
||||
{
|
||||
return Ok(DecodedHostedOAuthState {
|
||||
flow_id: payload.flow_id,
|
||||
instance_name: payload.instance_name.filter(|v| !v.is_empty()),
|
||||
is_legacy: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((instance_name, flow_id)) = state.split_once(':') {
|
||||
if flow_id.is_empty() {
|
||||
return Err("Hosted OAuth legacy state is missing flow_id".to_string());
|
||||
}
|
||||
return Ok(DecodedHostedOAuthState {
|
||||
flow_id: flow_id.to_string(),
|
||||
instance_name: if instance_name.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(instance_name.to_string())
|
||||
},
|
||||
is_legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
if state.is_empty() {
|
||||
return Err("Hosted OAuth state is empty".to_string());
|
||||
}
|
||||
|
||||
Ok(DecodedHostedOAuthState {
|
||||
flow_id: state.to_string(),
|
||||
instance_name: None,
|
||||
is_legacy: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the hosted callback state used by the public OAuth callback endpoint.
|
||||
/// The NEAR AI platform nginx proxy at `auth.DOMAIN` parses the instance name
|
||||
/// from the `state` query parameter (format: `instance:nonce`) to route the
|
||||
/// OAuth callback to the correct container.
|
||||
///
|
||||
/// New flows emit a versioned opaque envelope, while callback decoding accepts
|
||||
/// both the envelope and the legacy `instance:nonce` contract.
|
||||
/// Returns the nonce unchanged when `IRONCLAW_INSTANCE_NAME` is not set
|
||||
/// (local/non-platform mode).
|
||||
pub fn build_platform_state(nonce: &str) -> String {
|
||||
encode_hosted_oauth_state(nonce, current_instance_name().as_deref())
|
||||
let instance = std::env::var("IRONCLAW_INSTANCE_NAME")
|
||||
.or_else(|_| std::env::var("OPENCLAW_INSTANCE_NAME"))
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty());
|
||||
match instance {
|
||||
Some(name) => format!("{}:{}", name, nonce),
|
||||
None => nonce.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the instance prefix from a state parameter to recover the lookup nonce.
|
||||
@@ -644,62 +517,43 @@ pub fn strip_instance_prefix(state: &str) -> &str {
|
||||
.unwrap_or(state)
|
||||
}
|
||||
|
||||
pub struct ProxyTokenExchangeRequest<'a> {
|
||||
pub proxy_url: &'a str,
|
||||
pub gateway_token: &'a str,
|
||||
pub token_url: &'a str,
|
||||
pub client_id: &'a str,
|
||||
pub client_secret: Option<&'a str>,
|
||||
pub code: &'a str,
|
||||
pub redirect_uri: &'a str,
|
||||
pub code_verifier: Option<&'a str>,
|
||||
pub access_token_field: &'a str,
|
||||
pub extra_token_params: &'a HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Exchange an OAuth authorization code via the platform's token exchange proxy.
|
||||
///
|
||||
/// Authenticated via the gateway auth token (Bearer header). The caller may
|
||||
/// either rely on proxy-side secret lookup or forward a `client_secret` when
|
||||
/// the provider requires it.
|
||||
/// The proxy holds `client_secret` server-side so the container never sees it.
|
||||
/// Authenticated via the gateway auth token (Bearer header).
|
||||
///
|
||||
/// The proxy expects standard OAuth form params plus optional provider-specific
|
||||
/// token params and returns a standard token response such as
|
||||
/// `{access_token, refresh_token, expires_in}`.
|
||||
/// The proxy expects form params `{code, redirect_uri, code_verifier}` and
|
||||
/// returns a standard Google token response `{access_token, refresh_token, expires_in}`.
|
||||
pub async fn exchange_via_proxy(
|
||||
request: ProxyTokenExchangeRequest<'_>,
|
||||
proxy_url: &str,
|
||||
gateway_token: &str,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
code_verifier: Option<&str>,
|
||||
access_token_field: &str,
|
||||
) -> Result<OAuthTokenResponse, OAuthCallbackError> {
|
||||
if request.gateway_token.is_empty() {
|
||||
if gateway_token.is_empty() {
|
||||
return Err(OAuthCallbackError::Io(
|
||||
"Gateway auth token is required for proxy token exchange".to_string(),
|
||||
));
|
||||
}
|
||||
let exchange_url = format!("{}/oauth/exchange", request.proxy_url.trim_end_matches('/'));
|
||||
let exchange_url = format!("{}/oauth/exchange", proxy_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to build HTTP client: {}", e)))?;
|
||||
let mut params = vec![
|
||||
("code", request.code.to_string()),
|
||||
("redirect_uri", request.redirect_uri.to_string()),
|
||||
("token_url", request.token_url.to_string()),
|
||||
("client_id", request.client_id.to_string()),
|
||||
("access_token_field", request.access_token_field.to_string()),
|
||||
("code", code.to_string()),
|
||||
("redirect_uri", redirect_uri.to_string()),
|
||||
];
|
||||
if let Some(verifier) = request.code_verifier {
|
||||
if let Some(verifier) = code_verifier {
|
||||
params.push(("code_verifier", verifier.to_string()));
|
||||
}
|
||||
if let Some(secret) = request.client_secret {
|
||||
params.push(("client_secret", secret.to_string()));
|
||||
}
|
||||
for (key, value) in request.extra_token_params {
|
||||
params.push((key.as_str(), value.clone()));
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&exchange_url)
|
||||
.bearer_auth(request.gateway_token)
|
||||
.bearer_auth(gateway_token)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await
|
||||
@@ -722,7 +576,7 @@ pub async fn exchange_via_proxy(
|
||||
.map_err(|e| OAuthCallbackError::Io(format!("Failed to parse proxy response: {}", e)))?;
|
||||
|
||||
let access_token = token_data
|
||||
.get(request.access_token_field)
|
||||
.get(access_token_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
let fields: Vec<&str> = token_data
|
||||
@@ -731,7 +585,7 @@ pub async fn exchange_via_proxy(
|
||||
.unwrap_or_default();
|
||||
OAuthCallbackError::Io(format!(
|
||||
"No '{}' field in proxy response (fields present: {:?})",
|
||||
request.access_token_field, fields
|
||||
access_token_field, fields
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
@@ -751,10 +605,14 @@ pub async fn exchange_via_proxy(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::cli::oauth_defaults::{
|
||||
builtin_credentials, callback_host, callback_url, is_loopback_host, landing_html,
|
||||
};
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
|
||||
/// Serializes env-mutating tests to prevent parallel races.
|
||||
static ENV_MUTEX: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn test_is_loopback_host() {
|
||||
@@ -1077,7 +935,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_with_instance() {
|
||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
@@ -1085,11 +943,7 @@ mod tests {
|
||||
unsafe {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", "kind-deer");
|
||||
}
|
||||
let encoded = build_platform_state("abc123");
|
||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
|
||||
assert!(!decoded.is_legacy);
|
||||
assert_eq!(build_platform_state("abc123"), "kind-deer:abc123");
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
@@ -1101,7 +955,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_without_instance() {
|
||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
@@ -1111,11 +965,7 @@ mod tests {
|
||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||
std::env::remove_var("OPENCLAW_INSTANCE_NAME");
|
||||
}
|
||||
let encoded = build_platform_state("abc123");
|
||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
assert_eq!(decoded.instance_name, None);
|
||||
assert!(!decoded.is_legacy);
|
||||
assert_eq!(build_platform_state("abc123"), "abc123");
|
||||
unsafe {
|
||||
if let Some(val) = original {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
@@ -1128,7 +978,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_platform_state_with_openclaw_instance() {
|
||||
use crate::cli::oauth_defaults::{build_platform_state, decode_hosted_oauth_state};
|
||||
use crate::cli::oauth_defaults::build_platform_state;
|
||||
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original_ic = std::env::var("IRONCLAW_INSTANCE_NAME").ok();
|
||||
@@ -1138,11 +988,7 @@ mod tests {
|
||||
std::env::remove_var("IRONCLAW_INSTANCE_NAME");
|
||||
std::env::set_var("OPENCLAW_INSTANCE_NAME", "quiet-lion");
|
||||
}
|
||||
let encoded = build_platform_state("xyz789");
|
||||
let decoded = decode_hosted_oauth_state(&encoded).expect("decode hosted state");
|
||||
assert_eq!(decoded.flow_id, "xyz789");
|
||||
assert_eq!(decoded.instance_name.as_deref(), Some("quiet-lion"));
|
||||
assert!(!decoded.is_legacy);
|
||||
assert_eq!(build_platform_state("xyz789"), "quiet-lion:xyz789");
|
||||
unsafe {
|
||||
if let Some(val) = original_ic {
|
||||
std::env::set_var("IRONCLAW_INSTANCE_NAME", val);
|
||||
@@ -1171,42 +1017,6 @@ mod tests {
|
||||
assert_eq!(strip_instance_prefix(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_hosted_oauth_state_accepts_legacy_formats() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let decoded = decode_hosted_oauth_state("kind-deer:abc123").expect("legacy prefixed");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
assert_eq!(decoded.instance_name.as_deref(), Some("kind-deer"));
|
||||
assert!(decoded.is_legacy);
|
||||
|
||||
let decoded = decode_hosted_oauth_state("abc123").expect("legacy raw");
|
||||
assert_eq!(decoded.flow_id, "abc123");
|
||||
assert_eq!(decoded.instance_name, None);
|
||||
assert!(decoded.is_legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_hosted_oauth_state_falls_back_for_non_envelope_ic2_prefix() {
|
||||
use crate::cli::oauth_defaults::decode_hosted_oauth_state;
|
||||
|
||||
let decoded =
|
||||
decode_hosted_oauth_state("ic2.provider-owned-state").expect("prefixed fallback");
|
||||
assert_eq!(decoded.flow_id, "ic2.provider-owned-state");
|
||||
assert_eq!(decoded.instance_name, None);
|
||||
assert!(decoded.is_legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_hosted_oauth_state_rejects_tampered_checksum() {
|
||||
use crate::cli::oauth_defaults::{decode_hosted_oauth_state, encode_hosted_oauth_state};
|
||||
|
||||
let encoded = encode_hosted_oauth_state("abc123", Some("kind-deer"));
|
||||
let tampered = format!("{encoded}broken");
|
||||
let err = decode_hosted_oauth_state(&tampered).expect_err("tampered state should fail");
|
||||
assert!(err.contains("checksum"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
/// Verify that `build_oauth_url` includes the RFC 8707 `resource` parameter
|
||||
/// when passed through `extra_params`, which is how MCP OAuth gateway mode
|
||||
/// scopes tokens to a specific MCP server.
|
||||
|
||||
@@ -24,7 +24,6 @@ Commands:
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
import Import from other AI systems
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -23,7 +23,6 @@ Commands:
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -27,7 +27,6 @@ Commands:
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
import Import from other AI systems
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
@@ -26,7 +26,6 @@ Commands:
|
||||
logs View and manage gateway logs
|
||||
status Show system status
|
||||
completion Generate completions
|
||||
login Authenticate with a provider
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
|
||||
+7
-14
@@ -651,8 +651,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
|
||||
// Check for OAuth configuration
|
||||
if let Some(ref oauth) = auth.oauth {
|
||||
// For providers with shared tokens, combine scopes from all installed
|
||||
// tools so one auth covers everything.
|
||||
// For providers with shared tokens (e.g., all Google tools share google_oauth_token),
|
||||
// combine scopes from all installed tools so one auth covers everything.
|
||||
let combined = combine_provider_scopes(&tools_dir, &auth.secret_name, oauth).await;
|
||||
if combined.scopes.len() > oauth.scopes.len() {
|
||||
let extra = combined.scopes.len() - oauth.scopes.len();
|
||||
@@ -670,8 +670,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
}
|
||||
|
||||
/// Scan the tools directory for all capabilities files sharing the same secret_name
|
||||
/// and combine their OAuth scopes so one authorization covers the full shared
|
||||
/// credential set.
|
||||
/// and combine their OAuth scopes. This way, authing any Google tool requests scopes
|
||||
/// for ALL installed Google tools, so one login covers everything.
|
||||
async fn combine_provider_scopes(
|
||||
tools_dir: &Path,
|
||||
secret_name: &str,
|
||||
@@ -736,18 +736,11 @@ async fn auth_tool_oauth(
|
||||
})
|
||||
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))
|
||||
.ok_or_else(|| {
|
||||
let mut message = format!(
|
||||
anyhow::anyhow!(
|
||||
"OAuth client_id not configured.\n\
|
||||
Set {} env var",
|
||||
Set {} env var, or build with IRONCLAW_GOOGLE_CLIENT_ID.",
|
||||
oauth.client_id_env.as_deref().unwrap_or("the client_id")
|
||||
);
|
||||
if let Some(override_env) =
|
||||
oauth_defaults::builtin_client_id_override_env(&auth.secret_name)
|
||||
{
|
||||
message.push_str(&format!(", or build with {override_env}"));
|
||||
}
|
||||
message.push('.');
|
||||
anyhow::anyhow!(message)
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get client_secret: capabilities file > runtime env var > built-in defaults
|
||||
|
||||
@@ -111,10 +111,6 @@ impl ChannelsConfig {
|
||||
|
||||
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
|
||||
let gateway = if gateway_enabled {
|
||||
let user_id = optional_env("GATEWAY_USER_ID")?
|
||||
.or_else(|| cs.gateway_user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
Some(GatewayConfig {
|
||||
host: optional_env("GATEWAY_HOST")?
|
||||
.or_else(|| cs.gateway_host.clone())
|
||||
@@ -125,7 +121,7 @@ impl ChannelsConfig {
|
||||
)?,
|
||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
||||
.or_else(|| cs.gateway_auth_token.clone()),
|
||||
user_id,
|
||||
user_id: owner_id.to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
+11
-51
@@ -2,15 +2,12 @@ use std::sync::Arc;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::SessionManager;
|
||||
use crate::settings::Settings;
|
||||
use crate::workspace::EmbeddingProvider;
|
||||
|
||||
/// Default maximum number of cached embeddings.
|
||||
pub const DEFAULT_EMBEDDING_CACHE_SIZE: usize = 10_000;
|
||||
|
||||
/// Embeddings provider configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbeddingsConfig {
|
||||
@@ -29,12 +26,6 @@ pub struct EmbeddingsConfig {
|
||||
/// Custom base URL for OpenAI-compatible embedding providers.
|
||||
/// When set, overrides the default `https://api.openai.com`.
|
||||
pub openai_base_url: Option<String>,
|
||||
/// Maximum entries in the embedding LRU cache (default 10,000).
|
||||
///
|
||||
/// Approximate raw embedding payload: `cache_size × dimension × 4 bytes`.
|
||||
/// 10,000 × 1536 floats ≈ 58 MB (payload only; actual memory is higher
|
||||
/// due to HashMap buckets, per-entry Vec/timestamp overhead).
|
||||
pub cache_size: usize,
|
||||
}
|
||||
|
||||
impl Default for EmbeddingsConfig {
|
||||
@@ -49,7 +40,6 @@ impl Default for EmbeddingsConfig {
|
||||
ollama_base_url: "http://localhost:11434".to_string(),
|
||||
dimension,
|
||||
openai_base_url: None,
|
||||
cache_size: DEFAULT_EMBEDDING_CACHE_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +47,7 @@ impl Default for EmbeddingsConfig {
|
||||
/// Infer the embedding dimension from a well-known model name.
|
||||
///
|
||||
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
|
||||
pub(crate) fn default_dimension_for_model(model: &str) -> usize {
|
||||
fn default_dimension_for_model(model: &str) -> usize {
|
||||
match model {
|
||||
"text-embedding-3-small" => 1536,
|
||||
"text-embedding-3-large" => 3072,
|
||||
@@ -90,21 +80,6 @@ impl EmbeddingsConfig {
|
||||
|
||||
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
||||
|
||||
// Validate base URLs to prevent SSRF attacks (#1103).
|
||||
validate_base_url(&ollama_base_url, "OLLAMA_BASE_URL")?;
|
||||
if let Some(ref url) = openai_base_url {
|
||||
validate_base_url(url, "EMBEDDING_BASE_URL")?;
|
||||
}
|
||||
|
||||
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
|
||||
|
||||
if cache_size == 0 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "EMBEDDING_CACHE_SIZE".to_string(),
|
||||
message: "must be at least 1".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
@@ -113,7 +88,6 @@ impl EmbeddingsConfig {
|
||||
ollama_base_url,
|
||||
dimension,
|
||||
openai_base_url,
|
||||
cache_size,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,13 +183,13 @@ mod tests {
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||
std::env::remove_var("EMBEDDING_CACHE_SIZE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embeddings_disabled_not_overridden_by_openai_key() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
@@ -266,6 +240,7 @@ mod tests {
|
||||
#[test]
|
||||
fn embeddings_env_override_takes_precedence() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
@@ -299,12 +274,17 @@ mod tests {
|
||||
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_BASE_URL", "https://8.8.8.8");
|
||||
std::env::set_var("EMBEDDING_BASE_URL", "https://custom.example.com");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let config = EmbeddingsConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(config.openai_base_url.as_deref(), Some("https://8.8.8.8"));
|
||||
assert_eq!(
|
||||
config.openai_base_url.as_deref(),
|
||||
Some("https://custom.example.com"),
|
||||
"EMBEDDING_BASE_URL env var should be parsed into openai_base_url"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_BASE_URL");
|
||||
@@ -323,24 +303,4 @@ mod tests {
|
||||
"openai_base_url should be None when EMBEDDING_BASE_URL is not set"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_size_zero_rejected() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_embedding_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_CACHE_SIZE", "0");
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
let result = EmbeddingsConfig::resolve(&settings);
|
||||
assert!(result.is_err(), "cache_size=0 should be rejected");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("at least 1"), "should mention minimum: {err}");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_CACHE_SIZE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,151 +176,6 @@ pub(crate) fn parse_string_env(
|
||||
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
|
||||
}
|
||||
|
||||
/// Validate a user-configurable base URL to prevent SSRF attacks (#1103).
|
||||
///
|
||||
/// Rejects:
|
||||
/// - Non-HTTP(S) schemes (file://, ftp://, etc.)
|
||||
/// - HTTPS URLs pointing at private/loopback/link-local IPs
|
||||
/// - HTTP URLs pointing at anything other than localhost/127.0.0.1/::1
|
||||
///
|
||||
/// This is intended for config-time validation of base URLs like
|
||||
/// `OLLAMA_BASE_URL`, `EMBEDDING_BASE_URL`, `NEARAI_BASE_URL`, etc.
|
||||
pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!("invalid URL '{}': {}", url, e),
|
||||
})?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!("only http/https URLs are allowed, got '{}'", scheme),
|
||||
});
|
||||
}
|
||||
|
||||
let host = parsed.host_str().ok_or_else(|| ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: "URL is missing a host".to_string(),
|
||||
})?;
|
||||
|
||||
let host_lower = host.to_lowercase();
|
||||
|
||||
// For HTTP (non-TLS), only allow localhost — remote HTTP endpoints
|
||||
// risk credential leakage (e.g. NEAR AI bearer tokens sent over plaintext).
|
||||
if scheme == "http" {
|
||||
let is_localhost = host_lower == "localhost"
|
||||
|| host_lower == "127.0.0.1"
|
||||
|| host_lower == "::1"
|
||||
|| host_lower == "[::1]"
|
||||
|| host_lower.ends_with(".localhost");
|
||||
if !is_localhost {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"HTTP (non-TLS) is only allowed for localhost, got '{}'. \
|
||||
Use HTTPS for remote endpoints.",
|
||||
host
|
||||
),
|
||||
});
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check whether an IP is in a blocked range (private, loopback,
|
||||
// link-local, multicast, metadata, CGN, ULA).
|
||||
let is_dangerous_ip = |ip: &IpAddr| -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
|
||||
} else {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| (v6.octets()[0] & 0xfe) == 0xfc // ULA (fc00::/7)
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10)
|
||||
|| v6.octets()[0] == 0xff // multicast (ff00::/8)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// For HTTPS, reject private/loopback/link-local/metadata IPs.
|
||||
// Check both IP literals and resolved hostnames to prevent DNS-based SSRF.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_dangerous_ip(&ip) {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"URL points to a private/internal IP '{}'. \
|
||||
This is blocked to prevent SSRF attacks.",
|
||||
ip
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Hostname — resolve and check all resulting IPs as defense-in-depth.
|
||||
// NOTE: This does NOT fully prevent DNS rebinding attacks (the hostname
|
||||
// could resolve to a different IP at request time). Full protection
|
||||
// would require pinning the resolved IP in the HTTP client's connector.
|
||||
// This validation catches the common case of misconfigured or malicious URLs.
|
||||
//
|
||||
// NOTE: `to_socket_addrs()` performs blocking DNS resolution. This is
|
||||
// acceptable because `validate_base_url` runs at config-load time only,
|
||||
// before the async runtime is fully driving I/O. If this ever moves to
|
||||
// a hot path, wrap in `tokio::task::spawn_blocking` or use
|
||||
// `tokio::net::lookup_host`.
|
||||
use std::net::ToSocketAddrs;
|
||||
let port = parsed.port().unwrap_or(443);
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(addrs) => {
|
||||
for addr in addrs {
|
||||
if is_dangerous_ip(&addr.ip()) {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"hostname '{}' resolves to private/internal IP '{}'. \
|
||||
This is blocked to prevent SSRF attacks.",
|
||||
host,
|
||||
addr.ip()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"failed to resolve hostname '{}': {}. \
|
||||
Base URLs must be resolvable at config time.",
|
||||
host, e
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -371,122 +226,4 @@ mod tests {
|
||||
// Now the runtime override is visible again
|
||||
assert_eq!(env_or_override(key), Some("override_value".to_string()));
|
||||
}
|
||||
|
||||
// --- validate_base_url tests (regression for #1103) ---
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_allows_https() {
|
||||
// Use IP literals to avoid DNS resolution in sandboxed test environments.
|
||||
assert!(validate_base_url("https://8.8.8.8", "TEST").is_ok());
|
||||
assert!(validate_base_url("https://8.8.8.8/v1", "TEST").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_allows_http_localhost() {
|
||||
assert!(validate_base_url("http://localhost:11434", "TEST").is_ok());
|
||||
assert!(validate_base_url("http://127.0.0.1:11434", "TEST").is_ok());
|
||||
assert!(validate_base_url("http://[::1]:11434", "TEST").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_http_remote() {
|
||||
assert!(validate_base_url("http://evil.example.com", "TEST").is_err());
|
||||
assert!(validate_base_url("http://192.168.1.1", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_non_http_schemes() {
|
||||
assert!(validate_base_url("file:///etc/passwd", "TEST").is_err());
|
||||
assert!(validate_base_url("ftp://evil.com", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_cloud_metadata() {
|
||||
assert!(validate_base_url("https://169.254.169.254", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_private_ips() {
|
||||
assert!(validate_base_url("https://10.0.0.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://192.168.1.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://172.16.0.1", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_cgn_range() {
|
||||
// Carrier-grade NAT: 100.64.0.0/10
|
||||
assert!(validate_base_url("https://100.64.0.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://100.127.255.254", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv4_mapped_ipv6() {
|
||||
// ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to private IP
|
||||
assert!(validate_base_url("https://[::ffff:10.0.0.1]", "TEST").is_err());
|
||||
assert!(validate_base_url("https://[::ffff:169.254.169.254]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ula_ipv6() {
|
||||
// fc00::/7 — unique local addresses
|
||||
assert!(validate_base_url("https://[fc00::1]", "TEST").is_err());
|
||||
assert!(validate_base_url("https://[fd12:3456:789a::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_handles_url_with_credentials() {
|
||||
// URLs with embedded credentials — validate_base_url checks the host,
|
||||
// not the credentials. Use IP literal to avoid DNS in sandboxed envs.
|
||||
let result = validate_base_url("https://user:[email protected]", "TEST");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_empty_and_invalid() {
|
||||
assert!(validate_base_url("", "TEST").is_err());
|
||||
assert!(validate_base_url("not-a-url", "TEST").is_err());
|
||||
assert!(validate_base_url("://missing-scheme", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_unspecified_ipv4() {
|
||||
assert!(validate_base_url("https://0.0.0.0", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_loopback_https() {
|
||||
// IPv6 loopback is allowed over HTTP (localhost equivalent),
|
||||
// but must be rejected over HTTPS as a dangerous IP.
|
||||
assert!(validate_base_url("https://[::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_link_local() {
|
||||
// fe80::/10 — link-local addresses
|
||||
assert!(validate_base_url("https://[fe80::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_multicast() {
|
||||
// ff00::/8 — multicast addresses
|
||||
assert!(validate_base_url("https://[ff02::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_unspecified() {
|
||||
// :: — unspecified address
|
||||
assert!(validate_base_url("https://[::]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_dns_failure() {
|
||||
// .invalid TLD is guaranteed to never resolve (RFC 6761)
|
||||
let result = validate_base_url("https://ssrf-test.invalid", "TEST");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("failed to resolve"),
|
||||
"Expected DNS resolution failure, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-324
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url};
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::config::*;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
@@ -37,7 +37,6 @@ impl LlmConfig {
|
||||
},
|
||||
provider: None,
|
||||
bedrock: None,
|
||||
openai_codex: None,
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: false,
|
||||
@@ -73,12 +72,8 @@ impl LlmConfig {
|
||||
backend_lower == "nearai" || backend_lower == "near_ai" || backend_lower == "near";
|
||||
let is_bedrock =
|
||||
backend_lower == "bedrock" || backend_lower == "aws_bedrock" || backend_lower == "aws";
|
||||
let is_openai_codex = backend_lower == "openai_codex"
|
||||
|| backend_lower == "openai-codex"
|
||||
|| backend_lower == "codex";
|
||||
|
||||
if !is_nearai && !is_bedrock && !is_openai_codex && registry.find(&backend_lower).is_none()
|
||||
{
|
||||
if !is_nearai && !is_bedrock && registry.find(&backend_lower).is_none() {
|
||||
tracing::warn!(
|
||||
"Unknown LLM backend '{}'. Will attempt as openai_compatible fallback.",
|
||||
backend
|
||||
@@ -86,11 +81,9 @@ impl LlmConfig {
|
||||
}
|
||||
|
||||
// Session config (used by NearAI provider for OAuth/session-token auth)
|
||||
let nearai_auth_url = optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string());
|
||||
validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?;
|
||||
let session = SessionConfig {
|
||||
auth_base_url: nearai_auth_url,
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
@@ -99,19 +92,15 @@ impl LlmConfig {
|
||||
// Always resolve NEAR AI config (used for embeddings even when not the primary backend)
|
||||
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
let nearai = NearAiConfig {
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?,
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, "zai-org/GLM-latest")?,
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: {
|
||||
let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
"https://cloud-api.near.ai".to_string()
|
||||
} else {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
});
|
||||
validate_base_url(&url, "NEARAI_BASE_URL")?;
|
||||
url
|
||||
},
|
||||
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
"https://cloud-api.near.ai".to_string()
|
||||
} else {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
}),
|
||||
api_key: nearai_api_key,
|
||||
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
|
||||
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
|
||||
@@ -131,8 +120,8 @@ impl LlmConfig {
|
||||
smart_routing_cascade: parse_optional_env("SMART_ROUTING_CASCADE", true)?,
|
||||
};
|
||||
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock, non-Codex backends)
|
||||
let provider = if is_nearai || is_bedrock || is_openai_codex {
|
||||
// Resolve registry provider config (for non-NearAI, non-Bedrock backends)
|
||||
let provider = if is_nearai || is_bedrock {
|
||||
None
|
||||
} else {
|
||||
Some(Self::resolve_registry_provider(
|
||||
@@ -179,38 +168,6 @@ impl LlmConfig {
|
||||
None
|
||||
};
|
||||
|
||||
// Resolve OpenAI Codex config
|
||||
let openai_codex = if is_openai_codex {
|
||||
// Model: OPENAI_CODEX_MODEL > OPENAI_MODEL > settings.selected_model > default
|
||||
let model = optional_env("OPENAI_CODEX_MODEL")?
|
||||
.or(optional_env("OPENAI_MODEL")?)
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.unwrap_or_else(|| "gpt-5.3-codex".to_string());
|
||||
let auth_endpoint = optional_env("OPENAI_CODEX_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://auth.openai.com".to_string());
|
||||
validate_base_url(&auth_endpoint, "OPENAI_CODEX_AUTH_URL")?;
|
||||
let api_base_url = optional_env("OPENAI_CODEX_API_URL")?
|
||||
.unwrap_or_else(|| "https://chatgpt.com/backend-api/codex".to_string());
|
||||
validate_base_url(&api_base_url, "OPENAI_CODEX_API_URL")?;
|
||||
let client_id = optional_env("OPENAI_CODEX_CLIENT_ID")?
|
||||
.unwrap_or_else(|| "app_EMoamEEZ73f0CkXaXp7hrann".to_string());
|
||||
let session_path = optional_env("OPENAI_CODEX_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| ironclaw_base_dir().join("openai_codex_session.json"));
|
||||
let token_refresh_margin_secs =
|
||||
parse_optional_env("OPENAI_CODEX_REFRESH_MARGIN_SECS", 300)?;
|
||||
Some(OpenAiCodexConfig {
|
||||
model,
|
||||
auth_endpoint,
|
||||
api_base_url,
|
||||
client_id,
|
||||
session_path,
|
||||
token_refresh_margin_secs,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request_timeout_secs = parse_optional_env("LLM_REQUEST_TIMEOUT_SECS", 120)?;
|
||||
|
||||
// Generic cheap model (works with any backend).
|
||||
@@ -226,8 +183,6 @@ impl LlmConfig {
|
||||
"nearai".to_string()
|
||||
} else if is_bedrock {
|
||||
"bedrock".to_string()
|
||||
} else if is_openai_codex {
|
||||
"openai_codex".to_string()
|
||||
} else if let Some(ref p) = provider {
|
||||
p.provider_id.clone()
|
||||
} else {
|
||||
@@ -237,7 +192,6 @@ impl LlmConfig {
|
||||
nearai,
|
||||
provider,
|
||||
bedrock,
|
||||
openai_codex,
|
||||
request_timeout_secs,
|
||||
cheap_model,
|
||||
smart_routing_cascade,
|
||||
@@ -371,12 +325,6 @@ impl LlmConfig {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate base URL to prevent SSRF (#1103).
|
||||
if !base_url.is_empty() {
|
||||
let field = base_url_env.unwrap_or("LLM_BASE_URL");
|
||||
validate_base_url(&base_url, field)?;
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
let model = Self::resolve_model(model_env, settings, default_model)?;
|
||||
|
||||
@@ -389,14 +337,6 @@ impl LlmConfig {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let extra_headers = if canonical_id == "github_copilot" {
|
||||
merge_extra_headers(
|
||||
crate::llm::github_copilot_auth::default_headers(),
|
||||
extra_headers,
|
||||
)
|
||||
} else {
|
||||
extra_headers
|
||||
};
|
||||
|
||||
// Resolve OAuth token (Anthropic-specific: `claude login` flow).
|
||||
// Only check for OAuth token when the provider is actually Anthropic.
|
||||
@@ -481,26 +421,6 @@ fn parse_extra_headers(val: &str) -> Result<Vec<(String, String)>, ConfigError>
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn merge_extra_headers(
|
||||
defaults: Vec<(String, String)>,
|
||||
overrides: Vec<(String, String)>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut merged = Vec::new();
|
||||
let mut positions = std::collections::HashMap::<String, usize>::new();
|
||||
|
||||
for (key, value) in defaults.into_iter().chain(overrides) {
|
||||
let normalized = key.to_ascii_lowercase();
|
||||
if let Some(existing_index) = positions.get(&normalized).copied() {
|
||||
merged[existing_index] = (key, value);
|
||||
} else {
|
||||
positions.insert(normalized, merged.len());
|
||||
merged.push((key, value));
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
/// Get the default session file path (~/.ironclaw/session.json).
|
||||
pub fn default_session_path() -> PathBuf {
|
||||
ironclaw_base_dir().join("session.json")
|
||||
@@ -632,29 +552,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_extra_headers_prefers_overrides_case_insensitively() {
|
||||
let merged = merge_extra_headers(
|
||||
vec![
|
||||
("User-Agent".to_string(), "default-agent".to_string()),
|
||||
("X-Test".to_string(), "default".to_string()),
|
||||
],
|
||||
vec![
|
||||
("user-agent".to_string(), "override-agent".to_string()),
|
||||
("X-Extra".to_string(), "present".to_string()),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
merged,
|
||||
vec![
|
||||
("user-agent".to_string(), "override-agent".to_string()),
|
||||
("X-Test".to_string(), "default".to_string()),
|
||||
("X-Extra".to_string(), "present".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear all ollama-related env vars.
|
||||
fn clear_ollama_env() {
|
||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||
@@ -807,54 +704,6 @@ mod tests {
|
||||
assert_eq!(provider.protocol, ProviderProtocol::OpenAiCompletions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_provider_resolves_github_copilot_alias() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "github-copilot");
|
||||
std::env::set_var("GITHUB_COPILOT_TOKEN", "gho_test_token");
|
||||
std::env::set_var(
|
||||
"GITHUB_COPILOT_EXTRA_HEADERS",
|
||||
"Copilot-Integration-Id:custom-chat,X-Test:enabled",
|
||||
);
|
||||
}
|
||||
|
||||
let settings = Settings::default();
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "github_copilot");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(provider.provider_id, "github_copilot");
|
||||
assert_eq!(provider.base_url, "https://api.githubcopilot.com");
|
||||
assert_eq!(provider.model, "gpt-4o");
|
||||
assert!(
|
||||
provider
|
||||
.extra_headers
|
||||
.iter()
|
||||
.any(|(key, value)| { key == "Copilot-Integration-Id" && value == "custom-chat" })
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.extra_headers
|
||||
.iter()
|
||||
.any(|(key, value)| key == "User-Agent" && value == "GitHubCopilotChat/0.26.7")
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.extra_headers
|
||||
.iter()
|
||||
.any(|(key, value)| key == "X-Test" && value == "enabled")
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GITHUB_COPILOT_TOKEN");
|
||||
std::env::remove_var("GITHUB_COPILOT_EXTRA_HEADERS");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearai_backend_has_no_registry_provider() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
@@ -954,19 +803,19 @@ mod tests {
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("LLM_BACKEND", "openai_compatible");
|
||||
std::env::set_var("LLM_BASE_URL", "http://localhost:8000/v1");
|
||||
std::env::set_var("LLM_BASE_URL", "http://env-url/v1");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_compatible".to_string()),
|
||||
openai_compatible_base_url: Some("http://localhost:9000/v1".to_string()),
|
||||
openai_compatible_base_url: Some("http://settings-url/v1".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://localhost:8000/v1",
|
||||
provider.base_url, "http://env-url/v1",
|
||||
"env var should take priority over settings"
|
||||
);
|
||||
|
||||
@@ -978,7 +827,7 @@ mod tests {
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("should have provider config");
|
||||
assert_eq!(
|
||||
provider.base_url, "http://localhost:9000/v1",
|
||||
provider.base_url, "http://settings-url/v1",
|
||||
"settings should take priority over registry default"
|
||||
);
|
||||
|
||||
@@ -1208,159 +1057,4 @@ mod tests {
|
||||
std::env::remove_var("LLM_REQUEST_TIMEOUT_SECS");
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAI Codex tests ──────────────────────────────────────────
|
||||
|
||||
/// Clear all openai-codex-related env vars.
|
||||
fn clear_openai_codex_env() {
|
||||
// SAFETY: Only called under ENV_MUTEX in tests.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||
std::env::remove_var("OPENAI_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_resolves_config() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
assert_eq!(cfg.backend, "openai_codex");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-5.3-codex"); // default
|
||||
assert!(
|
||||
cfg.provider.is_none(),
|
||||
"codex should not use registry provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_model_env_resolution() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_CODEX_MODEL", "o3-pro");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "o3-pro");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_falls_back_to_openai_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_MODEL", "gpt-4o");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-4o");
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_codex_falls_back_to_selected_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
selected_model: Some("gpt-4o-mini".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let codex = cfg.openai_codex.expect("codex config should be present");
|
||||
assert_eq!(codex.model, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
/// Regression: SSRF validation on OPENAI_CODEX_API_URL (#1103).
|
||||
#[test]
|
||||
fn openai_codex_rejects_ssrf_api_url() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"OPENAI_CODEX_API_URL",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
);
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = LlmConfig::resolve(&settings).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("OPENAI_CODEX_API_URL"),
|
||||
"error should reference the field name: {msg}"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_API_URL");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: SSRF validation on OPENAI_CODEX_AUTH_URL (#1103).
|
||||
#[test]
|
||||
fn openai_codex_rejects_ssrf_auth_url() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
clear_openai_codex_env();
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::set_var("OPENAI_CODEX_AUTH_URL", "http://10.0.0.1");
|
||||
}
|
||||
|
||||
let settings = Settings {
|
||||
llm_backend: Some("openai_codex".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let err = LlmConfig::resolve(&settings).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("OPENAI_CODEX_AUTH_URL"),
|
||||
"error should reference the field name: {msg}"
|
||||
);
|
||||
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("OPENAI_CODEX_AUTH_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-21
@@ -9,7 +9,7 @@ mod agent;
|
||||
mod builder;
|
||||
mod channels;
|
||||
mod database;
|
||||
pub(crate) mod embeddings;
|
||||
mod embeddings;
|
||||
mod heartbeat;
|
||||
pub(crate) mod helpers;
|
||||
mod hygiene;
|
||||
@@ -24,7 +24,6 @@ mod skills;
|
||||
mod transcription;
|
||||
mod tunnel;
|
||||
mod wasm;
|
||||
mod workspace;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, Mutex, Once};
|
||||
@@ -39,7 +38,7 @@ pub use self::channels::{
|
||||
ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig,
|
||||
};
|
||||
pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path};
|
||||
pub use self::embeddings::{DEFAULT_EMBEDDING_CACHE_SIZE, EmbeddingsConfig};
|
||||
pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::default_session_path;
|
||||
@@ -54,9 +53,8 @@ pub use self::skills::SkillsConfig;
|
||||
pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use self::workspace::WorkspaceConfig;
|
||||
pub use crate::llm::config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
@@ -100,7 +98,6 @@ pub struct Config {
|
||||
pub skills: SkillsConfig,
|
||||
pub transcription: TranscriptionConfig,
|
||||
pub search: WorkspaceSearchConfig,
|
||||
pub workspace: WorkspaceConfig,
|
||||
pub observability: crate::observability::ObservabilityConfig,
|
||||
/// Channel-relay integration (Slack via external relay service).
|
||||
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
|
||||
@@ -178,9 +175,6 @@ impl Config {
|
||||
},
|
||||
transcription: TranscriptionConfig::default(),
|
||||
search: WorkspaceSearchConfig::default(),
|
||||
workspace: WorkspaceConfig {
|
||||
memory_layers: vec![],
|
||||
},
|
||||
observability: crate::observability::ObservabilityConfig::default(),
|
||||
relay: None,
|
||||
}
|
||||
@@ -311,21 +305,13 @@ impl Config {
|
||||
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let owner_id = resolve_owner_id(settings)?;
|
||||
|
||||
let tunnel = TunnelConfig::resolve(settings)?;
|
||||
let channels = ChannelsConfig::resolve(settings, &owner_id)?;
|
||||
let workspace_user_id = channels
|
||||
.gateway
|
||||
.as_ref()
|
||||
.map(|gw| gw.user_id.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
Ok(Self {
|
||||
owner_id: owner_id.clone(),
|
||||
database: DatabaseConfig::resolve()?,
|
||||
llm: LlmConfig::resolve(settings)?,
|
||||
embeddings: EmbeddingsConfig::resolve(settings)?,
|
||||
tunnel,
|
||||
channels,
|
||||
tunnel: TunnelConfig::resolve(settings)?,
|
||||
channels: ChannelsConfig::resolve(settings, &owner_id)?,
|
||||
agent: AgentConfig::resolve(settings)?,
|
||||
safety: resolve_safety_config(settings)?,
|
||||
wasm: WasmConfig::resolve(settings)?,
|
||||
@@ -339,7 +325,6 @@ impl Config {
|
||||
skills: SkillsConfig::resolve()?,
|
||||
transcription: TranscriptionConfig::resolve(settings)?,
|
||||
search: WorkspaceSearchConfig::resolve()?,
|
||||
workspace: WorkspaceConfig::resolve(&workspace_user_id)?,
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
},
|
||||
@@ -392,7 +377,7 @@ pub(crate) fn resolve_owner_id(settings: &Settings) -> Result<String, ConfigErro
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// so explicit env vars always win.
|
||||
///
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain / Linux
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
|
||||
/// credentials files) which don't require the secrets DB.
|
||||
pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
|
||||
+35
-29
@@ -7,7 +7,7 @@ use secrecy::SecretString;
|
||||
pub struct RelayConfig {
|
||||
/// Base URL of the channel-relay service (e.g., `http://localhost:3001`).
|
||||
pub url: String,
|
||||
/// Bearer token for authenticated channel-relay endpoints (`sk-agent-*`).
|
||||
/// API key for authenticated channel-relay endpoints.
|
||||
pub api_key: SecretString,
|
||||
/// Override for the OAuth callback URL (e.g., a tunnel URL).
|
||||
pub callback_url: Option<String>,
|
||||
@@ -15,8 +15,12 @@ pub struct RelayConfig {
|
||||
pub instance_id: Option<String>,
|
||||
/// HTTP request timeout in seconds (default: 30).
|
||||
pub request_timeout_secs: u64,
|
||||
/// Path for the webhook callback endpoint (default: `/relay/events`).
|
||||
pub webhook_path: String,
|
||||
/// SSE stream long-poll timeout in seconds (default: 86400 = 24 h).
|
||||
pub stream_timeout_secs: u64,
|
||||
/// Initial exponential backoff in milliseconds (default: 1000).
|
||||
pub backoff_initial_ms: u64,
|
||||
/// Maximum exponential backoff in milliseconds (default: 60000).
|
||||
pub backoff_max_ms: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RelayConfig {
|
||||
@@ -27,7 +31,9 @@ impl std::fmt::Debug for RelayConfig {
|
||||
.field("callback_url", &self.callback_url)
|
||||
.field("instance_id", &self.instance_id)
|
||||
.field("request_timeout_secs", &self.request_timeout_secs)
|
||||
.field("webhook_path", &self.webhook_path)
|
||||
.field("stream_timeout_secs", &self.stream_timeout_secs)
|
||||
.field("backoff_initial_ms", &self.backoff_initial_ms)
|
||||
.field("backoff_max_ms", &self.backoff_max_ms)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -35,10 +41,8 @@ impl std::fmt::Debug for RelayConfig {
|
||||
impl RelayConfig {
|
||||
/// Load relay config from environment variables.
|
||||
///
|
||||
/// Returns `None` if either of the required env vars (`CHANNEL_RELAY_URL`,
|
||||
/// `CHANNEL_RELAY_API_KEY`) is not set, making the relay integration opt-in.
|
||||
/// The signing secret is fetched from channel-relay at activation time via
|
||||
/// the authenticated `/relay/signing-secret` endpoint — no env var required.
|
||||
/// Returns `None` if either `CHANNEL_RELAY_URL` or `CHANNEL_RELAY_API_KEY`
|
||||
/// is not set, making the relay integration opt-in.
|
||||
pub fn from_env() -> Option<Self> {
|
||||
Self::from_env_reader(|key| std::env::var(key).ok())
|
||||
}
|
||||
@@ -51,7 +55,9 @@ impl RelayConfig {
|
||||
callback_url: None,
|
||||
instance_id: None,
|
||||
request_timeout_secs: 30,
|
||||
webhook_path: "/relay/events".into(),
|
||||
stream_timeout_secs: 86400,
|
||||
backoff_initial_ms: 1000,
|
||||
backoff_max_ms: 60000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +73,15 @@ impl RelayConfig {
|
||||
request_timeout_secs: env("RELAY_REQUEST_TIMEOUT_SECS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30),
|
||||
webhook_path: env("RELAY_WEBHOOK_PATH").unwrap_or_else(|| "/relay/events".into()),
|
||||
stream_timeout_secs: env("RELAY_STREAM_TIMEOUT_SECS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(86400),
|
||||
backoff_initial_ms: env("RELAY_BACKOFF_INITIAL_MS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000),
|
||||
backoff_max_ms: env("RELAY_BACKOFF_MAX_MS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60000),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -83,21 +97,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reader_requires_only_url_and_api_key() {
|
||||
// Signing secret is fetched at activation time — only URL + API key needed.
|
||||
let config = RelayConfig::from_env_reader(|key| match key {
|
||||
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
||||
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(
|
||||
config.is_some(),
|
||||
"relay config should load with just URL + API key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reader_loads_all_required() {
|
||||
fn from_env_reader_loads_defaults() {
|
||||
let config = RelayConfig::from_env_reader(|key| match key {
|
||||
"CHANNEL_RELAY_URL" => Some("http://localhost:3001".into()),
|
||||
"CHANNEL_RELAY_API_KEY" => Some("test-key".into()),
|
||||
@@ -107,7 +107,9 @@ mod tests {
|
||||
|
||||
assert_eq!(config.url, "http://localhost:3001");
|
||||
assert_eq!(config.request_timeout_secs, 30);
|
||||
assert_eq!(config.webhook_path, "/relay/events");
|
||||
assert_eq!(config.stream_timeout_secs, 86400);
|
||||
assert_eq!(config.backoff_initial_ms, 1000);
|
||||
assert_eq!(config.backoff_max_ms, 60000);
|
||||
assert!(config.callback_url.is_none());
|
||||
assert!(config.instance_id.is_none());
|
||||
}
|
||||
@@ -120,7 +122,9 @@ mod tests {
|
||||
"IRONCLAW_OAUTH_CALLBACK_URL" => Some("https://tunnel.example.com".into()),
|
||||
"IRONCLAW_INSTANCE_ID" => Some("my-instance".into()),
|
||||
"RELAY_REQUEST_TIMEOUT_SECS" => Some("60".into()),
|
||||
"RELAY_WEBHOOK_PATH" => Some("/custom/events".into()),
|
||||
"RELAY_STREAM_TIMEOUT_SECS" => Some("43200".into()),
|
||||
"RELAY_BACKOFF_INITIAL_MS" => Some("2000".into()),
|
||||
"RELAY_BACKOFF_MAX_MS" => Some("120000".into()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("config should be Some");
|
||||
@@ -131,7 +135,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(config.instance_id.as_deref(), Some("my-instance"));
|
||||
assert_eq!(config.request_timeout_secs, 60);
|
||||
assert_eq!(config.webhook_path, "/custom/events");
|
||||
assert_eq!(config.stream_timeout_secs, 43200);
|
||||
assert_eq!(config.backoff_initial_ms, 2000);
|
||||
assert_eq!(config.backoff_max_ms, 120000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -142,7 +148,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_redacts_secrets() {
|
||||
fn debug_redacts_api_key() {
|
||||
let config = RelayConfig::from_values("http://localhost:3001", "super-secret");
|
||||
let debug = format!("{:?}", config);
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -60,11 +60,6 @@ impl TranscriptionConfig {
|
||||
|
||||
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
|
||||
|
||||
// Validate base URL to prevent SSRF (#1103).
|
||||
if let Some(ref url) = base_url {
|
||||
validate_base_url(url, "TRANSCRIPTION_BASE_URL")?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
use crate::config::helpers::optional_env;
|
||||
use crate::error::ConfigError;
|
||||
use crate::workspace::layer::MemoryLayer;
|
||||
|
||||
/// Workspace memory configuration.
|
||||
///
|
||||
/// Controls memory layer definitions for privacy-aware writes.
|
||||
/// Layers are parsed from the `MEMORY_LAYERS` env var (JSON array)
|
||||
/// or default to a single private layer scoped to the gateway user.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkspaceConfig {
|
||||
pub memory_layers: Vec<MemoryLayer>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfig {
|
||||
pub(crate) fn resolve(user_id: &str) -> Result<Self, ConfigError> {
|
||||
let memory_layers: Vec<MemoryLayer> = match optional_env("MEMORY_LAYERS")? {
|
||||
Some(json_str) => {
|
||||
serde_json::from_str(&json_str).map_err(|e| ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: format!("must be valid JSON array of layer objects: {e}"),
|
||||
})?
|
||||
}
|
||||
None => MemoryLayer::default_for_user(user_id),
|
||||
};
|
||||
|
||||
// Validate layer names and scopes
|
||||
for layer in &memory_layers {
|
||||
if layer.name.trim().is_empty() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: "layer name must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
if layer.name.len() > 64 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: format!("layer name '{}' exceeds 64 characters", layer.name),
|
||||
});
|
||||
}
|
||||
if !layer
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: format!(
|
||||
"layer name '{}' contains invalid characters (only alphanumeric, _, - allowed)",
|
||||
layer.name
|
||||
),
|
||||
});
|
||||
}
|
||||
if layer.scope.trim().is_empty() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: format!("layer '{}' has an empty scope", layer.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate layer names
|
||||
{
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for layer in &memory_layers {
|
||||
if !seen.insert(&layer.name) {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "MEMORY_LAYERS".to_string(),
|
||||
message: format!("duplicate layer name '{}'", layer.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { memory_layers })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Serialize env-var-dependent tests to avoid races.
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn with_env(key: &str, val: Option<&str>, f: impl FnOnce()) {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let prev = std::env::var(key).ok();
|
||||
match val {
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
f();
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var(key, v) },
|
||||
None => unsafe { std::env::remove_var(key) },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_json_parses_correctly() {
|
||||
let json = r#"[{"name":"private","scope":"alice","writable":true,"sensitivity":"private"},{"name":"shared","scope":"shared","writable":true,"sensitivity":"shared"}]"#;
|
||||
with_env("MEMORY_LAYERS", Some(json), || {
|
||||
let config = WorkspaceConfig::resolve("alice").expect("should parse");
|
||||
assert_eq!(config.memory_layers.len(), 2);
|
||||
assert_eq!(config.memory_layers[0].name, "private");
|
||||
assert_eq!(config.memory_layers[1].name, "shared");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_returns_error() {
|
||||
with_env("MEMORY_LAYERS", Some("not json"), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(result.is_err(), "invalid JSON should fail");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("valid JSON"),
|
||||
"error should mention JSON: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_layer_name_returns_error() {
|
||||
let json = r#"[{"name":"","scope":"alice"}]"#;
|
||||
with_env("MEMORY_LAYERS", Some(json), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(result.is_err(), "empty layer name should fail");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("empty"), "error should mention empty: {err}");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_name_exceeding_64_chars_returns_error() {
|
||||
let long_name = "a".repeat(65);
|
||||
let json = format!(r#"[{{"name":"{long_name}","scope":"alice"}}]"#);
|
||||
with_env("MEMORY_LAYERS", Some(&json), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(result.is_err(), "long layer name should fail");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("exceeds 64"),
|
||||
"error should mention 64 chars: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_name_with_invalid_chars_returns_error() {
|
||||
for bad_name in ["has space", "has@at", "has.dot", "has/slash"] {
|
||||
let json = format!(r#"[{{"name":"{bad_name}","scope":"alice"}}]"#);
|
||||
with_env("MEMORY_LAYERS", Some(&json), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"layer name '{bad_name}' should fail validation"
|
||||
);
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("invalid characters"),
|
||||
"error for '{bad_name}' should mention invalid characters: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_scope_returns_error() {
|
||||
let json = r#"[{"name":"private","scope":""}]"#;
|
||||
with_env("MEMORY_LAYERS", Some(json), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(result.is_err(), "empty scope should fail");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("empty scope"),
|
||||
"error should mention empty scope: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_layer_names_returns_error() {
|
||||
let json = r#"[{"name":"private","scope":"alice"},{"name":"private","scope":"bob"}]"#;
|
||||
with_env("MEMORY_LAYERS", Some(json), || {
|
||||
let result = WorkspaceConfig::resolve("alice");
|
||||
assert!(result.is_err(), "duplicate names should fail");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("duplicate"),
|
||||
"error should mention duplicate: {err}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_env_defaults_to_single_private_layer() {
|
||||
with_env("MEMORY_LAYERS", None, || {
|
||||
let config = WorkspaceConfig::resolve("alice").expect("should default");
|
||||
assert_eq!(config.memory_layers.len(), 1);
|
||||
assert_eq!(config.memory_layers[0].name, "private");
|
||||
assert_eq!(config.memory_layers[0].scope, "alice");
|
||||
assert!(config.memory_layers[0].writable);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
//! Structured fallback deliverables for failed or stuck jobs.
|
||||
//!
|
||||
//! When a job fails or is detected as stuck, a [`FallbackDeliverable`] captures
|
||||
//! what was accomplished before the failure: partial results, action statistics,
|
||||
//! cost, and timing. This gives users visibility into terminal jobs instead of
|
||||
//! just an error string.
|
||||
//!
|
||||
//! Fallback deliverables are stored in `JobContext.metadata["fallback_deliverable"]`
|
||||
//! and surfaced through the `job_status` tool.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::context::memory::Memory;
|
||||
use crate::context::state::JobContext;
|
||||
|
||||
/// Structured summary of a failed or stuck job.
|
||||
///
|
||||
/// Stored in `JobContext.metadata["fallback_deliverable"]` when a job fails
|
||||
/// or is marked stuck. Surfaced through the `job_status` tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FallbackDeliverable {
|
||||
/// True if at least one action succeeded before failure.
|
||||
pub partial: bool,
|
||||
/// Why the job failed.
|
||||
pub failure_reason: String,
|
||||
/// Last action taken before failure.
|
||||
pub last_action: Option<LastAction>,
|
||||
/// Aggregate action statistics.
|
||||
pub action_stats: ActionStats,
|
||||
/// Total tokens consumed.
|
||||
pub tokens_used: u64,
|
||||
/// Total cost incurred (decimal as string for JSON safety).
|
||||
pub cost: String,
|
||||
/// Wall-clock elapsed time in seconds.
|
||||
pub elapsed_secs: f64,
|
||||
/// Number of self-repair attempts.
|
||||
pub repair_attempts: u32,
|
||||
}
|
||||
|
||||
/// Summary of the last action taken before failure.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LastAction {
|
||||
pub tool_name: String,
|
||||
/// Truncated to 200 bytes (UTF-8 safe).
|
||||
pub output_preview: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
/// Aggregate action counts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActionStats {
|
||||
pub total: u32,
|
||||
pub successful: u32,
|
||||
pub failed: u32,
|
||||
}
|
||||
|
||||
impl FallbackDeliverable {
|
||||
/// Build a fallback deliverable from a job context and its memory.
|
||||
pub fn build(ctx: &JobContext, memory: &Memory, reason: &str) -> Self {
|
||||
let successful = memory.successful_actions() as u32;
|
||||
let failed = memory.failed_actions() as u32;
|
||||
let total = memory.actions.len() as u32;
|
||||
|
||||
let last_action = memory.last_action().map(|a| {
|
||||
// Use sanitized output to avoid leaking secrets through the fallback API surface.
|
||||
// For failed actions (no sanitized output), fall back to the error message.
|
||||
// Borrow the string slice directly when possible to avoid cloning
|
||||
// potentially large outputs just for truncation.
|
||||
let owned_fallback;
|
||||
let preview_str: &str = if let Some(v) = a.output_sanitized.as_ref() {
|
||||
match v {
|
||||
serde_json::Value::String(s) => s.as_str(),
|
||||
other => {
|
||||
owned_fallback = serde_json::to_string(other).unwrap_or_default();
|
||||
&owned_fallback
|
||||
}
|
||||
}
|
||||
} else if let Some(ref err) = a.error {
|
||||
err.as_str()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let preview = truncate_str(preview_str, 200);
|
||||
LastAction {
|
||||
tool_name: a.tool_name.clone(),
|
||||
output_preview: preview.to_string(),
|
||||
success: a.success,
|
||||
}
|
||||
});
|
||||
|
||||
let elapsed_secs = ctx.elapsed().map_or(0.0, |d| d.as_secs_f64());
|
||||
|
||||
Self {
|
||||
partial: successful > 0,
|
||||
failure_reason: truncate_str(reason, 1000).to_string(),
|
||||
last_action,
|
||||
action_stats: ActionStats {
|
||||
total,
|
||||
successful,
|
||||
failed,
|
||||
},
|
||||
tokens_used: ctx.total_tokens_used,
|
||||
cost: ctx.actual_cost.to_string(),
|
||||
elapsed_secs,
|
||||
repair_attempts: ctx.repair_attempts,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to at most `max_len` bytes on a char boundary.
|
||||
fn truncate_str(s: &str, max_len: usize) -> &str {
|
||||
&s[..crate::util::floor_char_boundary(s, max_len)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::memory::Memory;
|
||||
use crate::context::state::JobContext;
|
||||
use chrono::{Duration, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
#[test]
|
||||
fn test_fallback_zero_actions() {
|
||||
let ctx = JobContext::new("Test", "Empty job");
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "timed out");
|
||||
|
||||
assert!(!fb.partial); // safety: test
|
||||
assert_eq!(fb.failure_reason, "timed out"); // safety: test
|
||||
assert!(fb.last_action.is_none()); // safety: test
|
||||
assert_eq!(fb.action_stats.total, 0); // safety: test
|
||||
assert_eq!(fb.action_stats.successful, 0); // safety: test
|
||||
assert_eq!(fb.action_stats.failed, 0); // safety: test
|
||||
assert_eq!(fb.tokens_used, 0); // safety: test
|
||||
assert_eq!(fb.cost, "0"); // safety: test
|
||||
assert_eq!(fb.repair_attempts, 0); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_mixed_actions() {
|
||||
let mut ctx = JobContext::new("Test", "Mixed job");
|
||||
ctx.total_tokens_used = 5000;
|
||||
ctx.actual_cost = Decimal::new(42, 2); // 0.42
|
||||
ctx.repair_attempts = 1;
|
||||
|
||||
let mut memory = Memory::new(ctx.job_id);
|
||||
|
||||
// 3 successes
|
||||
for _ in 0..3 {
|
||||
let action = memory
|
||||
.create_action("tool_a", serde_json::json!({}))
|
||||
.succeed(
|
||||
Some("output".to_string()),
|
||||
serde_json::json!({}),
|
||||
StdDuration::from_secs(1),
|
||||
);
|
||||
memory.record_action(action);
|
||||
}
|
||||
// 2 failures
|
||||
for _ in 0..2 {
|
||||
let action = memory
|
||||
.create_action("tool_b", serde_json::json!({}))
|
||||
.fail("broke", StdDuration::from_secs(1));
|
||||
memory.record_action(action);
|
||||
}
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "max iterations");
|
||||
|
||||
assert!(fb.partial); // safety: test
|
||||
assert_eq!(fb.action_stats.total, 5); // safety: test
|
||||
assert_eq!(fb.action_stats.successful, 3); // safety: test
|
||||
assert_eq!(fb.action_stats.failed, 2); // safety: test
|
||||
assert_eq!(fb.tokens_used, 5000); // safety: test
|
||||
assert_eq!(fb.cost, "0.42"); // safety: test
|
||||
assert_eq!(fb.repair_attempts, 1); // safety: test
|
||||
assert!(fb.last_action.is_some()); // safety: test
|
||||
let la = fb.last_action.unwrap(); // safety: test
|
||||
assert_eq!(la.tool_name, "tool_b"); // safety: test
|
||||
assert!(!la.success); // safety: test
|
||||
// Failed actions should surface the error message as the output preview
|
||||
assert_eq!(la.output_preview, "broke"); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_failed_action_shows_error() {
|
||||
let ctx = JobContext::new("Test", "Error preview");
|
||||
let mut memory = Memory::new(ctx.job_id);
|
||||
|
||||
let action = memory
|
||||
.create_action("broken_tool", serde_json::json!({}))
|
||||
.fail("connection timed out after 30s", StdDuration::from_secs(30));
|
||||
memory.record_action(action);
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "tool failure");
|
||||
let la = fb.last_action.unwrap(); // safety: test
|
||||
assert!(!la.success); // safety: test
|
||||
assert_eq!(la.output_preview, "connection timed out after 30s"); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_last_action_truncation() {
|
||||
let ctx = JobContext::new("Test", "Truncation");
|
||||
let mut memory = Memory::new(ctx.job_id);
|
||||
|
||||
let long_output = "x".repeat(500);
|
||||
let action = memory
|
||||
.create_action("tool_c", serde_json::json!({}))
|
||||
.succeed(
|
||||
Some(long_output.clone()),
|
||||
serde_json::Value::String(long_output),
|
||||
StdDuration::from_secs(1),
|
||||
);
|
||||
memory.record_action(action);
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
||||
let la = fb.last_action.unwrap(); // safety: test
|
||||
assert!(la.output_preview.len() <= 200); // safety: test
|
||||
assert!(!la.output_preview.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_uses_sanitized_output() {
|
||||
let ctx = JobContext::new("Test", "Sanitized");
|
||||
let mut memory = Memory::new(ctx.job_id);
|
||||
|
||||
let action = memory
|
||||
.create_action("tool_d", serde_json::json!({}))
|
||||
.succeed(
|
||||
Some("[REDACTED]".to_string()),
|
||||
serde_json::json!({"api_key": "sk-secret-key-12345"}),
|
||||
StdDuration::from_secs(1),
|
||||
);
|
||||
memory.record_action(action);
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
||||
let la = fb.last_action.unwrap(); // safety: test
|
||||
// Must use sanitized output, not raw
|
||||
assert!(!la.output_preview.contains("sk-secret")); // safety: test
|
||||
assert!(la.output_preview.contains("REDACTED")); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_elapsed_time() {
|
||||
let mut ctx = JobContext::new("Test", "Timing");
|
||||
let now = Utc::now();
|
||||
ctx.started_at = Some(now - Duration::seconds(10));
|
||||
ctx.completed_at = Some(now);
|
||||
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
||||
|
||||
// Should be approximately 10 seconds
|
||||
assert!((fb.elapsed_secs - 10.0).abs() < 0.1); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_no_started_at() {
|
||||
let ctx = JobContext::new("Test", "Never started");
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "failed");
|
||||
assert!((fb.elapsed_secs - 0.0).abs() < 0.001); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_elapsed_time_no_completed_at() {
|
||||
let mut ctx = JobContext::new("Test", "Still running");
|
||||
ctx.started_at = Some(Utc::now() - Duration::seconds(5));
|
||||
// completed_at is None — should use Utc::now() as fallback
|
||||
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "stuck");
|
||||
|
||||
// Should be approximately 5 seconds (using now as end time)
|
||||
assert!(fb.elapsed_secs >= 4.0 && fb.elapsed_secs <= 7.0); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_failure_reason_truncation() {
|
||||
let ctx = JobContext::new("Test", "Long reason");
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
|
||||
let long_reason = "x".repeat(5000);
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, &long_reason);
|
||||
|
||||
assert!(fb.failure_reason.len() <= 1000); // safety: test
|
||||
assert!(!fb.failure_reason.is_empty()); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_str_ascii() {
|
||||
assert_eq!(truncate_str("hello", 10), "hello"); // safety: test
|
||||
assert_eq!(truncate_str("hello world", 5), "hello"); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_str_unicode() {
|
||||
// "é" is 2 bytes in UTF-8
|
||||
let s = "café";
|
||||
assert_eq!(truncate_str(s, 10), "café"); // safety: test
|
||||
// Truncating at 4 would split "é", should back up to 3
|
||||
assert_eq!(truncate_str(s, 4), "caf"); // safety: test
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_serialization() {
|
||||
let ctx = JobContext::new("Test", "Serialize");
|
||||
let memory = Memory::new(ctx.job_id);
|
||||
let fb = FallbackDeliverable::build(&ctx, &memory, "test error");
|
||||
|
||||
// Should serialize to JSON and back without error
|
||||
let json = serde_json::to_value(&fb).unwrap(); // safety: test
|
||||
let deserialized: FallbackDeliverable = serde_json::from_value(json).unwrap(); // safety: test
|
||||
assert_eq!(deserialized.failure_reason, "test error"); // safety: test
|
||||
}
|
||||
}
|
||||
+11
-201
@@ -1,12 +1,11 @@
|
||||
//! Context manager for handling multiple job contexts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{JobContext, JobState, Memory};
|
||||
use crate::context::{JobContext, Memory};
|
||||
use crate::error::JobError;
|
||||
|
||||
/// Manages contexts for multiple concurrent jobs.
|
||||
@@ -46,41 +45,12 @@ impl ContextManager {
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
let context = JobContext::with_user(user_id, title, description);
|
||||
let job_id = context.job_id;
|
||||
self.insert_context(context).await?;
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
/// Register a sandbox job with a pre-determined ID.
|
||||
///
|
||||
/// Unlike `create_job_for_user` (which generates its own UUID), this method
|
||||
/// accepts an existing `job_id` — used by `execute_sandbox()` which creates
|
||||
/// the UUID before the container so it can be shared with Docker labels and
|
||||
/// DB persistence.
|
||||
///
|
||||
/// The job starts in `InProgress` state since the container is about to be
|
||||
/// created. Counts against `max_jobs` like any other job.
|
||||
pub async fn register_sandbox_job(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
user_id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
) -> Result<(), JobError> {
|
||||
let mut context = JobContext::with_user(user_id, title, description);
|
||||
context.job_id = job_id;
|
||||
context.state = JobState::InProgress;
|
||||
context.started_at = Some(chrono::Utc::now());
|
||||
self.insert_context(context).await
|
||||
}
|
||||
|
||||
/// Check max_jobs limit, insert context, and allocate memory.
|
||||
///
|
||||
/// Holds the write lock for the entire check-insert to prevent TOCTOU
|
||||
/// races where two concurrent calls both pass the parallel_count check.
|
||||
async fn insert_context(&self, context: JobContext) -> Result<(), JobError> {
|
||||
// Hold write lock for the entire check-insert to prevent TOCTOU races
|
||||
// where two concurrent calls both pass the parallel_count check.
|
||||
let mut contexts = self.contexts.write().await;
|
||||
// Only count jobs that consume execution slots (Pending, InProgress, Stuck).
|
||||
// Completed and Submitted jobs are no longer actively executing and shouldn't
|
||||
// block new job creation.
|
||||
let parallel_count = contexts
|
||||
.values()
|
||||
.filter(|c| c.state.is_parallel_blocking())
|
||||
@@ -90,16 +60,15 @@ impl ContextManager {
|
||||
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
|
||||
}
|
||||
|
||||
let context = JobContext::with_user(user_id, title, description);
|
||||
let job_id = context.job_id;
|
||||
contexts.insert(job_id, context);
|
||||
drop(contexts);
|
||||
|
||||
self.memories
|
||||
.write()
|
||||
.await
|
||||
.insert(job_id, Memory::new(job_id));
|
||||
let memory = Memory::new(job_id);
|
||||
self.memories.write().await.insert(job_id, memory);
|
||||
|
||||
Ok(())
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
/// Get a job context by ID.
|
||||
@@ -236,46 +205,12 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
/// Find stuck jobs.
|
||||
///
|
||||
/// Returns jobs that are explicitly in `Stuck` state, plus `InProgress`
|
||||
/// jobs that have been running longer than `elapsed_threshold` (if provided).
|
||||
/// The threshold-based detection catches jobs that never transitioned to
|
||||
/// `Stuck` (e.g., due to a deadlock or unhandled timeout).
|
||||
pub async fn find_stuck_jobs(&self) -> Vec<Uuid> {
|
||||
self.find_stuck_jobs_with_threshold(None).await
|
||||
}
|
||||
|
||||
/// Find stuck jobs with an optional elapsed threshold for `InProgress` detection.
|
||||
pub async fn find_stuck_jobs_with_threshold(
|
||||
&self,
|
||||
elapsed_threshold: Option<Duration>,
|
||||
) -> Vec<Uuid> {
|
||||
let now = chrono::Utc::now();
|
||||
self.contexts
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|(_, c)| {
|
||||
// Always include explicitly Stuck jobs.
|
||||
if c.state == crate::context::JobState::Stuck {
|
||||
return true;
|
||||
}
|
||||
// Detect InProgress jobs that have been running beyond the elapsed threshold.
|
||||
// NOTE: `started_at` is set on the first transition to InProgress and is
|
||||
// NOT reset when a job recovers from Stuck back to InProgress. This means
|
||||
// a recovered job may be re-detected on the next scan. A future improvement
|
||||
// could track `in_progress_since` or use the most recent StateTransition
|
||||
// with `to == InProgress` to avoid false positives on recovered jobs.
|
||||
if c.state == crate::context::JobState::InProgress
|
||||
&& let Some(threshold) = elapsed_threshold
|
||||
&& let Some(started) = c.started_at
|
||||
{
|
||||
let elapsed = now.signed_duration_since(started);
|
||||
let elapsed_secs = elapsed.num_seconds().max(0) as u64;
|
||||
return elapsed_secs > threshold.as_secs();
|
||||
}
|
||||
false
|
||||
})
|
||||
.filter(|(_, c)| c.state == crate::context::JobState::Stuck)
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
@@ -694,48 +629,6 @@ mod tests {
|
||||
assert_eq!(stuck[0], id2);
|
||||
}
|
||||
|
||||
/// Regression test for #1223: InProgress jobs exceeding the threshold
|
||||
/// should be detected as stuck even if they never transitioned to Stuck.
|
||||
#[tokio::test]
|
||||
async fn find_stuck_jobs_with_threshold_detects_idle_in_progress() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("Active job", "desc").await.unwrap();
|
||||
let id2 = manager.create_job("Idle job", "desc").await.unwrap();
|
||||
|
||||
// Both transition to InProgress
|
||||
for id in [id1, id2] {
|
||||
manager
|
||||
.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backdate id2's started_at to simulate a long-running job
|
||||
manager
|
||||
.update_context(id2, |ctx| -> Result<(), crate::error::JobError> {
|
||||
ctx.started_at = Some(chrono::Utc::now() - chrono::Duration::seconds(600));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// With a 5-minute threshold, only id2 (10 min) should be detected
|
||||
let stuck = manager
|
||||
.find_stuck_jobs_with_threshold(Some(Duration::from_secs(300)))
|
||||
.await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0], id2);
|
||||
|
||||
// Without threshold, neither InProgress job is detected (no explicit Stuck state)
|
||||
let stuck_no_threshold = manager.find_stuck_jobs().await;
|
||||
assert!(stuck_no_threshold.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_count_tracks_non_terminal_jobs() {
|
||||
let manager = ContextManager::new(10);
|
||||
@@ -1292,87 +1185,4 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Regression: sandbox jobs must be visible to query tools ===
|
||||
// Before the fix, execute_sandbox() only persisted to DB but never
|
||||
// registered in ContextManager, making sandbox jobs invisible to
|
||||
// list_jobs, job_status, job_events, and resolve_job_id.
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_visible_to_queries() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
manager
|
||||
.register_sandbox_job(
|
||||
job_id,
|
||||
"user-42",
|
||||
"Run tests",
|
||||
"Execute test suite in sandbox",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Job should be retrievable by ID (used by job_status, job_events)
|
||||
let ctx = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.job_id, job_id);
|
||||
assert_eq!(ctx.user_id, "user-42");
|
||||
assert_eq!(ctx.title, "Run tests");
|
||||
assert_eq!(ctx.state, JobState::InProgress);
|
||||
assert!(ctx.started_at.is_some());
|
||||
|
||||
// Job should appear in all_jobs (used by resolve_job_id prefix matching)
|
||||
let all = manager.all_jobs().await;
|
||||
assert!(all.contains(&job_id));
|
||||
|
||||
// Job should appear in user-scoped listing (used by list_jobs)
|
||||
let user_jobs = manager.all_jobs_for("user-42").await;
|
||||
assert!(user_jobs.contains(&job_id));
|
||||
|
||||
// Job should appear in active jobs listing
|
||||
let active = manager.active_jobs_for("user-42").await;
|
||||
assert!(active.contains(&job_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_respects_max_jobs() {
|
||||
let manager = ContextManager::new(2);
|
||||
|
||||
// Fill up the slots with sandbox jobs
|
||||
manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 1", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 2", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Third should fail
|
||||
let result = manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 3", "desc")
|
||||
.await;
|
||||
assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 2 })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_transitions_correctly() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
manager
|
||||
.register_sandbox_job(job_id, "user-1", "Task", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should be able to transition InProgress -> Completed
|
||||
manager
|
||||
.update_context(job_id, |ctx| ctx.transition_to(JobState::Completed, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let ctx = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
+70
-79
@@ -58,19 +58,15 @@ impl ActionRecord {
|
||||
}
|
||||
|
||||
/// Mark the action as successful.
|
||||
///
|
||||
/// `output_sanitized` is the tool output after safety processing (string).
|
||||
/// `output_raw` is the original tool result (JSON value, stored as a
|
||||
/// pretty-printed JSON string in `ActionRecord.output_raw`).
|
||||
pub fn succeed(
|
||||
mut self,
|
||||
output_sanitized: Option<String>,
|
||||
output_raw: serde_json::Value,
|
||||
output_raw: Option<String>,
|
||||
output_sanitized: serde_json::Value,
|
||||
duration: Duration,
|
||||
) -> Self {
|
||||
self.success = true;
|
||||
self.output_raw = Some(serde_json::to_string_pretty(&output_raw).unwrap_or_default());
|
||||
self.output_sanitized = output_sanitized.map(serde_json::Value::String);
|
||||
self.output_raw = output_raw;
|
||||
self.output_sanitized = Some(output_sanitized);
|
||||
self.duration = duration;
|
||||
self
|
||||
}
|
||||
@@ -252,15 +248,15 @@ mod tests {
|
||||
#[test]
|
||||
fn test_action_record() {
|
||||
let action = ActionRecord::new(0, "test", serde_json::json!({"key": "value"}));
|
||||
assert_eq!(action.sequence, 0); // safety: test
|
||||
assert!(!action.success); // safety: test
|
||||
assert_eq!(action.sequence, 0);
|
||||
assert!(!action.success);
|
||||
|
||||
let action = action.succeed(
|
||||
Some("raw".to_string()),
|
||||
serde_json::json!({"result": "ok"}),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(action.success); // safety: test
|
||||
assert!(action.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,7 +267,7 @@ mod tests {
|
||||
memory.add(ChatMessage::user("How are you?"));
|
||||
memory.add(ChatMessage::assistant("Good!"));
|
||||
|
||||
assert_eq!(memory.len(), 3); // Oldest removed // safety: test
|
||||
assert_eq!(memory.len(), 3); // Oldest removed
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -290,9 +286,9 @@ mod tests {
|
||||
.with_cost(Decimal::new(20, 1));
|
||||
memory.record_action(action2);
|
||||
|
||||
assert_eq!(memory.total_cost(), Decimal::new(30, 1)); // safety: test
|
||||
assert_eq!(memory.total_duration(), Duration::from_secs(3)); // safety: test
|
||||
assert_eq!(memory.successful_actions(), 2); // safety: test
|
||||
assert_eq!(memory.total_cost(), Decimal::new(30, 1));
|
||||
assert_eq!(memory.total_duration(), Duration::from_secs(3));
|
||||
assert_eq!(memory.successful_actions(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -300,11 +296,11 @@ mod tests {
|
||||
let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1}));
|
||||
let action = action.fail("something went wrong", Duration::from_millis(50));
|
||||
|
||||
assert!(!action.success); // safety: test
|
||||
assert_eq!(action.error.as_deref(), Some("something went wrong")); // safety: test
|
||||
assert_eq!(action.duration, Duration::from_millis(50)); // safety: test
|
||||
assert!(action.output_raw.is_none()); // safety: test
|
||||
assert!(action.output_sanitized.is_none()); // safety: test
|
||||
assert!(!action.success);
|
||||
assert_eq!(action.error.as_deref(), Some("something went wrong"));
|
||||
assert_eq!(action.duration, Duration::from_millis(50));
|
||||
assert!(action.output_raw.is_none());
|
||||
assert!(action.output_sanitized.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -312,9 +308,9 @@ mod tests {
|
||||
let action = ActionRecord::new(0, "risky_tool", serde_json::json!({}));
|
||||
let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]);
|
||||
|
||||
assert_eq!(action.sanitization_warnings.len(), 2); // safety: test
|
||||
assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); // safety: test
|
||||
assert_eq!(action.sanitization_warnings[1], "possible xss"); // safety: test
|
||||
assert_eq!(action.sanitization_warnings.len(), 2);
|
||||
assert_eq!(action.sanitization_warnings[0], "suspicious pattern");
|
||||
assert_eq!(action.sanitization_warnings[1], "possible xss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -323,46 +319,41 @@ mod tests {
|
||||
let cost = Decimal::new(42, 2); // 0.42
|
||||
let action = action.with_cost(cost);
|
||||
|
||||
assert_eq!(action.cost, Some(Decimal::new(42, 2))); // safety: test
|
||||
assert_eq!(action.cost, Some(Decimal::new(42, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_new_defaults() {
|
||||
let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"}));
|
||||
|
||||
assert_eq!(action.sequence, 5); // safety: test
|
||||
assert_eq!(action.tool_name, "my_tool"); // safety: test
|
||||
assert_eq!(action.input, serde_json::json!({"key": "val"})); // safety: test
|
||||
assert!(!action.success); // safety: test
|
||||
assert!(action.output_raw.is_none()); // safety: test
|
||||
assert!(action.output_sanitized.is_none()); // safety: test
|
||||
assert!(action.sanitization_warnings.is_empty()); // safety: test
|
||||
assert!(action.cost.is_none()); // safety: test
|
||||
assert_eq!(action.duration, Duration::ZERO); // safety: test
|
||||
assert!(action.error.is_none()); // safety: test
|
||||
assert_eq!(action.sequence, 5);
|
||||
assert_eq!(action.tool_name, "my_tool");
|
||||
assert_eq!(action.input, serde_json::json!({"key": "val"}));
|
||||
assert!(!action.success);
|
||||
assert!(action.output_raw.is_none());
|
||||
assert!(action.output_sanitized.is_none());
|
||||
assert!(action.sanitization_warnings.is_empty());
|
||||
assert!(action.cost.is_none());
|
||||
assert_eq!(action.duration, Duration::ZERO);
|
||||
assert!(action.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_action_record_succeed_sets_fields() {
|
||||
let action = ActionRecord::new(0, "tool", serde_json::json!({}));
|
||||
let action = action.succeed(
|
||||
Some("sanitized output".into()),
|
||||
Some("raw output here".into()),
|
||||
serde_json::json!({"clean": true}),
|
||||
Duration::from_secs(7),
|
||||
);
|
||||
|
||||
assert!(action.success); // safety: test
|
||||
// output_raw is the JSON value pretty-printed
|
||||
let expected_raw =
|
||||
serde_json::to_string_pretty(&serde_json::json!({"clean": true})).unwrap(); // safety: test
|
||||
assert_eq!(action.output_raw.as_deref(), Some(expected_raw.as_str())); // safety: test
|
||||
// output_sanitized wraps the string in a JSON string value
|
||||
assert!(action.success);
|
||||
assert_eq!(action.output_raw.as_deref(), Some("raw output here"));
|
||||
assert_eq!(
|
||||
/* safety: test */
|
||||
action.output_sanitized,
|
||||
Some(serde_json::json!("sanitized output"))
|
||||
Some(serde_json::json!({"clean": true}))
|
||||
);
|
||||
assert_eq!(action.duration, Duration::from_secs(7)); // safety: test
|
||||
assert_eq!(action.duration, Duration::from_secs(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,13 +361,13 @@ mod tests {
|
||||
let mut mem = ConversationMemory::new(10);
|
||||
mem.add(ChatMessage::user("hello"));
|
||||
mem.add(ChatMessage::assistant("hi"));
|
||||
assert_eq!(mem.len(), 2); // safety: test
|
||||
assert!(!mem.is_empty()); // safety: test
|
||||
assert_eq!(mem.len(), 2);
|
||||
assert!(!mem.is_empty());
|
||||
|
||||
mem.clear();
|
||||
assert_eq!(mem.len(), 0); // safety: test
|
||||
assert!(mem.is_empty()); // safety: test
|
||||
assert!(mem.messages().is_empty()); // safety: test
|
||||
assert_eq!(mem.len(), 0);
|
||||
assert!(mem.is_empty());
|
||||
assert!(mem.messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -388,20 +379,20 @@ mod tests {
|
||||
mem.add(ChatMessage::assistant("four"));
|
||||
|
||||
let last_2 = mem.last_n(2);
|
||||
assert_eq!(last_2.len(), 2); // safety: test
|
||||
assert_eq!(last_2[0].content, "three"); // safety: test
|
||||
assert_eq!(last_2[1].content, "four"); // safety: test
|
||||
assert_eq!(last_2.len(), 2);
|
||||
assert_eq!(last_2[0].content, "three");
|
||||
assert_eq!(last_2[1].content, "four");
|
||||
|
||||
// Requesting more than available returns all
|
||||
let last_100 = mem.last_n(100);
|
||||
assert_eq!(last_100.len(), 4); // safety: test
|
||||
assert_eq!(last_100.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversation_memory_last_n_empty() {
|
||||
let mem = ConversationMemory::new(10);
|
||||
let result = mem.last_n(5);
|
||||
assert!(result.is_empty()); // safety: test
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -414,13 +405,13 @@ mod tests {
|
||||
// At capacity (3). Adding one more should trim, but keep system.
|
||||
mem.add(ChatMessage::user("msg3"));
|
||||
|
||||
assert_eq!(mem.len(), 3); // safety: test
|
||||
assert_eq!(mem.len(), 3);
|
||||
// System message must survive
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
|
||||
assert_eq!(mem.messages()[0].content, "You are helpful"); // safety: test
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||
assert_eq!(mem.messages()[0].content, "You are helpful");
|
||||
// Oldest non-system message (msg1) should be gone
|
||||
assert_eq!(mem.messages()[1].content, "msg2"); // safety: test
|
||||
assert_eq!(mem.messages()[2].content, "msg3"); // safety: test
|
||||
assert_eq!(mem.messages()[1].content, "msg2");
|
||||
assert_eq!(mem.messages()[2].content, "msg3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -431,9 +422,9 @@ mod tests {
|
||||
// Now at capacity. Add another.
|
||||
mem.add(ChatMessage::user("b"));
|
||||
|
||||
assert_eq!(mem.len(), 2); // safety: test
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System); // safety: test
|
||||
assert_eq!(mem.messages()[1].content, "b"); // safety: test
|
||||
assert_eq!(mem.len(), 2);
|
||||
assert_eq!(mem.messages()[0].role, crate::llm::Role::System);
|
||||
assert_eq!(mem.messages()[1].content, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -449,7 +440,7 @@ mod tests {
|
||||
mem.add(ChatMessage::user("hello"));
|
||||
// Should have broken out rather than looping forever.
|
||||
// The system message is protected, so len may exceed max.
|
||||
assert!(mem.len() <= 2); // safety: test
|
||||
assert!(mem.len() <= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -468,14 +459,14 @@ mod tests {
|
||||
.fail("oops", Duration::from_millis(2));
|
||||
memory.record_action(err);
|
||||
|
||||
assert_eq!(memory.successful_actions(), 1); // safety: test
|
||||
assert_eq!(memory.failed_actions(), 1); // safety: test
|
||||
assert_eq!(memory.successful_actions(), 1);
|
||||
assert_eq!(memory.failed_actions(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_last_action() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
assert!(memory.last_action().is_none()); // safety: test
|
||||
assert!(memory.last_action().is_none());
|
||||
|
||||
let a1 = memory
|
||||
.create_action("first", serde_json::json!({}))
|
||||
@@ -487,8 +478,8 @@ mod tests {
|
||||
.fail("nope", Duration::ZERO);
|
||||
memory.record_action(a2);
|
||||
|
||||
let last = memory.last_action().unwrap(); // safety: test
|
||||
assert_eq!(last.tool_name, "second"); // safety: test
|
||||
let last = memory.last_action().unwrap();
|
||||
assert_eq!(last.tool_name, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -508,9 +499,9 @@ mod tests {
|
||||
);
|
||||
memory.record_action(a);
|
||||
|
||||
assert_eq!(memory.actions_by_tool("shell").len(), 3); // safety: test
|
||||
assert_eq!(memory.actions_by_tool("http").len(), 1); // safety: test
|
||||
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); // safety: test
|
||||
assert_eq!(memory.actions_by_tool("shell").len(), 3);
|
||||
assert_eq!(memory.actions_by_tool("http").len(), 1);
|
||||
assert_eq!(memory.actions_by_tool("nonexistent").len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -518,25 +509,25 @@ mod tests {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
|
||||
let a0 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a0.sequence, 0); // safety: test
|
||||
assert_eq!(a0.sequence, 0);
|
||||
|
||||
let a1 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a1.sequence, 1); // safety: test
|
||||
assert_eq!(a1.sequence, 1);
|
||||
|
||||
let a2 = memory.create_action("t", serde_json::json!({}));
|
||||
assert_eq!(a2.sequence, 2); // safety: test
|
||||
assert_eq!(a2.sequence, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_add_message_delegates_to_conversation() {
|
||||
let mut memory = Memory::new(Uuid::new_v4());
|
||||
assert!(memory.conversation.is_empty()); // safety: test
|
||||
assert!(memory.conversation.is_empty());
|
||||
|
||||
memory.add_message(ChatMessage::user("hello"));
|
||||
memory.add_message(ChatMessage::assistant("hi"));
|
||||
|
||||
assert_eq!(memory.conversation.len(), 2); // safety: test
|
||||
assert_eq!(memory.conversation.messages()[0].content, "hello"); // safety: test
|
||||
assert_eq!(memory.conversation.len(), 2);
|
||||
assert_eq!(memory.conversation.messages()[0].content, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -549,7 +540,7 @@ mod tests {
|
||||
.succeed(None, serde_json::json!({}), Duration::ZERO);
|
||||
memory.record_action(a);
|
||||
|
||||
assert_eq!(memory.total_cost(), Decimal::ZERO); // safety: test
|
||||
assert_eq!(memory.total_cost(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -569,6 +560,6 @@ mod tests {
|
||||
memory.record_action(a2);
|
||||
|
||||
// Both successful and failed actions contribute to total duration
|
||||
assert_eq!(memory.total_duration(), Duration::from_millis(300)); // safety: test
|
||||
assert_eq!(memory.total_duration(), Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@
|
||||
//! - State machine
|
||||
//! - Resource tracking
|
||||
|
||||
pub mod fallback;
|
||||
mod manager;
|
||||
mod memory;
|
||||
mod state;
|
||||
|
||||
pub use fallback::FallbackDeliverable;
|
||||
pub use manager::ContextManager;
|
||||
pub use memory::{ActionRecord, ConversationMemory, Memory};
|
||||
pub use state::{JobContext, JobState, StateTransition, TokenBudgetExceeded};
|
||||
|
||||
+3
-3
@@ -75,7 +75,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
|
||||
| Numeric/Decimal | `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
|
||||
| Arrays | `TEXT[]` | `TEXT` (JSON-encoded array) |
|
||||
| Booleans | `BOOLEAN` | `INTEGER` (0/1) |
|
||||
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(N)` via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index`) |
|
||||
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(1536)` via `libsql_vector_idx` |
|
||||
| Full-text search | `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
|
||||
| JSON path update | `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` |
|
||||
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
|
||||
@@ -90,7 +90,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
|
||||
|
||||
**Timestamp write format:** Always write timestamps with `fmt_ts(dt)` (RFC 3339, millisecond precision). Read with `get_ts()` / `get_opt_ts()` which handle legacy naive formats too.
|
||||
|
||||
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL dynamically creates `F32_BLOB(N)` with the correct dimension via `ensure_vector_index()` during `run_migrations()`, reading `EMBEDDING_DIMENSION` / `EMBEDDING_MODEL` from env vars.
|
||||
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL still uses `F32_BLOB(1536)` — if you use a different-dimension embedding model, the libSQL schema needs updating too.
|
||||
|
||||
**Connection per operation:** `LibSqlBackend::connect()` creates a fresh connection for every operation, sets `PRAGMA busy_timeout = 5000`, and closes it when the `Connection` is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across `await` points.
|
||||
|
||||
@@ -134,7 +134,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
|
||||
- **Settings reload** — `Config::from_db` skipped (requires `Store`)
|
||||
- **No incremental migrations** — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
|
||||
- **No encryption at rest** — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
|
||||
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; `ensure_vector_index()` dynamically creates the index with the correct `F32_BLOB(N)` dimension from env vars during `run_migrations()`
|
||||
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; however, the vector index is fixed at `F32_BLOB(1536)` while PostgreSQL switched to unbounded `vector` in V9
|
||||
- **Write serialization** — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
|
||||
|
||||
## Running Locally with libSQL
|
||||
|
||||
@@ -341,14 +341,6 @@ impl Database for LibSqlBackend {
|
||||
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
|
||||
// Apply incremental migrations (V9+) tracked in _migrations table.
|
||||
libsql_migrations::run_incremental(&conn).await?;
|
||||
|
||||
// Set up vector index if embeddings are configured.
|
||||
// This dynamically creates a libsql_vector_idx on memory_chunks.embedding
|
||||
// with the correct F32_BLOB(N) dimension inferred from env vars.
|
||||
if let Some(dimension) = workspace::resolve_embedding_dimension() {
|
||||
self.ensure_vector_index(dimension).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,34 +477,6 @@ impl RoutineStore for LibSqlBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_webhook_routine_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<Routine>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
&format!(
|
||||
"SELECT {} FROM routines WHERE enabled = 1 AND trigger_type = 'webhook' \
|
||||
AND (json_extract(trigger_config, '$.path') = ?1 \
|
||||
OR (json_extract(trigger_config, '$.path') IS NULL AND CAST(id AS TEXT) = ?1))",
|
||||
ROUTINE_COLUMNS
|
||||
),
|
||||
params![path],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
||||
|
||||
match rows
|
||||
.next()
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
||||
{
|
||||
Some(row) => Ok(Some(row_to_routine_libsql(&row)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
||||
let conn = self.connect().await?;
|
||||
let mut rows = conn
|
||||
|
||||
+7
-474
@@ -11,7 +11,7 @@ use super::{
|
||||
row_to_memory_document,
|
||||
};
|
||||
use crate::db::WorkspaceStore;
|
||||
use crate::error::{DatabaseError, WorkspaceError};
|
||||
use crate::error::WorkspaceError;
|
||||
use crate::workspace::{
|
||||
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
|
||||
fuse_results,
|
||||
@@ -19,227 +19,6 @@ use crate::workspace::{
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
/// Resolve the embedding dimension from environment variables.
|
||||
///
|
||||
/// Reads `EMBEDDING_ENABLED`, `EMBEDDING_DIMENSION`, and `EMBEDDING_MODEL`
|
||||
/// from env vars. Returns `None` if embeddings are disabled.
|
||||
///
|
||||
/// Note: this only reads env vars, not persisted `Settings`, because it runs
|
||||
/// during `run_migrations()` before the full config stack is available. Users
|
||||
/// who configure embeddings via the settings UI must also set
|
||||
/// `EMBEDDING_ENABLED=true` in their environment for the vector index to be
|
||||
/// created. The model→dimension mapping is shared with `EmbeddingsConfig` via
|
||||
/// `default_dimension_for_model()`.
|
||||
pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
|
||||
let enabled = std::env::var("EMBEDDING_ENABLED")
|
||||
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
if !enabled {
|
||||
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Ok(dim_str) = std::env::var("EMBEDDING_DIMENSION")
|
||||
&& let Ok(dim) = dim_str.parse::<usize>()
|
||||
&& dim > 0
|
||||
{
|
||||
return Some(dim);
|
||||
}
|
||||
|
||||
let model =
|
||||
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "text-embedding-3-small".to_string());
|
||||
|
||||
Some(crate::config::embeddings::default_dimension_for_model(
|
||||
&model,
|
||||
))
|
||||
}
|
||||
|
||||
impl LibSqlBackend {
|
||||
/// Ensure the `libsql_vector_idx` on `memory_chunks.embedding` matches the
|
||||
/// configured embedding dimension.
|
||||
///
|
||||
/// The V9 migration dropped the vector index (and changed `F32_BLOB(1536)`
|
||||
/// to `BLOB`) to support flexible dimensions. This method restores a
|
||||
/// properly-typed `F32_BLOB(N)` column and creates the vector index.
|
||||
///
|
||||
/// Tracks the active dimension in `_migrations` version `0` — a reserved
|
||||
/// metadata row where `name` stores the dimension as a string. Version 0
|
||||
/// is never used by incremental migrations (which start at 9), so there
|
||||
/// is no collision. If the stored dimension matches, this is a no-op.
|
||||
///
|
||||
/// **Precondition:** `run_migrations()` must have been called first so that
|
||||
/// the `_migrations` table exists. This is guaranteed when called from
|
||||
/// `Database::run_migrations()`, but callers using this directly must
|
||||
/// ensure migrations have run.
|
||||
pub async fn ensure_vector_index(&self, dimension: usize) -> Result<(), DatabaseError> {
|
||||
if dimension == 0 || dimension > 65536 {
|
||||
return Err(DatabaseError::Migration(format!(
|
||||
"ensure_vector_index: dimension {dimension} out of valid range (1..=65536)"
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = self.connect().await?;
|
||||
|
||||
// Check current dimension from _migrations version=0 (reserved metadata row).
|
||||
// The block scope ensures `rows` is dropped before `conn.transaction()` —
|
||||
// holding a result set open would cause "database table is locked" errors.
|
||||
let current_dim = {
|
||||
let mut rows = conn
|
||||
.query("SELECT name FROM _migrations WHERE version = 0", ())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to check vector index metadata: {e}"))
|
||||
})?;
|
||||
|
||||
rows.next().await.ok().flatten().and_then(|row| {
|
||||
row.get::<String>(0)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
})
|
||||
};
|
||||
|
||||
if current_dim == Some(dimension) {
|
||||
tracing::debug!(
|
||||
dimension,
|
||||
"Vector index already matches configured dimension"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
old_dimension = ?current_dim,
|
||||
new_dimension = dimension,
|
||||
"Rebuilding memory_chunks table for vector index"
|
||||
);
|
||||
|
||||
let tx = conn.transaction().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"ensure_vector_index: failed to start transaction: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// 1. Drop FTS triggers that reference the old table
|
||||
tx.execute_batch(
|
||||
"DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
|
||||
DROP TRIGGER IF EXISTS memory_chunks_fts_update;",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Migration(format!("Failed to drop FTS triggers: {e}")))?;
|
||||
|
||||
// 2. Drop old vector index
|
||||
tx.execute_batch("DROP INDEX IF EXISTS idx_memory_chunks_embedding;")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to drop old vector index: {e}"))
|
||||
})?;
|
||||
|
||||
// 3. Drop stale temp table (if a previous attempt crashed) and create fresh
|
||||
tx.execute_batch("DROP TABLE IF EXISTS memory_chunks_new;")
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to drop stale memory_chunks_new: {e}"))
|
||||
})?;
|
||||
|
||||
let create_sql = format!(
|
||||
"CREATE TABLE memory_chunks_new (
|
||||
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding F32_BLOB({dimension}),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (document_id, chunk_index)
|
||||
)"
|
||||
);
|
||||
tx.execute_batch(&create_sql).await.map_err(|e| {
|
||||
DatabaseError::Migration(format!(
|
||||
"Failed to create memory_chunks_new with F32_BLOB({dimension}): {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// 4. Copy data — embeddings with wrong byte length get NULLed
|
||||
// (they will be re-embedded on next background pass).
|
||||
// _rowid is explicitly preserved so the FTS5 content table
|
||||
// (memory_chunks_fts, content_rowid='_rowid') stays in sync.
|
||||
let expected_bytes = dimension * 4;
|
||||
let copy_sql = format!(
|
||||
"INSERT INTO memory_chunks_new
|
||||
(_rowid, id, document_id, chunk_index, content, embedding, created_at)
|
||||
SELECT _rowid, id, document_id, chunk_index, content,
|
||||
CASE WHEN length(embedding) = {expected_bytes} THEN embedding ELSE NULL END,
|
||||
created_at
|
||||
FROM memory_chunks"
|
||||
);
|
||||
tx.execute_batch(©_sql).await.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to copy data to memory_chunks_new: {e}"))
|
||||
})?;
|
||||
|
||||
// 5. Swap tables
|
||||
tx.execute_batch(
|
||||
"DROP TABLE memory_chunks;
|
||||
ALTER TABLE memory_chunks_new RENAME TO memory_chunks;",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to swap memory_chunks tables: {e}"))
|
||||
})?;
|
||||
|
||||
// 6. Recreate document index + vector index
|
||||
tx.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding ON memory_chunks(libsql_vector_idx(embedding));",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to create indexes: {e}"))
|
||||
})?;
|
||||
|
||||
// 7. Recreate FTS triggers
|
||||
tx.execute_batch(
|
||||
"CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
|
||||
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
|
||||
VALUES ('delete', old._rowid, old.content);
|
||||
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
|
||||
END;",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to recreate FTS triggers: {e}"))
|
||||
})?;
|
||||
|
||||
// 8. Upsert dimension into _migrations(version=0)
|
||||
tx.execute(
|
||||
"INSERT INTO _migrations (version, name) VALUES (0, ?1)
|
||||
ON CONFLICT(version) DO UPDATE SET name = ?1,
|
||||
applied_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
|
||||
params![dimension.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DatabaseError::Migration(format!("Failed to record vector index dimension: {e}"))
|
||||
})?;
|
||||
|
||||
tx.commit().await.map_err(|e| {
|
||||
DatabaseError::Migration(format!("ensure_vector_index: commit failed: {e}"))
|
||||
})?;
|
||||
|
||||
tracing::info!(dimension, "Vector index created successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkspaceStore for LibSqlBackend {
|
||||
async fn get_document_by_path(
|
||||
@@ -616,9 +395,6 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
let id = Uuid::new_v4();
|
||||
// Note: embedding dimension is not validated here — the F32_BLOB(N)
|
||||
// column type created by ensure_vector_index() enforces byte length at
|
||||
// the libSQL level and will reject mismatched dimensions.
|
||||
let embedding_blob = embedding.map(|e| {
|
||||
let bytes: Vec<u8> = e.iter().flat_map(|f| f.to_le_bytes()).collect();
|
||||
bytes
|
||||
@@ -785,9 +561,9 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
.join(",")
|
||||
);
|
||||
|
||||
// vector_top_k requires a libsql_vector_idx index created by
|
||||
// ensure_vector_index(). If the index is missing (embeddings not
|
||||
// configured or dimension mismatch), fall back to FTS-only.
|
||||
// vector_top_k requires a libsql_vector_idx index. After the V9
|
||||
// migration the index is dropped (to support flexible embedding
|
||||
// dimensions), so this query may fail. Fall back to FTS-only.
|
||||
match conn
|
||||
.query(
|
||||
r#"
|
||||
@@ -821,9 +597,9 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
results
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Vector index query failed (ensure_vector_index may not have run \
|
||||
or dimension mismatch), falling back to FTS-only: {e}"
|
||||
tracing::debug!(
|
||||
"Vector index query failed (expected after V9 migration), \
|
||||
falling back to FTS-only: {e}"
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
@@ -841,246 +617,3 @@ impl WorkspaceStore for LibSqlBackend {
|
||||
Ok(fuse_results(fts_results, vector_results, config))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
/// Helper: create a file-backed backend with migrations applied.
|
||||
async fn setup_backend() -> (LibSqlBackend, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db_path = dir.path().join("test_vector.db");
|
||||
let backend = LibSqlBackend::new_local(&db_path).await.expect("new_local");
|
||||
backend.run_migrations().await.expect("migrations");
|
||||
(backend, dir)
|
||||
}
|
||||
|
||||
/// Helper: insert a document and chunk with an optional embedding.
|
||||
async fn insert_test_chunk(
|
||||
backend: &LibSqlBackend,
|
||||
user_id: &str,
|
||||
path: &str,
|
||||
content: &str,
|
||||
embedding: Option<&[f32]>,
|
||||
) -> (Uuid, Uuid) {
|
||||
let conn = backend.connect().await.expect("connect");
|
||||
let doc_id = Uuid::new_v4();
|
||||
let now = super::fmt_ts(&Utc::now());
|
||||
conn.execute(
|
||||
"INSERT INTO memory_documents (id, user_id, path, content, created_at, updated_at, metadata)
|
||||
VALUES (?1, ?2, ?3, '', ?4, ?4, '{}')",
|
||||
params![doc_id.to_string(), user_id, path, now],
|
||||
)
|
||||
.await
|
||||
.expect("insert doc");
|
||||
let chunk_id = backend
|
||||
.insert_chunk(doc_id, 0, content, embedding)
|
||||
.await
|
||||
.expect("insert chunk");
|
||||
(doc_id, chunk_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_vector_index_enables_vector_search() {
|
||||
let (backend, _dir) = setup_backend().await;
|
||||
|
||||
// Create vector index with dim=4
|
||||
backend.ensure_vector_index(4).await.expect("ensure dim=4");
|
||||
// Insert a chunk with a 4-dim embedding
|
||||
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
|
||||
let (_doc_id, _chunk_id) = insert_test_chunk(
|
||||
&backend,
|
||||
"test",
|
||||
"notes.md",
|
||||
"hello world",
|
||||
Some(&embedding),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Query using vector_top_k — should find the chunk
|
||||
let conn = backend.connect().await.expect("connect");
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"SELECT c.id
|
||||
FROM vector_top_k('idx_memory_chunks_embedding', vector('[1,0,0,0]'), 5) AS top_k
|
||||
JOIN memory_chunks c ON c._rowid = top_k.id"#,
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("vector_top_k query");
|
||||
let row = rows
|
||||
.next()
|
||||
.await
|
||||
.expect("row fetch")
|
||||
.expect("expected a result row");
|
||||
let id: String = row.get(0).expect("get id");
|
||||
assert!(!id.is_empty(), "vector search should return the chunk");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_vector_index_dimension_change() {
|
||||
let (backend, _dir) = setup_backend().await;
|
||||
|
||||
// Create with dim=4 and insert data
|
||||
backend.ensure_vector_index(4).await.expect("ensure dim=4");
|
||||
let embedding_4d = [1.0_f32, 2.0, 3.0, 4.0];
|
||||
insert_test_chunk(&backend, "test", "a.md", "content a", Some(&embedding_4d)).await;
|
||||
|
||||
// Recreate with dim=8 — old 4-dim embeddings should be NULLed
|
||||
backend.ensure_vector_index(8).await.expect("ensure dim=8");
|
||||
// Verify metadata updated
|
||||
let conn = backend.connect().await.expect("connect");
|
||||
let mut rows = conn
|
||||
.query("SELECT name FROM _migrations WHERE version = 0", ())
|
||||
.await
|
||||
.expect("query metadata");
|
||||
let row = rows.next().await.expect("fetch").expect("metadata row");
|
||||
let dim_str: String = row.get(0).expect("get name");
|
||||
assert_eq!(dim_str, "8");
|
||||
// Verify old embedding was NULLed (wrong byte length for dim=8)
|
||||
let mut rows = conn
|
||||
.query("SELECT embedding IS NULL FROM memory_chunks LIMIT 1", ())
|
||||
.await
|
||||
.expect("query embedding");
|
||||
let row = rows.next().await.expect("fetch").expect("chunk row");
|
||||
let is_null: i64 = row.get(0).expect("get is_null");
|
||||
assert_eq!(
|
||||
is_null, 1,
|
||||
"old 4-dim embedding should be NULLed after dim change to 8"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ensure_vector_index_noop_when_unchanged() {
|
||||
let (backend, _dir) = setup_backend().await;
|
||||
|
||||
// Create with dim=4 and insert data
|
||||
backend.ensure_vector_index(4).await.expect("ensure dim=4");
|
||||
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
|
||||
insert_test_chunk(&backend, "test", "b.md", "content b", Some(&embedding)).await;
|
||||
|
||||
// Run again with same dimension — should be a no-op
|
||||
backend
|
||||
.ensure_vector_index(4)
|
||||
.await
|
||||
.expect("ensure dim=4 again");
|
||||
// Verify data is untouched (embedding not NULLed)
|
||||
let conn = backend.connect().await.expect("connect");
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT embedding IS NOT NULL FROM memory_chunks LIMIT 1",
|
||||
(),
|
||||
)
|
||||
.await
|
||||
.expect("query embedding");
|
||||
let row = rows.next().await.expect("fetch").expect("chunk row");
|
||||
let has_embedding: i64 = row.get(0).expect("get");
|
||||
assert_eq!(
|
||||
has_embedding, 1,
|
||||
"embedding should be preserved on no-op call"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hybrid_search_returns_vector_results() {
|
||||
let (backend, _dir) = setup_backend().await;
|
||||
|
||||
// Create vector index with dim=4
|
||||
backend.ensure_vector_index(4).await.expect("ensure dim=4");
|
||||
// Insert chunk with embedding and searchable content
|
||||
let embedding = [0.5_f32, 0.5, 0.0, 0.0];
|
||||
insert_test_chunk(
|
||||
&backend,
|
||||
"user1",
|
||||
"notes.md",
|
||||
"quantum computing research",
|
||||
Some(&embedding),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Search via the WorkspaceStore trait with vector enabled
|
||||
let query_emb = [0.5_f32, 0.5, 0.0, 0.0];
|
||||
let config = SearchConfig::default().with_limit(5);
|
||||
let results = backend
|
||||
.hybrid_search("user1", None, "quantum", Some(&query_emb), &config)
|
||||
.await
|
||||
.expect("hybrid_search");
|
||||
assert!(!results.is_empty(), "hybrid search should return results");
|
||||
let first = &results[0];
|
||||
assert!(
|
||||
first.vector_rank.is_some(),
|
||||
"result should have a vector_rank"
|
||||
);
|
||||
assert_eq!(first.content, "quantum computing research");
|
||||
}
|
||||
|
||||
mod resolve_dimension {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
|
||||
fn clear_embedding_env() {
|
||||
// SAFETY: called under ENV_MUTEX
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_DIMENSION");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_disabled() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
||||
clear_embedding_env();
|
||||
assert!(resolve_embedding_dimension().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_explicit_dimension() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
||||
clear_embedding_env();
|
||||
// SAFETY: under ENV_MUTEX
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_ENABLED", "true");
|
||||
std::env::set_var("EMBEDDING_DIMENSION", "768");
|
||||
}
|
||||
assert_eq!(resolve_embedding_dimension(), Some(768));
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_DIMENSION");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_from_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
||||
clear_embedding_env();
|
||||
// SAFETY: under ENV_MUTEX
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_ENABLED", "1");
|
||||
std::env::set_var("EMBEDDING_MODEL", "all-minilm");
|
||||
}
|
||||
assert_eq!(resolve_embedding_dimension(), Some(384));
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_1536_for_unknown_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex");
|
||||
clear_embedding_env();
|
||||
// SAFETY: under ENV_MUTEX
|
||||
unsafe {
|
||||
std::env::set_var("EMBEDDING_ENABLED", "true");
|
||||
std::env::set_var("EMBEDDING_MODEL", "some-unknown-model");
|
||||
}
|
||||
assert_eq!(resolve_embedding_dimension(), Some(1536));
|
||||
unsafe {
|
||||
std::env::remove_var("EMBEDDING_ENABLED");
|
||||
std::env::remove_var("EMBEDDING_MODEL");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,9 +240,9 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
|
||||
|
||||
-- No vector index in base schema: BLOB column accepts any embedding dimension.
|
||||
-- Vector index is created dynamically by ensure_vector_index() during
|
||||
-- run_migrations() when embeddings are configured (EMBEDDING_ENABLED=true).
|
||||
-- No vector index: BLOB column accepts any embedding dimension.
|
||||
-- Vector search uses brute-force cosine distance (fast enough for
|
||||
-- personal assistant workspaces). Matches PostgreSQL after V9 migration.
|
||||
|
||||
-- FTS5 virtual table for full-text search
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
|
||||
@@ -593,9 +593,10 @@ pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
// constraint so any embedding dimension works. Existing embeddings
|
||||
// are preserved; users only need to re-embed if they change models.
|
||||
//
|
||||
// The vector index is dropped here; ensure_vector_index() recreates
|
||||
// it with the correct F32_BLOB(N) dimension during run_migrations()
|
||||
// when embeddings are configured.
|
||||
// The vector index (libsql_vector_idx) requires a fixed-dimension
|
||||
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
|
||||
// brute-force cosine distance which is fast enough for personal
|
||||
// assistant workspaces. This matches PostgreSQL after its V9 migration.
|
||||
//
|
||||
// SQLite cannot ALTER COLUMN types, so we recreate the table.
|
||||
r#"
|
||||
|
||||
@@ -525,11 +525,6 @@ pub trait RoutineStore: Send + Sync {
|
||||
run_id: Uuid,
|
||||
job_id: Uuid,
|
||||
) -> Result<(), DatabaseError>;
|
||||
async fn get_webhook_routine_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<Routine>, DatabaseError>;
|
||||
|
||||
/// List routine runs that were dispatched as full_job but have not yet
|
||||
/// been finalized (status='running' with a linked job_id).
|
||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError>;
|
||||
|
||||
@@ -504,13 +504,6 @@ impl RoutineStore for PgBackend {
|
||||
self.store.link_routine_run_to_job(run_id, job_id).await
|
||||
}
|
||||
|
||||
async fn get_webhook_routine_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<Routine>, DatabaseError> {
|
||||
self.store.get_webhook_routine_by_path(path).await
|
||||
}
|
||||
|
||||
async fn list_dispatched_routine_runs(&self) -> Result<Vec<RoutineRun>, DatabaseError> {
|
||||
self.store.list_dispatched_routine_runs().await
|
||||
}
|
||||
|
||||
@@ -168,9 +168,6 @@ pub enum ToolError {
|
||||
#[error("Tool {name} requires authentication")]
|
||||
AuthRequired { name: String },
|
||||
|
||||
#[error("Tool {name} is not available for autonomous execution: {reason}")]
|
||||
AutonomousUnavailable { name: String, reason: String },
|
||||
|
||||
#[error("Tool {name} is rate limited, retry after {retry_after:?}")]
|
||||
RateLimited {
|
||||
name: String,
|
||||
@@ -303,21 +300,6 @@ pub enum WorkspaceError {
|
||||
|
||||
#[error("I/O error: {reason}")]
|
||||
IoError { reason: String },
|
||||
|
||||
#[error("Not found: {path}")]
|
||||
NotFound { path: String },
|
||||
|
||||
#[error("Layer not found: {name}")]
|
||||
LayerNotFound { name: String },
|
||||
|
||||
#[error("Layer '{name}' is read-only")]
|
||||
LayerReadOnly { name: String },
|
||||
|
||||
#[error("Cannot write sensitive content: no private layer available for redirect")]
|
||||
PrivacyRedirectFailed,
|
||||
|
||||
#[error("Write rejected for '{path}': prompt injection detected ({reason})")]
|
||||
InjectionRejected { path: String, reason: String },
|
||||
}
|
||||
|
||||
/// Orchestrator errors (internal API, container management).
|
||||
@@ -388,9 +370,6 @@ pub enum RoutineError {
|
||||
#[error("Not authorized to trigger routine {id}")]
|
||||
NotAuthorized { id: Uuid },
|
||||
|
||||
#[error("Routine {name} is in cooldown period")]
|
||||
Cooldown { name: String },
|
||||
|
||||
#[error("Routine {name} at max concurrent runs")]
|
||||
MaxConcurrent { name: String },
|
||||
|
||||
|
||||
+220
-572
File diff suppressed because it is too large
Load Diff
@@ -1105,22 +1105,6 @@ impl Store {
|
||||
rows.iter().map(row_to_routine).collect()
|
||||
}
|
||||
|
||||
/// Find an enabled webhook routine by its configured path (or fallback to ID).
|
||||
pub async fn get_webhook_routine_by_path(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<Routine>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
let row = conn
|
||||
.query_opt(
|
||||
"SELECT * FROM routines WHERE enabled AND trigger_type = 'webhook' \
|
||||
AND (trigger_config->>'path' = $1 OR (trigger_config->>'path' IS NULL AND id::text = $1))",
|
||||
&[&path],
|
||||
)
|
||||
.await?;
|
||||
row.as_ref().map(row_to_routine).transpose()
|
||||
}
|
||||
|
||||
/// List all enabled cron routines whose next_fire_at <= now.
|
||||
pub async fn list_due_cron_routines(&self) -> Result<Vec<Routine>, DatabaseError> {
|
||||
let conn = self.conn().await?;
|
||||
|
||||
@@ -60,7 +60,6 @@ pub mod llm;
|
||||
pub mod observability;
|
||||
pub mod orchestrator;
|
||||
pub mod pairing;
|
||||
pub mod profile;
|
||||
pub mod registry;
|
||||
pub mod safety;
|
||||
pub mod sandbox;
|
||||
|
||||
+1
-45
@@ -13,9 +13,6 @@ Multi-provider LLM integration with circuit breaker, retry, failover, and respon
|
||||
| `nearai_chat.rs` | NEAR AI Chat Completions provider (dual auth: session token or API key) |
|
||||
| `codex_auth.rs` | Reads Codex CLI `auth.json`, extracts tokens, refreshes ChatGPT OAuth access tokens |
|
||||
| `codex_chatgpt.rs` | Custom Responses API provider for Codex ChatGPT backend (`/backend-api/codex`) |
|
||||
| `openai_codex_provider.rs` | OpenAI Codex Responses API client (SSE streaming, JWT auth, subscription billing) |
|
||||
| `openai_codex_session.rs` | OAuth 2.0 session manager for OpenAI Codex (device code flow, token persistence) |
|
||||
| `token_refreshing.rs` | Token-refreshing `LlmProvider` decorator for OpenAI Codex (pre-emptive refresh, zero-cost billing) |
|
||||
| `reasoning.rs` | `Reasoning` struct, `ReasoningContext`, `RespondResult`, `ActionPlan`, `ToolSelection`; thinking-tag stripping; `SILENT_REPLY_TOKEN` |
|
||||
| `session.rs` | NEAR AI session token management with disk + DB persistence, OAuth login flow |
|
||||
| `circuit_breaker.rs` | Circuit breaker: Closed → Open → HalfOpen state machine |
|
||||
@@ -37,12 +34,10 @@ Set via `LLM_BACKEND` env var:
|
||||
| `nearai` (default) | NEAR AI Chat Completions | `NEARAI_SESSION_TOKEN` or `NEARAI_API_KEY` |
|
||||
| `openai` | OpenAI | `OPENAI_API_KEY` |
|
||||
| `anthropic` | Anthropic | `ANTHROPIC_API_KEY` |
|
||||
| `github_copilot` | GitHub Copilot Chat API | `GITHUB_COPILOT_TOKEN`, `GITHUB_COPILOT_MODEL` |
|
||||
| `ollama` | Ollama local | `OLLAMA_BASE_URL` |
|
||||
| `openai_compatible` | Any OpenAI-compatible endpoint | `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` |
|
||||
| `tinfoil` | Tinfoil TEE inference | `TINFOIL_API_KEY`, `TINFOIL_MODEL` |
|
||||
| `bedrock` | AWS Bedrock (requires `--features bedrock`) | `BEDROCK_REGION`, `BEDROCK_MODEL`, `AWS_PROFILE` |
|
||||
| `openai_codex` | OpenAI Codex (ChatGPT subscription) | `OPENAI_CODEX_MODEL`, `OPENAI_CODEX_CLIENT_ID` |
|
||||
|
||||
Codex auth reuse:
|
||||
- Set `LLM_USE_CODEX_AUTH=true` to load credentials from `~/.codex/auth.json` (override with `CODEX_AUTH_PATH`).
|
||||
@@ -61,27 +56,6 @@ Uses the native Converse API via `aws-sdk-bedrockruntime` (`bedrock.rs`). Requir
|
||||
- `BEDROCK_MODEL` — Required model ID (e.g., `anthropic.claude-opus-4-6-v1`)
|
||||
- `BEDROCK_CROSS_REGION` — Optional cross-region inference prefix (`us`, `eu`, `apac`, `global`)
|
||||
|
||||
## GitHub Copilot Provider Notes
|
||||
|
||||
`github_copilot` uses a dedicated `GithubCopilotProvider` (`github_copilot.rs`) with
|
||||
direct HTTP via `reqwest::Client`. It cannot use `RigAdapter` because the Copilot API
|
||||
requires a two-step authentication flow: a long-lived GitHub OAuth token is exchanged
|
||||
for a short-lived Copilot session token via `api.github.com/copilot_internal/v2/token`.
|
||||
The session token is cached and auto-refreshed before expiry by `CopilotTokenManager`
|
||||
in `github_copilot_auth.rs`.
|
||||
|
||||
The API endpoint is `https://api.githubcopilot.com/chat/completions` (OpenAI Chat
|
||||
Completions format). Token source: `GITHUB_COPILOT_TOKEN` env var, or the
|
||||
`oauth_token` from your IDE sign-in flow (`~/.config/github-copilot/apps.json`).
|
||||
The setup wizard supports GitHub device login or manual token paste.
|
||||
|
||||
**Known risk:** The device login flow uses the VS Code Copilot OAuth client ID
|
||||
(`Iv1.b507a08c87ecfe98`) and injects VS Code identity headers (`User-Agent`,
|
||||
`Editor-Version`, `Editor-Plugin-Version`, `Copilot-Integration-Id`). GitHub could
|
||||
rotate this client ID at any time. If GitHub publishes an official third-party client
|
||||
ID, migrate to it immediately. Advanced users can override headers via
|
||||
`GITHUB_COPILOT_EXTRA_HEADERS`.
|
||||
|
||||
## NEAR AI Provider Gotchas
|
||||
|
||||
**Dual auth modes:**
|
||||
@@ -174,27 +148,9 @@ To add a new provider:
|
||||
|
||||
Set `LLM_EXTRA_HEADERS=Key:Value,Key2:Value2` to inject headers into every request. Useful for OpenRouter attribution (`HTTP-Referer`, `X-Title`). Invalid header names/values are skipped with a warning (not a fatal error).
|
||||
|
||||
## OpenAI Codex Provider
|
||||
|
||||
Uses the Responses API at `chatgpt.com/backend-api/codex/responses` with ChatGPT subscription OAuth tokens (zero API cost — billing through subscription).
|
||||
|
||||
**Auth flow:** Device code OAuth via `auth.openai.com/api/accounts/deviceauth/*` endpoints. On first run, displays a code for the user to enter at a URL. Tokens are persisted to `~/.ironclaw/openai_codex_session.json` (mode 0600) and auto-refreshed before expiry.
|
||||
|
||||
**Provider chain:** `OpenAiCodexProvider` → `TokenRefreshingProvider` (pre-emptive refresh + retry on 401) → standard decorator chain. The `TokenRefreshingProvider` intercepts `AuthFailed`/`SessionExpired` errors, refreshes the OAuth token, and retries once.
|
||||
|
||||
**Key differences from other providers:**
|
||||
- Uses Responses API (not Chat Completions) — SSE streaming with different event types
|
||||
- System messages are sent as `instructions` field, not in `input` array
|
||||
- Tool schemas are normalized via `normalize_schema_strict()` for OpenAI strict mode
|
||||
- `cost_per_token()` returns `(0, 0)` — subscription-based billing
|
||||
- `set_model()` returns error — model is fixed at construction time
|
||||
- Image attachments are silently dropped with a warning log
|
||||
|
||||
**Env vars:** `OPENAI_CODEX_MODEL` (default: `gpt-5.3-codex`), `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_AUTH_URL`, `OPENAI_CODEX_API_URL`.
|
||||
|
||||
## Provider Chain Construction
|
||||
|
||||
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. It creates the base provider (dispatching to `create_openai_codex_provider()` for codex, `create_llm_provider()` for everything else), then applies all decorators inline:
|
||||
`build_provider_chain()` in `mod.rs` is the single source of truth for assembling decorators. The chain is:
|
||||
|
||||
```
|
||||
Raw provider
|
||||
|
||||
@@ -22,6 +22,8 @@ use crate::llm::provider::{
|
||||
ToolCompletionRequest, ToolCompletionResponse, strip_unsupported_completion_params,
|
||||
strip_unsupported_tool_params,
|
||||
};
|
||||
use crate::llm::retry::cap_retry_after;
|
||||
|
||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// OAuth beta requires 2023-06-01; the 2024-10-22 version is not valid with the beta flag.
|
||||
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
|
||||
@@ -142,9 +144,15 @@ impl AnthropicOAuthProvider {
|
||||
|
||||
if !status.is_success() {
|
||||
// Parse Retry-After header before consuming the body.
|
||||
let retry_after = Some(crate::llm::retry::parse_retry_after(
|
||||
response.headers().get("retry-after"),
|
||||
));
|
||||
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map(std::time::Duration::from_secs)
|
||||
.map(cap_retry_after)
|
||||
.or(Some(std::time::Duration::from_secs(60)));
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
@@ -701,4 +709,84 @@ mod tests {
|
||||
// Subsequent reads see the updated token
|
||||
assert_eq!(token.read().unwrap().expose_secret(), "new_token");
|
||||
}
|
||||
|
||||
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_parsing_delay_seconds() {
|
||||
// Verify delay-seconds format is parsed correctly
|
||||
let header_value = "45";
|
||||
let duration = parse_retry_after_anthropic_for_test(header_value);
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(45)),
|
||||
"Should parse delay-seconds format"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_fallback_missing_header() {
|
||||
// Regression test: When Retry-After header is missing,
|
||||
// should fall back to 60s instead of None
|
||||
let duration = parse_retry_after_anthropic_for_test("");
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(60)),
|
||||
"Missing header should fallback to 60s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_fallback_invalid_format() {
|
||||
// Regression test: When Retry-After header is in unexpected format,
|
||||
// should fall back to 60s instead of None
|
||||
let invalid_formats = vec![
|
||||
"invalid",
|
||||
"not-a-number",
|
||||
"30.5", // float instead of int
|
||||
"abc123",
|
||||
"Mon, 02 Mar 2026 18:00:00 GMT", // RFC2822 not supported in anthropic version
|
||||
];
|
||||
|
||||
for format in invalid_formats {
|
||||
let duration = parse_retry_after_anthropic_for_test(format);
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(60)),
|
||||
"Invalid format '{}' should fallback to 60s",
|
||||
format
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_zero_seconds_accepted() {
|
||||
// Verify zero seconds is a valid retry delay
|
||||
let duration = parse_retry_after_anthropic_for_test("0");
|
||||
assert_eq!(duration, Some(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_large_number() {
|
||||
// Verify large numbers are capped to the safe maximum
|
||||
let duration = parse_retry_after_anthropic_for_test("7200"); // 2 hours
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(
|
||||
crate::llm::retry::MAX_RETRY_AFTER_SECS
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper function to test Retry-After header parsing logic for Anthropic
|
||||
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
|
||||
fn parse_retry_after_anthropic_for_test(header_value: &str) -> Option<std::time::Duration> {
|
||||
header_value
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(std::time::Duration::from_secs)
|
||||
.map(cap_retry_after)
|
||||
.or(Some(std::time::Duration::from_secs(60)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//! Shared test helpers for OpenAI Codex provider tests.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use crate::config::OpenAiCodexConfig;
|
||||
|
||||
/// Build a minimal JWT for testing (header.payload.signature).
|
||||
pub(crate) fn make_test_jwt(account_id: &str) -> String {
|
||||
use base64::Engine;
|
||||
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
|
||||
let header = engine.encode(b"{\"alg\":\"RS256\",\"typ\":\"JWT\"}");
|
||||
let payload_json = serde_json::json!({
|
||||
"sub": "user123",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
},
|
||||
});
|
||||
let payload = engine.encode(payload_json.to_string().as_bytes());
|
||||
let sig = engine.encode(b"fake-signature");
|
||||
format!("{header}.{payload}.{sig}")
|
||||
}
|
||||
|
||||
/// Build a test `OpenAiCodexConfig` with a given session path.
|
||||
pub(crate) fn test_codex_config(session_path: std::path::PathBuf) -> OpenAiCodexConfig {
|
||||
OpenAiCodexConfig {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
auth_endpoint: "https://auth.openai.com".to_string(),
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||
client_id: "test_client_id".to_string(),
|
||||
session_path,
|
||||
token_refresh_margin_secs: 300,
|
||||
}
|
||||
}
|
||||
+4
-36
@@ -9,7 +9,6 @@ use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::llm::registry::ProviderProtocol;
|
||||
use crate::llm::session::SessionConfig;
|
||||
|
||||
@@ -103,36 +102,6 @@ pub struct RegistryProviderConfig {
|
||||
pub unsupported_params: Vec<String>,
|
||||
}
|
||||
|
||||
/// Configuration for OpenAI Codex (ChatGPT subscription OAuth).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAiCodexConfig {
|
||||
/// Model to use (default: "gpt-5.3-codex").
|
||||
pub model: String,
|
||||
/// OAuth authorization server (default: "https://auth.openai.com").
|
||||
pub auth_endpoint: String,
|
||||
/// Responses API base URL (default: "https://chatgpt.com/backend-api/codex").
|
||||
pub api_base_url: String,
|
||||
/// OAuth client ID (default: OpenAI's public Codex client).
|
||||
pub client_id: String,
|
||||
/// Path to session file (default: ~/.ironclaw/openai_codex_session.json).
|
||||
pub session_path: PathBuf,
|
||||
/// Seconds before expiry to proactively refresh (default: 300).
|
||||
pub token_refresh_margin_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for OpenAiCodexConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "gpt-5.3-codex".to_string(),
|
||||
auth_endpoint: "https://auth.openai.com".to_string(),
|
||||
api_base_url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||
client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
|
||||
session_path: ironclaw_base_dir().join("openai_codex_session.json"),
|
||||
token_refresh_margin_secs: 300,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for AWS Bedrock (native Converse API).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BedrockConfig {
|
||||
@@ -165,8 +134,6 @@ pub struct LlmConfig {
|
||||
pub provider: Option<RegistryProviderConfig>,
|
||||
/// AWS Bedrock config (populated when backend=bedrock, requires --features bedrock).
|
||||
pub bedrock: Option<BedrockConfig>,
|
||||
/// OpenAI Codex config (populated when backend=openai_codex).
|
||||
pub openai_codex: Option<OpenAiCodexConfig>,
|
||||
/// HTTP request timeout in seconds for LLM API calls.
|
||||
/// Default: 120. Increase for local LLMs (Ollama, vLLM, LM Studio) that
|
||||
/// need more time for prompt evaluation on consumer hardware.
|
||||
@@ -237,7 +204,8 @@ impl NearAiConfig {
|
||||
/// appropriate base URL (cloud-api when API key is present,
|
||||
/// private.near.ai for session-token auth).
|
||||
pub(crate) fn for_model_discovery() -> Self {
|
||||
let api_key = crate::config::helpers::env_or_override("NEARAI_API_KEY")
|
||||
let api_key = std::env::var("NEARAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.map(SecretString::from);
|
||||
|
||||
@@ -246,8 +214,8 @@ impl NearAiConfig {
|
||||
} else {
|
||||
"https://private.near.ai"
|
||||
};
|
||||
let base_url = crate::config::helpers::env_or_override("NEARAI_BASE_URL")
|
||||
.unwrap_or_else(|| default_base.to_string());
|
||||
let base_url =
|
||||
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string());
|
||||
|
||||
Self {
|
||||
model: String::new(),
|
||||
|
||||
@@ -1,712 +0,0 @@
|
||||
//! GitHub Copilot provider (direct HTTP with token exchange).
|
||||
//!
|
||||
//! The GitHub Copilot API at `api.githubcopilot.com` speaks OpenAI Chat
|
||||
//! Completions format but requires a two-step authentication flow:
|
||||
//! 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
|
||||
//! 2. A short-lived Copilot session token (exchanged via GitHub API)
|
||||
//!
|
||||
//! The standard OpenAI rig-core client sends `Authorization: Bearer <token>`
|
||||
//! with the raw OAuth token, which gets rejected with "Authorization header
|
||||
//! is badly formatted". This provider handles the token exchange transparently.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use rust_decimal::Decimal;
|
||||
use secrecy::ExposeSecret;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm::config::RegistryProviderConfig;
|
||||
use crate::llm::costs;
|
||||
use crate::llm::error::LlmError;
|
||||
use crate::llm::github_copilot_auth::CopilotTokenManager;
|
||||
use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, LlmProvider,
|
||||
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||
strip_unsupported_completion_params, strip_unsupported_tool_params,
|
||||
};
|
||||
|
||||
/// GitHub Copilot provider with automatic token exchange.
|
||||
pub struct GithubCopilotProvider {
|
||||
client: Client,
|
||||
token_manager: Arc<CopilotTokenManager>,
|
||||
model: String,
|
||||
base_url: String,
|
||||
active_model: std::sync::RwLock<String>,
|
||||
extra_headers: Vec<(String, String)>,
|
||||
/// Parameter names that this provider does not support.
|
||||
unsupported_params: HashSet<String>,
|
||||
}
|
||||
|
||||
impl GithubCopilotProvider {
|
||||
pub fn new(
|
||||
config: &RegistryProviderConfig,
|
||||
request_timeout_secs: u64,
|
||||
) -> Result<Self, LlmError> {
|
||||
let oauth_token = config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.map(|k| k.expose_secret().to_string())
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("No API key configured for github_copilot — check GITHUB_COPILOT_TOKEN env var or secrets store");
|
||||
LlmError::AuthFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(request_timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Failed to build HTTP client: {e}"),
|
||||
})?;
|
||||
|
||||
let token_manager = Arc::new(CopilotTokenManager::new(client.clone(), oauth_token));
|
||||
|
||||
let base_url = if config.base_url.is_empty() {
|
||||
"https://api.githubcopilot.com".to_string()
|
||||
} else {
|
||||
config.base_url.clone()
|
||||
};
|
||||
|
||||
let active_model = std::sync::RwLock::new(config.model.clone());
|
||||
let unsupported_params: HashSet<String> =
|
||||
config.unsupported_params.iter().cloned().collect();
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
token_manager,
|
||||
model: config.model.clone(),
|
||||
base_url,
|
||||
active_model,
|
||||
extra_headers: config.extra_headers.clone(),
|
||||
unsupported_params,
|
||||
})
|
||||
}
|
||||
|
||||
fn api_url(&self) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
format!("{base}/chat/completions")
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
||||
strip_unsupported_completion_params(&self.unsupported_params, req);
|
||||
}
|
||||
|
||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
||||
strip_unsupported_tool_params(&self.unsupported_params, req);
|
||||
}
|
||||
|
||||
async fn send_request<R: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
body: &impl Serialize,
|
||||
) -> Result<R, LlmError> {
|
||||
let url = self.api_url();
|
||||
// Map token exchange failures to RequestFailed (retryable) rather than
|
||||
// AuthFailed (non-retryable), since transient network errors during
|
||||
// exchange should be retried by RetryProvider.
|
||||
let token = self.token_manager.get_token().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Copilot: token exchange failed");
|
||||
LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Token exchange failed: {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
let mut request = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(token.expose_secret())
|
||||
.header("Content-Type", "application/json");
|
||||
|
||||
// Inject Copilot identity headers
|
||||
for (key, value) in &self.extra_headers {
|
||||
request = request.header(key.as_str(), value.as_str());
|
||||
}
|
||||
|
||||
let response = request.json(body).send().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Copilot: HTTP request failed");
|
||||
LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
// Use shared retry-after parser (supports HTTP-date, default 60s)
|
||||
let retry_after = Some(crate::llm::retry::parse_retry_after(
|
||||
response.headers().get(reqwest::header::RETRY_AFTER),
|
||||
));
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("(failed to read error body: {e})"));
|
||||
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body = %crate::agent::truncate_for_preview(&response_text, 256),
|
||||
"Copilot: API error response"
|
||||
);
|
||||
|
||||
if status.as_u16() == 401 {
|
||||
// Invalidate the cached session token and retry once with a
|
||||
// fresh exchange — stale tokens are the most common 401 cause.
|
||||
tracing::warn!("Copilot: 401 Unauthorized — invalidating session token, retrying");
|
||||
self.token_manager.invalidate().await;
|
||||
let fresh = self.token_manager.get_token().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Copilot: re-exchange after 401 failed");
|
||||
LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Token re-exchange after 401 failed: {e}"),
|
||||
}
|
||||
})?;
|
||||
let mut retry_req = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(fresh.expose_secret())
|
||||
.header("Content-Type", "application/json");
|
||||
for (key, value) in &self.extra_headers {
|
||||
retry_req = retry_req.header(key.as_str(), value.as_str());
|
||||
}
|
||||
let retry =
|
||||
retry_req
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Retry after 401 failed: {e}"),
|
||||
})?;
|
||||
if retry.status().is_success() {
|
||||
let text = retry.text().await.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Failed to read retry response body: {e}"),
|
||||
})?;
|
||||
return serde_json::from_str(&text).map_err(|e| {
|
||||
let truncated = crate::agent::truncate_for_preview(&text, 512);
|
||||
LlmError::InvalidResponse {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let retry_status = retry.status();
|
||||
tracing::warn!(
|
||||
status = %retry_status,
|
||||
"Copilot: 401 retry also failed"
|
||||
);
|
||||
return Err(LlmError::AuthFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
});
|
||||
}
|
||||
if status.as_u16() == 429 {
|
||||
tracing::warn!(retry_after = ?retry_after, "Copilot: rate limited");
|
||||
return Err(LlmError::RateLimited {
|
||||
provider: "github_copilot".to_string(),
|
||||
retry_after,
|
||||
});
|
||||
}
|
||||
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("HTTP {status}: {truncated}"),
|
||||
});
|
||||
}
|
||||
|
||||
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("Failed to read response body: {e}"),
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&response_text).map_err(|e| {
|
||||
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
body = %truncated,
|
||||
"Copilot: failed to parse response JSON"
|
||||
);
|
||||
LlmError::InvalidResponse {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: format!("JSON parse error: {e}. Raw: {truncated}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for GithubCopilotProvider {
|
||||
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_completion_params(&mut req);
|
||||
let messages = convert_messages(req.messages);
|
||||
|
||||
let request = OpenAiRequest {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: req.max_tokens,
|
||||
temperature: req.temperature,
|
||||
stop: req.stop_sequences,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
|
||||
let response: OpenAiResponse = self.send_request(&request).await?;
|
||||
let choice =
|
||||
response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| LlmError::InvalidResponse {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: "No choices in response".to_string(),
|
||||
})?;
|
||||
|
||||
let (content, _tool_calls) = extract_choice_content(&choice);
|
||||
|
||||
let finish_reason = match choice.finish_reason.as_deref() {
|
||||
Some("stop") => FinishReason::Stop,
|
||||
Some("length") => FinishReason::Length,
|
||||
Some("tool_calls") => FinishReason::ToolUse,
|
||||
Some("content_filter") => FinishReason::ContentFilter,
|
||||
_ => FinishReason::Unknown,
|
||||
};
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: content.unwrap_or_default(),
|
||||
finish_reason,
|
||||
input_tokens: response
|
||||
.usage
|
||||
.as_ref()
|
||||
.map(|u| u.prompt_tokens)
|
||||
.unwrap_or(0),
|
||||
output_tokens: response
|
||||
.usage
|
||||
.as_ref()
|
||||
.map(|u| u.completion_tokens)
|
||||
.unwrap_or(0),
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn complete_with_tools(
|
||||
&self,
|
||||
mut req: ToolCompletionRequest,
|
||||
) -> Result<ToolCompletionResponse, LlmError> {
|
||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
||||
self.strip_unsupported_tool_params(&mut req);
|
||||
let messages = convert_messages(req.messages);
|
||||
|
||||
let tools: Vec<OpenAiTool> = req
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|t| OpenAiTool {
|
||||
tool_type: "function".to_string(),
|
||||
function: OpenAiFunction {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tool_choice = req.tool_choice.map(|tc| match tc.as_str() {
|
||||
"auto" | "required" | "none" => serde_json::Value::String(tc),
|
||||
specific => serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {"name": specific}
|
||||
}),
|
||||
});
|
||||
|
||||
let request = OpenAiRequest {
|
||||
model,
|
||||
messages,
|
||||
max_tokens: req.max_tokens,
|
||||
temperature: req.temperature,
|
||||
stop: req.stop_sequences,
|
||||
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||
tool_choice,
|
||||
};
|
||||
|
||||
let response: OpenAiResponse = self.send_request(&request).await?;
|
||||
let choice =
|
||||
response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| LlmError::InvalidResponse {
|
||||
provider: "github_copilot".to_string(),
|
||||
reason: "No choices in response".to_string(),
|
||||
})?;
|
||||
|
||||
let (content, tool_calls) = extract_choice_content(&choice);
|
||||
|
||||
let finish_reason = match choice.finish_reason.as_deref() {
|
||||
Some("stop") => FinishReason::Stop,
|
||||
Some("length") => FinishReason::Length,
|
||||
Some("tool_calls") => FinishReason::ToolUse,
|
||||
Some("content_filter") => FinishReason::ContentFilter,
|
||||
_ => {
|
||||
if !tool_calls.is_empty() {
|
||||
FinishReason::ToolUse
|
||||
} else {
|
||||
FinishReason::Unknown
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ToolCompletionResponse {
|
||||
content,
|
||||
tool_calls,
|
||||
finish_reason,
|
||||
input_tokens: response
|
||||
.usage
|
||||
.as_ref()
|
||||
.map(|u| u.prompt_tokens)
|
||||
.unwrap_or(0),
|
||||
output_tokens: response
|
||||
.usage
|
||||
.as_ref()
|
||||
.map(|u| u.completion_tokens)
|
||||
.unwrap_or(0),
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn model_name(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn cost_per_token(&self) -> (Decimal, Decimal) {
|
||||
let model = self.active_model_name();
|
||||
costs::model_cost(&model).unwrap_or_else(costs::default_cost)
|
||||
}
|
||||
|
||||
fn active_model_name(&self) -> String {
|
||||
match self.active_model.read() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<(), LlmError> {
|
||||
match self.active_model.write() {
|
||||
Ok(mut guard) => {
|
||||
*guard = model.to_string();
|
||||
}
|
||||
Err(poisoned) => {
|
||||
*poisoned.into_inner() = model.to_string();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- OpenAI Chat Completions API types ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiRequest {
|
||||
model: String,
|
||||
messages: Vec<OpenAiMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stop: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<OpenAiTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<OpenAiContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenAiToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI content can be a plain string or an array of parts (for multimodal).
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum OpenAiContent {
|
||||
Text(String),
|
||||
Parts(Vec<OpenAiContentPart>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum OpenAiContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: OpenAiImageUrl },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiImageUrl {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiToolCall {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
call_type: String,
|
||||
function: OpenAiToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiToolCallFunction {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiTool {
|
||||
#[serde(rename = "type")]
|
||||
tool_type: String,
|
||||
function: OpenAiFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAiFunction {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponse {
|
||||
choices: Vec<OpenAiChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiChoice {
|
||||
message: OpenAiResponseMessage,
|
||||
#[serde(default)]
|
||||
finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<OpenAiResponseToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponseToolCall {
|
||||
id: String,
|
||||
function: OpenAiResponseFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiResponseFunction {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiUsage {
|
||||
#[serde(default)]
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
}
|
||||
|
||||
/// Convert IronClaw messages to OpenAI Chat Completions format.
|
||||
fn convert_messages(messages: Vec<ChatMessage>) -> Vec<OpenAiMessage> {
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|msg| match msg.role {
|
||||
Role::System => OpenAiMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OpenAiContent::Text(msg.content)),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
},
|
||||
Role::User => {
|
||||
let content = if msg.content_parts.is_empty() {
|
||||
Some(OpenAiContent::Text(msg.content))
|
||||
} else {
|
||||
let mut parts = Vec::with_capacity(1 + msg.content_parts.len());
|
||||
if !msg.content.is_empty() {
|
||||
parts.push(OpenAiContentPart::Text { text: msg.content });
|
||||
}
|
||||
for part in msg.content_parts {
|
||||
match part {
|
||||
ContentPart::Text { text } => {
|
||||
parts.push(OpenAiContentPart::Text { text });
|
||||
}
|
||||
ContentPart::ImageUrl { image_url } => {
|
||||
parts.push(OpenAiContentPart::ImageUrl {
|
||||
image_url: OpenAiImageUrl { url: image_url.url },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(OpenAiContent::Parts(parts))
|
||||
};
|
||||
OpenAiMessage {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
Role::Assistant => {
|
||||
let tool_calls = msg.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|tc| OpenAiToolCall {
|
||||
id: tc.id,
|
||||
call_type: "function".to_string(),
|
||||
function: OpenAiToolCallFunction {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments.to_string(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let content = if msg.content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(OpenAiContent::Text(msg.content))
|
||||
};
|
||||
OpenAiMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_calls,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
Role::Tool => OpenAiMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAiContent::Text(msg.content)),
|
||||
tool_calls: None,
|
||||
tool_call_id: msg.tool_call_id,
|
||||
name: msg.name,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extract text and tool calls from an OpenAI response choice.
|
||||
fn extract_choice_content(choice: &OpenAiChoice) -> (Option<String>, Vec<ToolCall>) {
|
||||
let content = choice.message.content.clone();
|
||||
let tool_calls = choice
|
||||
.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|calls| {
|
||||
calls
|
||||
.iter()
|
||||
.map(|tc| ToolCall {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
arguments: serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
(content, tool_calls)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_messages_basic() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are helpful."),
|
||||
ChatMessage::user("Hello"),
|
||||
ChatMessage::assistant("Hi there!"),
|
||||
];
|
||||
let converted = convert_messages(messages);
|
||||
assert_eq!(converted.len(), 3);
|
||||
assert_eq!(converted[0].role, "system");
|
||||
assert_eq!(converted[1].role, "user");
|
||||
assert_eq!(converted[2].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_messages_tool_calls() {
|
||||
let tool_calls = vec![ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "search".to_string(),
|
||||
arguments: serde_json::json!({"q": "test"}),
|
||||
}];
|
||||
let messages = vec![
|
||||
ChatMessage::user("Search"),
|
||||
ChatMessage::assistant_with_tool_calls(Some("Searching...".to_string()), tool_calls),
|
||||
ChatMessage::tool_result("call_1", "search", "found it"),
|
||||
];
|
||||
let converted = convert_messages(messages);
|
||||
assert_eq!(converted.len(), 3);
|
||||
assert!(converted[1].tool_calls.is_some());
|
||||
assert_eq!(converted[2].role, "tool");
|
||||
assert_eq!(converted[2].tool_call_id, Some("call_1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_choice_text_only() {
|
||||
let choice = OpenAiChoice {
|
||||
message: OpenAiResponseMessage {
|
||||
content: Some("Hello!".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
finish_reason: Some("stop".to_string()),
|
||||
};
|
||||
let (content, tool_calls) = extract_choice_content(&choice);
|
||||
assert_eq!(content, Some("Hello!".to_string()));
|
||||
assert!(tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_choice_with_tool_calls() {
|
||||
let choice = OpenAiChoice {
|
||||
message: OpenAiResponseMessage {
|
||||
content: Some("Let me search.".to_string()),
|
||||
tool_calls: Some(vec![OpenAiResponseToolCall {
|
||||
id: "call_1".to_string(),
|
||||
function: OpenAiResponseFunction {
|
||||
name: "search".to_string(),
|
||||
arguments: r#"{"q":"test"}"#.to_string(),
|
||||
},
|
||||
}]),
|
||||
},
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
};
|
||||
let (content, tool_calls) = extract_choice_content(&choice);
|
||||
assert_eq!(content, Some("Let me search.".to_string()));
|
||||
assert_eq!(tool_calls.len(), 1);
|
||||
assert_eq!(tool_calls[0].name, "search");
|
||||
assert_eq!(tool_calls[0].arguments["q"], "test");
|
||||
}
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
// ─── Risk: hardcoded VS Code Copilot identity ───────────────────────────────
|
||||
//
|
||||
// The client ID and editor identity headers below are extracted from the
|
||||
// VS Code Copilot Chat extension. This is the *only* publicly documented
|
||||
// way to access the Copilot completions API with a personal GitHub token.
|
||||
//
|
||||
// **Known risks:**
|
||||
// • GitHub may rotate or revoke this client ID at any time, which would
|
||||
// break authentication for all IronClaw users until the constant is
|
||||
// updated and a new release is shipped.
|
||||
// • Using another product's client ID may violate GitHub's Terms of
|
||||
// Service. Maintainers should seek explicit guidance from GitHub
|
||||
// before shipping this to a wide audience.
|
||||
// • The editor version strings (`vscode/1.99.3`, `copilot-chat/0.26.7`)
|
||||
// will become stale and could eventually be rejected by the API.
|
||||
//
|
||||
// **Mitigation:** If GitHub publishes an official Copilot API client ID or
|
||||
// an OAuth app registration flow for third-party tools, migrate to it
|
||||
// immediately.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
pub const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
|
||||
pub const GITHUB_COPILOT_SCOPE: &str = "read:user";
|
||||
pub const GITHUB_COPILOT_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
|
||||
pub const GITHUB_COPILOT_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
|
||||
pub const GITHUB_COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models";
|
||||
pub const GITHUB_COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
|
||||
pub const GITHUB_COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7";
|
||||
pub const GITHUB_COPILOT_EDITOR_VERSION: &str = "vscode/1.99.3";
|
||||
pub const GITHUB_COPILOT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.26.7";
|
||||
pub const GITHUB_COPILOT_INTEGRATION_ID: &str = "vscode-chat";
|
||||
|
||||
/// Buffer before token expiry to trigger a refresh (5 minutes).
|
||||
const TOKEN_REFRESH_BUFFER_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
#[serde(default = "default_poll_interval_secs")]
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AccessTokenResponse {
|
||||
access_token: Option<String>,
|
||||
error: Option<String>,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GithubCopilotAuthError {
|
||||
#[error("failed to start device login: {0}")]
|
||||
DeviceCodeRequest(String),
|
||||
#[error("failed to poll device login: {0}")]
|
||||
TokenPolling(String),
|
||||
#[error("device login was denied")]
|
||||
AccessDenied,
|
||||
#[error("device login expired before authorization completed")]
|
||||
Expired,
|
||||
#[error("github copilot token validation failed: {0}")]
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DevicePollingStatus {
|
||||
Pending,
|
||||
SlowDown,
|
||||
Authorized(String),
|
||||
}
|
||||
|
||||
pub fn default_headers() -> Vec<(String, String)> {
|
||||
vec![
|
||||
(
|
||||
"User-Agent".to_string(),
|
||||
GITHUB_COPILOT_USER_AGENT.to_string(),
|
||||
),
|
||||
(
|
||||
"Editor-Version".to_string(),
|
||||
GITHUB_COPILOT_EDITOR_VERSION.to_string(),
|
||||
),
|
||||
(
|
||||
"Editor-Plugin-Version".to_string(),
|
||||
GITHUB_COPILOT_EDITOR_PLUGIN_VERSION.to_string(),
|
||||
),
|
||||
(
|
||||
"Copilot-Integration-Id".to_string(),
|
||||
GITHUB_COPILOT_INTEGRATION_ID.to_string(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn default_poll_interval_secs() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
pub async fn request_device_code(
|
||||
client: &reqwest::Client,
|
||||
) -> Result<DeviceCodeResponse, GithubCopilotAuthError> {
|
||||
let response = client
|
||||
.post(GITHUB_COPILOT_DEVICE_CODE_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
|
||||
.form(&[
|
||||
("client_id", GITHUB_COPILOT_CLIENT_ID),
|
||||
("scope", GITHUB_COPILOT_SCOPE),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
url = %GITHUB_COPILOT_DEVICE_CODE_URL,
|
||||
"Copilot: device code request failed"
|
||||
);
|
||||
GithubCopilotAuthError::DeviceCodeRequest(format_reqwest_error(&e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body = %truncate_for_error(&body),
|
||||
"Copilot: device code endpoint returned error"
|
||||
);
|
||||
return Err(GithubCopilotAuthError::DeviceCodeRequest(format!(
|
||||
"HTTP {status}: {}",
|
||||
truncate_for_error(&body)
|
||||
)));
|
||||
}
|
||||
|
||||
let device = response
|
||||
.json::<DeviceCodeResponse>()
|
||||
.await
|
||||
.map_err(|e| GithubCopilotAuthError::DeviceCodeRequest(e.to_string()))?;
|
||||
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub async fn poll_for_access_token(
|
||||
client: &reqwest::Client,
|
||||
device_code: &str,
|
||||
) -> Result<DevicePollingStatus, GithubCopilotAuthError> {
|
||||
let response = client
|
||||
.post(GITHUB_COPILOT_ACCESS_TOKEN_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header(reqwest::header::USER_AGENT, GITHUB_COPILOT_USER_AGENT)
|
||||
.form(&[
|
||||
("client_id", GITHUB_COPILOT_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
url = %GITHUB_COPILOT_ACCESS_TOKEN_URL,
|
||||
"Copilot: poll request failed"
|
||||
);
|
||||
GithubCopilotAuthError::TokenPolling(format_reqwest_error(&e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body = %truncate_for_error(&body),
|
||||
"Copilot: poll endpoint returned error"
|
||||
);
|
||||
return Err(GithubCopilotAuthError::TokenPolling(format!(
|
||||
"HTTP {status}: {}",
|
||||
truncate_for_error(&body)
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.json::<AccessTokenResponse>()
|
||||
.await
|
||||
.map_err(|e| GithubCopilotAuthError::TokenPolling(e.to_string()))?;
|
||||
|
||||
if let Some(token) = body.access_token {
|
||||
return Ok(DevicePollingStatus::Authorized(token));
|
||||
}
|
||||
|
||||
match body.error.as_deref() {
|
||||
Some("authorization_pending") | None => Ok(DevicePollingStatus::Pending),
|
||||
Some("slow_down") => {
|
||||
tracing::debug!("Copilot: GitHub requested slow_down, increasing poll interval");
|
||||
Ok(DevicePollingStatus::SlowDown)
|
||||
}
|
||||
Some("access_denied") => {
|
||||
tracing::warn!("Copilot: device login was denied by user");
|
||||
Err(GithubCopilotAuthError::AccessDenied)
|
||||
}
|
||||
Some("expired_token") => {
|
||||
tracing::warn!("Copilot: device code expired before authorization");
|
||||
Err(GithubCopilotAuthError::Expired)
|
||||
}
|
||||
Some(other) => {
|
||||
let desc = body
|
||||
.error_description
|
||||
.filter(|description| !description.is_empty())
|
||||
.unwrap_or_else(|| other.to_string());
|
||||
tracing::warn!(error = %other, description = %desc, "Copilot: unexpected poll error");
|
||||
Err(GithubCopilotAuthError::TokenPolling(desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum consecutive transient poll failures before giving up.
|
||||
const MAX_POLL_FAILURES: u32 = 5;
|
||||
|
||||
pub async fn wait_for_device_login(
|
||||
client: &reqwest::Client,
|
||||
device: &DeviceCodeResponse,
|
||||
) -> Result<String, GithubCopilotAuthError> {
|
||||
let expires_at = std::time::Instant::now()
|
||||
.checked_add(Duration::from_secs(device.expires_in))
|
||||
.ok_or(GithubCopilotAuthError::Expired)?;
|
||||
let mut poll_interval = device.interval.max(1);
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() >= expires_at {
|
||||
tracing::warn!("Copilot: device login expired");
|
||||
return Err(GithubCopilotAuthError::Expired);
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(poll_interval)).await;
|
||||
|
||||
match poll_for_access_token(client, &device.device_code).await {
|
||||
Ok(DevicePollingStatus::Pending) => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
Ok(DevicePollingStatus::SlowDown) => {
|
||||
consecutive_failures = 0;
|
||||
poll_interval = poll_interval.saturating_add(5);
|
||||
}
|
||||
Ok(DevicePollingStatus::Authorized(token)) => {
|
||||
return Ok(token);
|
||||
}
|
||||
// Definitive failures — propagate immediately
|
||||
Err(GithubCopilotAuthError::AccessDenied) => {
|
||||
return Err(GithubCopilotAuthError::AccessDenied);
|
||||
}
|
||||
Err(GithubCopilotAuthError::Expired) => {
|
||||
return Err(GithubCopilotAuthError::Expired);
|
||||
}
|
||||
// Transient failures — retry with backoff
|
||||
Err(e) => {
|
||||
consecutive_failures += 1;
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
attempt = consecutive_failures,
|
||||
max = MAX_POLL_FAILURES,
|
||||
"Copilot: transient poll failure, will retry"
|
||||
);
|
||||
if consecutive_failures >= MAX_POLL_FAILURES {
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
"Copilot: too many consecutive poll failures, giving up"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
// Back off on transient errors
|
||||
poll_interval = (poll_interval + 2).min(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a GitHub OAuth token by performing the Copilot token exchange.
|
||||
///
|
||||
/// This exchanges the raw OAuth token for a Copilot session token (proving the
|
||||
/// token is valid and the user has Copilot access), then verifies the session
|
||||
/// token works against the models endpoint.
|
||||
pub async fn validate_token(
|
||||
client: &reqwest::Client,
|
||||
token: &str,
|
||||
) -> Result<(), GithubCopilotAuthError> {
|
||||
// Step 1: Exchange the OAuth token for a Copilot session token.
|
||||
// This validates both that the OAuth token is valid and that the user
|
||||
// has an active Copilot subscription.
|
||||
let session = exchange_copilot_token(client, token).await?;
|
||||
// Step 2: Verify the session token works against the models endpoint.
|
||||
let mut request = client
|
||||
.get(GITHUB_COPILOT_MODELS_URL)
|
||||
.bearer_auth(&session.token)
|
||||
.timeout(Duration::from_secs(15));
|
||||
|
||||
for (key, value) in default_headers() {
|
||||
request = request.header(&key, value);
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
"Copilot: models endpoint request failed"
|
||||
);
|
||||
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body = %truncate_for_error(&body),
|
||||
"Copilot: models endpoint returned error during validation"
|
||||
);
|
||||
Err(GithubCopilotAuthError::Validation(format!(
|
||||
"HTTP {status}: {}",
|
||||
truncate_for_error(&body)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Response from the Copilot token exchange endpoint.
|
||||
///
|
||||
/// The `token` field is an HMAC-signed session token (not a JWT) used as
|
||||
/// `Authorization: Bearer <token>` for requests to `api.githubcopilot.com`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CopilotTokenResponse {
|
||||
/// The Copilot session token (HMAC-signed, not a JWT).
|
||||
pub token: String,
|
||||
/// Unix timestamp (seconds) when this token expires.
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Exchange a GitHub OAuth token for a Copilot API session token.
|
||||
///
|
||||
/// Calls `GET https://api.github.com/copilot_internal/v2/token` with the
|
||||
/// GitHub OAuth token in `Authorization: token <oauth_token>` format.
|
||||
/// Returns a short-lived session token for `api.githubcopilot.com`.
|
||||
pub async fn exchange_copilot_token(
|
||||
client: &reqwest::Client,
|
||||
oauth_token: &str,
|
||||
) -> Result<CopilotTokenResponse, GithubCopilotAuthError> {
|
||||
let token_trimmed = oauth_token.trim();
|
||||
let mut request = client
|
||||
.get(GITHUB_COPILOT_TOKEN_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
// GitHub Copilot uses `token` auth scheme, not `Bearer`
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("token {token_trimmed}"),
|
||||
)
|
||||
.timeout(Duration::from_secs(15));
|
||||
|
||||
for (key, value) in default_headers() {
|
||||
request = request.header(&key, value);
|
||||
}
|
||||
|
||||
let response = request.send().await.map_err(|e| {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
"Copilot: token exchange HTTP request failed"
|
||||
);
|
||||
GithubCopilotAuthError::Validation(format_reqwest_error(&e))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body = %truncate_for_error(&body),
|
||||
"Copilot: token exchange endpoint returned error"
|
||||
);
|
||||
return Err(GithubCopilotAuthError::Validation(format!(
|
||||
"Copilot token exchange failed: HTTP {status}: {}",
|
||||
truncate_for_error(&body)
|
||||
)));
|
||||
}
|
||||
|
||||
let token_response = response.json::<CopilotTokenResponse>().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "Copilot: failed to parse token exchange response");
|
||||
GithubCopilotAuthError::Validation(e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(token_response)
|
||||
}
|
||||
|
||||
/// Manages a cached Copilot API session token with automatic refresh.
|
||||
///
|
||||
/// The GitHub Copilot API requires a two-step authentication:
|
||||
/// 1. A long-lived GitHub OAuth token (from device login or IDE sign-in)
|
||||
/// 2. A short-lived Copilot session token (exchanged via `/copilot_internal/v2/token`)
|
||||
///
|
||||
/// This manager caches the session token and refreshes it automatically
|
||||
/// before it expires (with a 5-minute buffer).
|
||||
pub struct CopilotTokenManager {
|
||||
client: reqwest::Client,
|
||||
oauth_token: SecretString,
|
||||
cached: RwLock<Option<CachedCopilotToken>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedCopilotToken {
|
||||
token: SecretString,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
impl CopilotTokenManager {
|
||||
/// Create a new token manager with the given GitHub OAuth token.
|
||||
pub fn new(client: reqwest::Client, oauth_token: String) -> Self {
|
||||
Self {
|
||||
client,
|
||||
oauth_token: SecretString::from(oauth_token),
|
||||
cached: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a valid Copilot session token, refreshing if needed.
|
||||
///
|
||||
/// Returns the cached token if it has more than 5 minutes remaining,
|
||||
/// otherwise exchanges the OAuth token for a fresh session token.
|
||||
pub async fn get_token(&self) -> Result<SecretString, GithubCopilotAuthError> {
|
||||
// Fast path: check if cached token is still valid under read lock.
|
||||
{
|
||||
let guard = self.cached.read().await;
|
||||
if let Some(ref cached) = *guard {
|
||||
let now = unix_now();
|
||||
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
|
||||
return Ok(cached.token.clone());
|
||||
}
|
||||
tracing::debug!(
|
||||
expires_at = cached.expires_at,
|
||||
now = now,
|
||||
"Copilot: cached session token expired or expiring soon, refreshing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: acquire write lock and re-check (another caller may have
|
||||
// already refreshed while we waited for the lock).
|
||||
let mut guard = self.cached.write().await;
|
||||
if let Some(ref cached) = *guard {
|
||||
let now = unix_now();
|
||||
if cached.expires_at > now + TOKEN_REFRESH_BUFFER_SECS {
|
||||
return Ok(cached.token.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let response =
|
||||
exchange_copilot_token(&self.client, self.oauth_token.expose_secret()).await?;
|
||||
let token = SecretString::from(response.token);
|
||||
|
||||
let expires_at = response.expires_at;
|
||||
*guard = Some(CachedCopilotToken {
|
||||
token: token.clone(),
|
||||
expires_at,
|
||||
});
|
||||
|
||||
tracing::debug!(expires_at = expires_at, "Copilot session token refreshed");
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Invalidate the cached session token.
|
||||
///
|
||||
/// Called when the API returns 401, so the next `get_token()` call
|
||||
/// will perform a fresh token exchange instead of reusing the stale token.
|
||||
pub async fn invalidate(&self) {
|
||||
let mut guard = self.cached.write().await;
|
||||
*guard = None;
|
||||
tracing::debug!("Copilot session token invalidated");
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_for_error(body: &str) -> String {
|
||||
const LIMIT: usize = 200;
|
||||
if body.len() <= LIMIT {
|
||||
return body.to_string();
|
||||
}
|
||||
let end = crate::util::floor_char_boundary(body, LIMIT);
|
||||
format!("{}...", &body[..end])
|
||||
}
|
||||
|
||||
/// Format a reqwest error with its full causal chain for debugging.
|
||||
///
|
||||
/// `reqwest::Error::to_string()` often just says "error sending request"
|
||||
/// without the underlying cause (timeout, DNS, TLS, connection refused).
|
||||
/// This walks the `source()` chain to surface the real problem.
|
||||
fn format_reqwest_error(e: &reqwest::Error) -> String {
|
||||
use std::error::Error;
|
||||
let mut msg = e.to_string();
|
||||
let mut source = e.source();
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(": {cause}"));
|
||||
source = cause.source();
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_headers_include_required_identity_headers() {
|
||||
let headers = default_headers();
|
||||
assert!(headers.iter().any(|(key, value)| {
|
||||
key == "Copilot-Integration-Id" && value == GITHUB_COPILOT_INTEGRATION_ID
|
||||
}));
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(key, value)| key == "Editor-Version"
|
||||
&& value == GITHUB_COPILOT_EDITOR_VERSION)
|
||||
);
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(key, value)| key == "User-Agent" && value == GITHUB_COPILOT_USER_AGENT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_error_preserves_utf8_boundaries() {
|
||||
let long = "日本語".repeat(100);
|
||||
let truncated = truncate_for_error(&long);
|
||||
assert!(truncated.ends_with("..."));
|
||||
assert!(truncated.is_char_boundary(truncated.len() - 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_error_short_strings_unchanged() {
|
||||
let short = "hello";
|
||||
assert_eq!(truncate_for_error(short), "hello");
|
||||
}
|
||||
|
||||
// --- poll_for_access_token response parsing ---
|
||||
|
||||
fn parse_access_token_body(json: &str) -> AccessTokenResponse {
|
||||
serde_json::from_str(json).expect("valid JSON")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_authorization_pending_response() {
|
||||
let body: AccessTokenResponse =
|
||||
parse_access_token_body(r#"{"error": "authorization_pending"}"#);
|
||||
assert!(body.access_token.is_none());
|
||||
assert_eq!(body.error.as_deref(), Some("authorization_pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_slow_down_response() {
|
||||
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "slow_down"}"#);
|
||||
assert_eq!(body.error.as_deref(), Some("slow_down"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_access_denied_response() {
|
||||
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "access_denied"}"#);
|
||||
assert_eq!(body.error.as_deref(), Some("access_denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expired_token_response() {
|
||||
let body: AccessTokenResponse = parse_access_token_body(r#"{"error": "expired_token"}"#);
|
||||
assert_eq!(body.error.as_deref(), Some("expired_token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_successful_token_response() {
|
||||
let body: AccessTokenResponse =
|
||||
parse_access_token_body(r#"{"access_token": "ghu_abc123"}"#);
|
||||
assert_eq!(body.access_token.as_deref(), Some("ghu_abc123"));
|
||||
assert!(body.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_with_description() {
|
||||
let body: AccessTokenResponse = parse_access_token_body(
|
||||
r#"{"error": "bad_verification_code", "error_description": "The code has expired"}"#,
|
||||
);
|
||||
assert_eq!(body.error.as_deref(), Some("bad_verification_code"));
|
||||
assert_eq!(
|
||||
body.error_description.as_deref(),
|
||||
Some("The code has expired")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_device_code_response_with_defaults() {
|
||||
let json = r#"{
|
||||
"device_code": "dc_123",
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://github.com/login/device",
|
||||
"expires_in": 900
|
||||
}"#;
|
||||
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
|
||||
assert_eq!(resp.device_code, "dc_123");
|
||||
assert_eq!(resp.user_code, "ABCD-1234");
|
||||
assert_eq!(resp.interval, 5); // default_poll_interval_secs
|
||||
assert_eq!(resp.expires_in, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_device_code_response_with_custom_interval() {
|
||||
let json = r#"{
|
||||
"device_code": "dc_456",
|
||||
"user_code": "EFGH-5678",
|
||||
"verification_uri": "https://github.com/login/device",
|
||||
"expires_in": 600,
|
||||
"interval": 10
|
||||
}"#;
|
||||
let resp: DeviceCodeResponse = serde_json::from_str(json).expect("valid JSON");
|
||||
assert_eq!(resp.interval, 10);
|
||||
}
|
||||
|
||||
// --- CopilotTokenManager ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_manager_caches_token_and_returns_same_value() {
|
||||
// Pre-populate the cache with a token that expires far in the future.
|
||||
let client = reqwest::Client::new();
|
||||
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||
|
||||
let far_future = unix_now() + 3600;
|
||||
{
|
||||
let mut guard = manager.cached.write().await;
|
||||
*guard = Some(CachedCopilotToken {
|
||||
token: SecretString::from("cached_session_token".to_string()),
|
||||
expires_at: far_future,
|
||||
});
|
||||
}
|
||||
|
||||
let token = manager.get_token().await.expect("should return cached");
|
||||
assert_eq!(token.expose_secret(), "cached_session_token");
|
||||
|
||||
// A second call should return the same cached token.
|
||||
let token2 = manager.get_token().await.expect("should return cached");
|
||||
assert_eq!(token2.expose_secret(), "cached_session_token");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_manager_invalidation_clears_cache() {
|
||||
let client = reqwest::Client::new();
|
||||
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||
|
||||
let far_future = unix_now() + 3600;
|
||||
{
|
||||
let mut guard = manager.cached.write().await;
|
||||
*guard = Some(CachedCopilotToken {
|
||||
token: SecretString::from("old_token".to_string()),
|
||||
expires_at: far_future,
|
||||
});
|
||||
}
|
||||
|
||||
manager.invalidate().await;
|
||||
|
||||
let guard = manager.cached.read().await;
|
||||
assert!(guard.is_none(), "cache should be empty after invalidation");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_manager_expired_token_triggers_refresh_path() {
|
||||
let client = reqwest::Client::new();
|
||||
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||
|
||||
// Set a token that is already expired (expires_at in the past).
|
||||
{
|
||||
let mut guard = manager.cached.write().await;
|
||||
*guard = Some(CachedCopilotToken {
|
||||
token: SecretString::from("stale_token".to_string()),
|
||||
expires_at: 1, // way in the past
|
||||
});
|
||||
}
|
||||
|
||||
// get_token will try the slow path (token exchange) which will fail
|
||||
// because we have no real server, but this proves the cached stale
|
||||
// token is NOT returned.
|
||||
let result = manager.get_token().await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expired cached token should trigger exchange, which fails without a server"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_manager_within_buffer_triggers_refresh() {
|
||||
let client = reqwest::Client::new();
|
||||
let manager = CopilotTokenManager::new(client, "unused_oauth".to_string());
|
||||
|
||||
// Set a token that expires within the refresh buffer window.
|
||||
let expires_soon = unix_now() + TOKEN_REFRESH_BUFFER_SECS - 10;
|
||||
{
|
||||
let mut guard = manager.cached.write().await;
|
||||
*guard = Some(CachedCopilotToken {
|
||||
token: SecretString::from("expiring_soon".to_string()),
|
||||
expires_at: expires_soon,
|
||||
});
|
||||
}
|
||||
|
||||
let result = manager.get_token().await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"token within buffer should trigger exchange"
|
||||
);
|
||||
}
|
||||
|
||||
// --- CopilotTokenResponse parsing ---
|
||||
|
||||
#[test]
|
||||
fn parse_copilot_token_response() {
|
||||
let json = r#"{"token": "tid=abc;exp=999;sku=123;sig=xyz", "expires_at": 1700000000}"#;
|
||||
let resp: CopilotTokenResponse = serde_json::from_str(json).expect("valid JSON");
|
||||
assert!(resp.token.starts_with("tid="));
|
||||
assert_eq!(resp.expires_at, 1700000000);
|
||||
}
|
||||
}
|
||||
+3
-80
@@ -18,12 +18,8 @@ pub mod config;
|
||||
pub mod costs;
|
||||
pub mod error;
|
||||
pub mod failover;
|
||||
mod github_copilot;
|
||||
pub(crate) mod github_copilot_auth;
|
||||
mod nearai_chat;
|
||||
pub mod oauth_helpers;
|
||||
pub mod openai_codex_provider;
|
||||
pub mod openai_codex_session;
|
||||
mod provider;
|
||||
mod reasoning;
|
||||
pub mod recording;
|
||||
@@ -33,10 +29,6 @@ pub mod retry;
|
||||
mod rig_adapter;
|
||||
pub mod session;
|
||||
pub mod smart_routing;
|
||||
mod token_refreshing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod codex_test_helpers;
|
||||
|
||||
pub mod image_models;
|
||||
pub mod models;
|
||||
@@ -45,14 +37,12 @@ pub mod vision_models;
|
||||
|
||||
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
|
||||
pub use config::{
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER, OpenAiCodexConfig,
|
||||
BedrockConfig, CacheRetention, LlmConfig, NearAiConfig, OAUTH_PLACEHOLDER,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use error::LlmError;
|
||||
pub use failover::{CooldownConfig, FailoverProvider};
|
||||
pub use nearai_chat::{DEFAULT_MODEL, ModelInfo, NearAiChatProvider, default_models};
|
||||
pub use openai_codex_provider::OpenAiCodexProvider;
|
||||
pub use openai_codex_session::{OpenAiCodexSession, OpenAiCodexSessionManager};
|
||||
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
|
||||
pub use provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, ContentPart, FinishReason, ImageUrl,
|
||||
LlmProvider, ModelMetadata, Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
|
||||
@@ -69,7 +59,6 @@ pub use retry::{RetryConfig, RetryProvider};
|
||||
pub use rig_adapter::RigAdapter;
|
||||
pub use session::{SessionConfig, SessionManager, create_session_manager};
|
||||
pub use smart_routing::{SmartRoutingConfig, SmartRoutingProvider, TaskComplexity};
|
||||
pub use token_refreshing::TokenRefreshingProvider;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -108,15 +97,6 @@ pub async fn create_llm_provider(
|
||||
}
|
||||
}
|
||||
|
||||
if config.backend == "openai_codex" {
|
||||
return Err(LlmError::RequestFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason:
|
||||
"OpenAI Codex uses a dedicated factory path. Use build_provider_chain() instead of create_llm_provider()."
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let reg_config = config
|
||||
.provider
|
||||
.as_ref()
|
||||
@@ -173,17 +153,6 @@ fn create_registry_provider(
|
||||
ProviderProtocol::OpenAiCompletions => create_openai_compat_from_registry(config),
|
||||
ProviderProtocol::Anthropic => create_anthropic_from_registry(config),
|
||||
ProviderProtocol::Ollama => create_ollama_from_registry(config),
|
||||
ProviderProtocol::GithubCopilot => {
|
||||
let provider =
|
||||
github_copilot::GithubCopilotProvider::new(config, request_timeout_secs)?;
|
||||
tracing::debug!(
|
||||
provider = %config.provider_id,
|
||||
model = %config.model,
|
||||
base_url = %config.base_url,
|
||||
"Using GitHub Copilot provider (token exchange)"
|
||||
);
|
||||
Ok(Arc::new(provider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,47 +374,6 @@ fn create_ollama_from_registry(
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
|
||||
/// Create an OpenAI Codex provider with OAuth authentication.
|
||||
///
|
||||
/// This is async because it needs to ensure authentication before
|
||||
/// creating the provider (which requires a valid Bearer token).
|
||||
///
|
||||
/// Uses the Responses API (`chatgpt.com/backend-api/codex/responses`)
|
||||
/// instead of the Chat Completions API, matching OpenClaw's approach.
|
||||
async fn create_openai_codex_provider(
|
||||
config: &LlmConfig,
|
||||
) -> Result<Arc<dyn LlmProvider>, LlmError> {
|
||||
let codex = config
|
||||
.openai_codex
|
||||
.as_ref()
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})?;
|
||||
|
||||
let session_mgr = Arc::new(OpenAiCodexSessionManager::new(codex.clone())?);
|
||||
session_mgr.ensure_authenticated().await?;
|
||||
|
||||
let token = session_mgr.get_access_token().await?;
|
||||
|
||||
let provider = Arc::new(OpenAiCodexProvider::new(
|
||||
&codex.model,
|
||||
&codex.api_base_url,
|
||||
token.expose_secret(),
|
||||
config.request_timeout_secs,
|
||||
)?);
|
||||
|
||||
tracing::info!(
|
||||
"Using OpenAI Codex (Responses API, model: {}, base: {})",
|
||||
codex.model,
|
||||
codex.api_base_url,
|
||||
);
|
||||
|
||||
Ok(Arc::new(TokenRefreshingProvider::new(
|
||||
provider,
|
||||
session_mgr,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
///
|
||||
/// Resolution order:
|
||||
@@ -532,11 +460,7 @@ pub async fn build_provider_chain(
|
||||
),
|
||||
LlmError,
|
||||
> {
|
||||
let llm: Arc<dyn LlmProvider> = if config.backend == "openai_codex" {
|
||||
create_openai_codex_provider(config).await?
|
||||
} else {
|
||||
create_llm_provider(config, session.clone()).await?
|
||||
};
|
||||
let llm = create_llm_provider(config, session.clone()).await?;
|
||||
tracing::debug!("LLM provider initialized: {}", llm.model_name());
|
||||
|
||||
// 1. Retry
|
||||
@@ -708,7 +632,6 @@ mod tests {
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: true,
|
||||
openai_codex: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-3
@@ -332,8 +332,8 @@ pub(crate) async fn fetch_openai_compatible_models(
|
||||
/// Uses [`NearAiConfig::for_model_discovery()`] to construct a minimal NEAR AI
|
||||
/// config, then wraps it in an `LlmConfig` with session config for auth.
|
||||
pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||
let auth_base_url = crate::config::helpers::env_or_override("NEARAI_AUTH_URL")
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string());
|
||||
let auth_base_url =
|
||||
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||
|
||||
crate::config::LlmConfig {
|
||||
backend: "nearai".to_string(),
|
||||
@@ -347,6 +347,5 @@ pub(crate) fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
||||
request_timeout_secs: 120,
|
||||
cheap_model: None,
|
||||
smart_routing_cascade: false,
|
||||
openai_codex: None,
|
||||
}
|
||||
}
|
||||
|
||||
+144
-19
@@ -22,7 +22,7 @@ use crate::llm::provider::{
|
||||
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
|
||||
ToolCompletionRequest, ToolCompletionResponse,
|
||||
};
|
||||
use crate::llm::{costs, session::SessionManager};
|
||||
use crate::llm::{costs, retry::cap_retry_after, session::SessionManager};
|
||||
|
||||
/// Information about an available model from NEAR AI API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -35,21 +35,6 @@ pub struct ModelInfo {
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
/// Default NEAR AI model used when no model is configured.
|
||||
pub const DEFAULT_MODEL: &str = "Qwen/Qwen3.5-122B-A10B";
|
||||
|
||||
/// Fallback model list used by the setup wizard when the `/models` API is
|
||||
/// unreachable. Returns `(model_id, display_label)` pairs.
|
||||
pub fn default_models() -> Vec<(String, String)> {
|
||||
vec![
|
||||
(DEFAULT_MODEL.into(), "Qwen 3.5 122B (default)".into()),
|
||||
(
|
||||
"Qwen/Qwen3-32B".into(),
|
||||
"Qwen 3 32B (smaller, faster)".into(),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// NEAR AI provider (Chat Completions API, dual auth).
|
||||
pub struct NearAiChatProvider {
|
||||
client: Client,
|
||||
@@ -258,9 +243,30 @@ impl NearAiChatProvider {
|
||||
|
||||
let status = response.status();
|
||||
// Extract Retry-After header before consuming the response body.
|
||||
let retry_after_header = Some(crate::llm::retry::parse_retry_after(
|
||||
response.headers().get("retry-after"),
|
||||
));
|
||||
// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats.
|
||||
// Falls back to 60s if header is missing or unparseable (prevents "retry after None" errors).
|
||||
let retry_after_header = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| {
|
||||
// Try delay-seconds first (most common from API providers)
|
||||
if let Ok(secs) = v.trim().parse::<u64>() {
|
||||
return Some(cap_retry_after(std::time::Duration::from_secs(secs)));
|
||||
}
|
||||
// Try HTTP-date (e.g. "Mon, 02 Mar 2026 18:00:00 GMT")
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
|
||||
let now = chrono::Utc::now();
|
||||
let delta = dt.signed_duration_since(now);
|
||||
// Use max(0) so past/present dates yield Duration::ZERO
|
||||
// rather than None (which would cause an immediate retry).
|
||||
return Some(cap_retry_after(std::time::Duration::from_secs(
|
||||
delta.num_seconds().max(0) as u64,
|
||||
)));
|
||||
}
|
||||
None
|
||||
})
|
||||
.or(Some(std::time::Duration::from_secs(60)));
|
||||
let response_text = response.text().await.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "nearai_chat".to_string(),
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
@@ -2212,4 +2218,123 @@ mod tests {
|
||||
"http://example.com/api/proxy/v1/chat/completions"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Retry-After header parsing tests (regression for rate limit "None" bug) --
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_parsing_delay_seconds() {
|
||||
// Verify delay-seconds format (most common) is parsed correctly
|
||||
let header_value = "30";
|
||||
let duration = parse_retry_after_for_test(header_value);
|
||||
assert_eq!(duration, Some(std::time::Duration::from_secs(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_parsing_rfc2822_date() {
|
||||
// Verify HTTP-date (RFC 2822) format is parsed correctly
|
||||
// Use a date 60 seconds in the future
|
||||
let now = chrono::Utc::now();
|
||||
let future = now + chrono::Duration::seconds(60);
|
||||
let date_str = future.to_rfc2822();
|
||||
|
||||
let duration = parse_retry_after_for_test(&date_str);
|
||||
assert!(duration.is_some());
|
||||
let d = duration.unwrap();
|
||||
// Allow ±5 seconds of drift due to processing time
|
||||
assert!(
|
||||
d.as_secs() >= 55 && d.as_secs() <= 65,
|
||||
"Expected ~60s, got {}s",
|
||||
d.as_secs()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_fallback_missing_header() {
|
||||
// Regression test: When Retry-After header is missing,
|
||||
// should fall back to 60s instead of None
|
||||
let duration = parse_retry_after_for_test("");
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(60)),
|
||||
"Missing header should fallback to 60s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_fallback_invalid_format() {
|
||||
// Regression test: When Retry-After header is in unexpected format,
|
||||
// should fall back to 60s instead of None
|
||||
let invalid_formats = vec![
|
||||
"invalid",
|
||||
"not-a-number",
|
||||
"30.5", // float instead of int
|
||||
"abc123",
|
||||
];
|
||||
|
||||
for format in invalid_formats {
|
||||
let duration = parse_retry_after_for_test(format);
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::from_secs(60)),
|
||||
"Invalid format '{}' should fallback to 60s",
|
||||
format
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_past_date_returns_zero() {
|
||||
// When HTTP-date is in the past, should return Duration::ZERO
|
||||
// (not None, which would trigger immediate retry)
|
||||
let past = chrono::Utc::now() - chrono::Duration::seconds(60);
|
||||
let past_date_str = past.to_rfc2822();
|
||||
|
||||
let duration = parse_retry_after_for_test(&past_date_str);
|
||||
assert_eq!(
|
||||
duration,
|
||||
Some(std::time::Duration::ZERO),
|
||||
"Past date should return Duration::ZERO, not None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_zero_seconds_accepted() {
|
||||
// Verify zero seconds is a valid retry delay
|
||||
let duration = parse_retry_after_for_test("0");
|
||||
assert_eq!(duration, Some(std::time::Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_large_number() {
|
||||
// Verify large numbers are capped to the safe maximum
|
||||
let duration = parse_retry_after_for_test("3600"); // 1 hour
|
||||
assert_eq!(duration, Some(std::time::Duration::from_secs(3600)));
|
||||
|
||||
let huge = parse_retry_after_for_test("18446744073709551615");
|
||||
assert_eq!(
|
||||
huge,
|
||||
Some(std::time::Duration::from_secs(
|
||||
crate::llm::retry::MAX_RETRY_AFTER_SECS
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper function to test Retry-After header parsing logic
|
||||
/// (simulates the parsing done in send_request without actual HTTP, including fallback)
|
||||
fn parse_retry_after_for_test(header_value: &str) -> Option<std::time::Duration> {
|
||||
let trimmed = header_value.trim();
|
||||
let parsed = if let Ok(secs) = trimmed.parse::<u64>() {
|
||||
Some(cap_retry_after(std::time::Duration::from_secs(secs)))
|
||||
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(trimmed) {
|
||||
let now = chrono::Utc::now();
|
||||
let delta = dt.signed_duration_since(now);
|
||||
Some(cap_retry_after(std::time::Duration::from_secs(
|
||||
delta.num_seconds().max(0) as u64,
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Apply fallback to 60s if parsing failed (matches actual code behavior)
|
||||
parsed.or(Some(std::time::Duration::from_secs(60)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,9 @@ pub enum OAuthCallbackError {
|
||||
/// deployments where `127.0.0.1` is unreachable from the user's browser),
|
||||
/// then falls back to `http://{callback_host()}:{OAUTH_CALLBACK_PORT}`.
|
||||
pub fn callback_url() -> String {
|
||||
crate::config::helpers::env_or_override("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||
std::env::var("IRONCLAW_OAUTH_CALLBACK_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| format!("http://{}:{}", callback_host(), OAUTH_CALLBACK_PORT))
|
||||
}
|
||||
|
||||
@@ -55,8 +57,7 @@ pub fn callback_url() -> String {
|
||||
/// Note: this transmits the session token over plain HTTP — prefer SSH port
|
||||
/// forwarding (`ssh -L 9876:127.0.0.1:9876 user@host`) when possible.
|
||||
pub fn callback_host() -> String {
|
||||
crate::config::helpers::env_or_override("OAUTH_CALLBACK_HOST")
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string())
|
||||
std::env::var("OAUTH_CALLBACK_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
/// Returns `true` if `host` is a loopback address that only accepts local connections.
|
||||
@@ -361,7 +362,6 @@ pub fn landing_html(provider_name: &str, success: bool) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::helpers::ENV_MUTEX;
|
||||
|
||||
#[test]
|
||||
fn loopback_detection() {
|
||||
@@ -386,22 +386,12 @@ mod tests {
|
||||
assert!(!is_wildcard_host("localhost"));
|
||||
}
|
||||
|
||||
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn bind_rejects_wildcard_ipv4() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
// SAFETY: test is single-threaded; env var is restored immediately after.
|
||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "0.0.0.0") };
|
||||
let result = bind_callback_listener().await;
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
match &original {
|
||||
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
|
||||
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
|
||||
}
|
||||
}
|
||||
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
@@ -410,22 +400,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Lock held across await to serialize env-var mutation; the awaited op is a quick local TCP bind.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
#[tokio::test]
|
||||
async fn bind_rejects_wildcard_ipv6() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
let original = std::env::var("OAUTH_CALLBACK_HOST").ok();
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
// SAFETY: test is single-threaded; env var is restored immediately after.
|
||||
unsafe { std::env::set_var("OAUTH_CALLBACK_HOST", "::") };
|
||||
let result = bind_callback_listener().await;
|
||||
// SAFETY: Under ENV_MUTEX, no concurrent env access.
|
||||
unsafe {
|
||||
match &original {
|
||||
Some(v) => std::env::set_var("OAUTH_CALLBACK_HOST", v),
|
||||
None => std::env::remove_var("OAUTH_CALLBACK_HOST"),
|
||||
}
|
||||
}
|
||||
unsafe { std::env::remove_var("OAUTH_CALLBACK_HOST") };
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,731 +0,0 @@
|
||||
//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
|
||||
//!
|
||||
//! Supports two auth flows:
|
||||
//! - **Device Code** (primary): Works on headless servers, no browser needed.
|
||||
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
|
||||
//!
|
||||
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
|
||||
//! auto-refreshed before expiry.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
|
||||
use secrecy::SecretString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
use crate::config::OpenAiCodexConfig;
|
||||
use crate::error::LlmError;
|
||||
|
||||
/// Persisted OAuth session data.
|
||||
///
|
||||
/// Note: `Debug` is manually implemented to redact tokens.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OpenAiCodexSession {
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) refresh_token: String,
|
||||
pub(crate) expires_at: DateTime<Utc>,
|
||||
pub(crate) created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OpenAiCodexSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenAiCodexSession")
|
||||
.field("access_token", &"[REDACTED]")
|
||||
.field("refresh_token", &"[REDACTED]")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.field("created_at", &self.created_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for the device code usercode endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UserCodeRequest {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
/// Response from the device code usercode endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserCodeResponse {
|
||||
/// Unique ID for this device auth session.
|
||||
device_auth_id: String,
|
||||
/// Code the user enters in their browser.
|
||||
user_code: String,
|
||||
/// URL where the user enters the code (may not be present).
|
||||
#[serde(default = "default_verification_uri")]
|
||||
verification_uri: String,
|
||||
/// Polling interval in seconds (OpenAI sends this as a string).
|
||||
#[serde(
|
||||
default = "default_interval",
|
||||
deserialize_with = "deserialize_string_or_u64"
|
||||
)]
|
||||
interval: u64,
|
||||
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
|
||||
#[serde(default)]
|
||||
expires_at: Option<String>,
|
||||
/// Seconds until the device code expires (standard field, may not be present).
|
||||
#[serde(default)]
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_verification_uri() -> String {
|
||||
"https://auth.openai.com/codex/device".to_string()
|
||||
}
|
||||
|
||||
fn default_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Deserialize a value that may be either a string or a number as u64.
|
||||
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de;
|
||||
|
||||
struct StringOrU64;
|
||||
impl<'de> de::Visitor<'de> for StringOrU64 {
|
||||
type Value = u64;
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string or integer")
|
||||
}
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
|
||||
Ok(v)
|
||||
}
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
|
||||
v.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(StringOrU64)
|
||||
}
|
||||
|
||||
impl UserCodeResponse {
|
||||
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
|
||||
fn expires_in_secs(&self) -> u64 {
|
||||
if let Some(secs) = self.expires_in {
|
||||
return secs;
|
||||
}
|
||||
if let Some(ref ts) = self.expires_at
|
||||
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
|
||||
{
|
||||
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
|
||||
return remaining.max(0) as u64;
|
||||
}
|
||||
900 // default 15 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for polling the device auth token endpoint.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceTokenPollRequest {
|
||||
device_auth_id: String,
|
||||
user_code: String,
|
||||
}
|
||||
|
||||
/// Successful response from the device auth token endpoint.
|
||||
/// Returns an authorization code + PKCE pair for the final token exchange.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceAuthCodeResponse {
|
||||
authorization_code: String,
|
||||
#[allow(dead_code)]
|
||||
code_challenge: String,
|
||||
code_verifier: String,
|
||||
}
|
||||
|
||||
/// Response from the final OAuth token exchange.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[serde(default)]
|
||||
refresh_token: String,
|
||||
#[serde(default)]
|
||||
expires_in: u64,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
}
|
||||
|
||||
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
|
||||
pub struct OpenAiCodexSessionManager {
|
||||
config: OpenAiCodexConfig,
|
||||
client: Client,
|
||||
session: RwLock<Option<OpenAiCodexSession>>,
|
||||
renewal_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl OpenAiCodexSessionManager {
|
||||
/// Create a new session manager. Tries to load existing session from disk.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `LlmError` if the HTTP client cannot be constructed.
|
||||
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
let client = Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| LlmError::RequestFailed {
|
||||
provider: "openai_codex".into(),
|
||||
reason: format!("HTTP client build failed: {e}"),
|
||||
})?;
|
||||
|
||||
let mgr = Self {
|
||||
config,
|
||||
client,
|
||||
session: RwLock::new(None),
|
||||
renewal_lock: Mutex::new(()),
|
||||
};
|
||||
|
||||
// Try synchronous load from disk during construction
|
||||
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
|
||||
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
|
||||
&& let Ok(mut guard) = mgr.session.try_write()
|
||||
{
|
||||
*guard = Some(session);
|
||||
tracing::info!(
|
||||
"Loaded OpenAI Codex session from {}",
|
||||
mgr.config.session_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(mgr)
|
||||
}
|
||||
|
||||
/// Check if we have a session (may be expired).
|
||||
pub async fn has_session(&self) -> bool {
|
||||
self.session.read().await.is_some()
|
||||
}
|
||||
|
||||
/// Check if the current access token needs refreshing.
|
||||
pub async fn needs_refresh(&self) -> bool {
|
||||
let guard = self.session.read().await;
|
||||
match guard.as_ref() {
|
||||
None => true,
|
||||
Some(s) => {
|
||||
let margin =
|
||||
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
|
||||
Utc::now() + margin >= s.expires_at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current access token, refreshing if needed.
|
||||
///
|
||||
/// If the token is within the refresh margin, silently refreshes first.
|
||||
/// If no session exists, returns an AuthFailed error.
|
||||
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
|
||||
if self.needs_refresh().await {
|
||||
let has_refresh = self
|
||||
.session
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|s| !s.refresh_token.is_empty())
|
||||
.unwrap_or(false);
|
||||
if has_refresh {
|
||||
self.refresh_tokens().await?;
|
||||
} else {
|
||||
return Err(LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let guard = self.session.read().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|s| SecretString::from(s.access_token.clone()))
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
|
||||
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
|
||||
// Try loading from disk if we don't have a session
|
||||
if !self.has_session().await {
|
||||
let _ = self.load_session().await;
|
||||
}
|
||||
|
||||
if !self.has_session().await {
|
||||
// No session at all -- need to authenticate
|
||||
return self.device_code_login().await;
|
||||
}
|
||||
|
||||
if self.needs_refresh().await {
|
||||
// Try refresh; if it fails, re-authenticate
|
||||
match self.refresh_tokens().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
|
||||
self.device_code_login().await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run OpenAI's device code auth flow.
|
||||
///
|
||||
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
|
||||
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
|
||||
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
|
||||
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
|
||||
pub async fn device_code_login(&self) -> Result<(), LlmError> {
|
||||
let _guard = self.renewal_lock.lock().await;
|
||||
|
||||
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
|
||||
|
||||
// Step 1: Request device code
|
||||
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&usercode_url)
|
||||
.json(&UserCodeRequest {
|
||||
client_id: self.config.client_id.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device code request failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let body_text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to read device code response: {}", e),
|
||||
})?;
|
||||
tracing::debug!("Device code response received ({} bytes)", body_text.len());
|
||||
let device: UserCodeResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!(
|
||||
"Failed to parse device code response: {} ({} bytes)",
|
||||
e,
|
||||
body_text.len()
|
||||
),
|
||||
})?;
|
||||
|
||||
// Step 2: Display code to user
|
||||
println!();
|
||||
println!("===========================================================");
|
||||
println!(" OpenAI Codex Authentication ");
|
||||
println!("===========================================================");
|
||||
println!();
|
||||
println!(" 1. Open this URL in any browser:");
|
||||
println!(" {}", device.verification_uri);
|
||||
println!();
|
||||
println!(" 2. Enter this code:");
|
||||
println!();
|
||||
println!(" [ {} ]", device.user_code);
|
||||
println!();
|
||||
let expires_secs = device.expires_in_secs();
|
||||
println!(
|
||||
" Waiting for authorization... (expires in {} min)",
|
||||
expires_secs / 60
|
||||
);
|
||||
println!("===========================================================");
|
||||
println!();
|
||||
|
||||
// Step 3: Poll for authorization code
|
||||
let poll_url = format!("{}/deviceauth/token", auth_base);
|
||||
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
|
||||
|
||||
let auth_code = loop {
|
||||
tokio::time::sleep(interval).await;
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: "Device code authorization timed out".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&poll_url)
|
||||
.json(&DeviceTokenPollRequest {
|
||||
device_auth_id: device.device_auth_id.clone(),
|
||||
user_code: device.user_code.clone(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token poll request failed: {}", e),
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let code_resp: DeviceAuthCodeResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse auth code response: {}", e),
|
||||
})?;
|
||||
break code_resp;
|
||||
}
|
||||
|
||||
// 403 = authorization_pending, keep polling
|
||||
// 404 = device code not found / not enabled
|
||||
if status == reqwest::StatusCode::FORBIDDEN {
|
||||
continue;
|
||||
}
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Slow down on 429, cap at 60s to avoid unbounded growth
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
interval = (interval + std::time::Duration::from_secs(5))
|
||||
.min(std::time::Duration::from_secs(60));
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
};
|
||||
|
||||
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
|
||||
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", &auth_code.authorization_code),
|
||||
("code_verifier", &auth_code.code_verifier),
|
||||
("client_id", &self.config.client_id),
|
||||
(
|
||||
"redirect_uri",
|
||||
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
|
||||
),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token exchange failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse token response: {}", e),
|
||||
})?;
|
||||
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: Utc::now()
|
||||
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
||||
token_resp.expires_in
|
||||
} else {
|
||||
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
||||
3600
|
||||
} as i64),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.save_session(&session).await?;
|
||||
self.set_session(session).await;
|
||||
|
||||
println!();
|
||||
println!("Authentication successful!");
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh the access token using the refresh token.
|
||||
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
|
||||
let _guard = self.renewal_lock.lock().await;
|
||||
|
||||
// Double-check: another task may have refreshed while we waited on the lock
|
||||
if !self.needs_refresh().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let refresh_token = {
|
||||
let guard = self.session.read().await;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|s| s.refresh_token.clone())
|
||||
.ok_or_else(|| LlmError::AuthFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
})?
|
||||
};
|
||||
|
||||
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&token_url)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token.as_str()),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token refresh request failed: {}", e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
resp.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse refresh response: {}", e),
|
||||
})?;
|
||||
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token,
|
||||
expires_at: Utc::now()
|
||||
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
||||
token_resp.expires_in
|
||||
} else {
|
||||
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
||||
3600
|
||||
} as i64),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
self.save_session(&session).await?;
|
||||
self.set_session(session).await;
|
||||
|
||||
tracing::debug!("OpenAI Codex token refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save session data to disk with restrictive permissions.
|
||||
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
|
||||
if let Some(parent) = self.config.session_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to create session directory: {}", e),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to serialize session: {}", e),
|
||||
})?;
|
||||
|
||||
tokio::fs::write(&self.config.session_path, &json)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to write session file: {}", e),
|
||||
))
|
||||
})?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
tokio::fs::set_permissions(&self.config.session_path, perms)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to set permissions: {}", e),
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load session from disk.
|
||||
pub async fn load_session(&self) -> Result<(), LlmError> {
|
||||
let data = tokio::fs::read_to_string(&self.config.session_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
LlmError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("Failed to read session file: {}", e),
|
||||
))
|
||||
})?;
|
||||
|
||||
let session: OpenAiCodexSession =
|
||||
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
|
||||
provider: "openai_codex".to_string(),
|
||||
reason: format!("Failed to parse session file: {}", e),
|
||||
})?;
|
||||
|
||||
let mut guard = self.session.write().await;
|
||||
*guard = Some(session);
|
||||
tracing::info!(
|
||||
"Loaded OpenAI Codex session from {}",
|
||||
self.config.session_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set session directly (for testing or after auth).
|
||||
pub async fn set_session(&self, session: OpenAiCodexSession) {
|
||||
let mut guard = self.session.write().await;
|
||||
*guard = Some(session);
|
||||
}
|
||||
|
||||
/// Handle a 401 response by refreshing, or re-authenticating.
|
||||
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
|
||||
match self.refresh_tokens().await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => self.device_code_login().await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm::codex_test_helpers::test_codex_config as test_config;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_session() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("session.json");
|
||||
let config = test_config(path.clone());
|
||||
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// No session initially
|
||||
assert!(!mgr.has_session().await);
|
||||
|
||||
// Save a session
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.save_session(&session).await.unwrap();
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(mgr.has_session().await);
|
||||
|
||||
// Load from disk in a new manager
|
||||
let config2 = test_config(path);
|
||||
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
|
||||
mgr2.load_session().await.unwrap();
|
||||
assert!(mgr2.has_session().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_needs_refresh_when_near_expiry() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = test_config(dir.path().join("session.json"));
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// Token expiring in 2 minutes (margin is 300s = 5 min)
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(mgr.needs_refresh().await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_code_parse_error_redacts_body() {
|
||||
// Regression: the parse error used to include raw body_text which could
|
||||
// contain sensitive auth data. Now it only shows byte count.
|
||||
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
|
||||
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
|
||||
assert!(err.is_err());
|
||||
let e = err.unwrap_err();
|
||||
let error_msg = format!(
|
||||
"Failed to parse device code response: {} ({} bytes)",
|
||||
e,
|
||||
body_text.len()
|
||||
);
|
||||
assert!(
|
||||
!error_msg.contains("sk-12345"),
|
||||
"error message must not contain raw body: {error_msg}"
|
||||
);
|
||||
assert!(
|
||||
error_msg.contains("bytes"),
|
||||
"error message should show byte count"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_refresh_when_fresh() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = test_config(dir.path().join("session.json"));
|
||||
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
||||
|
||||
// Token expiring in 30 minutes (margin is 300s = 5 min)
|
||||
let session = OpenAiCodexSession {
|
||||
access_token: "access_abc".to_string(),
|
||||
refresh_token: "refresh_xyz".to_string(),
|
||||
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
mgr.set_session(session).await;
|
||||
|
||||
assert!(!mgr.needs_refresh().await);
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,6 @@ pub enum ProviderProtocol {
|
||||
Anthropic,
|
||||
/// Ollama API (OpenAI-ish, no API key required).
|
||||
Ollama,
|
||||
/// GitHub Copilot API (OpenAI-compatible with token exchange).
|
||||
GithubCopilot,
|
||||
}
|
||||
|
||||
/// How the setup wizard should collect credentials for this provider.
|
||||
|
||||
@@ -78,33 +78,6 @@ pub(crate) fn cap_retry_after(duration: Duration) -> Duration {
|
||||
duration.min(Duration::from_secs(MAX_RETRY_AFTER_SECS))
|
||||
}
|
||||
|
||||
/// Parse a `Retry-After` header value into a capped `Duration`.
|
||||
///
|
||||
/// Supports both delay-seconds (RFC 7231 §7.1.3) and HTTP-date formats (RFC 7231
|
||||
/// §7.1.1 / IMF-fixdate). The implementation uses `chrono::DateTime::parse_from_rfc2822`,
|
||||
/// which also accepts RFC 2822-style dates.
|
||||
/// Returns `DEFAULT_RETRY_AFTER` (60 s) if the header is missing or unparseable.
|
||||
pub(crate) fn parse_retry_after(header: Option<&reqwest::header::HeaderValue>) -> Duration {
|
||||
header
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| {
|
||||
if let Ok(secs) = v.trim().parse::<u64>() {
|
||||
return Some(cap_retry_after(Duration::from_secs(secs)));
|
||||
}
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(v.trim()) {
|
||||
let now = chrono::Utc::now();
|
||||
let delta = dt.signed_duration_since(now);
|
||||
return Some(cap_retry_after(Duration::from_secs(
|
||||
delta.num_seconds().max(0) as u64,
|
||||
)));
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or(Duration::from_secs(DEFAULT_RETRY_AFTER_SECS))
|
||||
}
|
||||
|
||||
const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
|
||||
|
||||
/// Configuration for the retry decorator.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
@@ -471,53 +444,4 @@ mod tests {
|
||||
Duration::from_secs(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_delay_seconds() {
|
||||
let val = reqwest::header::HeaderValue::from_static("30");
|
||||
assert_eq!(parse_retry_after(Some(&val)), Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_missing_header() {
|
||||
assert_eq!(
|
||||
parse_retry_after(None),
|
||||
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_unparseable() {
|
||||
let val = reqwest::header::HeaderValue::from_static("not-a-number");
|
||||
assert_eq!(
|
||||
parse_retry_after(Some(&val)),
|
||||
Duration::from_secs(DEFAULT_RETRY_AFTER_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_clamps_large_value() {
|
||||
let val = reqwest::header::HeaderValue::from_static("999999");
|
||||
assert_eq!(
|
||||
parse_retry_after(Some(&val)),
|
||||
Duration::from_secs(MAX_RETRY_AFTER_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_retry_after_http_date() {
|
||||
let future = chrono::Utc::now() + chrono::Duration::seconds(30);
|
||||
let date_str = future.to_rfc2822();
|
||||
let val = reqwest::header::HeaderValue::from_str(&date_str).unwrap();
|
||||
let parsed = parse_retry_after(Some(&val));
|
||||
let diff = if parsed > Duration::from_secs(30) {
|
||||
parsed - Duration::from_secs(30)
|
||||
} else {
|
||||
Duration::from_secs(30) - parsed
|
||||
};
|
||||
assert!(
|
||||
diff <= Duration::from_secs(2),
|
||||
"expected ~30s, got {parsed:?} (diff {diff:?}) from header {date_str:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user