Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ accumulate under `Unreleased` until a `v*` tag publishes them.
terminal (still capped at 80 columns on wide terminals).
- `max_tool_rounds` default raised from 32 to 256, and goal
continuations per turn from 5 to 8, for super-long-horizon turns.
- October-review contract decisions land: the four never-produced
wire events (`plan_proposed`, `plan_approved`, `goal_set`,
`goal_completed`) are removed together with their frontend consumers
and the TS SDK members (breaking wire change — nothing ever emitted
them); the app server answers every failed request, extractor
rejections included, with the same JSON `{"error": ...}` body;
`mcp serve` `tools/list` carries MCP `annotations`
(`readOnlyHint`/`destructiveHint`) mirroring each tool's effect
classification; `web_fetch` treats the reserved name `localhost` as
the loopback literal it is (RFC 6761, answered without DNS), so it
reaches local dev servers exactly like the already-allowed
`127.0.0.1` literal; and the system prompt's `Shell:` line reports
the shell the `WAVECODE_SHELL` override actually spawns instead of
the login shell.
- Warnings and loop notices render in the theme's warning color
instead of dim body text, and the round-limit warning names the
`max_tool_rounds` config key.
Expand Down
91 changes: 73 additions & 18 deletions crates/capabilities/tools/src/web_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,30 @@ fn is_private_addr(addr: std::net::IpAddr) -> bool {
/// literals skip DNS entirely, so a literal redirect target is judged
/// per hop by [`is_link_local_host`] (link-local refused; the
/// loopback/RFC1918 allowance is documented there).
///
/// The reserved name `localhost` is treated as the loopback literal it
/// is (RFC 6761: it never names a remote machine and must not be
/// forwarded to DNS): the resolver answers it with the loopback
/// addresses directly, so the same local-dev reach a `127.0.0.1`
/// literal already has covers the name too, and a hosts-file override
/// can never point it elsewhere.
#[derive(Debug)]
struct NonPrivateResolver;

impl reqwest::dns::Resolve for NonPrivateResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let name = name.as_str().to_string();
Box::pin(async move {
if name.eq_ignore_ascii_case("localhost") {
return Ok(Box::new(
[
std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
std::net::SocketAddr::from(([0u16, 0, 0, 0, 0, 0, 0, 1], 0)),
]
.into_iter(),
)
as Box<dyn Iterator<Item = std::net::SocketAddr> + Send>);
}
let host = name.clone();
let addrs = tokio::task::spawn_blocking(move || {
std::net::ToSocketAddrs::to_socket_addrs(&(host.as_str(), 0u16))
Expand All @@ -196,23 +213,38 @@ impl reqwest::dns::Resolve for NonPrivateResolver {
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?;
let vetted: Vec<std::net::SocketAddr> = addrs
.into_iter()
.filter(|addr| !is_private_addr(addr.ip()))
.collect();
if vetted.is_empty() {
return Err(Box::new(std::io::Error::other(format!(
"host '{name}' resolves only into private address ranges; \
this tool never reaches private networks by name"
)))
as Box<dyn std::error::Error + Send + Sync>);
}
let vetted = vet_public_addrs(&name, addrs).map_err(
|message| -> Box<dyn std::error::Error + Send + Sync> {
Box::new(std::io::Error::other(message))
},
)?;
Ok(Box::new(vetted.into_iter())
as Box<dyn Iterator<Item = std::net::SocketAddr> + Send>)
})
}
}

/// Filter resolved addresses down to the publicly routable ones; empty
/// output means the name resolves only into private space and the fetch
/// is refused (a mixed answer keeps its public legs, so a rebinding
/// attempt cannot smuggle a private leg past the vet).
fn vet_public_addrs(
name: &str,
addrs: Vec<std::net::SocketAddr>,
) -> std::result::Result<Vec<std::net::SocketAddr>, String> {
let vetted: Vec<std::net::SocketAddr> = addrs
.into_iter()
.filter(|addr| !is_private_addr(addr.ip()))
.collect();
if vetted.is_empty() {
return Err(format!(
"host '{name}' resolves only into private address ranges; \
this tool never reaches private networks by name"
));
}
Ok(vetted)
}

/// Decode a body as text: explicit latin-1 family charsets decode each
/// byte to the same codepoint, everything else is UTF-8 (lossy fallback
/// so a truncation-split multibyte tail cannot fail the fetch).
Expand Down Expand Up @@ -660,15 +692,38 @@ mod tests {
}
}

/// A name resolving only into private space fails the resolver;
/// `localhost` is the universally available specimen (hosts file,
/// no network needed).
/// A name resolving only into private space fails the vet; a mixed
/// answer keeps only its public legs (rebinding cannot smuggle a
/// private leg past the filter).
#[test]
fn names_resolving_only_private_are_refused() {
let addr = |ip: [u8; 4]| std::net::SocketAddr::from((ip, 80u16));
let err = vet_public_addrs("private.example", vec![addr([127, 0, 0, 1])]).unwrap_err();
assert!(err.contains("private address ranges"), "{err}");
let kept = vet_public_addrs(
"rebinding.example",
vec![addr([10, 0, 0, 5]), addr([8, 8, 8, 8])],
)
.unwrap();
assert_eq!(kept, vec![addr([8, 8, 8, 8])]);
}

/// The reserved name `localhost` resolves as the loopback literal it
/// is (RFC 6761): the same reach a `127.0.0.1` literal has covers
/// the name, answered without DNS so no hosts override applies.
#[tokio::test]
async fn resolver_blocks_names_resolving_private() {
async fn localhost_resolves_as_the_loopback_literal() {
use reqwest::dns::Resolve as _;
let name: reqwest::dns::Name = "localhost".parse().unwrap();
let result = NonPrivateResolver.resolve(name).await;
assert!(result.is_err(), "localhost must not resolve");
for spelling in ["localhost", "LOCALHOST"] {
let name: reqwest::dns::Name = spelling.parse().unwrap();
let addrs: Vec<std::net::SocketAddr> =
NonPrivateResolver.resolve(name).await.unwrap().collect();
assert!(!addrs.is_empty(), "{spelling} resolves");
assert!(
addrs.iter().all(|addr| addr.ip().is_loopback()),
"localhost answers loopback only: {addrs:?}"
);
}
}

/// Redirect targets get the same literal check as the initial URL:
Expand Down
14 changes: 0 additions & 14 deletions crates/foundation/wire/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,20 +371,6 @@ pub enum EventMsg {
/// a warning instead of this event).
turns: u32,
},
/// A reviewed plan was proposed and awaits approval (display routing).
PlanProposed {
/// Proposed plan text for transcript rendering.
text: String,
},
/// A reviewed plan was approved.
PlanApproved,
/// A durable goal was set (display routing).
GoalSet {
/// Goal objective text for transcript rendering.
objective: String,
},
/// A durable goal was completed.
GoalCompleted,
/// Non-fatal condition worth surfacing.
Warning {
/// Human-readable warning text.
Expand Down
12 changes: 0 additions & 12 deletions crates/foundation/wire/src/tests/round_trips.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,6 @@ fn event_tag(msg: &EventMsg) -> &'static str {
EventMsg::CompactStarted { .. } => "compact_started",
EventMsg::CompactCompleted { .. } => "compact_completed",
EventMsg::HistoryRewound { .. } => "history_rewound",
EventMsg::PlanProposed { .. } => "plan_proposed",
EventMsg::PlanApproved => "plan_approved",
EventMsg::GoalSet { .. } => "goal_set",
EventMsg::GoalCompleted => "goal_completed",
EventMsg::Warning { .. } => "warning",
EventMsg::Error { .. } => "error",
EventMsg::TurnCompleted { .. } => "turn_completed",
Expand Down Expand Up @@ -177,14 +173,6 @@ fn every_event_msg_variant_round_trips_with_its_locked_tag() {
// nosemgrep: codacy.yaml.security.hard-coded-tokens
EventMsg::CompactCompleted { summary_tokens: 99 },
EventMsg::HistoryRewound { turns: 2 },
EventMsg::PlanProposed {
text: "plan".to_string(),
},
EventMsg::PlanApproved,
EventMsg::GoalSet {
objective: "ship".to_string(),
},
EventMsg::GoalCompleted,
EventMsg::Warning {
message: "w".to_string(),
},
Expand Down
14 changes: 0 additions & 14 deletions crates/foundation/wire/src/tests/tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,6 @@ fn wire_tags_event_msg_are_locked() {
"compact_completed",
),
(EventMsg::HistoryRewound { turns: 1 }, "history_rewound"),
(
EventMsg::PlanProposed {
text: "x".to_string(),
},
"plan_proposed",
),
(EventMsg::PlanApproved, "plan_approved"),
(
EventMsg::GoalSet {
objective: "x".to_string(),
},
"goal_set",
),
(EventMsg::GoalCompleted, "goal_completed"),
(
EventMsg::Warning {
message: "w".to_string(),
Expand Down
19 changes: 0 additions & 19 deletions crates/frontends/console/src/chrome/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@ pub fn row1(state: &AppState, tip: Option<&str>, columns: usize) -> String {
&format!(" {} {branch}", crate::chrome::symbols::BRANCH),
));
}
if let Some(goal) = &state.goal {
let minutes = (goal.since.elapsed().as_secs() / 60).max(1);
line.push_str(&theme.paint(Token::Primary, &format!(" goal ● {minutes}m")));
}
if let Some(tip) = tip {
let used = width::width(&line);
let tip_text = theme.paint(Token::TextMuted, &format!(" | {tip}"));
Expand Down Expand Up @@ -168,21 +164,6 @@ mod tests {
assert!(!width::strip_ansi(&bare).contains("⎇"), "{bare}");
}

#[test]
fn row1_shows_goal_badge_while_active() {
theme::set(theme::Theme::dark());
let mut with_goal = state();
with_goal.goal = Some(crate::state::GoalBadge {
since: std::time::Instant::now(),
});
let line = row1(&with_goal, None, 120);
let plain = width::strip_ansi(&line);
assert!(plain.contains("goal ●"), "{plain}");
assert!(plain.contains("1m"), "{plain}");
let bare = row1(&state(), None, 120);
assert!(!width::strip_ansi(&bare).contains("goal ●"), "{bare}");
}

#[test]
fn row2_shows_context_and_exit_hint() {
theme::set(theme::Theme::dark());
Expand Down
13 changes: 0 additions & 13 deletions crates/frontends/console/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
//! streaming phase, context usage, and queued input.

use std::path::PathBuf;
use std::time::Instant;

/// What the harness is doing right now; drives the input pulse.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -71,13 +70,6 @@ pub struct DialogueEntry {
pub text: String,
}

/// The active goal, shown as a timed badge in the footer row 1.
#[derive(Debug, Clone)]
pub struct GoalBadge {
/// When the goal was set (this run), for the elapsed timer.
pub since: Instant,
}

/// Mutable UI state snapshot; components read it during rendering.
#[derive(Debug, Clone)]
pub struct AppState {
Expand Down Expand Up @@ -126,10 +118,6 @@ pub struct AppState {
/// Full user/assistant dialogue for `/export` and `/copy`; unlike
/// the transcript this is never trimmed.
pub dialogue: Vec<DialogueEntry>,
/// Active goal (GoalSet until GoalCompleted); renders as a timed
/// footer badge. Not restored on resume — the badge only reflects
/// goals seen this run.
pub goal: Option<GoalBadge>,
/// First line of the external status-line command, replacing
/// footer row 1 when present.
pub status_line: Option<String>,
Expand Down Expand Up @@ -164,7 +152,6 @@ impl AppState {
git_branch: None,
update_notice: None,
dialogue: Vec::new(),
goal: None,
status_line: None,
}
}
Expand Down
23 changes: 1 addition & 22 deletions crates/frontends/console/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ use crate::messages::usage::UsagePanel;
use crate::messages::{AssistantMessage, ExpandedFlag, StatusLine, Thinking, UserMessage};
use crate::panes;
use crate::slash;
use crate::state::{AppState, GoalBadge, StreamingPhase, TodoEntry, TodoStatus};
use crate::state::{AppState, StreamingPhase, TodoEntry, TodoStatus};
use crate::theme::{self, Token};
use crate::transcript::Transcript;
use crate::welcome::Welcome;
Expand Down Expand Up @@ -1018,27 +1018,6 @@ impl ConsoleUi {
self.apply_rewound(*turns);
true
}
EventMsg::PlanProposed { text } => {
let text = tui_engine::sanitize::sanitize_terminal(text);
self.push_assistant_message(&text);
true
}
EventMsg::PlanApproved => {
self.push_status("plan approved", false);
true
}
EventMsg::GoalSet { objective } => {
self.state.goal = Some(GoalBadge {
since: Instant::now(),
});
self.push_status(&format!("goal set: {objective}"), false);
true
}
EventMsg::GoalCompleted => {
self.state.goal = None;
self.push_status("goal completed", false);
true
}
EventMsg::Warning { message } => {
// Wire messages usually arrive bare, but senders that
// prefix already (or pass-through notices) must not
Expand Down
16 changes: 0 additions & 16 deletions crates/frontends/harness/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,22 +124,6 @@ pub(crate) fn render_event(
stderr.push_str(&format!("[history rewound: {turns} turns]\n"));
None
}
EventMsg::PlanProposed { text } => {
stderr.push_str(&format!("[plan proposed]\n{}\n", clean(text)));
None
}
EventMsg::PlanApproved => {
stderr.push_str("[plan approved]\n");
None
}
EventMsg::GoalSet { objective } => {
stderr.push_str(&format!("[goal set]\n{}\n", clean(objective)));
None
}
EventMsg::GoalCompleted => {
stderr.push_str("[goal completed]\n");
None
}
EventMsg::Warning { message } => {
stderr.push_str(&format!("[warn] {}\n", clean(message)));
None
Expand Down
35 changes: 35 additions & 0 deletions crates/infrastructure/base/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,27 @@ pub fn shell_invocation() -> (String, &'static str) {
}
}

/// Best-effort display name of the shell [`shell_invocation`] spawns:
/// the `WAVECODE_SHELL` override's file stem when set, else the
/// platform default (`cmd` / `sh`).
///
/// The system prompt's `Shell:` line reports this name, so the model
/// sees the shell that will actually run its commands rather than the
/// login shell — the same resolution every spawn path uses.
pub fn shell_name() -> String {
let (program, _) = shell_invocation();
display_shell_name(&program)
}

/// File stem of a shell program path (`…\cmd.exe` → `cmd`,
/// `/bin/zsh` → `zsh`); a stemless value passes through.
fn display_shell_name(program: &str) -> String {
std::path::Path::new(program)
.file_stem()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_owned())
}

/// Build the command that runs `command` as a command string.
///
/// One place turns the shared resolution into a spawn, so every
Expand Down Expand Up @@ -485,6 +506,20 @@ mod tests {
use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;

/// The prompt's shell name is the file stem of the spawn program:
/// an override's path and the platform default both reduce to the
/// name the model should expect in its own command strings.
#[test]
fn display_shell_name_is_the_program_stem() {
// Backslashes separate only on Windows: the stem rule itself is
// cross-platform, so the Windows-shaped input is asserted where
// the shape means what it says.
#[cfg(windows)]
assert_eq!(display_shell_name(r"C:\Windows\system32\cmd.exe"), "cmd");
assert_eq!(display_shell_name("/bin/zsh"), "zsh");
assert_eq!(display_shell_name("pwsh"), "pwsh");
}

/// The blocking-pool runner must keep the runtime worker free: the
/// work runs on the blocking pool, so a task scheduled on the same
/// worker keeps making progress while it runs. A worker that ran
Expand Down
Loading
Loading