mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 09:59:45 -10:00
fix: harden project config, repository reads, fetch refspecs and downloads
Cause and fix, per area: - Project config: a repository's .codewhale/config.toml could set notes_path, the append target of the auto-approved note tool. notes_path joins DENY_AT_PROJECT_SCOPE. The note tool and /note also refuse a symlinked notes file or a notes directory that resolves outside the workspace, and the note tool flushes its write before reporting success (a tokio file finishes writes in the background). - Repository reads: diff/show collections passed --no-ext-diff and --no-textconv, but a submodule dirty check or diff.submodule=diff spawns a child git inside the submodule that inherits neither flag, so the submodule's clean filter and external diff driver ran. Git:: REVIEW_DIFF_ARGS (--no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty) is now the one flag set used by codewhale review, review_pr, the git_diff/git_show tools, commit planning, verify, delivery, classifier-approved git reads and the runtime API diff routes (which previously lacked --no-textconv). git_diff, git_show and commit planning now run through Git::review_command. - git_fetch: a src:dst refspec could write refs/heads/* or refs/tags/*. The destination must now be under refs/remotes/. - Windows URL opening used `cmd /C start`, which parses & | ^ % in the URL. It now uses rundll32 url.dll,FileProtocolHandler. - Skills install/sync buffered the whole body before checking the size cap and the registry index had no cap. utils::read_response_body_capped (declared-length check plus a streaming cap) now backs skills downloads, the registry index (4 MiB) and the MCP Streamable HTTP reader. - Runtime thread/turn/item ids used 8 hex chars (32 bits) in flat per-kind directories where saves replace by id. runtime_record_id uses the full UUID. Tests (codewhale-tui --lib, CARGO_BUILD_JOBS=4): - new project_overlay_ignores_notes_path, diff_and_show_reads_run_no_ repository_configured_commands, download_with_cap_stops_reading_at_the_cap, runtime_record_ids_carry_a_full_uuid and the refspec cases in git_fetch_rejects_malformed_refspecs each fail with its fix reverted. - project_overlay 22 passed; 0 failed. tools::git:: 16/0. tools::git_history:: 16/0. tools::git_tool:: 5/0. skills::install:: 21/0. tools::shell:: 155/0 (1 ignored). memory::note:: 15/0. mcp::tests::streamable_http 3/0. tools::review 65/0. tools::verify:: 16/0. subagent::delivery 19/0. review_pr 18/0. runtime_api:: 334/0. runtime_threads:: 238/1 in a parallel run; the failure, approval_wait_heartbeat_is_never_sequenced_after_the_decision, passed 3/3 run alone. - cargo clippy -p codewhale-tui --lib --tests: only too_many_arguments, which CI allows. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
94130d91ae
commit
6fae32453f
21 files changed
+688
-167
No files matched your search
@@ -160,6 +160,28 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
resend without the image and a message saying so
|
||||
([#6396](https://github.com/Hmbown/Codewhale/issues/6396)).
|
||||
|
||||
### Security
|
||||
|
||||
- A project's `.codewhale/config.toml` can no longer set `notes_path`; it is
|
||||
ignored with the other user-only keys. The `note` tool and `/note` refuse a
|
||||
notes file that is a symlink or whose directory resolves outside the
|
||||
workspace, and a note is flushed to disk before the tool reports success.
|
||||
- Diffs and shows that read repository content (`codewhale review`, the
|
||||
`git_diff`/`git_show` tools, verification, delivery and the runtime API diff
|
||||
routes) share one flag set: no external diff, no textconv, and submodules
|
||||
compared by commit only, so a submodule's configured filters and diff
|
||||
drivers never run. `git_diff`, `git_show` and commit planning also read
|
||||
through the hardened review command.
|
||||
- `git_fetch` accepts a `src:dst` refspec only when the destination is under
|
||||
`refs/remotes/`; local branches and tags are never written.
|
||||
- Opening a URL on Windows goes through the URL protocol handler instead of
|
||||
`cmd /C start`, so characters in the URL are not interpreted by the shell.
|
||||
- Skill downloads and the skills registry index are read through a streaming
|
||||
size cap shared with the MCP HTTP transport, instead of buffering the whole
|
||||
body before checking its size.
|
||||
- Durable runtime thread, turn and item ids carry a full UUID instead of 32
|
||||
random bits, so two records can no longer collide and overwrite each other.
|
||||
|
||||
### Removed
|
||||
|
||||
- Flags, settings and tool parameters that did nothing are gone
|
||||
|
||||
@@ -160,6 +160,28 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
resend without the image and a message saying so
|
||||
([#6396](https://github.com/Hmbown/Codewhale/issues/6396)).
|
||||
|
||||
### Security
|
||||
|
||||
- A project's `.codewhale/config.toml` can no longer set `notes_path`; it is
|
||||
ignored with the other user-only keys. The `note` tool and `/note` refuse a
|
||||
notes file that is a symlink or whose directory resolves outside the
|
||||
workspace, and a note is flushed to disk before the tool reports success.
|
||||
- Diffs and shows that read repository content (`codewhale review`, the
|
||||
`git_diff`/`git_show` tools, verification, delivery and the runtime API diff
|
||||
routes) share one flag set: no external diff, no textconv, and submodules
|
||||
compared by commit only, so a submodule's configured filters and diff
|
||||
drivers never run. `git_diff`, `git_show` and commit planning also read
|
||||
through the hardened review command.
|
||||
- `git_fetch` accepts a `src:dst` refspec only when the destination is under
|
||||
`refs/remotes/`; local branches and tags are never written.
|
||||
- Opening a URL on Windows goes through the URL protocol handler instead of
|
||||
`cmd /C start`, so characters in the URL are not interpreted by the shell.
|
||||
- Skill downloads and the skills registry index are read through a streaming
|
||||
size cap shared with the MCP HTTP transport, instead of buffering the whole
|
||||
body before checking its size.
|
||||
- Durable runtime thread, turn and item ids carry a full UUID instead of 32
|
||||
random bits, so two records can no longer collide and overwrite each other.
|
||||
|
||||
### Removed
|
||||
|
||||
- Flags, settings and tool parameters that did nothing are gone
|
||||
|
||||
@@ -26,8 +26,14 @@ fn note(workspace: &Path, content: Option<&str>) -> CommandResult {
|
||||
|
||||
let notes_path = notes_path(workspace);
|
||||
let (command, rest) = split_command(input);
|
||||
let command = command.to_ascii_lowercase();
|
||||
if !matches!(command.as_str(), "path" | "help")
|
||||
&& let Err(error) = ensure_notes_target_in_workspace(¬es_path, workspace)
|
||||
{
|
||||
return CommandResult::error(error);
|
||||
}
|
||||
|
||||
match command.to_ascii_lowercase().as_str() {
|
||||
match command.as_str() {
|
||||
"add" => append_note_command(¬es_path, rest),
|
||||
"list" => list_notes_command(¬es_path),
|
||||
"show" => show_note_command(¬es_path, rest),
|
||||
@@ -51,6 +57,39 @@ fn notes_path(workspace: &Path) -> PathBuf {
|
||||
workspace.join(".deepseek").join("notes.md")
|
||||
}
|
||||
|
||||
/// The notes file lives in the workspace, which may be a cloned repository:
|
||||
/// a committed `notes.md -> ~/.zshrc` or a symlinked `.codewhale/` must not
|
||||
/// let `/note clear` empty (or `/note list` print) a file outside it. Same
|
||||
/// rule as the `note` tool.
|
||||
fn ensure_notes_target_in_workspace(notes_path: &Path, workspace: &Path) -> Result<(), String> {
|
||||
if let Ok(meta) = fs::symlink_metadata(notes_path)
|
||||
&& (meta.file_type().is_symlink() || !meta.is_file())
|
||||
{
|
||||
return Err(format!(
|
||||
"Refusing to use {}: the notes path is a symlink or not a regular file.",
|
||||
notes_path.display()
|
||||
));
|
||||
}
|
||||
let (Some(parent), Ok(root)) = (notes_path.parent(), fs::canonicalize(workspace)) else {
|
||||
return Ok(());
|
||||
};
|
||||
// Only existing ancestors can redirect; the rest is created as real
|
||||
// directories.
|
||||
let mut existing = parent.to_path_buf();
|
||||
while !existing.exists() {
|
||||
if !existing.pop() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
match fs::canonicalize(&existing) {
|
||||
Ok(resolved) if !resolved.starts_with(&root) => Err(format!(
|
||||
"Refusing to use {}: its directory resolves outside the workspace.",
|
||||
notes_path.display()
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_command(input: &str) -> (&str, Option<&str>) {
|
||||
match input.find(char::is_whitespace) {
|
||||
Some(index) => (&input[..index], Some(input[index..].trim())),
|
||||
@@ -449,6 +488,27 @@ mod tests {
|
||||
assert!(!listed.contains("Second note"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn note_commands_refuse_notes_that_leave_the_workspace() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let rc = outside.path().join("zshrc");
|
||||
std::fs::write(&rc, "export PATH=keep\n").unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join(".deepseek")).unwrap();
|
||||
std::os::unix::fs::symlink(&rc, notes_path(&tmpdir)).unwrap();
|
||||
for command in ["clear", "remove 1", "edit 1 x", "list", "hello"] {
|
||||
let result = note(tmpdir.path(), Some(command));
|
||||
assert!(result.is_error, "/note {command} must be refused");
|
||||
}
|
||||
assert_eq!(std::fs::read_to_string(&rc).unwrap(), "export PATH=keep\n");
|
||||
|
||||
let linked = TempDir::new().unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), linked.path().join(".deepseek")).unwrap();
|
||||
assert!(note(linked.path(), Some("clear")).is_error);
|
||||
assert!(!outside.path().join("notes.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_clear_empties_file() {
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -539,8 +539,20 @@ pub(crate) fn apply_git_noninteractive_env(cmd: &mut Command) {
|
||||
}
|
||||
|
||||
impl Git {
|
||||
/// Flags every `diff`, `show` or patch `log` that collects repository
|
||||
/// content passes, so the output never runs a repository-configured
|
||||
/// command. `--no-ext-diff`/`--no-textconv` skip diff drivers; a dirty
|
||||
/// check or `diff.submodule=diff` spawns a child git inside each submodule
|
||||
/// that inherits neither flag, so submodules compare by commit only.
|
||||
pub(crate) const REVIEW_DIFF_ARGS: [&'static str; 4] = [
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--submodule=short",
|
||||
"--ignore-submodules=dirty",
|
||||
];
|
||||
|
||||
/// Construct a read-only review command with content conversion disabled.
|
||||
/// Review callers also pass `--no-ext-diff` and `--no-textconv` for diffs.
|
||||
/// Review callers also pass [`Self::REVIEW_DIFF_ARGS`] for diffs.
|
||||
/// Configured filters otherwise execute even when those flags are present.
|
||||
pub(crate) fn review_command(workspace: &Path) -> anyhow::Result<Command> {
|
||||
use anyhow::{Context, bail};
|
||||
|
||||
+25
-11
@@ -10030,7 +10030,8 @@ fn collect_diff(
|
||||
let mut cmd = crate::dependencies::Git::review_command(workspace)?;
|
||||
// Review repository content without executing its diff drivers.
|
||||
cmd.current_dir(workspace)
|
||||
.args(["diff", "--no-ext-diff", "--no-textconv"]);
|
||||
.arg("diff")
|
||||
.args(crate::dependencies::Git::REVIEW_DIFF_ARGS);
|
||||
if args.staged {
|
||||
cmd.arg("--cached");
|
||||
}
|
||||
@@ -11246,6 +11247,9 @@ fn merge_project_config_with_approval_baseline(
|
||||
"mcp_config_path",
|
||||
"mcp_oauth_callback_port",
|
||||
"mcp_oauth_callback_url",
|
||||
// The auto-approved `note` tool appends to `notes_path`, so a
|
||||
// project value would be a write target the user never reviewed.
|
||||
"notes_path",
|
||||
];
|
||||
for key in DENY_AT_PROJECT_SCOPE {
|
||||
if table.contains_key(*key) {
|
||||
@@ -11258,7 +11262,7 @@ fn merge_project_config_with_approval_baseline(
|
||||
}
|
||||
|
||||
// String fields a project may legitimately override (model,
|
||||
// approval/sandbox tightening, notes path, reasoning effort).
|
||||
// approval/sandbox tightening, reasoning effort).
|
||||
if !config.environment_model_applied
|
||||
&& let Some(model) = table.get("model").and_then(toml::Value::as_str)
|
||||
&& !model.is_empty()
|
||||
@@ -11267,15 +11271,10 @@ fn merge_project_config_with_approval_baseline(
|
||||
config.set_provider_model_override(config.api_provider(), Some(model.to_string()));
|
||||
config.remembered_selection_scope = Some(false);
|
||||
}
|
||||
for (key, field) in [
|
||||
("reasoning_effort", &mut config.reasoning_effort),
|
||||
("notes_path", &mut config.notes_path),
|
||||
] {
|
||||
if let Some(v) = table.get(key).and_then(toml::Value::as_str)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
*field = Some(v.to_string());
|
||||
}
|
||||
if let Some(v) = table.get("reasoning_effort").and_then(toml::Value::as_str)
|
||||
&& !v.is_empty()
|
||||
{
|
||||
config.reasoning_effort = Some(v.to_string());
|
||||
}
|
||||
|
||||
if let Some(v) = table.get("approval_policy").and_then(toml::Value::as_str)
|
||||
@@ -19497,6 +19496,21 @@ sandbox_mode = "read-only"
|
||||
assert_eq!(config.sandbox_mode.as_deref(), Some("read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_ignores_notes_path() {
|
||||
// The auto-approved `note` tool appends to `notes_path`; a project
|
||||
// config must not choose that write target.
|
||||
for value in ["~/.zshrc", "/etc/profile", "../../.bashrc", "docs/notes.md"] {
|
||||
let tmp = workspace_with_project_config(&format!("notes_path = {value:?}\n"));
|
||||
let mut config = Config::default();
|
||||
merge_project_config(&mut config, tmp.path());
|
||||
assert_eq!(
|
||||
config.notes_path, None,
|
||||
"project notes_path {value:?} must be ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overlay_denies_approval_auto_and_sandbox_danger_values() {
|
||||
// #417 value-deny: the loosest values (`approval_policy = "auto"`,
|
||||
|
||||
@@ -218,17 +218,9 @@ pub(super) async fn read_body_capped(
|
||||
response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.context("failed to read MCP response body")?;
|
||||
if buf.len().saturating_add(chunk.len()) > max_bytes {
|
||||
anyhow::bail!("MCP response body exceeds {max_bytes} bytes — aborting");
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
let buf = crate::utils::read_response_body_capped(response, max_bytes)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("MCP {error:#}"))?;
|
||||
Ok(String::from_utf8_lossy(&buf).into_owned())
|
||||
}
|
||||
|
||||
|
||||
@@ -385,18 +385,10 @@ pub(super) async fn git_diff(
|
||||
require_repo(&workspace)?;
|
||||
let base = diff_base(&workspace).await?;
|
||||
let path_arg = path.to_string_lossy().into_owned();
|
||||
let run = git_read(
|
||||
&workspace,
|
||||
&[
|
||||
"diff",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
&base,
|
||||
"--",
|
||||
&path_arg,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let mut args = vec!["diff", "--no-color"];
|
||||
args.extend(Git::REVIEW_DIFF_ARGS);
|
||||
args.extend([base.as_str(), "--", &path_arg]);
|
||||
let run = git_read(&workspace, &args).await?;
|
||||
if !run.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff failed: {}",
|
||||
@@ -407,7 +399,14 @@ pub(super) async fn git_diff(
|
||||
let untracked = if diff.is_empty() {
|
||||
let status = git_read(
|
||||
&workspace,
|
||||
&["status", "--porcelain=v1", "-z", "--", &path_arg],
|
||||
&[
|
||||
"status",
|
||||
"--porcelain=v1",
|
||||
"-z",
|
||||
"--ignore-submodules=dirty",
|
||||
"--",
|
||||
&path_arg,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
status
|
||||
@@ -451,7 +450,10 @@ pub(super) async fn workspace_diff(
|
||||
require_repo(&workspace)?;
|
||||
let base = diff_base(&workspace).await?;
|
||||
|
||||
let numstat = git_read(&workspace, &["diff", "--numstat", &base]).await?;
|
||||
let mut numstat_args = vec!["diff", "--numstat"];
|
||||
numstat_args.extend(Git::REVIEW_DIFF_ARGS);
|
||||
numstat_args.push(&base);
|
||||
let numstat = git_read(&workspace, &numstat_args).await?;
|
||||
if !numstat.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff --numstat failed: {}",
|
||||
@@ -475,7 +477,10 @@ pub(super) async fn workspace_diff(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let run = git_read(&workspace, &["diff", "--no-color", "--no-ext-diff", &base]).await?;
|
||||
let mut diff_args = vec!["diff", "--no-color"];
|
||||
diff_args.extend(Git::REVIEW_DIFF_ARGS);
|
||||
diff_args.push(&base);
|
||||
let run = git_read(&workspace, &diff_args).await?;
|
||||
if !run.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff failed: {}",
|
||||
|
||||
@@ -291,6 +291,13 @@ fn strip_summary_section(base: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh durable record id: `<prefix>_<32 hex>`. Records live in flat
|
||||
/// per-kind directories and saves replace by id, so ids carry a full UUID; an
|
||||
/// 8-hex suffix (32 bits) collides often enough to overwrite another record.
|
||||
fn runtime_record_id(prefix: &str) -> String {
|
||||
format!("{prefix}_{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn validated_record_id<'a>(id: &'a str, label: &str) -> Result<&'a str> {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1410,7 +1417,7 @@ fn unaccepted_routed_usage_turn_id(
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
if identities.is_empty() {
|
||||
return format!("turn_unaccepted_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
return runtime_record_id("turn_unaccepted");
|
||||
}
|
||||
identities.sort_unstable();
|
||||
identities.dedup();
|
||||
@@ -7942,7 +7949,7 @@ impl RuntimeThreadManager {
|
||||
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("thr_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("thr"),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
model,
|
||||
@@ -8178,7 +8185,7 @@ impl RuntimeThreadManager {
|
||||
codewhale_config::notifications::NotificationEvent::parse(kind).is_some(),
|
||||
"notice kind must be a NotificationEvent name: {kind}"
|
||||
);
|
||||
let id = format!("notice_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let id = runtime_record_id("notice");
|
||||
let mut notices = self.notices.lock();
|
||||
let list = notices.entry(thread_id.to_string()).or_default();
|
||||
list.push(ActiveNotice {
|
||||
@@ -9126,7 +9133,7 @@ impl RuntimeThreadManager {
|
||||
let source = self.get_thread(id).await?;
|
||||
let mut forked = source.clone();
|
||||
let now = Utc::now();
|
||||
forked.id = format!("thr_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
forked.id = runtime_record_id("thr");
|
||||
forked.created_at = now;
|
||||
forked.updated_at = now;
|
||||
forked.latest_turn_id = None;
|
||||
@@ -9168,7 +9175,7 @@ impl RuntimeThreadManager {
|
||||
let mut cloned_records = Vec::with_capacity(source_turns.len());
|
||||
for source_turn in source_turns {
|
||||
let mut cloned_turn = source_turn.clone();
|
||||
cloned_turn.id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
cloned_turn.id = runtime_record_id("turn");
|
||||
cloned_turn.thread_id = forked.id.clone();
|
||||
if let Some(checkpoint) = forked.saved_session_checkpoint.as_mut()
|
||||
&& checkpoint.covered_turn_id.as_deref() == Some(source_turn.id.as_str())
|
||||
@@ -9181,7 +9188,7 @@ impl RuntimeThreadManager {
|
||||
let mut cloned_items = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let mut cloned_item = item.clone();
|
||||
cloned_item.id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
cloned_item.id = runtime_record_id("item");
|
||||
cloned_item.turn_id = cloned_turn.id.clone();
|
||||
cloned_turn.item_ids.push(cloned_item.id.clone());
|
||||
cloned_items.push(cloned_item);
|
||||
@@ -9495,7 +9502,7 @@ impl RuntimeThreadManager {
|
||||
// between the two paths.
|
||||
let mut forked = source.clone();
|
||||
let now = Utc::now();
|
||||
forked.id = format!("thr_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
forked.id = runtime_record_id("thr");
|
||||
forked.created_at = now;
|
||||
forked.updated_at = now;
|
||||
forked.latest_turn_id = None;
|
||||
@@ -9609,7 +9616,7 @@ impl RuntimeThreadManager {
|
||||
let mut cloned_records = Vec::with_capacity(cutoff_turn_idx);
|
||||
for source_turn in source_turns.iter().take(cutoff_turn_idx) {
|
||||
let mut cloned_turn = source_turn.clone();
|
||||
cloned_turn.id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
cloned_turn.id = runtime_record_id("turn");
|
||||
cloned_turn.thread_id = forked.id.clone();
|
||||
if let Some(checkpoint) = forked.saved_session_checkpoint.as_mut()
|
||||
&& checkpoint.covered_turn_id.as_deref() == Some(source_turn.id.as_str())
|
||||
@@ -9622,7 +9629,7 @@ impl RuntimeThreadManager {
|
||||
let mut cloned_items = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
let mut cloned_item = item.clone();
|
||||
cloned_item.id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
cloned_item.id = runtime_record_id("item");
|
||||
cloned_item.turn_id = cloned_turn.id.clone();
|
||||
cloned_turn.item_ids.push(cloned_item.id.clone());
|
||||
cloned_items.push(cloned_item);
|
||||
@@ -9948,14 +9955,14 @@ impl RuntimeThreadManager {
|
||||
|
||||
for turn_seed in turns {
|
||||
let turn_at = next_seed_stamp();
|
||||
let turn_id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let turn_id = runtime_record_id("turn");
|
||||
let summary =
|
||||
crate::utils::truncate_with_ellipsis(&turn_seed.user_text, SUMMARY_LIMIT, "...");
|
||||
let mut item_ids = Vec::new();
|
||||
|
||||
// Save user message item.
|
||||
if !turn_seed.user_text.is_empty() || !turn_seed.image_content.is_empty() {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
let item_at = next_seed_stamp();
|
||||
let mut item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
@@ -9980,7 +9987,7 @@ impl RuntimeThreadManager {
|
||||
|
||||
// Save assistant content items in order.
|
||||
for seed_item in &turn_seed.items {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
let item_at = next_seed_stamp();
|
||||
match seed_item {
|
||||
SeedItem::Text(text) => {
|
||||
@@ -10179,7 +10186,7 @@ impl RuntimeThreadManager {
|
||||
.transpose()?;
|
||||
let turn_id = match requested_turn_id.as_deref() {
|
||||
Some(turn_id) => turn_id.to_string(),
|
||||
None => format!("turn_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
None => runtime_record_id("turn"),
|
||||
};
|
||||
Ok(Some(PreparedRuntimeTurnOperation {
|
||||
binding: RuntimeTurnOperationBinding {
|
||||
@@ -11246,7 +11253,7 @@ impl RuntimeThreadManager {
|
||||
let turn_id = operation
|
||||
.as_ref()
|
||||
.map(|operation| operation.binding.turn_id.clone())
|
||||
.unwrap_or_else(|| format!("turn_{}", &Uuid::new_v4().to_string()[..8]));
|
||||
.unwrap_or_else(|| runtime_record_id("turn"));
|
||||
compaction.runtime_cost_owner = Some(turn_id.clone());
|
||||
let input_summary = req
|
||||
.input_summary
|
||||
@@ -11295,7 +11302,7 @@ impl RuntimeThreadManager {
|
||||
// including this classifier batch. Pre-persisting the count here and
|
||||
// adding TurnComplete at settlement would count the same gap twice.
|
||||
|
||||
let user_item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let user_item_id = runtime_record_id("item");
|
||||
let mut user_item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: user_item_id.clone(),
|
||||
@@ -11599,7 +11606,7 @@ impl RuntimeThreadManager {
|
||||
let queued_turn;
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.to_string(),
|
||||
kind: TurnItemKind::UserMessage,
|
||||
// Queued, not Completed: the text is in the engine's mailbox, not
|
||||
@@ -11730,8 +11737,8 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
|
||||
let now = Utc::now();
|
||||
let turn_id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let compaction_id = format!("compact_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let turn_id = runtime_record_id("turn");
|
||||
let compaction_id = runtime_record_id("compact");
|
||||
compaction.runtime_cost_owner = Some(turn_id.clone());
|
||||
// The same projection the turn record receipts, computed once: the
|
||||
// compaction runs under the thread's persisted policy.
|
||||
@@ -12765,7 +12772,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.to_string(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -12965,7 +12972,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
EngineEvent::MessageStarted { .. } => {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: item_id.clone(),
|
||||
@@ -13038,7 +13045,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
EngineEvent::ThinkingStarted { .. } => {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: item_id.clone(),
|
||||
@@ -13107,7 +13114,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
EngineEvent::ToolCallStarted { id, name, input } => {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
tool_items.insert(id.clone(), item_id.clone());
|
||||
let kind = tool_kind_for_name(&name);
|
||||
let summary = summarize_text(&format!("{name} started"), SUMMARY_LIMIT);
|
||||
@@ -13334,7 +13341,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
}
|
||||
EngineEvent::CompactionStarted { id, auto, message } => {
|
||||
let item_id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let item_id = runtime_record_id("item");
|
||||
compaction_items.insert(id.clone(), item_id.clone());
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
@@ -13471,7 +13478,7 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -13506,7 +13513,7 @@ impl RuntimeThreadManager {
|
||||
let message = format!("Sub-agent {id}: {status}");
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -13552,7 +13559,7 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -13621,7 +13628,7 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -14140,7 +14147,7 @@ impl RuntimeThreadManager {
|
||||
);
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Status,
|
||||
status: TurnItemLifecycleStatus::Completed,
|
||||
@@ -14173,7 +14180,7 @@ impl RuntimeThreadManager {
|
||||
let message = envelope.message.clone();
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Error,
|
||||
status: TurnItemLifecycleStatus::Failed,
|
||||
@@ -14395,7 +14402,7 @@ impl RuntimeThreadManager {
|
||||
turn_error = Some(EMPTY_TURN_REASON.to_string());
|
||||
let item = TurnItemRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
id: format!("item_{}", &Uuid::new_v4().to_string()[..8]),
|
||||
id: runtime_record_id("item"),
|
||||
turn_id: turn_id.clone(),
|
||||
kind: TurnItemKind::Error,
|
||||
status: TurnItemLifecycleStatus::Failed,
|
||||
@@ -14856,7 +14863,7 @@ impl crate::tools::spec::DynamicToolExecutor for RuntimeThreadManager {
|
||||
"runtime dynamic tool '{name}' has no active turn"
|
||||
))
|
||||
})?;
|
||||
let call_id = format!("call_{}", &Uuid::new_v4().to_string()[..8]);
|
||||
let call_id = runtime_record_id("call");
|
||||
let params = DynamicToolCallParams {
|
||||
thread_id: thread_id.clone(),
|
||||
turn_id: turn_id.clone(),
|
||||
|
||||
@@ -1492,6 +1492,21 @@ const EVENT_PROCESS_HELPER: &str = "runtime_threads::tests::runtime_event_proces
|
||||
// a test watchdog, not an expected runtime latency or a customer-facing SLO.
|
||||
const TURN_SETTLEMENT_DEADLOCK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Durable record ids carry a full UUID: records live in flat per-kind
|
||||
/// directories and a save replaces by id, so a short suffix would let one
|
||||
/// thread's record overwrite another's.
|
||||
#[test]
|
||||
fn runtime_record_ids_carry_a_full_uuid() {
|
||||
let id = runtime_record_id("thr");
|
||||
let suffix = id.strip_prefix("thr_").expect("prefix");
|
||||
assert_eq!(suffix.len(), 32, "{id}");
|
||||
assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit()), "{id}");
|
||||
assert_eq!(validated_record_id(&id, "thread id").expect("valid"), id);
|
||||
let ids: std::collections::HashSet<String> =
|
||||
(0..10_000).map(|_| runtime_record_id("item")).collect();
|
||||
assert_eq!(ids.len(), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "spawned by real cross-process Runtime event tests"]
|
||||
fn runtime_event_process_child_helper() {
|
||||
|
||||
@@ -76,6 +76,8 @@ pub const DEFAULT_REGISTRY_URL: &str =
|
||||
/// gzip bomb can't blow up RAM.
|
||||
pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
|
||||
const SYNC_REGISTRY_CONCURRENCY: usize = 8;
|
||||
/// Upper bound on a registry index document.
|
||||
const MAX_REGISTRY_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// File written under each installed skill so [`update`] / [`uninstall`] can
|
||||
/// recover the original [`InstallSource`] without re-parsing user input.
|
||||
@@ -554,17 +556,17 @@ pub async fn fetch_registry(
|
||||
Decision::Deny => return Ok(RegistryFetchResult::Denied(host)),
|
||||
Decision::Prompt => return Ok(RegistryFetchResult::NeedsApproval(host)),
|
||||
}
|
||||
let body = reqwest_client()
|
||||
let response = reqwest_client()
|
||||
.get(registry_url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch registry {registry_url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("registry {registry_url} returned an error status"))?
|
||||
.text()
|
||||
.with_context(|| format!("registry {registry_url} returned an error status"))?;
|
||||
let body = crate::utils::read_response_body_capped(response, MAX_REGISTRY_BYTES)
|
||||
.await
|
||||
.with_context(|| format!("failed to read registry body from {registry_url}"))?;
|
||||
let parsed: RegistryDocument = serde_json::from_str(&body)
|
||||
let parsed: RegistryDocument = serde_json::from_slice(&body)
|
||||
.with_context(|| format!("failed to parse registry json from {registry_url}"))?;
|
||||
Ok(RegistryFetchResult::Loaded(parsed))
|
||||
}
|
||||
@@ -764,24 +766,18 @@ async fn sync_one_skill(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let compressed_cap = max_size.saturating_mul(4);
|
||||
let bytes = match resp.bytes().await {
|
||||
let compressed_cap = compressed_download_cap(max_size);
|
||||
let bytes = match crate::utils::read_response_body_capped(resp, compressed_cap).await {
|
||||
Ok(b) => b,
|
||||
Err(err) => {
|
||||
return SkillSyncOutcome::Failed {
|
||||
name: name.to_string(),
|
||||
reason: format!("failed to read body from {url}: {err:#}"),
|
||||
reason: format!(
|
||||
"failed to read body from {url} (compressed size cap {compressed_cap} bytes): {err:#}"
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
if bytes.len() as u64 > compressed_cap {
|
||||
return SkillSyncOutcome::Failed {
|
||||
name: name.to_string(),
|
||||
reason: format!(
|
||||
"download from {url} exceeds compressed size cap ({compressed_cap} bytes)"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Compute SHA-256 of the downloaded bytes.
|
||||
let sha256 = sha256_hex(&bytes);
|
||||
@@ -1158,17 +1154,19 @@ async fn download_with_cap(url: &str, max_size: u64) -> Result<DownloadAttempt>
|
||||
}
|
||||
bail!("download {url} returned {status}");
|
||||
}
|
||||
// Soft cap on the *compressed* download — well above max_size to allow
|
||||
// for highly compressible payloads but still bounded.
|
||||
let compressed_cap = max_size.saturating_mul(4);
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
let compressed_cap = compressed_download_cap(max_size);
|
||||
let bytes = crate::utils::read_response_body_capped(resp, compressed_cap)
|
||||
.await
|
||||
.with_context(|| format!("failed to read body of {url}"))?;
|
||||
if (bytes.len() as u64) > compressed_cap {
|
||||
bail!("download {url} exceeds compressed size cap of {compressed_cap} bytes");
|
||||
}
|
||||
Ok(DownloadAttempt::Bytes(bytes.to_vec()))
|
||||
.with_context(|| {
|
||||
format!("failed to read body of {url} (compressed size cap {compressed_cap} bytes)")
|
||||
})?;
|
||||
Ok(DownloadAttempt::Bytes(bytes))
|
||||
}
|
||||
|
||||
/// Soft cap on the *compressed* download — well above `max_size` to allow for
|
||||
/// highly compressible payloads but still bounded.
|
||||
fn compressed_download_cap(max_size: u64) -> usize {
|
||||
usize::try_from(max_size.saturating_mul(4)).unwrap_or(usize::MAX)
|
||||
}
|
||||
|
||||
struct StagedSkill {
|
||||
@@ -1668,6 +1666,70 @@ fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Serve one response per connection: an endless chunked body, or a
|
||||
/// small body that declares an oversized `Content-Length`.
|
||||
async fn oversized_body_server() -> std::net::SocketAddr {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 1024];
|
||||
while let Ok(n) = socket.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if request.starts_with(b"GET /declared ") {
|
||||
let _ = socket
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 999999999\r\n\r\nxx")
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let _ = socket
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
.await;
|
||||
let chunk = [b'x'; 4096];
|
||||
// Never terminates: only a streaming cap ends this read.
|
||||
loop {
|
||||
if socket.write_all(b"1000\r\n").await.is_err()
|
||||
|| socket.write_all(&chunk).await.is_err()
|
||||
|| socket.write_all(b"\r\n").await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_with_cap_stops_reading_at_the_cap() {
|
||||
let addr = oversized_body_server().await;
|
||||
for path in ["endless", "declared"] {
|
||||
let url = format!("http://{addr}/{path}");
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
download_with_cap(&url, 16 * 1024),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("{path}: the size cap must end the read"));
|
||||
let err = match result {
|
||||
Ok(_) => panic!("{path}: an oversized body must be refused"),
|
||||
Err(err) => format!("{err:#}"),
|
||||
};
|
||||
assert!(err.contains("exceeds"), "{path}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_github_source() {
|
||||
let s = InstallSource::parse("github:Hmbown/test-skill").unwrap();
|
||||
|
||||
+159
-11
@@ -209,9 +209,9 @@ impl ToolSpec for GitDiffTool {
|
||||
"core.quotepath=false".to_string(),
|
||||
"diff".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
format!("--unified={unified}"),
|
||||
];
|
||||
args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
args.push(format!("--unified={unified}"));
|
||||
if cached {
|
||||
args.push("--cached".to_string());
|
||||
}
|
||||
@@ -221,7 +221,7 @@ impl ToolSpec for GitDiffTool {
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
let output = run_git_review_command(&git_ctx.working_dir, &args)?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
@@ -318,15 +318,16 @@ impl ToolSpec for GitCommitPlanTool {
|
||||
"diff".to_string(),
|
||||
"HEAD".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
"-U3".to_string(),
|
||||
];
|
||||
diff_args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
if let Some(pathspec) = &git_ctx.pathspec {
|
||||
diff_args.push("--".to_string());
|
||||
diff_args.push(pathspec.display().to_string());
|
||||
}
|
||||
let command = format_command(working_dir, &diff_args);
|
||||
let mut files = match git_stdout(working_dir, &diff_args)? {
|
||||
let diff_output = run_git_review_command(working_dir, &diff_args)?;
|
||||
let mut files = match stdout_or_failure(working_dir, &diff_args, diff_output) {
|
||||
Ok(stdout) => parse_diff(&String::from_utf8_lossy(&stdout)),
|
||||
Err(failure) => return Ok(failure),
|
||||
};
|
||||
@@ -374,13 +375,16 @@ impl ToolSpec for GitCommitPlanTool {
|
||||
);
|
||||
}
|
||||
|
||||
let staged_args = vec![
|
||||
let mut staged_args = vec![
|
||||
"diff".to_string(),
|
||||
"--cached".to_string(),
|
||||
"--quiet".to_string(),
|
||||
];
|
||||
let index_has_staged_changes =
|
||||
run_git_command(working_dir, &staged_args)?.status.code() == Some(1);
|
||||
staged_args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
let index_has_staged_changes = run_git_review_command(working_dir, &staged_args)?
|
||||
.status
|
||||
.code()
|
||||
== Some(1);
|
||||
|
||||
let commits = match plan_commits(files) {
|
||||
Ok(commits) => commits,
|
||||
@@ -512,6 +516,24 @@ fn run_git_command(working_dir: &Path, args: &[String]) -> Result<std::process::
|
||||
})
|
||||
}
|
||||
|
||||
/// Run a content-reading `diff` under [`crate::dependencies::Git::review_command`],
|
||||
/// so repository-configured filters, fsmonitor and hooks do not execute.
|
||||
pub(super) fn run_git_review_command(
|
||||
working_dir: &Path,
|
||||
args: &[String],
|
||||
) -> Result<std::process::Output, ToolError> {
|
||||
if crate::dependencies::Git::command().is_none() {
|
||||
return Err(ToolError::not_available(
|
||||
"git is not installed or not in PATH",
|
||||
));
|
||||
}
|
||||
let mut cmd = crate::dependencies::Git::review_command(working_dir)
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to prepare git: {e:#}")))?;
|
||||
cmd.args(args);
|
||||
cmd.output()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to run git: {e}")))
|
||||
}
|
||||
|
||||
fn format_command(working_dir: &Path, args: &[String]) -> String {
|
||||
// `[String]::join` produces the same string as collecting `&str` first, so
|
||||
// join the slice directly and skip the intermediate `Vec<&str>` allocation.
|
||||
@@ -643,15 +665,23 @@ fn git_stdout(
|
||||
args: &[String],
|
||||
) -> Result<Result<Vec<u8>, ToolResult>, ToolError> {
|
||||
let output = run_git_command(working_dir, args)?;
|
||||
Ok(stdout_or_failure(working_dir, args, output))
|
||||
}
|
||||
|
||||
fn stdout_or_failure(
|
||||
working_dir: &Path,
|
||||
args: &[String],
|
||||
output: std::process::Output,
|
||||
) -> Result<Vec<u8>, ToolResult> {
|
||||
if output.status.success() {
|
||||
return Ok(Ok(output.stdout));
|
||||
return Ok(output.stdout);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(Err(ToolResult::error(format!(
|
||||
Err(ToolResult::error(format!(
|
||||
"{} failed: {}",
|
||||
format_command(working_dir, args),
|
||||
stderr.trim()
|
||||
))))
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse `git diff` output into per-file hunks. Every `diff --git` section
|
||||
@@ -1722,4 +1752,122 @@ Binary files a/image.png and b/image.png differ
|
||||
result.content
|
||||
);
|
||||
}
|
||||
|
||||
/// Diff and show reads must not run commands a repository configures:
|
||||
/// a superproject textconv driver, or — through a child git spawned in a
|
||||
/// submodule for its dirty check or `diff.submodule=diff` — a submodule's
|
||||
/// clean filter or external diff driver.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn diff_and_show_reads_run_no_repository_configured_commands() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let marker = tmp.path().join("marker");
|
||||
let script = |name: &str, body: &str| {
|
||||
let path = tmp.path().join(name);
|
||||
fs::write(
|
||||
&path,
|
||||
format!("#!/bin/sh\necho {name} >> '{}'\n{body}", marker.display()),
|
||||
)
|
||||
.expect("write script");
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
path.display().to_string()
|
||||
};
|
||||
let clean = script("clean.sh", "cat\n");
|
||||
let external = script("external.sh", "");
|
||||
let textconv = script("textconv.sh", "cat \"$1\"\n");
|
||||
let git = |dir: &Path, args: &[&str]| {
|
||||
let status = crate::dependencies::Git::status(args, dir).expect("git should spawn");
|
||||
assert!(status.success(), "git {args:?} failed");
|
||||
};
|
||||
let identity = ["-c", "[email protected]", "-c", "user.name=Test"];
|
||||
|
||||
let upstream = tmp.path().join("upstream");
|
||||
fs::create_dir_all(&upstream).expect("mkdir upstream");
|
||||
init_git_repo(&upstream);
|
||||
fs::write(upstream.join("f.txt"), "a\n").expect("write");
|
||||
commit_all(&upstream, "init");
|
||||
|
||||
let repo = tmp.path().join("repo");
|
||||
fs::create_dir_all(&repo).expect("mkdir repo");
|
||||
init_git_repo(&repo);
|
||||
fs::write(repo.join(".gitattributes"), "a.md diff=conv\n").expect("attrs");
|
||||
fs::write(repo.join("a.md"), "one\n").expect("write");
|
||||
let upstream_arg = upstream.display().to_string();
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"-c",
|
||||
"protocol.file.allow=always",
|
||||
"submodule",
|
||||
"add",
|
||||
"-q",
|
||||
&upstream_arg,
|
||||
"sub",
|
||||
],
|
||||
);
|
||||
commit_all(&repo, "init");
|
||||
|
||||
let sub = repo.join("sub");
|
||||
fs::write(sub.join(".gitattributes"), "* filter=evil\n").expect("sub attrs");
|
||||
git(&sub, &[&identity[..], &["add", "."]].concat());
|
||||
git(
|
||||
&sub,
|
||||
&[&identity[..], &["commit", "-q", "-m", "attrs"]].concat(),
|
||||
);
|
||||
git(&sub, &["config", "filter.evil.clean", &clean]);
|
||||
git(&sub, &["config", "diff.external", &external]);
|
||||
let edited = sub.join("f.txt");
|
||||
fs::write(&edited, "c\n").expect("same-size edit");
|
||||
fs::File::options()
|
||||
.write(true)
|
||||
.open(&edited)
|
||||
.and_then(|file| file.set_modified(std::time::UNIX_EPOCH))
|
||||
.expect("age the edit so git re-reads it");
|
||||
git(&repo, &["config", "diff.submodule", "diff"]);
|
||||
git(&repo, &["config", "submodule.sub.ignore", "none"]);
|
||||
git(&repo, &["config", "diff.conv.textconv", &textconv]);
|
||||
fs::write(repo.join("a.md"), "two\n").expect("modify");
|
||||
|
||||
let no_marker = |step: &str| {
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"{step} ran a repository-configured command: {}",
|
||||
fs::read_to_string(&marker).unwrap_or_default()
|
||||
);
|
||||
};
|
||||
let ctx = ToolContext::new(&repo);
|
||||
let diff = GitDiffTool
|
||||
.execute(json!({}), &ctx)
|
||||
.await
|
||||
.expect("git_diff");
|
||||
no_marker("git_diff");
|
||||
assert!(diff.success, "{}", diff.content);
|
||||
assert!(
|
||||
diff.content.contains("Subproject commit"),
|
||||
"{}",
|
||||
diff.content
|
||||
);
|
||||
assert!(diff.content.contains("+two"), "{}", diff.content);
|
||||
|
||||
git(&repo, &["add", "sub"]);
|
||||
git(
|
||||
&repo,
|
||||
&[&identity[..], &["commit", "-q", "-m", "bump", "--", "sub"]].concat(),
|
||||
);
|
||||
let show = super::super::git_history::GitShowTool
|
||||
.execute(json!({"rev": "HEAD"}), &ctx)
|
||||
.await
|
||||
.expect("git_show");
|
||||
no_marker("git_show");
|
||||
assert!(show.success, "{}", show.content);
|
||||
assert!(
|
||||
show.content.contains("Subproject commit"),
|
||||
"{}",
|
||||
show.content
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -218,11 +218,8 @@ impl ToolSpec for GitShowTool {
|
||||
let stat = optional_bool(&input, "stat", true)?;
|
||||
let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED);
|
||||
|
||||
let mut args = vec![
|
||||
"show".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
];
|
||||
let mut args = vec!["show".to_string(), "--no-color".to_string()];
|
||||
args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
if patch {
|
||||
args.push(format!("--unified={unified}"));
|
||||
} else {
|
||||
@@ -238,7 +235,12 @@ impl ToolSpec for GitShowTool {
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
|
||||
let working_dir = git_ctx.working_dir.clone();
|
||||
let output = tokio::task::spawn_blocking(move || {
|
||||
super::git::run_git_review_command(&working_dir, &args)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))??;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(ToolResult::error(format!(
|
||||
@@ -452,7 +454,7 @@ impl ToolSpec for GitFetchTool {
|
||||
"refspecs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional refspecs to fetch (e.g. pull/123/head). Empty fetches the remote's defaults."
|
||||
"description": "Optional refspecs to fetch (e.g. pull/123/head); a `src:dst` destination must be under refs/remotes/. Empty fetches the remote's defaults."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
@@ -790,9 +792,19 @@ fn validate_git_refspec(refspec: &str) -> Result<(), ToolError> {
|
||||
"git refspec '{refspec}' must have at most one ':'"
|
||||
)));
|
||||
}
|
||||
for side in parts {
|
||||
for side in &parts {
|
||||
validate_git_refspec_side(refspec, side)?;
|
||||
}
|
||||
// The tool updates remote-tracking refs only: a destination under
|
||||
// `refs/heads/` or `refs/tags/` (or a bare name git would resolve there)
|
||||
// could rewrite local branches and tags.
|
||||
if let Some(dst) = parts.get(1)
|
||||
&& (!dst.starts_with("refs/remotes/") || dst.split('/').any(|part| part == ".."))
|
||||
{
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"git refspec '{refspec}' must write under refs/remotes/; omit the destination to fetch into FETCH_HEAD"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1228,6 +1240,26 @@ mod tests {
|
||||
"{refspec}: {err}"
|
||||
);
|
||||
}
|
||||
for refspec in [
|
||||
"+main:refs/heads/main",
|
||||
"v1:refs/tags/v1",
|
||||
"main:other",
|
||||
"main:refs/remotes/../heads/main",
|
||||
] {
|
||||
let err = validate_git_refspec(refspec)
|
||||
.expect_err("a destination outside refs/remotes/ must be refused");
|
||||
assert!(
|
||||
err.to_string().contains("refs/remotes/"),
|
||||
"{refspec}: {err}"
|
||||
);
|
||||
}
|
||||
for refspec in [
|
||||
"pull/123/head",
|
||||
"refs/heads/x:refs/remotes/origin/x",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
] {
|
||||
validate_git_refspec(refspec).expect(refspec);
|
||||
}
|
||||
let err = GitFetchTool
|
||||
.execute(json!({ "refspecs": "pull/1/head" }), &ctx)
|
||||
.await
|
||||
|
||||
@@ -144,7 +144,7 @@ impl ToolSpec for GitTool {
|
||||
"refspecs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Optional refspecs to fetch, e.g. pull/123/head (action=fetch). Empty fetches the remote's defaults."
|
||||
"description": "Optional refspecs to fetch, e.g. pull/123/head (action=fetch); a `src:dst` destination must be under refs/remotes/. Empty fetches the remote's defaults."
|
||||
},
|
||||
"ours": {
|
||||
"type": "string",
|
||||
|
||||
@@ -1730,11 +1730,8 @@ async fn resolve_diff_target(
|
||||
None
|
||||
};
|
||||
|
||||
let mut args = vec![
|
||||
"diff".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
"--no-textconv".to_string(),
|
||||
];
|
||||
let mut args = vec!["diff".to_string()];
|
||||
args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
if staged {
|
||||
args.push("--cached".to_string());
|
||||
if let Some(base_commit) = base_commit {
|
||||
|
||||
@@ -286,25 +286,26 @@ fn diff_with(
|
||||
if !commit_id(base) {
|
||||
bail!("The pinned PR commits do not have one available merge base");
|
||||
}
|
||||
let diff = run(
|
||||
Program::Git,
|
||||
&[
|
||||
"diff".into(),
|
||||
"--no-ext-diff".into(),
|
||||
"--no-textconv".into(),
|
||||
"--no-color".into(),
|
||||
"--no-relative".into(),
|
||||
"--full-index".into(),
|
||||
"--find-renames=50%".into(),
|
||||
"--src-prefix=a/".into(),
|
||||
"--dst-prefix=b/".into(),
|
||||
"--ignore-submodules=none".into(),
|
||||
"--submodule=short".into(),
|
||||
base.into(),
|
||||
view.head_sha.clone(),
|
||||
"--".into(),
|
||||
],
|
||||
)?;
|
||||
let mut diff_args = vec!["diff".to_string()];
|
||||
diff_args.extend(Git::REVIEW_DIFF_ARGS.map(String::from));
|
||||
diff_args.extend(
|
||||
[
|
||||
"--no-color",
|
||||
"--no-relative",
|
||||
"--full-index",
|
||||
"--find-renames=50%",
|
||||
"--src-prefix=a/",
|
||||
"--dst-prefix=b/",
|
||||
// Commit-to-commit: no worktree is read, so every
|
||||
// submodule pointer change belongs in the file set.
|
||||
"--ignore-submodules=none",
|
||||
base,
|
||||
view.head_sha.as_str(),
|
||||
"--",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
let diff = run(Program::Git, &diff_args)?;
|
||||
complete_file_set(&diff, view)?;
|
||||
Ok(diff)
|
||||
})();
|
||||
|
||||
@@ -4187,18 +4187,17 @@ fn hardened_readonly_argv(command: &str) -> Result<(String, Vec<String>)> {
|
||||
let at = subcommand_index + 1;
|
||||
argv.splice(
|
||||
at..at,
|
||||
["--no-ext-diff".to_string(), "--no-textconv".to_string()],
|
||||
crate::dependencies::Git::REVIEW_DIFF_ARGS.map(String::from),
|
||||
);
|
||||
}
|
||||
"log" | "show" => {
|
||||
let at = subcommand_index + 1;
|
||||
argv.splice(
|
||||
at..at,
|
||||
[
|
||||
"--no-ext-diff".to_string(),
|
||||
"--no-textconv".to_string(),
|
||||
"--no-show-signature".to_string(),
|
||||
],
|
||||
crate::dependencies::Git::REVIEW_DIFF_ARGS
|
||||
.into_iter()
|
||||
.chain(["--no-show-signature"])
|
||||
.map(String::from),
|
||||
);
|
||||
}
|
||||
"status" | "ls-files" | "blame" | "grep" => {}
|
||||
@@ -6566,6 +6565,49 @@ fn shell_delta_with_accumulated_output(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a notes target this auto-approved tool must not append to.
|
||||
///
|
||||
/// The file itself must not be a symlink (a committed `notes.md -> ~/.zshrc`
|
||||
/// would redirect the append), and a target placed inside the workspace must
|
||||
/// resolve inside it after symlinked parent directories are followed.
|
||||
async fn ensure_notes_target_is_safe(
|
||||
notes_path: &std::path::Path,
|
||||
workspace: &std::path::Path,
|
||||
) -> Result<(), ToolError> {
|
||||
if let Ok(meta) = tokio::fs::symlink_metadata(notes_path).await
|
||||
&& (meta.file_type().is_symlink() || !meta.is_file())
|
||||
{
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"Refusing to append a note to {}: the notes path is a symlink or not a regular file.",
|
||||
notes_path.display()
|
||||
)));
|
||||
}
|
||||
if notes_path.starts_with(workspace)
|
||||
&& let (Some(parent), Ok(root)) = (
|
||||
notes_path.parent(),
|
||||
tokio::fs::canonicalize(workspace).await,
|
||||
)
|
||||
{
|
||||
// Walk up to the nearest existing ancestor: the rest is created
|
||||
// below as real directories, so only existing links can redirect.
|
||||
let mut existing = parent.to_path_buf();
|
||||
while !tokio::fs::try_exists(&existing).await.unwrap_or(false) {
|
||||
if !existing.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Ok(resolved) = tokio::fs::canonicalize(&existing).await
|
||||
&& !resolved.starts_with(&root)
|
||||
{
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"Refusing to append a note to {}: its directory resolves outside the workspace.",
|
||||
notes_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tool for appending notes to a notes file.
|
||||
pub struct NoteTool;
|
||||
|
||||
@@ -6606,6 +6648,7 @@ impl ToolSpec for NoteTool {
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let note_content = required_str(&input, "content")?;
|
||||
ensure_notes_target_is_safe(&context.notes_path, &context.workspace).await?;
|
||||
|
||||
// Ensure parent directory exists. Tool handlers run on the Tokio
|
||||
// runtime, so filesystem calls use tokio::fs (blocking-call
|
||||
@@ -6628,6 +6671,11 @@ impl ToolSpec for NoteTool {
|
||||
file.write_all(format!("\n---\n{note_content}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to write note: {e}")))?;
|
||||
// A tokio file finishes writes in the background; flush so the note
|
||||
// is on disk when the tool reports success.
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to write note: {e}")))?;
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"Note appended to {}",
|
||||
|
||||
@@ -945,11 +945,13 @@ fn readonly_argv_is_shell_free_and_disables_git_helpers() {
|
||||
let (program, args) = hardened_readonly_argv("git show HEAD").expect("argv");
|
||||
assert_eq!(program, "git");
|
||||
assert_eq!(
|
||||
&args[..4],
|
||||
&args[..6],
|
||||
[
|
||||
"show",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--submodule=short",
|
||||
"--ignore-submodules=dirty",
|
||||
"--no-show-signature"
|
||||
]
|
||||
);
|
||||
@@ -4952,3 +4954,53 @@ fn pty_raw_stdin_roundtrips_nul_and_non_utf8_bytes() {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto-approved `note` tool appends to the configured notes file. A
|
||||
/// committed symlink (`notes.md -> ~/.zshrc`) or a symlinked notes directory
|
||||
/// must not redirect that append outside the workspace.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn note_tool_refuses_symlinked_targets_that_leave_the_workspace() {
|
||||
let workspace = tempdir().expect("workspace");
|
||||
let outside = tempdir().expect("outside");
|
||||
let rc = outside.path().join(".zshrc");
|
||||
std::fs::write(&rc, "# rc\n").expect("write rc");
|
||||
|
||||
let linked_file = workspace.path().join("notes.md");
|
||||
std::os::unix::fs::symlink(&rc, &linked_file).expect("symlink notes file");
|
||||
let context = ToolContext::with_options(workspace.path(), false, &linked_file, "mcp.json");
|
||||
let err = NoteTool
|
||||
.execute(json!({"content": "appended text"}), &context)
|
||||
.await
|
||||
.expect_err("a symlinked notes file must be refused");
|
||||
assert!(err.to_string().contains("symlink"), "{err}");
|
||||
|
||||
let linked_dir = workspace.path().join("notes");
|
||||
std::os::unix::fs::symlink(outside.path(), &linked_dir).expect("symlink notes dir");
|
||||
let context = ToolContext::with_options(
|
||||
workspace.path(),
|
||||
false,
|
||||
linked_dir.join("sub/notes.md"),
|
||||
"mcp.json",
|
||||
);
|
||||
let err = NoteTool
|
||||
.execute(json!({"content": "appended text"}), &context)
|
||||
.await
|
||||
.expect_err("a notes dir resolving outside the workspace must be refused");
|
||||
assert!(err.to_string().contains("outside the workspace"), "{err}");
|
||||
|
||||
assert_eq!(std::fs::read_to_string(&rc).expect("read rc"), "# rc\n");
|
||||
assert!(!outside.path().join("sub").exists());
|
||||
|
||||
let plain = workspace.path().join("docs/notes.md");
|
||||
let context = ToolContext::with_options(workspace.path(), false, &plain, "mcp.json");
|
||||
NoteTool
|
||||
.execute(json!({"content": "kept"}), &context)
|
||||
.await
|
||||
.expect("an ordinary in-workspace notes file is writable");
|
||||
assert!(
|
||||
std::fs::read_to_string(&plain)
|
||||
.expect("read notes")
|
||||
.contains("kept")
|
||||
);
|
||||
}
|
||||
@@ -208,19 +208,10 @@ impl DeliveryEvidence {
|
||||
let mut candidates = status_paths(&baseline.root)?;
|
||||
candidates.extend(baseline.dirty.keys().cloned());
|
||||
if let Some(head) = baseline.head.as_deref() {
|
||||
let output = git(
|
||||
&baseline.root,
|
||||
&[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--name-only",
|
||||
"-z",
|
||||
head,
|
||||
"HEAD",
|
||||
"--",
|
||||
],
|
||||
)?;
|
||||
let mut args = vec!["diff"];
|
||||
args.extend(crate::dependencies::Git::REVIEW_DIFF_ARGS);
|
||||
args.extend(["--name-only", "-z", head, "HEAD", "--"]);
|
||||
let output = git(&baseline.root, &args)?;
|
||||
for path in output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|path| !path.is_empty())
|
||||
|
||||
@@ -681,7 +681,8 @@ async fn run_git_diff(workspace: &Path, args: &[String]) -> Result<Option<String
|
||||
// git not installed: degrade gracefully rather than failing the tool.
|
||||
return Ok(None);
|
||||
};
|
||||
cmd.args(["diff", "--no-ext-diff", "--no-textconv"]);
|
||||
cmd.arg("diff")
|
||||
.args(crate::dependencies::Git::REVIEW_DIFF_ARGS);
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
+46
-5
@@ -687,12 +687,40 @@ pub fn flush_and_sync(writer: &mut std::io::BufWriter<std::fs::File>) -> std::io
|
||||
writer.get_ref().sync_all()
|
||||
}
|
||||
|
||||
/// Read a whole response body, failing as soon as it would exceed `max_bytes`.
|
||||
///
|
||||
/// A declared `Content-Length` over the cap is refused before anything is
|
||||
/// read; chunked or length-less bodies are bounded while streaming, so a
|
||||
/// server cannot make the caller buffer an unbounded body before a size check.
|
||||
pub async fn read_response_body_capped(
|
||||
response: reqwest::Response,
|
||||
max_bytes: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
if let Some(len) = response.content_length()
|
||||
&& len > max_bytes as u64
|
||||
{
|
||||
anyhow::bail!("response body of {len} bytes exceeds {max_bytes} bytes — aborting");
|
||||
}
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| anyhow::anyhow!("failed to read response body: {e}"))?;
|
||||
if buf.len().saturating_add(chunk.len()) > max_bytes {
|
||||
anyhow::bail!("response body exceeds {max_bytes} bytes — aborting");
|
||||
}
|
||||
buf.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Open a URL in the system's default browser.
|
||||
///
|
||||
/// Dispatches to the platform-appropriate opener:
|
||||
/// - macOS: `open`
|
||||
/// - Linux / BSD: `xdg-open`
|
||||
/// - Windows: `cmd /C start ""`
|
||||
/// - Windows: `rundll32 url.dll,FileProtocolHandler`
|
||||
/// - Other: returns an error.
|
||||
///
|
||||
/// This is the single entry point for URL opening — every call site in
|
||||
@@ -733,10 +761,12 @@ fn browser_open_command(url: &str) -> Result<Command> {
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
// Not `cmd /C start`: cmd.exe would parse `&`, `|`, `^` and `%` inside
|
||||
// the URL. The protocol handler receives it as data.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", "start", "", url]);
|
||||
let mut cmd = Command::new("rundll32");
|
||||
cmd.args(["url.dll,FileProtocolHandler", url]);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@@ -1908,13 +1938,24 @@ mod project_mapping_tests {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
assert_eq!(command.get_program(), "cmd");
|
||||
assert_eq!(command.get_program(), "rundll32");
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["/C", "start", "", "https://example.com"]
|
||||
vec!["url.dll,FileProtocolHandler", "https://example.com"]
|
||||
);
|
||||
// Shell metacharacters stay inside the single URL argument.
|
||||
let url = "https://example.com/?a=1&b=2|x^y%PATH%";
|
||||
let command = super::browser_open_command(url).expect("command");
|
||||
assert_eq!(command.get_program(), "rundll32");
|
||||
assert_eq!(
|
||||
command
|
||||
.get_args()
|
||||
.last()
|
||||
.map(|arg| arg.to_string_lossy().into_owned()),
|
||||
Some(url.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +318,6 @@ Supported keys in the project overlay (top-level fields only):
|
||||
| `reasoning_effort` | force `"high"` / `"max"` for a complex repo |
|
||||
| `approval_policy` | only values that tighten the user's current permission posture |
|
||||
| `sandbox_mode` | only values that tighten the user's current sandbox posture |
|
||||
| `notes_path` | keep notes in-repo |
|
||||
| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |
|
||||
| `allow_shell` | `false` can disable shell access; `true` is ignored |
|
||||
|
||||
@@ -327,7 +326,7 @@ maintainer is most likely to want to standardize across contributors.
|
||||
Credential, endpoint, provider-selection, MCP config, hooks, skills,
|
||||
retry, hotbar bindings, and `instructions = [...]` settings stay user-global.
|
||||
If a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,
|
||||
`mcp_config_path`, `hotbar`, `allow_shell = true`, or `instructions`,
|
||||
`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,
|
||||
Codewhale ignores that key and keeps the user's global setting.
|
||||
|
||||
The consolidated `codewhale` runtime uses one config file for DeepSeek auth
|
||||
|
||||
Reference in new issue
Block a user