Compare commits

...

21 Commits

Author SHA1 Message Date
YeonGyu-Kim d229a9b022 Merge pull request #3227 from TheArchitectit/worktree-wizard-entry-points
feat: wizard entry points — /setup command, claw setup subcommand (rebased)
2026-06-08 15:06:11 +09:00
YeonGyu-Kim 05f0201ec7 fix: preserve runtime config validation compatibility 2026-06-08 15:01:48 +09:00
YeonGyu-Kim 36f6afcafd Merge pull request #3230 from Gaurav-x111/contributor/example-change
docs: add interactive session example to quick start
2026-06-08 14:44:43 +09:00
YeonGyu-Kim 2e52ea7a67 Merge pull request #3237 from hiisandog/fix/redirection-path-scope-20260608
fix: validate attached redirection paths
2026-06-08 14:43:52 +09:00
陈家名 eb21179dde fix: validate attached redirection paths 2026-06-08 10:18:48 +08:00
YeonGyu-Kim 9b3548ca43 Merge pull request #3236 from ultraworkers/fix/ollama-qwen-reasoning-field
fix: parse Ollama reasoning fields
2026-06-08 10:11:52 +09:00
YeonGyu-Kim 01f4dd48a3 docs: mention local Ollama reasoning setup 2026-06-08 10:08:44 +09:00
YeonGyu-Kim 222faabd7f fix(cli): hint Ollama for Qwen tags 2026-06-08 10:08:38 +09:00
YeonGyu-Kim 7503c1c031 fix(providers): parse Ollama reasoning fields 2026-06-08 10:08:32 +09:00
YeonGyu-Kim 6001156a6c Merge pull request #3234 from ultraworkers/fix/openai-compatible-reasoning-history
fix(providers): preserve OpenAI-compatible reasoning history
2026-06-08 09:27:33 +09:00
YeonGyu-Kim a1da1ca8e6 test(cli): serialize env-sensitive model alias checks 2026-06-08 01:37:28 +09:00
YeonGyu-Kim 27acfe1014 test(runtime): isolate session and git metadata checks 2026-06-08 01:23:32 +09:00
YeonGyu-Kim c1646613d1 fix(providers): preserve OpenAI-compatible reasoning history 2026-06-08 01:23:13 +09:00
Sigrid Jin (ง'̀-'́)ง oO ae2f203eb5 Merge pull request #3232 from Ajinkya-Ghuge/fix/deepseek-model-routing 2026-06-07 18:40:11 +09:00
Ajinkya-Ghuge 0755ddff3c fix(providers): strip provider prefix from model names for openai_compat endpoints 2026-06-06 22:29:59 +05:30
Gaurav-x111 db9ff49256 docs: add interactive session example to quick start
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:25:55 +05:45
Sigrid Jin (ง'̀-'́)ง oO 3acb677d70 Update README.md 2026-06-06 18:16:58 +09:00
Sigrid Jin (ง'̀-'́)ง oO 43eac8fbec Update README.md 2026-06-06 18:16:26 +09:00
Sigrid Jin (ง'̀-'́)ง oO c8505092f8 update readme 2026-06-06 18:14:04 +09:00
TheArchitectit d58197cca4 fix: update slash command count and add /setup assertion in test
- Update slash_command_specs().len() assertion from 139 to 140.
  The /setup command added by this PR increased the spec count by 1
  but the test's expected count was not updated, causing CI failure.

- Add assert!(help.contains("/setup")) to the
  renders_help_from_shared_specs test so the new command is
  verified in the help output.

  Fixes CI Build  and Test  on #3218.
2026-06-04 22:06:32 -05:00
Your Name 3845040b9d feat: wizard entry points -- /setup command, claw setup subcommand, and RuntimeProviderConfig
The setup wizard was merged in PR #3017 but was orphaned -- it was not
declared as a module in main.rs, making it unreachable. Additionally,
the setup_wizard.rs imports RuntimeProviderConfig which did not exist
on upstream/main. This commit makes the wizard accessible and adds the
necessary RuntimeProviderConfig type.

Changes:
- Add RuntimeProviderConfig struct to runtime/src/config.rs with
  kind(), api_key(), base_url(), model() accessors.
- Add parse_optional_provider_config() to parse the provider object
  from merged settings JSON.
- Add provider() method to RuntimeConfig and RuntimeFeatureConfig.
- Export RuntimeProviderConfig, save_user_provider_settings,
  clear_user_provider_settings, and default_config_home from runtime
  crate public API (runtime/src/lib.rs).
- Add mod setup_wizard to rusty-claude-cli/src/main.rs.
- Add claw setup CLI subcommand.
- Add /setup slash command.
- Add Setup variant to SlashCommand enum.
- Add Setup to LocalHelpTopic enum.
- Add setup to diagnostic subcommand matching.
- Add subagentModel to TOP_LEVEL_FIELDS in config_validate.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:52:48 -05:00
15 changed files with 553 additions and 106 deletions
+64
View File
@@ -1,5 +1,66 @@
# Claw Code
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/LazyCodex-codex%20for%20no--brainers-111111?style=for-the-badge&logo=github&logoColor=white" alt="LazyCodex banner" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Gajae--Code-red--claw%20agent%20harness-B22222?style=for-the-badge&logo=github&logoColor=white" alt="Gajae-Code banner" />
</a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://opengraph.githubassets.com/lazycodex-card/code-yeongyu/lazycodex" alt="LazyCodex GitHub card" width="280" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://opengraph.githubassets.com/gajae-code-card/Yeachan-Heo/gajae-code" alt="Gajae-Code GitHub card" width="280" />
</a>
</p>
<h3 align="center">start with the real crab-powered harnesses</h3>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex"><b>github.com/code-yeongyu/lazycodex</b></a>
<br/>
<a href="https://github.com/Yeachan-Heo/gajae-code"><b>github.com/Yeachan-Heo/gajae-code</b></a>
</p>
<p align="center">
<a href="https://github.com/code-yeongyu/lazycodex">
<img src="https://img.shields.io/badge/Open-LazyCodex-111111?style=flat-square&logo=github&logoColor=white" alt="Open LazyCodex on GitHub" />
</a>
<a href="https://github.com/Yeachan-Heo/gajae-code">
<img src="https://img.shields.io/badge/Open-Gajae--Code-B22222?style=flat-square&logo=github&logoColor=white" alt="Open Gajae-Code on GitHub" />
</a>
</p>
<p align="center">
<a href="https://discord.gg/GtjhvgjnV">
<img src="https://img.shields.io/badge/Discord-join%20the%20harness%20lab-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the harness lab on Discord" />
</a>
<a href="https://discord.gg/4Rt79F7dF">
<img src="https://img.shields.io/badge/Discord-join%20the%20crab%20tank-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join the crab tank on Discord" />
</a>
</p>
<p align="center">
Join the Discords:
<a href="https://discord.gg/GtjhvgjnV"><b>ultraworkers discord</b></a>
·
<a href="https://discord.gg/4Rt79F7dF"><b>gajae-code discord</b></a>
</p>
> [!IMPORTANT]
> **Claw Code is not the serious production project here.**
> This repository is closer to a museum exhibit than a product pitch, a crustacean-run artifact kept alive by clawed gajaes, swept and labeled by agents, and automatically maintained according to the harnesses above.
>
> As already described in the project philosophy, this is not meant to be hand-operated like a normal product repo. It is an **agent-managed exhibit**: the harnesses plan, execute, verify, label, and preserve the artifact while the crabs keep the tank running.
>
> If you want to actually run work, start with **[LazyCodex](https://github.com/code-yeongyu/lazycodex)** or **[Gajae-Code](https://github.com/Yeachan-Heo/gajae-code)**. If you want to inspect the strange little fossil of the Claw Code moment, continue below.
>
> For the longer public explanation behind this philosophy, see [here](https://x.com/realsigridjin/status/2039472968624185713).
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
@@ -73,6 +134,9 @@ export ANTHROPIC_API_KEY="sk-ant-..."
# 4. Run a prompt
./target/debug/claw prompt "say hello"
# 5. Start an interactive session
./target/debug/claw
```
> [!NOTE]
+5
View File
@@ -40,6 +40,11 @@ Or provide an OAuth bearer token directly:
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
For local OpenAI-compatible servers such as Ollama, including Qwen reasoning
models, see [`../docs/local-openai-compatible-providers.md`](../docs/local-openai-compatible-providers.md).
Use the exact model tag exposed by the server, for example `qwen3:latest`, and
prefer `OLLAMA_HOST` for Ollama-specific local routing.
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
+9
View File
@@ -296,6 +296,15 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
None
}
#[must_use]
pub fn strip_provider_prefix(canonical_model: &str) -> String {
if let Some(pos) = canonical_model.find('/') {
canonical_model[pos + 1..].to_string()
} else {
canonical_model.to_string()
}
}
#[must_use]
pub fn provider_diagnostics_for_model(model: &str) -> ProviderDiagnostics {
let resolved_model = resolve_model_alias(model);
+32 -13
View File
@@ -16,7 +16,7 @@ use crate::types::{
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
use super::{preflight_message_request, Provider, ProviderFuture};
use super::{preflight_message_request, resolve_model_alias, Provider, ProviderFuture};
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
@@ -215,18 +215,19 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageResponse, ApiError> {
let request = MessageRequest {
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut request = MessageRequest {
stream: false,
..request.clone()
};
request.model = canonical;
preflight_message_request(&request)?;
let response = self.send_with_retry(&request).await?;
let request_id = request_id_from_headers(response.headers());
let body = response.text().await.map_err(ApiError::from)?;
// Some backends return {"error":{"message":"...","type":"...","code":...}}
// instead of a valid completion object. Check for this before attempting
// full deserialization so the user sees the actual error, not a cryptic
// "missing field 'id'" parse failure.
if let Ok(raw) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(err_obj) = raw.get("error") {
let msg = err_obj
@@ -258,12 +259,13 @@ impl OpenAiCompatClient {
}
}
let payload = serde_json::from_str::<ChatCompletionResponse>(&body).map_err(|error| {
ApiError::json_deserialize(self.config.provider_name, &request.model, &body, error)
ApiError::json_deserialize(self.config.provider_name, &original_model, &body, error)
})?;
let mut normalized = normalize_response(&request.model, payload)?;
if normalized.request_id.is_none() {
normalized.request_id = request_id;
}
normalized.model = original_model;
Ok(normalized)
}
@@ -271,17 +273,25 @@ impl OpenAiCompatClient {
&self,
request: &MessageRequest,
) -> Result<MessageStream, ApiError> {
preflight_message_request(request)?;
let response = self
.send_with_retry(&request.clone().with_streaming())
.await?;
let original_model = request.model.clone();
let canonical = resolve_model_alias(&request.model);
let mut streaming_request = request.clone().with_streaming();
streaming_request.model = canonical;
preflight_message_request(&streaming_request)?;
let response = self.send_with_retry(&streaming_request).await?;
Ok(MessageStream {
request_id: request_id_from_headers(response.headers()),
response,
parser: OpenAiSseParser::with_context(self.config.provider_name, request.model.clone()),
parser: OpenAiSseParser::with_context(
self.config.provider_name,
original_model.clone(),
),
pending: VecDeque::new(),
done: false,
state: StreamState::new(request.model.clone()),
state: StreamState::new(original_model),
})
}
@@ -562,6 +572,7 @@ impl StreamState {
.delta
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.delta.reasoning.filter(|value| !value.is_empty()))
.or(choice
.delta
.thinking
@@ -817,6 +828,8 @@ struct ChatMessage {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
tool_calls: Vec<ResponseToolCall>,
}
@@ -891,6 +904,8 @@ struct ChunkDelta {
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
thinking: Option<ThinkingDelta>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<DeltaToolCall>,
@@ -1500,6 +1515,7 @@ fn normalize_response(
.message
.reasoning_content
.filter(|value| !value.is_empty())
.or(choice.message.reasoning.filter(|value| !value.is_empty()))
{
content.push(OutputContentBlock::Thinking {
thinking,
@@ -1982,6 +1998,7 @@ mod tests {
role: "assistant".to_string(),
content: Some("final answer".to_string()),
reasoning_content: Some("hidden thought".to_string()),
reasoning: None,
tool_calls: Vec::new(),
},
finish_reason: Some("stop".to_string()),
@@ -2019,6 +2036,7 @@ mod tests {
delta: super::ChunkDelta {
content: None,
reasoning_content: Some("think".to_string()),
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
},
@@ -2036,6 +2054,7 @@ mod tests {
delta: super::ChunkDelta {
content: Some(" answer".to_string()),
reasoning_content: None,
reasoning: None,
thinking: None,
tool_calls: Vec::new(),
},
@@ -166,6 +166,55 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() {
assert_eq!(body["thinking"], json!({"type": "enabled"}));
}
#[tokio::test]
async fn send_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_ollama_reasoning\",",
"\"model\":\"qwen3:latest\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"reasoning\":\"Think locally\",\"content\":\"Answer locally\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let response = client
.send_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("request should succeed");
assert_eq!(
response.content,
vec![
OutputContentBlock::Thinking {
thinking: "Think locally".to_string(),
signature: None,
},
OutputContentBlock::Text {
text: "Answer locally".to_string(),
},
]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
}
#[tokio::test]
async fn local_openai_gateway_strips_routing_prefix_and_preserves_extra_body_params() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -389,6 +438,83 @@ async fn stream_message_normalizes_text_and_multiple_tool_calls() {
assert!(request.body.contains("\"stream\":true"));
}
#[tokio::test]
async fn stream_message_preserves_ollama_reasoning_before_text() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"model\":\"qwen3:latest\",\"choices\":[{\"delta\":{\"reasoning\":\"Think\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_ollama_reasoning\",\"choices\":[{\"delta\":{\"content\":\" answer\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_ollama_reasoning_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("ollama-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&MessageRequest {
model: "openai/qwen3:latest".to_string(),
..sample_request(false)
})
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_ollama_reasoning_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 0,
content_block: OutputContentBlock::Thinking { .. },
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 0,
delta: ContentBlockDelta::ThinkingDelta { .. },
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::Text { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::TextDelta { .. },
})
));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("qwen3:latest"));
assert_eq!(body["stream"], json!(true));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn stream_message_retries_retryable_sse_handshake_failures() {
@@ -548,12 +674,13 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
.with_base_url("http://origin.invalid/v1");
let response = client
.send_message(&MessageRequest {
model: "gpt-4o".to_string(),
model: "openai/gpt-4.1-mini".to_string(),
..sample_request(false)
})
.await
.expect("proxy should return the OpenAI-compatible response");
assert_eq!(response.model, "openai/gpt-4.1-mini");
assert_eq!(response.total_tokens(), 7);
let captured = state.lock().await;
let request = captured.first().expect("proxy should capture request");
@@ -562,6 +689,8 @@ async fn openai_compatible_client_honors_http_proxy_for_requests() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer openai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("openai/gpt-4.1-mini"));
}
#[allow(clippy::await_holding_lock)]
+17 -2
View File
@@ -720,6 +720,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "setup",
aliases: &[],
summary: "Run the interactive provider setup wizard",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "notifications",
aliases: &[],
@@ -1102,6 +1109,7 @@ pub enum SlashCommand {
args: Option<String>,
},
Doctor,
Setup,
Login,
Logout,
Vim,
@@ -1223,6 +1231,7 @@ impl SlashCommand {
Self::Compact { .. } => "/compact",
Self::Cost => "/cost",
Self::Doctor => "/doctor",
Self::Setup => "/setup",
Self::Config { .. } => "/config",
Self::Memory { .. } => "/memory",
Self::History { .. } => "/history",
@@ -1392,6 +1401,10 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"setup" => {
validate_no_args(command, &args)?;
SlashCommand::Setup
}
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
@@ -1914,7 +1927,7 @@ fn slash_command_category(name: &str) -> &'static str {
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
| "api-key" | "terminal-setup" | "notifications" | "telemetry" | "providers" | "env"
| "project" | "reasoning" | "budget" | "rate-limit" | "workspace" | "reset" | "ide"
| "desktop" | "upgrade" => "Config",
| "desktop" | "upgrade" | "setup" => "Config",
"debug-tool-call" | "doctor" | "sandbox" | "diagnostics" | "tool-details" | "changelog"
| "metrics" => "Debug",
_ => "Tools",
@@ -5381,6 +5394,7 @@ pub fn handle_slash_command(
| SlashCommand::AddDir { .. }
| SlashCommand::History { .. }
| SlashCommand::Team { .. }
| SlashCommand::Setup
| SlashCommand::Unknown(_) => None,
}
}
@@ -5997,7 +6011,8 @@ mod tests {
assert!(help.contains("aliases: /skill"));
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert!(help.contains("/setup"));
assert_eq!(slash_command_specs().len(), 140);
assert!(resume_supported_slash_commands().len() >= 39);
}
+68
View File
@@ -162,6 +162,7 @@ pub struct RuntimeFeatureConfig {
trusted_roots: Vec<String>,
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
provider: RuntimeProviderConfig,
}
/// Controls which external AI coding framework rules are imported into the system prompt.
@@ -189,6 +190,41 @@ impl RulesImportConfig {
}
}
/// Stored provider configuration from the setup wizard.
///
/// Represents the `provider` section in `~/.claw/settings.json`, used as a
/// fallback when environment variables are absent (3-tier resolution:
/// env var > .env file > stored config).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeProviderConfig {
kind: Option<String>,
api_key: Option<String>,
base_url: Option<String>,
model: Option<String>,
}
impl RuntimeProviderConfig {
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
}
#[must_use]
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[must_use]
pub fn base_url(&self) -> Option<&str> {
self.base_url.as_deref()
}
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}
/// Ordered chain of fallback model identifiers used when the primary
/// provider returns a retryable failure (429/500/503/etc.). The chain is
/// strict: each entry is tried in order until one succeeds.
@@ -764,6 +800,7 @@ fn build_runtime_config(
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
api_timeout: parse_optional_api_timeout_config(&merged_value)?,
rules_import: parse_optional_rules_import(&merged_value)?,
provider: parse_optional_provider_config(&merged_value)?,
};
Ok(RuntimeConfig {
@@ -878,6 +915,11 @@ impl RuntimeConfig {
&self.feature_config.rules_import
}
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.feature_config.provider
}
/// Merge config-level default trusted roots with per-call roots.
///
/// Config roots are defaults and are kept first; per-call roots extend the
@@ -891,6 +933,13 @@ impl RuntimeConfig {
}
impl RuntimeFeatureConfig {
/// Parsed provider configuration (kind, apiKey, baseUrl, model) from
/// merged settings.
#[must_use]
pub fn provider(&self) -> &RuntimeProviderConfig {
&self.provider
}
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
@@ -2104,6 +2153,25 @@ fn parse_optional_rules_import(root: &JsonValue) -> Result<RulesImportConfig, Co
}
}
fn parse_optional_provider_config(root: &JsonValue) -> Result<RuntimeProviderConfig, ConfigError> {
let Some(provider_value) = root.as_object().and_then(|object| object.get("provider")) else {
return Ok(RuntimeProviderConfig::default());
};
let Some(object) = provider_value.as_object() else {
return Ok(RuntimeProviderConfig::default());
};
let kind = optional_string(object, "kind", "provider")?.map(str::to_string);
let api_key = optional_string(object, "apiKey", "provider")?.map(str::to_string);
let base_url = optional_string(object, "baseUrl", "provider")?.map(str::to_string);
let model = optional_string(object, "model", "provider")?.map(str::to_string);
Ok(RuntimeProviderConfig {
kind,
api_key,
base_url,
model,
})
}
fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
match value {
"off" => Ok(FilesystemIsolationMode::Off),
+4 -51
View File
@@ -216,6 +216,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "rulesImport",
expected: FieldType::RulesImport,
},
FieldSpec {
name: "subagentModel",
expected: FieldType::String,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
@@ -421,8 +425,6 @@ fn validate_object_keys(
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key — preserve compatibility by surfacing it as a warning
// instead of blocking otherwise valid config files.
let suggestion = suggest_field(key, &known_names);
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
@@ -436,56 +438,8 @@ fn validate_object_keys(
result
}
/// Emit deprecation warnings for bare string hook entries in the hooks object.
/// Legacy `["command-string"]` arrays still load but suggest migration to the
/// structured `{matcher, hooks:[{type, command}]}` form.
fn validate_hook_entry_format(
hooks: &BTreeMap<String, JsonValue>,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
for spec in HOOKS_FIELDS {
let Some(value) = hooks.get(spec.name) else {
continue;
};
let Some(array) = value.as_array() else {
continue;
};
for item in array {
if item.as_str().is_some() {
result.warnings.push(ConfigDiagnostic {
path: path_display.to_string(),
field: format!("hooks.{}", spec.name),
line: find_key_line(source, spec.name),
kind: DiagnosticKind::Deprecated {
replacement: "object-style hook entries with hooks:[{type:\"command\",command:\"...\"}]",
},
});
// One deprecation warning per event is enough
break;
}
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
// #461: prefix-aware matching — if input is a prefix of a candidate,
// treat it as distance 0 (perfect prefix match) to avoid edit-distance
// misranking (e.g., "mcp" → "env" instead of "mcpServers").
let prefix_match = candidates
.iter()
.filter(|c| c.to_ascii_lowercase().starts_with(&input_lower))
.min_by_key(|c| c.len())
.map(|name| name.to_string());
if prefix_match.is_some() {
return prefix_match;
}
candidates
.iter()
.filter_map(|candidate| {
@@ -555,7 +509,6 @@ pub fn validate_config_file(
source,
&path_display,
));
result.merge(validate_hook_entry_format(hooks, source, &path_display));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
+2 -1
View File
@@ -65,6 +65,7 @@ pub use compact::{
get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult,
};
pub use config::{
clear_user_provider_settings, default_config_home, save_user_provider_settings,
suppress_config_warnings_for_json_mode, ApiTimeoutConfig, ConfigEntry, ConfigError,
ConfigFileReport, ConfigFileStatus, ConfigInspection, ConfigLoader, ConfigSource,
McpConfigCollection, McpInvalidServerConfig, McpManagedProxyServerConfig, McpOAuthConfig,
@@ -72,7 +73,7 @@ pub use config::{
McpWebSocketServerConfig, OAuthConfig, ProviderFallbackConfig, ResolvedPermissionMode,
RulesImportConfig, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookCommand, RuntimeHookConfig,
RuntimeInvalidHookConfig, RuntimePermissionRuleConfig, RuntimePluginConfig,
ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
RuntimeProviderConfig, ScopedMcpServerConfig, CLAW_SETTINGS_SCHEMA_NAME,
};
pub use config_validate::{
check_unsupported_format, format_diagnostics, validate_config_file, ConfigDiagnostic,
@@ -832,6 +832,28 @@ mod tests {
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1290,8 +1312,11 @@ mod tests {
#[test]
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
// given — create sessions with 0 messages (empty)
let _env_guard = crate::test_env_lock();
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let isolated_config_home = base.join("config-home");
let _claw_config_home = EnvVarGuard::set("CLAW_CONFIG_HOME", &isolated_config_home);
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
+6 -9
View File
@@ -1644,16 +1644,13 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
let worktree = tmp.path().join("worktree");
let git_dir = tmp.path().join("external-gitdir");
fs::create_dir_all(&worktree).expect("worktree dir");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect(".git file");
Command::new("git")
.arg("init")
.current_dir(&worktree)
.output()
.expect("git init should run");
let git_dir = worktree.join(".git");
let original_permissions = fs::metadata(&git_dir)
.expect("gitdir metadata")
+125 -6
View File
@@ -17,6 +17,7 @@
mod init;
mod input;
mod render;
mod setup_wizard;
use std::collections::BTreeSet;
use std::env;
@@ -1095,6 +1096,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
CliAction::SessionList { output_format } => run_session_list(output_format)?,
CliAction::State { output_format } => run_worker_state(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
CliAction::Setup { output_format: _ } => run_setup()?,
// #146: dispatch pure-local introspection. Text mode uses existing
// render_config_report/render_diff_report; JSON mode uses the
// corresponding _json helpers already exposed for resume sessions.
@@ -1238,6 +1240,9 @@ enum CliAction {
Init {
output_format: CliOutputFormat,
},
Setup {
output_format: CliOutputFormat,
},
// #146: `claw config` and `claw diff` are pure-local read-only
// introspection commands; wire them as standalone CLI subcommands.
Config {
@@ -1301,6 +1306,7 @@ enum LocalHelpTopic {
Model,
Settings,
Diff,
Setup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1765,6 +1771,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"resume" => Some(LocalHelpTopic::Resume),
"session" => Some(LocalHelpTopic::Session),
@@ -2144,6 +2151,15 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
Ok(CliAction::Init { output_format })
}
"setup" => {
if rest.len() > 1 {
let extra = rest[1..].join(" ");
return Err(format!(
"unexpected extra arguments after `claw setup`: {extra}\nUsage: claw setup"
));
}
Ok(CliAction::Setup { output_format })
}
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
let mut read_stdin = false;
@@ -2271,6 +2287,7 @@ fn parse_local_help_action(
"doctor" => LocalHelpTopic::Doctor,
"acp" => LocalHelpTopic::Acp,
"init" => LocalHelpTopic::Init,
"setup" => LocalHelpTopic::Setup,
"state" => LocalHelpTopic::State,
"export" => LocalHelpTopic::Export,
"version" => LocalHelpTopic::Version,
@@ -2316,7 +2333,7 @@ fn parse_single_word_command_alias(
let verb = &rest[0];
let is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
"help" | "version" | "status" | "sandbox" | "doctor" | "setup" | "state"
);
if is_diagnostic && rest.len() > 1 {
@@ -2336,6 +2353,7 @@ fn parse_single_word_command_alias(
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
@@ -2390,6 +2408,7 @@ fn parse_single_word_command_alias(
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"setup" => Some(LocalHelpTopic::Setup),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
@@ -2452,6 +2471,7 @@ fn parse_single_word_command_alias(
.map(PermissionModeProvenance::from_flag)
.unwrap_or_else(permission_mode_provenance_for_current_dir),
})),
"setup" => Some(Ok(CliAction::Setup { output_format })),
"state" => Some(Ok(CliAction::State { output_format })),
// #146: let `config` and `diff` fall through to parse_subcommand
// where they are wired as pure-local introspection, instead of
@@ -2744,6 +2764,7 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
"status",
"sandbox",
"doctor",
"setup",
"state",
"dump-manifests",
"bootstrap-plan",
@@ -2939,6 +2960,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
err_msg.push_str("\nDid you mean `openai/");
err_msg.push_str(trimmed);
err_msg.push_str("`? (Requires OPENAI_API_KEY env var)");
} else if trimmed.starts_with("qwen") && trimmed.contains(':') {
err_msg.push_str("\nFor a local Ollama model, set `OPENAI_BASE_URL=http://127.0.0.1:11434/v1` before using tagged names like `");
err_msg.push_str(trimmed);
err_msg.push_str("`.");
} else if trimmed.starts_with("qwen") {
err_msg.push_str("\nDid you mean `qwen/");
err_msg.push_str(trimmed);
@@ -3725,6 +3750,11 @@ fn run_doctor(
Ok(())
}
/// Run the interactive setup wizard to configure provider, API key, and model.
fn run_setup() -> Result<(), Box<dyn std::error::Error>> {
setup_wizard::run_setup_wizard()
}
/// Starts a minimal Model Context Protocol server that exposes claw's
/// built-in tools over stdio.
///
@@ -6896,7 +6926,8 @@ fn run_resume_command(
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::Team { .. } => Err("unsupported resumed slash command".into()),
| SlashCommand::Team { .. }
| SlashCommand::Setup => Err("unsupported resumed slash command".into()),
}
}
@@ -8157,6 +8188,12 @@ impl LiveCli {
);
false
}
SlashCommand::Setup => {
if let Err(e) = setup_wizard::run_setup_wizard() {
eprintln!("Setup wizard failed: {e}");
}
false
}
SlashCommand::History { count } => {
self.print_prompt_history(count.as_deref());
false
@@ -10226,6 +10263,13 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
Formats text (default), json
Related /diff · ROADMAP #148"
.to_string(),
LocalHelpTopic::Setup => "Setup
Usage claw setup
Aliases /setup (inside the REPL)
Purpose run the interactive provider setup wizard to configure API key, model, and base URL
Output writes provider settings to ~/.claw/settings.json (0600 permissions)
Related /model · /config · claw doctor"
.to_string(),
}
}
@@ -10253,6 +10297,7 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
LocalHelpTopic::Model => "models",
LocalHelpTopic::Settings => "settings",
LocalHelpTopic::Diff => "diff",
LocalHelpTopic::Setup => "setup",
}
}
@@ -13737,8 +13782,15 @@ fn push_output_block(
};
*pending_tool = Some((id, name, initial_input));
}
OutputContentBlock::Thinking { thinking, .. } => {
OutputContentBlock::Thinking {
thinking,
signature,
} => {
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
events.push(AssistantEvent::Thinking {
thinking,
signature,
});
*block_has_thinking_summary = true;
}
OutputContentBlock::RedactedThinking { .. } => {
@@ -19073,6 +19125,13 @@ UU conflicted.rs",
assert!(matches!(
&events[0],
AssistantEvent::Thinking {
thinking,
signature
} if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&events[1],
AssistantEvent::TextDelta(text) if text == "Final answer"
));
let rendered = String::from_utf8(out).expect("utf8");
@@ -19649,6 +19708,41 @@ mod dump_manifests_tests {
#[cfg(test)]
mod alias_resolution_tests {
fn ollama_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.expect("ollama env lock poisoned")
}
struct EnvVarGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvVarGuard {
fn unset(key: &'static str) -> Self {
let previous = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, previous }
}
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
use super::{resolve_model_alias_with_config, validate_model_syntax};
#[test]
@@ -19670,6 +19764,8 @@ mod alias_resolution_tests {
#[test]
fn test_alias_resolution_syntax_validation() {
let _guard = ollama_env_lock();
let _env = EnvVarGuard::unset("OLLAMA_HOST");
// Resolved aliases should pass syntax validation
let resolved = resolve_model_alias_with_config("opus");
assert!(validate_model_syntax(&resolved).is_ok());
@@ -19680,6 +19776,8 @@ mod alias_resolution_tests {
#[test]
fn test_unknown_alias_fails_validation() {
let _guard = ollama_env_lock();
let _env = EnvVarGuard::unset("OLLAMA_HOST");
// Unknown aliases resolve to themselves
let resolved = resolve_model_alias_with_config("unknown-alias");
assert_eq!(resolved, "unknown-alias");
@@ -19690,6 +19788,28 @@ mod alias_resolution_tests {
assert!(result.unwrap_err().contains("invalid model syntax"));
}
#[test]
fn qwen_invalid_model_hint_mentions_local_ollama_openai_base_url() {
let _guard = ollama_env_lock();
let _ollama_env = EnvVarGuard::unset("OLLAMA_HOST");
let _openai_env = EnvVarGuard::unset("OPENAI_BASE_URL");
let result = validate_model_syntax("qwen3:8b");
let error = result.expect_err("Ollama tag without local base URL should fail");
assert!(
error.contains("Ollama"),
"Qwen Ollama tag error should mention Ollama: {error}"
);
assert!(
error.contains("OPENAI_BASE_URL"),
"Qwen Ollama tag error should mention OPENAI_BASE_URL: {error}"
);
assert!(
error.contains("http://127.0.0.1:11434/v1"),
"Qwen Ollama tag error should show local Ollama OpenAI URL: {error}"
);
}
#[test]
fn test_direct_provider_model_passes() {
// Direct provider/model strings should remain unchanged and pass
@@ -19699,14 +19819,13 @@ mod alias_resolution_tests {
}
#[test]
fn test_ollama_host_bypasses_model_validation() {
// Safety: test sets and clears env var within the test.
std::env::set_var("OLLAMA_HOST", "http://127.0.0.1:11434");
let _guard = ollama_env_lock();
let _env = EnvVarGuard::set("OLLAMA_HOST", "http://127.0.0.1:11434");
// Ollama model names with colons pass
assert!(validate_model_syntax("qwen3:8b").is_ok());
assert!(validate_model_syntax("gemma4:e2b").is_ok());
assert!(validate_model_syntax("qwen3.6:27b-nvfp4").is_ok());
// Empty model still rejected
assert!(validate_model_syntax("").is_err());
std::env::remove_var("OLLAMA_HOST");
}
}
@@ -23,7 +23,10 @@ const DEFAULT_BASE_URLS: &[(&str, &str)] = &[
("anthropic", "https://api.anthropic.com"),
("xai", "https://api.x.ai/v1"),
("openai", "https://api.openai.com/v1"),
("dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
(
"dashscope",
"https://dashscope.aliyuncs.com/compatible-mode/v1",
),
];
const API_KEY_ENV_VARS: &[(&str, &str)] = &[
@@ -51,12 +54,7 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
let model = prompt_model(&kind, &current)?;
let fast_model = prompt_fast_model(&current, model.as_deref())?;
save_user_provider_settings(
&kind,
&api_key,
base_url.as_deref(),
model.as_deref(),
)?;
save_user_provider_settings(&kind, &api_key, base_url.as_deref(), model.as_deref())?;
if let Some(fast) = &fast_model {
save_settings_field("subagentModel", fast)?;
@@ -64,7 +62,10 @@ pub fn run_setup_wizard() -> Result<(), Box<dyn std::error::Error>> {
println!();
println!(" \x1b[32mProvider saved to ~/.claw/settings.json\x1b[0m");
println!(" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.", model.as_deref().unwrap_or(&kind));
println!(
" Run \x1b[1m/model {}\x1b[0m or restart claw to activate.",
model.as_deref().unwrap_or(&kind)
);
println!();
Ok(())
@@ -82,7 +83,11 @@ fn prompt_provider(current: &RuntimeProviderConfig) -> Result<String, Box<dyn st
let current_kind = current.kind().unwrap_or("anthropic");
println!(" \x1b[1mProvider\x1b[0m");
for (num, label, kind) in PROVIDERS {
let marker = if *kind == current_kind { " (current)" } else { "" };
let marker = if *kind == current_kind {
" (current)"
} else {
""
};
println!(" [{num}] {label}{marker}");
}
let default = PROVIDERS
@@ -129,9 +134,7 @@ fn prompt_api_key(
};
// Check if env var is already set
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored key)");
}
@@ -144,7 +147,9 @@ fn prompt_api_key(
};
if key.is_empty() && !env_set {
eprintln!(" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m");
eprintln!(
" \x1b[33mWarning: no API key configured. Set {env_var} or re-run setup.\x1b[0m"
);
}
Ok(key)
@@ -174,9 +179,7 @@ fn prompt_base_url(
"dashscope" => "DASHSCOPE_BASE_URL",
_ => "BASE_URL",
};
let env_set = std::env::var(env_var)
.ok()
.is_some_and(|v| !v.is_empty());
let env_set = std::env::var(env_var).ok().is_some_and(|v| !v.is_empty());
if env_set {
println!(" {env_var} is set in environment (will take priority over stored URL)");
}
@@ -203,7 +206,9 @@ fn prompt_model(
.find(|(k, _)| *k == kind)
.map_or(empty, |(_, models)| *models);
let current_model = current.model().unwrap_or(aliases.first().copied().unwrap_or(""));
let current_model = current
.model()
.unwrap_or(aliases.first().copied().unwrap_or(""));
println!(" \x1b[1mModel\x1b[0m");
if !aliases.is_empty() {
@@ -235,12 +240,16 @@ fn prompt_fast_model(
println!(" Press Enter to skip (agents will use your main model).");
let current_fast = load_current_settings_field("subagentModel");
let default_hint = current_fast
.as_deref()
.or(main_model)
.unwrap_or("");
let default_hint = current_fast.as_deref().or(main_model).unwrap_or("");
let input = read_line(&format!(" Fast model [{}]: ", if default_hint.is_empty() { "same as main" } else { default_hint }))?;
let input = read_line(&format!(
" Fast model [{}]: ",
if default_hint.is_empty() {
"same as main"
} else {
default_hint
}
))?;
if input.trim().is_empty() {
Ok(current_fast)
} else {
@@ -269,7 +278,10 @@ fn save_settings_field(field: &str, value: &str) -> Result<(), Box<dyn std::erro
};
if let Some(obj) = settings.as_object_mut() {
obj.insert(field.to_string(), serde_json::Value::String(value.to_string()));
obj.insert(
field.to_string(),
serde_json::Value::String(value.to_string()),
);
}
std::fs::create_dir_all(&settings_dir)?;
+9
View File
@@ -11,6 +11,7 @@ _GLOB_META = set('*?[')
_WINDOWS_DRIVE_RE = re.compile(r'^[A-Za-z]:[\\/]')
_WINDOWS_UNC_RE = re.compile(r'^(?:\\\\|//)[^\\/]+[\\/][^\\/]+')
_ENV_ASSIGNMENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*=')
_REDIRECTION_TARGET_RE = re.compile(r'^(?:\d*)?(?:<>|>>?|<)(.+)$|^&>>?(.+)$')
@dataclass(frozen=True)
@@ -118,6 +119,7 @@ def extract_path_candidates(payload: str) -> tuple[str, ...]:
for token in (*tokens, *raw_tokens):
if not token or token.startswith('-') or _ENV_ASSIGNMENT_RE.match(token):
continue
token = _strip_redirection_operator(token)
expanded = os.path.expandvars(os.path.expanduser(token))
if _looks_like_path(token) or _looks_like_path(expanded):
candidate = expanded if _looks_like_path(expanded) else token
@@ -138,6 +140,13 @@ def _looks_like_path(token: str) -> bool:
)
def _strip_redirection_operator(token: str) -> str:
match = _REDIRECTION_TARGET_RE.match(token)
if match is None:
return token
return next(group for group in match.groups() if group is not None)
def _is_windows_absolute(value: str) -> bool:
return bool(_WINDOWS_DRIVE_RE.match(value) or _WINDOWS_UNC_RE.match(value))
+22
View File
@@ -72,6 +72,28 @@ class WorkspacePathScopeTests(unittest.TestCase):
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_attached_shell_redirection_targets_are_validated(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = root / 'workspace'
outside = root / 'outside'
workspace.mkdir()
outside.mkdir()
(outside / 'secret.txt').write_text('secret')
self.assertEqual(
('../outside/secret.txt', '../outside/error.log'),
extract_path_candidates(
'cat <../outside/secret.txt 2>../outside/error.log'
),
)
decision = WorkspacePathScope.from_root(workspace).validate_payload(
'cat <../outside/secret.txt 2>../outside/error.log'
)
self.assertFalse(decision.allowed)
self.assertIn(str(outside.resolve()), decision.resolved or '')
def test_explicit_worktree_roots_are_allowed(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)