Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .repository-projection.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"projection": "deixic-code",
"projectionSchemaVersion": 1,
"sourceRepository": "dx-corp/mono",
"sourceSha": "518c4d79c54135d51a4d885f38cf4f4e701e44a0",
"sourceSha": "d88f75d4469c36598fddc2b14e5c8e9fe7016c57",
"destinationRepository": "dx-corp/code",
"priorProjectedBase": "bac6ed17bdc31b56dfdc9d7740103300965fec5c",
"priorProjectedBase": "256415bec6bd5ba5e1882abaa64fdbf246704856",
"definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7",
"toolDigest": "f8cb071b0f27267120ccf45a00d0982f45113bd23535bef6a1555b4933f99f13",
"contentDigest": "00d6e14ad707b1d000be9e241309bd5d6a63d5aaa0c95f61c30df1be063723ef",
"contentDigest": "8b1a228038fec31ab61c343fb2057e854477e6f5642b148202cb874362c5080e",
"publicationEligible": true
}
6 changes: 5 additions & 1 deletion packages/local-host-rs/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ pub(crate) fn provider_system_prompt(
/// build its concrete executor.
#[derive(Debug, Clone)]
pub struct NativeAgentConfig {
/// Host-selected legacy, scoped, or denied access to background commands.
pub background_task_access: crate::tools::background_tasks::BackgroundTaskAccess,
pub model: String,
/// Headless sessions bind prompt receipts and provider rendering to one snapshot.
pub model_capabilities: Option<maestro_runtime::agent::NativeModelCapabilities>,
Expand All @@ -138,6 +140,7 @@ impl Default for NativeAgentConfig {
fn default() -> Self {
let model = "gpt-5.1-codex-max".to_owned();
Self {
background_task_access: crate::tools::background_tasks::BackgroundTaskAccess::Legacy,
max_tokens: crate::model_catalog::default_max_output_tokens(&model),
model,
model_capabilities: None,
Expand Down Expand Up @@ -630,7 +633,8 @@ fn build_local_host(
) -> Result<NativeExecutionHostHandle> {
let mut executor = ToolExecutor::with_credential_vault(&config.cwd, credential_vault)
.with_code_authority()
.with_managed_mcp_policy(config.managed_mcp_policy.clone());
.with_managed_mcp_policy(config.managed_mcp_policy.clone())
.with_background_task_access(config.background_task_access.clone());
if let Some(policy) = config.sandbox_policy.clone() {
executor = executor.with_sandbox_policy(policy);
}
Expand Down
30 changes: 2 additions & 28 deletions packages/local-host-rs/src/headless_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ use crate::semantic_text::{
FlushReason as SemanticFlushReason, Release as SemanticRelease, SemanticTextRelease,
};

mod agent_config;
mod managed_authorization;
mod semantic_stream;

Expand Down Expand Up @@ -426,34 +427,7 @@ impl HeadlessState {
fn ensure_agent(&mut self) -> Result<&NativeAgent> {
if self.agent.is_none() {
let started = Instant::now();
let config = NativeAgentConfig {
model_dynamics: headless_model_dynamics(crate::config::model_dynamics_config()),
model: self.model.clone(),
model_capabilities: Some(self.model_capabilities),
max_tokens: crate::model_catalog::default_max_output_tokens(&self.model),
max_tokens_source: MaxTokensSource::Catalog,
system_prompt: Some(self.system_prompt.clone()),
thinking_enabled: self.thinking_enabled,
thinking_budget: self.thinking_budget,
cwd: self.cwd.clone(),
// The headless protocol's own `ApprovalMode` (Auto/Fail/Prompt,
// imported above) only resolves calls the runner already
// marked `requires_approval`; preserve the prior (mode-unaware)
// per-tool heuristic here exactly so that decision is unchanged.
approval_mode: crate::state::ApprovalMode::Selective,
context_window: None,
// Headless has no sandbox-policy resolution today (unlike the interactive TUI's
// `config::resolve_interactive_sandbox_policy` or print
// mode's `PrintModeOptions::sandbox_policy`); preserve that
// status quo explicitly rather than silently expanding this
// PR's scope to headless sandboxing.
sandbox_policy: None,
managed_mcp_policy: None,
max_turn_steps: crate::agent::DEFAULT_MAX_TURN_STEPS,
allow_unbounded_turn: false,
retry_config: crate::agent::retry::RetryConfig::hosted_outage(),
external_tool_schema_policy: crate::agent::ExternalToolSchemaPolicy::Eager,
};
let config = self.native_agent_config();
let (agent, mut event_rx) = if let Some(grant) = self.governed_grant.as_ref() {
let (allowed_tools, external_tools, bindings) = governed_agent_inputs(grant)?;
let created = NativeAgent::new_with_governed_tools_and_credential_vault(
Expand Down
73 changes: 73 additions & 0 deletions packages/local-host-rs/src/headless_server/agent_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Construct the headless host config without inventing gateway task ownership.
use super::*;

impl HeadlessState {
pub(super) fn native_agent_config(&self) -> NativeAgentConfig {
NativeAgentConfig {
background_task_access: headless_background_access(
std::env::var("MAESTRO_GATEWAY_BACKGROUND_SCOPE_REQUIRED")
.as_deref()
.ok(),
),
model_dynamics: headless_model_dynamics(crate::config::model_dynamics_config()),
model: self.model.clone(),
model_capabilities: Some(self.model_capabilities),
max_tokens: crate::model_catalog::default_max_output_tokens(&self.model),
max_tokens_source: MaxTokensSource::Catalog,
system_prompt: Some(self.system_prompt.clone()),
thinking_enabled: self.thinking_enabled,
thinking_budget: self.thinking_budget,
cwd: self.cwd.clone(),
// The headless protocol's own `ApprovalMode` (Auto/Fail/Prompt,
// imported above) only resolves calls the runner already
// marked `requires_approval`; preserve the prior (mode-unaware)
// per-tool heuristic here exactly so that decision is unchanged.
approval_mode: crate::state::ApprovalMode::Selective,
context_window: None,
// Headless has no sandbox-policy resolution today (unlike the interactive TUI's
// `config::resolve_interactive_sandbox_policy` or print
// mode's `PrintModeOptions::sandbox_policy`); preserve that
// status quo explicitly rather than silently expanding this
// PR's scope to headless sandboxing.
sandbox_policy: None,
managed_mcp_policy: None,
max_turn_steps: crate::agent::DEFAULT_MAX_TURN_STEPS,
allow_unbounded_turn: false,
retry_config: crate::agent::retry::RetryConfig::hosted_outage(),
external_tool_schema_policy: crate::agent::ExternalToolSchemaPolicy::Eager,
}
}
}

fn headless_background_access(
required: Option<&str>,
) -> crate::tools::background_tasks::BackgroundTaskAccess {
use crate::tools::background_tasks::BackgroundTaskAccess;
if required == Some("1") {
BackgroundTaskAccess::Denied
} else {
BackgroundTaskAccess::Legacy
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::tools::background_tasks::BackgroundTaskAccess;

#[test]
fn gateway_headless_requires_authorized_background_scope() {
assert_eq!(
headless_background_access(Some("1")),
BackgroundTaskAccess::Denied
);
assert_eq!(
headless_background_access(None),
BackgroundTaskAccess::Legacy
);
assert_eq!(
headless_background_access(Some("0")),
BackgroundTaskAccess::Legacy
);
}
}
144 changes: 142 additions & 2 deletions packages/local-host-rs/src/init_cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
Expand Down Expand Up @@ -95,6 +95,10 @@ struct InitOptions {
trace_mode: Option<String>,
ttl_seconds: Option<u64>,
workspace_id: Option<String>,
/// Also accept the redirect address pasted at the terminal. Only the
/// standalone login commands set it: they exit after login, so the
/// terminal reader cannot take a line meant for a later prompt.
accept_pasted_callback: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -749,6 +753,13 @@ async fn login_with_scopes(
.append_pair("workspace_id", &workspace_id);
}
}
// Over SSH the browser runs on another machine, and its redirect to
// 127.0.0.1 never reaches this listener; the person can paste the
// address it ended on instead.
let pasted_lines = (authorization_url_sender.is_none()
&& options.accept_pasted_callback
&& std::io::stdin().is_terminal())
.then(terminal_lines);
if let Some(sender) = authorization_url_sender {
// The alternate-screen TUI owns all terminal output. Give it the link
// before launching the browser so it can offer a manual fallback.
Expand All @@ -765,6 +776,12 @@ async fn login_with_scopes(
)
);
eprintln!("{}", authorization_url.as_str());
if pasted_lines.is_some() {
eprintln!("{}", crate::localization::cli_locale().format(
"If your browser runs on another machine (for example over SSH), paste the address it ends on here and press Enter:",
&[],
));
}
} else {
status(options, "Waiting for EvalOps identity callback...");
println!(
Expand All @@ -775,9 +792,24 @@ async fn login_with_scopes(
)
);
println!("{}", authorization_url.as_str());
if pasted_lines.is_some() {
println!("{}", crate::localization::cli_locale().format(
"If your browser runs on another machine (for example over SSH), paste the address it ends on here and press Enter:",
&[],
));
}
}
open_browser(authorization_url.as_str());
let callback = tokio::time::timeout(Duration::from_mins(5), accept_callback(listener, &state))
let callback = async {
match pasted_lines {
Some(lines) => tokio::select! {
callback = accept_callback(listener, &state) => callback,
callback = accept_pasted_callback(lines, &state) => callback,
},
None => accept_callback(listener, &state).await,
}
};
let callback = tokio::time::timeout(Duration::from_mins(5), callback)
.await
.context("EvalOps login timed out after 5 minutes")??;
let token_body = url::form_urlencoded::Serializer::new(String::new())
Expand Down Expand Up @@ -844,6 +876,66 @@ struct CallbackResult {
code: String,
}

/// Lines typed at the terminal, read on a detached thread so a login that
/// completes through the browser never waits on stdin.
fn terminal_lines() -> tokio::sync::mpsc::UnboundedReceiver<String> {
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
std::thread::spawn(move || {
for line in std::io::stdin().lines() {
let Ok(line) = line else { break };
if sender.send(line).is_err() {
break;
}
}
});
receiver
}

/// Waits for a pasted callback address; other lines get a hint. Never
/// resolves once the terminal closes, leaving the browser callback to win.
async fn accept_pasted_callback(
mut lines: tokio::sync::mpsc::UnboundedReceiver<String>,
expected_state: &str,
) -> Result<CallbackResult> {
while let Some(line) = lines.recv().await {
if line.trim().is_empty() {
continue;
}
if let Some(callback) = pasted_callback(&line, expected_state)? {
return Ok(callback);
}
eprintln!("{}", crate::localization::cli_locale().format(
"That is not the EvalOps login callback address. Paste the full address from your browser's address bar:",
&[],
));
}
std::future::pending().await
}

/// The callback address a browser ended on, pasted at the terminal. It must
/// name this login's callback (host, port and path) and carries the same code
/// and state, so the same checks as the listener apply.
fn pasted_callback(input: &str, expected_state: &str) -> Result<Option<CallbackResult>> {
let Ok(url) = Url::parse(input.trim()) else {
return Ok(None);
};
let local_host = matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"));
if !local_host || url.port() != Some(callback_port()) || url.path() != CALLBACK_PATH {
return Ok(None);
}
let query = url.query_pairs().into_owned().collect::<BTreeMap<_, _>>();
if let Some(error) = query.get("error") {
bail!(
"{}",
crate::localization::cli_locale().format(
"EvalOps identity login failed: {0}",
std::slice::from_ref(error)
)
);
}
validated_callback_code(&query, expected_state).map(|code| Some(CallbackResult { code }))
}

async fn accept_callback(listener: TcpListener, expected_state: &str) -> Result<CallbackResult> {
loop {
let (mut stream, _) = listener.accept().await?;
Expand Down Expand Up @@ -2450,6 +2542,7 @@ pub async fn perform_evalops_login() -> Result<()> {
.context("build EvalOps HTTP client")?;
let options = InitOptions {
force_login: true,
accept_pasted_callback: true,
..InitOptions::default()
};
status(&options, "Opening EvalOps login");
Expand All @@ -2471,6 +2564,7 @@ pub async fn perform_evalops_login_with_scopes(extra_scopes: &str) -> Result<()>
.context("build EvalOps HTTP client")?;
let options = InitOptions {
force_login: true,
accept_pasted_callback: true,
..InitOptions::default()
};
let scopes = merge_login_scopes(REQUIRED_LOGIN_SCOPES, extra_scopes);
Expand Down Expand Up @@ -3106,6 +3200,52 @@ mod tests {
assert!(validated_callback_code(&BTreeMap::new(), "expected").is_err());
}

#[test]
fn a_pasted_callback_address_is_held_to_the_listener_checks() {
let port = callback_port();
let pasted = |address: String| pasted_callback(&address, "expected");
let callback = pasted(format!(
"http://127.0.0.1:{port}/auth/callback/evalops?code=abc&state=expected"
))
.unwrap()
.expect("the callback address is accepted");
assert_eq!(callback.code, "abc");
assert!(
pasted(format!(
" http://localhost:{port}/auth/callback/evalops?code=abc&state=expected\n"
))
.unwrap()
.is_some(),
"surrounding whitespace and localhost are fine"
);
// Another login's state is refused, as the listener refuses it.
assert!(
pasted(format!(
"http://127.0.0.1:{port}/auth/callback/evalops?code=abc&state=other"
))
.is_err()
);
// An identity error ends the login.
assert!(
pasted(format!(
"http://127.0.0.1:{port}/auth/callback/evalops?error=access_denied&state=expected"
))
.is_err()
);
// Anything that is not this callback is not a callback.
for other in [
"not a url".to_owned(),
format!("https://evil.example:{port}/auth/callback/evalops?code=abc&state=expected"),
format!(
"http://127.0.0.1:{}/auth/callback/evalops?code=abc&state=expected",
port + 1
),
format!("http://127.0.0.1:{port}/elsewhere?code=abc&state=expected"),
] {
assert!(pasted(other).unwrap().is_none());
}
}

#[test]
fn oauth_error_detail_prefers_structured_description() {
assert_eq!(
Expand Down
6 changes: 6 additions & 0 deletions packages/local-host-rs/src/model_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,12 @@ pub fn bundled_models() -> &'static [ModelInfo] {
&BUNDLED_CATALOG.models
}

/// Version of the committed catalog used by [`bundled_rates`].
#[must_use]
pub fn bundled_catalog_version() -> u64 {
BUNDLED_CATALOG.generated_at
}

/// Per-million-token USD rates for one model.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModelRates {
Expand Down
1 change: 1 addition & 0 deletions packages/local-host-rs/src/subagents/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ impl SubagentManager {
)
};
let config = NativeAgentConfig {
background_task_access: crate::tools::background_tasks::BackgroundTaskAccess::Legacy,
model_capabilities: None,
model_dynamics: crate::config::model_dynamics_config(),
model,
Expand Down
Loading
Loading