From 780eca951669de5e9740b7212af91f027a194eb9 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Fri, 18 Sep 2026 18:52:03 -0700 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 13 + CHANGELOG.md | 14 + Cargo.lock | 34 +- Dockerfile | 2 +- README.md | 8 +- docs/SESSIONS.md | 47 +- package-lock.json | 12 +- package.json | 4 +- packages/ai-rs/src/client.rs | 8 +- packages/ai-rs/src/openai.rs | 55 ++ packages/ai-rs/src/openai_wrapper.rs | 23 + packages/coding-acceptance-rs/BUILD.bazel | 2 +- packages/coding-acceptance-rs/src/computer.rs | 384 +++++++++ packages/coding-acceptance-rs/src/lib.rs | 108 ++- packages/local-host-rs/src/agent/mod.rs | 14 + .../src/agent/native_admission_tests.rs | 13 +- packages/local-host-rs/src/code_authority.rs | 34 +- packages/local-host-rs/src/credential_mode.rs | 48 +- packages/local-host-rs/src/evalops_cli.rs | 1 + packages/local-host-rs/src/headless_server.rs | 1 + .../local-host-rs/src/hooks/integration.rs | 6 + .../local-host-rs/src/hosted_runner/tests.rs | 300 +------ .../hosted_runner/tests/deferred_rejection.rs | 378 ++++++++ .../local-host-rs/src/hosted_runner_cli.rs | 2 + packages/local-host-rs/src/init_cli.rs | 3 + packages/local-host-rs/src/lib.rs | 1 + packages/local-host-rs/src/local_models.rs | 259 +++++- .../local-host-rs/src/managed_setup/mod.rs | 5 + packages/local-host-rs/src/mission_cli.rs | 2 + packages/local-host-rs/src/model_catalog.rs | 4 + packages/local-host-rs/src/orb_connection.rs | 1 + .../src/private_code_authority.rs | 418 +++++++++ packages/local-host-rs/src/safety/guardian.rs | 9 +- packages/local-host-rs/src/safety/mod.rs | 12 +- packages/local-host-rs/src/safety/policy.rs | 804 +++++++++++++++++- packages/local-host-rs/src/subagents/mod.rs | 5 + .../local-host-rs/src/telemetry/journal.rs | 33 +- .../src/telemetry/staged_rollout.rs | 6 +- packages/local-host-rs/src/tools/bash/mod.rs | 4 +- .../src/tools/registry/coding_task.rs | 645 +++++++++++++- .../registry/coding_task/dispatcher_tests.rs | 19 +- .../local-host-rs/src/tools/registry/tests.rs | 33 +- .../tools/registry/tests/streaming_order.rs | 59 ++ packages/local-host-rs/src/ui_prefs.rs | 2 +- packages/maestro-rs/Cargo.toml | 2 +- packages/runtime-rs/src/agent/native.rs | 50 +- .../src/agent/native/cancellation.rs | 65 ++ .../runtime-rs/src/agent/native/commands.rs | 4 + packages/runtime-rs/src/agent/native/tests.rs | 101 +-- .../agent/native/tests/session_transition.rs | 159 ++++ packages/session-rs/src/session/index.rs | 35 +- packages/session-rs/src/session/manager.rs | 80 +- .../src/session/manager/info_tests.rs | 66 ++ .../tui-rs/docs/user-guide/11-sessions.md | 8 +- packages/tui-rs/src/app.rs | 171 ++-- packages/tui-rs/src/app/command_handlers.rs | 186 +--- packages/tui-rs/src/app/input_handlers.rs | 32 + packages/tui-rs/src/app/session_commands.rs | 252 ++++++ packages/tui-rs/src/app/session_transition.rs | 648 ++++++++++++++ packages/tui-rs/src/app/startup.rs | 659 ++++++++++++-- packages/tui-rs/src/app/tests.rs | 89 +- packages/tui-rs/src/cli_commands.rs | 15 + packages/tui-rs/src/commands/registry.rs | 20 +- .../commands/registry/session_navigation.rs | 34 + packages/tui-rs/src/commands/types.rs | 2 + .../tui-rs/src/components/model_selector.rs | 162 +++- .../tui-rs/src/components/session_switcher.rs | 585 ++++++++++++- packages/tui-rs/src/entrypoint.rs | 3 + packages/tui-rs/src/onboarding_checks.rs | 3 + packages/tui-rs/src/orb_cli.rs | 1 + packages/tui-rs/src/print_mode.rs | 7 +- packages/tui-rs/src/remote_cli.rs | 1 + packages/tui-rs/src/update_cli.rs | 4 + packages/tui-rs/src/value_cli.rs | 1 + packages/tui-rs/tests/pty_e2e.rs | 519 ++++++++++- .../tui-rs/tests/support/terminal_capture.rs | 5 + packages/ui-rs/src/translations.rs | 344 ++++++++ scripts/check-sync-pr-checks.mjs | 84 +- scripts/tag-release-contract.test.mjs | 2 - skills/mcp-builder/scripts/requirements.txt | 4 +- 80 files changed, 7146 insertions(+), 1092 deletions(-) create mode 100644 .repository-projection.json create mode 100644 packages/coding-acceptance-rs/src/computer.rs create mode 100644 packages/local-host-rs/src/hosted_runner/tests/deferred_rejection.rs create mode 100644 packages/local-host-rs/src/private_code_authority.rs create mode 100644 packages/local-host-rs/src/tools/registry/tests/streaming_order.rs create mode 100644 packages/runtime-rs/src/agent/native/cancellation.rs create mode 100644 packages/runtime-rs/src/agent/native/tests/session_transition.rs create mode 100644 packages/session-rs/src/session/manager/info_tests.rs create mode 100644 packages/tui-rs/src/app/session_commands.rs create mode 100644 packages/tui-rs/src/app/session_transition.rs create mode 100644 packages/tui-rs/src/commands/registry/session_navigation.rs diff --git a/.repository-projection.json b/.repository-projection.json new file mode 100644 index 000000000..ca4ec5dc3 --- /dev/null +++ b/.repository-projection.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 1, + "projection": "deixic-code", + "projectionSchemaVersion": 1, + "sourceRepository": "dx-corp/mono", + "sourceSha": "6878323651fdb771b8cbf76b08597b930938ce36", + "destinationRepository": "dx-corp/code", + "priorProjectedBase": "4c484fec884560fda66a55a5a61722edf1a2d71e", + "definitionDigest": "07c1b987e17ae0398b4af8817192627b95c45168c305069446594fdaa3414682", + "toolDigest": "16fcaea2f0e7e35de09bcd8e515d915f65cdf1eb13033eaf2845f60eaa9e0a4f", + "contentDigest": "325d36d4b27c054e6edd55e71460299cdf56f8dc3ed887ae92ac326b531e60bc", + "publicationEligible": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e6be32b18..64d35aac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,20 @@ versioning when releases are cut. and keep scheduled public runs inert so public publishing stays downstream of the internal source-of-truth release. +## [0.10.91] - 2026-09-18 + +### Added + +- Administrators can require a signed machine policy that permits only approved + customer model endpoints, retains local history, and skips first-party update, + sign-in, session-sync and telemetry workers during disconnected operation. +- Enrolled machines can use a customer-hosted tool authority while retaining + hardware approval, tenant scope and revocation checks. + +The private distribution remains experimental. These changes do not establish +an installed customer control plane, a qualified hosted coding workflow, an +independent model-quality benchmark or air-gap certification. + ## [0.10.90] - 2026-09-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index f708005a8..c109de597 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -169,7 +169,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1985,7 +1985,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2156,7 +2156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3140,7 +3140,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3568,7 +3568,7 @@ checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "maestro" -version = "0.10.90" +version = "0.10.91" dependencies = [ "anyhow", "ctor", @@ -4288,7 +4288,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5264,7 +5264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.10.5", "log", "multimap", "petgraph 0.8.3", @@ -5283,7 +5283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.119", @@ -5470,7 +5470,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5969,7 +5969,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6028,7 +6028,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6526,7 +6526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6697,7 +6697,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6719,7 +6719,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7370,7 +7370,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8207,7 +8207,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Dockerfile b/Dockerfile index 2e89248f3..77ffe3e9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/lukemathwalker/cargo-chef:0.1.77-rust-bookworm@sha256:1689f62cfaa6603480356923cb5966544b2dd6ea523e30486bee4f149965d5bc AS chef +FROM docker.io/lukemathwalker/cargo-chef:0.1.78-rust-bookworm@sha256:14ac293f096e29d01296479bb2f81ec1fb0d6ff166dc2be399cf79cc79ec4906 AS chef WORKDIR /app FROM chef AS planner diff --git a/README.md b/README.md index 2b409c5e4..1a62d2be0 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,14 @@ deixic-code serve --port 3000 # HTTP runtime gateway deixic-code hosted-runner ``` -Sign in with `deixic-code evalops login` before starting a model turn. +Sign in with `deixic-code evalops login` to use managed inference. The default model is GLM-5.3 through Deixic's LLM gateway. Your organization must have model access enabled; signing in alone does not grant paid inference. Provider credentials stay in the gateway. Explicit model settings and -`--model` continue to select another model, and using your own provider key -still requires Deixic login. +`--model` continue to select another model. Direct provider credentials and +explicit local model routes can run without Deixic login. Administrators can +install a signed [disconnected profile](docs/disconnected-profile.md) to restrict +models to customer endpoints and disable vendor connections. ## Build a local product reference diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 175c00e1b..c4fb82845 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -3,14 +3,14 @@ Audience: users/operators managing session persistence. Nav: [Docs index](README.md) · [Quickstart](QUICKSTART.md) · [Safety](SAFETY.md) · [Features](FEATURES.md) -Maestro persists conversation history in JSONL files under -`~/.maestro/agent/sessions/----`. Understanding the format helps when you +Deixic Code persists conversation history in JSONL files under +`~/.composer/agent/sessions/----`. Understanding the format helps when you want to inspect, back up, or clean up sessions. ## Directory Layout ``` -~/.maestro/agent/ +~/.composer/agent/ └─ sessions/ └─ --Users-me-project--/ ├─ 2025-01-15T18-05-23.982Z_.jsonl @@ -45,10 +45,47 @@ Important types: | Flag | Effect | | --------------- | --------------------------------------------- | | `--continue` | Load the most recent session for the cwd | -| `--resume` | Interactive picker of existing sessions | +| `--resume-session ` | Resume a saved session by ID | | `--session path`| Use a specific JSONL file (absolute or relative) | | `--no-session` | Disable persistence entirely for this run | +## Find and resume saved conversations + +Open `/sessions`, or press Ctrl+Alt+R, and type a phrase from a saved +conversation. Search includes names, IDs, working directories, user messages, +assistant replies, and tool results. Matching conversations show an excerpt. +Search runs locally in the background; opening the picker does not send a +model request. Enter resumes the selected conversation. Escape closes the +picker and keeps your draft. +For very large messages, search covers the first 50,000 characters. + +## Forks and session changes + +Run `/fork` to save a branch of the current conversation. The parent stays +selected, and an active response keeps running. The result includes a command +you can copy to resume the fork: + +```sh +deixic-code --resume-session +``` + +Run `/tree` to browse the current conversation's saved branches. In `/sessions`, +Ctrl+F switches between all conversations and the selected conversation's +branches. Each fork shows its parent's ID. Enter resumes the selection; Escape +returns to your draft. Branches remain available after restarting Deixic Code. + +During an active response, `/new` and `/rewind ` ask before stopping it. +Escape on the confirmation keeps the response running. Enter stops the response, waits for tool +cleanup, saves its final events, and then opens the new conversation or saved +rewind branch. Queued prompts return to the composer. If cleanup cannot be +confirmed, the current conversation stays selected and an error is shown. +Keeping the current conversation during cleanup does not undo cancellation. +Session changes remain blocked until cleanup is acknowledged, including on a retry. + +`/rewind ` keeps the original conversation and leaves files unchanged. +`/rewind --files` also restores the affected file checkpoints. Use +`--dry-run` to preview a rewind without changing the conversation or files. + ## Secure Transfer For moving a session family between trusted installations, use the explicit @@ -137,4 +174,4 @@ these without touching the JSONL by using: - Use `--no-session` in CI or ephemeral workspaces to avoid clutter. Future enhancements (continuous context, shared KBs) will reuse this directory, -so keep it tidy but don’t remove unrelated files under `~/.maestro/agent/`. +so keep it tidy but don’t remove unrelated files under `~/.composer/agent/`. diff --git a/package-lock.json b/package-lock.json index c2de41f95..6577afd5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,25 @@ { "name": "@evalops/maestro", - "version": "0.10.90", + "version": "0.10.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@evalops/maestro", - "version": "0.10.90", + "version": "0.10.91", "license": "BUSL-1.1", "bin": { "deixic-code": "bin/deixic-code", "maestro": "bin/maestro" }, "devDependencies": { - "yaml": "^2.9.0" + "yaml": "^2.9.1" } }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index c56f936d4..d63926e8f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@evalops/deixic-code", "description": "Deixic Code — native Rust coding agent, CLI, TUI, and runtime gateway", - "version": "0.10.90", + "version": "0.10.91", "private": false, "type": "module", "bin": { @@ -98,6 +98,6 @@ }, "packageManager": "npm@11.18.0", "devDependencies": { - "yaml": "^2.9.0" + "yaml": "^2.9.1" } } diff --git a/packages/ai-rs/src/client.rs b/packages/ai-rs/src/client.rs index 955fcb5d7..8abd978ae 100644 --- a/packages/ai-rs/src/client.rs +++ b/packages/ai-rs/src/client.rs @@ -2500,6 +2500,7 @@ mod stream_idle_policy_tests { // unrelated connection-refused failure. let mut drain_deadline = None; let mut quiet_since = None; + let mut stalled_connections = Vec::new(); loop { if server_stop.load(Ordering::SeqCst) { let now = std::time::Instant::now(); @@ -2525,9 +2526,10 @@ mod stream_idle_policy_tests { let _ = stream.read(&mut request).expect("read gateway request"); server_requests.fetch_add(1, Ordering::SeqCst); quiet_since = Some(std::time::Instant::now()); - // Accept the request but never open an HTTP response. - // Keep the socket alive past the client-side boundary. - std::thread::sleep(Duration::from_millis(50)); + // Never open a response or close the socket before the + // client emits its terminal event. A fixed sleep can + // expire before a timeout task runs on a loaded host. + stalled_connections.push(stream); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(2)); diff --git a/packages/ai-rs/src/openai.rs b/packages/ai-rs/src/openai.rs index 3fa262a8b..b1351770d 100644 --- a/packages/ai-rs/src/openai.rs +++ b/packages/ai-rs/src/openai.rs @@ -1115,6 +1115,22 @@ impl OpenAiClient { }) } + /// Customer-controlled route: redirects cannot escape the approved endpoint. + pub fn with_disconnected_endpoint( + api_key: impl Into, + endpoint: impl Into, + provider: &str, + ) -> Result { + let mut client = Self::with_base_url(api_key, endpoint)?.with_route_provider(provider); + client.client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_mins(5)) + .build() + .context("Failed to create disconnected model client")?; + Ok(client) + } + /// Create a new client with a custom base URL (for Mistral or other providers) pub fn with_base_url(api_key: impl Into, base_url: impl Into) -> Result { let client = reqwest::Client::builder() @@ -3289,6 +3305,45 @@ struct ToolCallAccumulator { #[cfg(test)] mod tests { + #[tokio::test] + async fn disconnected_endpoint_refuses_redirects() { + use std::io::{Read, Write}; + let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + destination.set_nonblocking(true).unwrap(); + let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let origin_url = format!("http://{}", origin.local_addr().unwrap()); + let location = format!("http://{}", destination.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = origin.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0, "request ended before HTTP headers"); + request.extend_from_slice(&buffer[..count]); + assert!(request.len() <= 16 * 1024, "request headers too large"); + } + write!(stream, "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let client = + super::OpenAiClient::with_disconnected_endpoint("fixture", &origin_url, "ollama") + .unwrap(); + let response = client.client.get(&origin_url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + destination.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + assert_eq!( + client.request_model_name("ollama/customer-model"), + "customer-model" + ); + server.join().unwrap(); + } + #[test] fn cached_prompt_buckets_do_not_inflate_native_context() { // Counts observed during the governed Fireworks tmux trial. diff --git a/packages/ai-rs/src/openai_wrapper.rs b/packages/ai-rs/src/openai_wrapper.rs index 9cd2459fb..9843a1d8e 100644 --- a/packages/ai-rs/src/openai_wrapper.rs +++ b/packages/ai-rs/src/openai_wrapper.rs @@ -60,6 +60,29 @@ impl OpenAiClient { Self::from_base(base, Some(base_url)) } + /// Exact administrator-approved route, without proxy or redirect discovery. + pub fn with_disconnected_endpoint( + api_key: impl Into, + endpoint: impl Into, + provider: &str, + ) -> Result { + let api_key = api_key.into(); + let endpoint = endpoint.into(); + let base = openai_base::OpenAiClient::with_disconnected_endpoint( + api_key.clone(), + endpoint.clone(), + provider, + )?; + Ok(Self { + base, + kimi_k3: None, + api_key, + base_url: Some(endpoint), + route_provider: Some(provider.to_string()), + managed_gateway: false, + }) + } + pub(crate) fn with_route_provider(mut self, provider: &str) -> Self { self.base = self.base.with_route_provider(provider); let provider = provider.trim(); diff --git a/packages/coding-acceptance-rs/BUILD.bazel b/packages/coding-acceptance-rs/BUILD.bazel index e49a74c89..b019e4dc3 100644 --- a/packages/coding-acceptance-rs/BUILD.bazel +++ b/packages/coding-acceptance-rs/BUILD.bazel @@ -5,7 +5,7 @@ load("@rules_rust//rust:defs.bzl", "rust_library") package(default_visibility = ["//visibility:public"]) -exports_files(["src/lib.rs"]) +exports_files(["src/lib.rs", "src/computer.rs"]) cargo_toml_env_vars( name = "cargo_toml_env_vars", diff --git a/packages/coding-acceptance-rs/src/computer.rs b/packages/coding-acceptance-rs/src/computer.rs new file mode 100644 index 000000000..e4c829969 --- /dev/null +++ b/packages/coding-acceptance-rs/src/computer.rs @@ -0,0 +1,384 @@ +//! Typed task-Computer operations and references to their settled owner records. +//! +//! These values travel through the existing ToolExecution request/result. A +//! runtime copy is not authority: Platform compares it to the execution it +//! observed and checkpointed under its run lease before accepting completion. + +use serde::{Deserialize, Serialize}; + +pub const CODING_WORKSPACE_SCHEMA: &str = "dex.computer_coding_workspace.v1"; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde( + tag = "action", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum CodingWorkspaceRequest { + Baseline { + #[serde(default, rename = "idempotency_key")] + idempotency_key: String, + }, + Inspect { + expected_base_revision: String, + }, + Readiness { + #[serde(default, rename = "idempotency_key")] + idempotency_key: String, + expected_revision: String, + command: String, + timeout_ms: u64, + }, + Outputs { + expected_base_revision: String, + expected_revision: String, + paths: Vec, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodingWorkspaceBaselineKind { + AcceptedSnapshot, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodingWorkspaceResponse { + pub schema: String, + pub project_resource_id: String, + pub accepted_snapshot_id: String, + pub source_workspace_id: String, + pub task_computer_id: String, + pub run_id: String, + pub tool_execution_id: String, + pub owner_lease_epoch: u64, + pub base_revision: String, + pub revision: String, + pub baseline_kind: CodingWorkspaceBaselineKind, + pub clean: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readiness: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodingWorkspaceReadiness { + pub command: String, + pub revision_before: String, + pub revision_after: String, + pub exit_code: i32, + pub stdout: String, + pub stderr: String, + pub truncated: bool, + pub timed_out: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodingWorkspaceOutput { + pub path: String, + pub sha256: String, + pub content: String, + pub git_blob_id: String, +} + +/// References only. The worker resolves these against its own settled records. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComputerCodingProof { + pub baseline_execution_id: String, + pub inspection_execution_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outputs_execution_id: Option, + pub readiness_execution_ids: Vec, +} + +fn idempotency_valid(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && !value + .chars() + .any(|ch| ch.is_control() || ch.is_whitespace()) +} + +fn revision_valid(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn paths_valid(paths: &[String]) -> bool { + !paths.is_empty() + && paths.len() <= 8 + && paths + .iter() + .all(|path| super::valid_output_path(path) && path != ".dex-source.json") + && paths + .iter() + .collect::>() + .len() + == paths.len() +} + +impl CodingWorkspaceRequest { + pub fn validate(&self) -> Result<(), &'static str> { + let valid = match self { + Self::Baseline { idempotency_key } => idempotency_valid(idempotency_key), + Self::Inspect { + expected_base_revision, + } => revision_valid(expected_base_revision), + Self::Readiness { + idempotency_key, + expected_revision, + command, + timeout_ms, + } => { + idempotency_valid(idempotency_key) + && revision_valid(expected_revision) + && !command.trim().is_empty() + && command.len() <= 16_384 + && !command.contains('\0') + && (1..=120_000).contains(timeout_ms) + } + Self::Outputs { + expected_base_revision, + expected_revision, + paths, + } => { + revision_valid(expected_base_revision) + && revision_valid(expected_revision) + && expected_base_revision != expected_revision + && paths_valid(paths) + } + }; + valid + .then_some(()) + .ok_or("invalid coding workspace request") + } +} + +impl CodingWorkspaceResponse { + pub fn validate(&self) -> Result<(), &'static str> { + use sha2::{Digest, Sha256}; + if self.owner_lease_epoch == 0 + || self.schema != CODING_WORKSPACE_SCHEMA + || [ + &self.project_resource_id, + &self.accepted_snapshot_id, + &self.source_workspace_id, + &self.task_computer_id, + &self.run_id, + &self.tool_execution_id, + ] + .iter() + .any(|value| { + value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) + }) + || !revision_valid(&self.base_revision) + || !revision_valid(&self.revision) + { + return Err("invalid coding workspace identity"); + } + if let Some(readiness) = &self.readiness + && (readiness.command.trim().is_empty() + || readiness.command.len() > 16_384 + || !revision_valid(&readiness.revision_before) + || !revision_valid(&readiness.revision_after) + || readiness + .stdout + .len() + .saturating_add(readiness.stderr.len()) + > 16_384) + { + return Err("invalid coding readiness result"); + } + if !self.outputs.is_empty() { + let paths: Vec<_> = self + .outputs + .iter() + .map(|output| output.path.clone()) + .collect(); + if !paths_valid(&paths) + || self + .outputs + .iter() + .map(|output| output.content.len()) + .sum::() + > 16_384 + { + return Err("invalid coding output manifest"); + } + for output in &self.outputs { + if output.content.is_empty() + || output.content.len() > 8192 + || !revision_valid(&output.git_blob_id) + || output.sha256 != format!("{:x}", Sha256::digest(output.content.as_bytes())) + { + return Err("invalid coding output content"); + } + } + } + Ok(()) + } + + /// Validate the owner response shape against the operation actually admitted. + /// Tenant/task/run equality remains the caller's owner-linkage check. + pub fn validate_for(&self, request: &CodingWorkspaceRequest) -> Result<(), &'static str> { + request.validate()?; + self.validate()?; + let valid = match request { + CodingWorkspaceRequest::Baseline { .. } => { + self.clean + && self.base_revision == self.revision + && self.outputs.is_empty() + && self.readiness.is_none() + } + CodingWorkspaceRequest::Inspect { + expected_base_revision, + } => { + &self.base_revision == expected_base_revision + && self.outputs.is_empty() + && self.readiness.is_none() + } + CodingWorkspaceRequest::Readiness { + expected_revision, + command, + .. + } => { + self.clean + && &self.revision == expected_revision + && self.outputs.is_empty() + && self.readiness.as_ref().is_some_and(|readiness| { + &readiness.command == command + && &readiness.revision_before == expected_revision + && &readiness.revision_after == expected_revision + }) + } + CodingWorkspaceRequest::Outputs { + expected_base_revision, + expected_revision, + paths, + } => { + self.clean + && &self.base_revision == expected_base_revision + && &self.revision == expected_revision + && self.readiness.is_none() + && self.outputs.len() == paths.len() + && self + .outputs + .iter() + .map(|output| &output.path) + .collect::>() + == paths.iter().collect::>() + } + }; + valid + .then_some(()) + .ok_or("coding workspace result does not match admitted operation") + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn response() -> CodingWorkspaceResponse { + CodingWorkspaceResponse { + schema: CODING_WORKSPACE_SCHEMA.into(), + project_resource_id: "project".into(), + accepted_snapshot_id: "snapshot".into(), + source_workspace_id: "source".into(), + task_computer_id: "task".into(), + run_id: "run".into(), + tool_execution_id: "execution".into(), + owner_lease_epoch: 1, + base_revision: "a".repeat(40), + revision: "b".repeat(40), + baseline_kind: CodingWorkspaceBaselineKind::AcceptedSnapshot, + clean: true, + readiness: None, + outputs: vec![], + } + } + #[test] + fn coding_workspace_result_rejects_wrong_revision_content_and_shape() { + let mut no_lease = response(); + no_lease.owner_lease_epoch = 0; + assert!(no_lease.validate().is_err()); + use sha2::{Digest, Sha256}; + let request = CodingWorkspaceRequest::Outputs { + expected_base_revision: "a".repeat(40), + expected_revision: "b".repeat(40), + paths: vec!["answer.py".into()], + }; + let mut result = response(); + assert!(result.validate_for(&request).is_err()); + result.outputs.push(CodingWorkspaceOutput { + path: "answer.py".into(), + content: "answer\n".into(), + sha256: format!("{:x}", Sha256::digest(b"answer\n")), + git_blob_id: "c".repeat(40), + }); + assert!(result.validate_for(&request).is_ok()); + let mut wrong = result.clone(); + wrong.revision = "d".repeat(40); + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result.clone(); + wrong.outputs[0].content.push('!'); + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result.clone(); + wrong.outputs[0].path = "unexpected.py".into(); + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result.clone(); + wrong.clean = false; + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result.clone(); + wrong.outputs.push(wrong.outputs[0].clone()); + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result.clone(); + wrong.outputs[0].content = "x".repeat(8193); + assert!(wrong.validate_for(&request).is_err()); + let mut wrong = result; + wrong.schema = "caller-observation".into(); + assert!(wrong.validate_for(&request).is_err()); + assert!( + CodingWorkspaceRequest::Readiness { + idempotency_key: "fixture".into(), + expected_revision: "b".repeat(40), + command: "test".into(), + timeout_ms: 120001 + } + .validate() + .is_err() + ); + } + #[test] + fn coding_workspace_mutations_require_owner_idempotency_before_validation() { + let mut request: CodingWorkspaceRequest = + serde_json::from_value(serde_json::json!({"action":"baseline"})).unwrap(); + assert!(request.validate().is_err()); + if let CodingWorkspaceRequest::Baseline { idempotency_key } = &mut request { + *idempotency_key = "client-tool:owner-execution".into(); + } + assert!(request.validate().is_ok()); + assert_eq!( + serde_json::to_value(request).unwrap()["idempotency_key"], + "client-tool:owner-execution" + ); + } + #[test] + fn coding_workspace_requests_reject_caller_scope_and_unknown_fields() { + assert!( + serde_json::from_value::( + serde_json::json!({"action":"baseline","taskComputerId":"forged"}) + ) + .is_err() + ); + assert!(serde_json::from_value::(serde_json::json!({"action":"readiness","expectedRevision":"commit","command":"test","timeoutMs":5000,"cwd":"/host"})).is_err()); + let request: CodingWorkspaceRequest = serde_json::from_value(serde_json::json!({"action":"outputs","expectedBaseRevision":"base","expectedRevision":"commit","paths":["answer.py"]})).unwrap(); + assert!( + matches!(request, CodingWorkspaceRequest::Outputs { expected_base_revision, .. } if expected_base_revision == "base") + ); + } +} diff --git a/packages/coding-acceptance-rs/src/lib.rs b/packages/coding-acceptance-rs/src/lib.rs index d3bee67a8..274b3776a 100644 --- a/packages/coding-acceptance-rs/src/lib.rs +++ b/packages/coding-acceptance-rs/src/lib.rs @@ -4,6 +4,8 @@ #![forbid(unsafe_code)] +pub mod computer; + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -28,6 +30,10 @@ pub struct CodingAcceptanceContract { /// Exact handoff item IDs the admitting owner permits to be deferred/dismissed. #[serde(default)] pub authorized_dispositions: Vec, + /// Exact UTF-8 source files whose blob content must differ from the admitted + /// baseline (or be newly added). Mode-only changes do not satisfy this option. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub output_paths: Vec, } impl CodingAcceptanceContract { @@ -42,6 +48,12 @@ impl CodingAcceptanceContract { || self.readiness_requirements.is_empty() || !self.require_review || !self.require_behavior + || self.output_paths.len() > 8 + || (!self.output_paths.is_empty() && !unique_ids(&self.output_paths)) + || self + .output_paths + .iter() + .any(|path| !valid_output_path(path)) || self.generation == 0 || !unique_ids(&self.required_assertion_ids) || !unique_ids(&self.readiness_requirements) @@ -146,6 +158,26 @@ pub struct CodingCompletionSubmission { pub review: Option, pub behavior: Option, pub handoff_items: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodingOutputFile { + pub path: String, + pub content: String, +} + +pub fn valid_output_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 512 + && !path.starts_with('/') + && !path.contains('\\') + && !path.chars().any(char::is_control) + && path + .split('/') + .all(|part| !matches!(part, "" | "." | ".." | ".git")) } impl CodingCompletionSubmission { @@ -210,6 +242,30 @@ pub fn evaluate_coding_acceptance( .push("missing coding completion submission".into()); return decision; }; + let actual: BTreeSet<_> = submission + .outputs + .iter() + .map(|file| file.path.as_str()) + .collect(); + let expected: BTreeSet<_> = contract.output_paths.iter().map(String::as_str).collect(); + if actual != expected + || actual.len() != submission.outputs.len() + || submission + .outputs + .iter() + .any(|file| !valid_output_path(&file.path) || file.content.is_empty()) + || submission + .outputs + .iter() + .map(|file| file.content.len()) + .sum::() + > 65_536 + { + decision + .reasons + .push("coding output manifest mismatch or size exceeded".into()); + } + if submission.task_id != contract.task_id || submission.repository_id != contract.repository_id || submission.work_id != scope.work_id @@ -384,7 +440,13 @@ fn has_evidence(refs: &[String]) -> bool { fn digest(value: &impl Serialize) -> String { // These concrete types have no map keys or fallible custom serializers. let bytes = serde_json::to_vec(value).expect("coding acceptance types serialize"); - format!("sha256:{:x}", Sha256::digest(bytes)) + format!( + "sha256:{}", + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) } #[cfg(test)] @@ -415,6 +477,7 @@ mod tests { readiness_requirements: vec!["test".into()], authorized_skips: vec![], authorized_dispositions: vec![], + output_paths: vec![], }; let assertion = |id: &str| CodingAssertionResult { assertion_id: id.into(), @@ -466,6 +529,7 @@ mod tests { review: Some(review), behavior: Some(behavior), handoff_items: vec![], + outputs: vec![], }; (contract, submission, children) } @@ -619,4 +683,46 @@ mod tests { contract.readiness_requirements = vec!["unknown-check".into()]; assert!(contract.validate().is_err()); } + #[test] + fn coding_output_manifest_is_exact_and_bounded() { + let (mut contract, mut submission, children) = fixture(); + contract.output_paths = vec!["sum.py".into()]; + submission.contract_digest = contract.digest(); + let scope = CodingAcceptanceScope { + organization_id: "org-1", + workspace_id: "workspace-1", + work_id: "work-1", + implementation_session_id: "session-impl", + }; + assert!( + evaluate_coding_acceptance(&contract, Some(&submission), &scope, &children) + .reasons + .iter() + .any(|r| r.contains("output manifest")) + ); + submission.outputs = vec![CodingOutputFile { + path: "other.py".into(), + content: "pass\n".into(), + }]; + assert!( + evaluate_coding_acceptance(&contract, Some(&submission), &scope, &children) + .reasons + .iter() + .any(|r| r.contains("output manifest")) + ); + submission.outputs[0].path = "sum.py".into(); + assert!( + !evaluate_coding_acceptance(&contract, Some(&submission), &scope, &children) + .reasons + .iter() + .any(|r| r.contains("output manifest")) + ); + submission.outputs[0].content = "x".repeat(65_537); + assert!( + evaluate_coding_acceptance(&contract, Some(&submission), &scope, &children) + .reasons + .iter() + .any(|r| r.contains("output manifest")) + ); + } } diff --git a/packages/local-host-rs/src/agent/mod.rs b/packages/local-host-rs/src/agent/mod.rs index f2ddb7089..6f619c6d8 100644 --- a/packages/local-host-rs/src/agent/mod.rs +++ b/packages/local-host-rs/src/agent/mod.rs @@ -504,6 +504,20 @@ fn resolve_native_client( NativeResolvedClient, Option, )> { + if let Some(client) = crate::credential_mode::disconnected_client(model)? { + anyhow::ensure!( + client_override.is_none(), + "Disconnected policy does not allow client overrides" + ); + return Ok(( + NativeResolvedClient { + provider_name: client.provider_name().to_owned(), + client: Some(client), + model_route: NativeModelRoute::DirectProvider, + }, + None, + )); + } let route = crate::codex_auth::resolve_model_route(model); if let Some(override_client) = client_override { let identity_scope = if override_client.skips_identity_verification() { diff --git a/packages/local-host-rs/src/agent/native_admission_tests.rs b/packages/local-host-rs/src/agent/native_admission_tests.rs index 0754b6864..24f642914 100644 --- a/packages/local-host-rs/src/agent/native_admission_tests.rs +++ b/packages/local-host-rs/src/agent/native_admission_tests.rs @@ -201,11 +201,14 @@ fn local_runtime_ignores_expired_stored_identity_but_managed_route_does_not() { .expect("write stored OAuth fixture"); crate::init_cli::invalidate_evalops_credentials_cache(); - let (resolved, telemetry_scope) = super::resolve_native_client("ollama/qwen3", None) - .expect("local runtime must remain available offline"); - assert_eq!(resolved.provider_name, "ollama"); - assert!(resolved.client.is_some()); - assert!(telemetry_scope.is_none()); + for provider in ["ollama", "lmstudio", "llamacpp"] { + let (resolved, telemetry_scope) = + super::resolve_native_client(&format!("{provider}/qwen3"), None) + .expect("local runtime must remain available offline"); + assert_eq!(resolved.provider_name, provider); + assert!(resolved.client.is_some()); + assert!(telemetry_scope.is_none()); + } let managed = match super::resolve_native_client("evalops/gpt-5.5", None) { Ok(_) => panic!("managed inference must not use an expired offline session"), diff --git a/packages/local-host-rs/src/code_authority.rs b/packages/local-host-rs/src/code_authority.rs index 5ab3eac5e..bbf5fe258 100644 --- a/packages/local-host-rs/src/code_authority.rs +++ b/packages/local-host-rs/src/code_authority.rs @@ -1,5 +1,6 @@ //! Code-only live tool authority. Enrollment is global user state; repository -//! configuration cannot enable it or replace Identity's fixed HTTPS authority. +//! configuration cannot enable it or replace Identity authority. A protected +//! signed machine policy can select a customer-operated Identity endpoint. use anyhow::{Context as _, Result, bail}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -73,7 +74,10 @@ impl CodeToolAuthority { } pub(crate) fn configured() -> Option { - let record = enrollment_path()?; + Self::configured_at(enrollment_path()?) + } + + pub(crate) fn configured_at(record: PathBuf) -> Option { // Once the file exists, malformed state is an error at execution, not // a downgrade to the old approval policy. record.exists().then(|| { @@ -146,6 +150,19 @@ struct Context { } impl Context { fn load() -> Result { + if let Some(customer) = crate::private_code_authority::load()? { + return Ok(Self { + http: customer.http, + base: customer.base, + token: customer.session.access_token, + organization: customer.session.organization_id, + workspace: customer + .session + .workspace_id + .context("Customer authority requires a workspace")?, + }); + } + crate::safety::require_vendor_network()?; let env: HashMap = std::env::vars().collect(); let snapshot = crate::init_cli::load_evalops_snapshot()?; let session = crate::credential_mode::platform_session_from(snapshot.as_ref(), &env) @@ -167,6 +184,11 @@ impl Context { }) } async fn post(&self, path: &str, body: Value) -> Result { + crate::private_code_authority::validate_request_target( + &self.base, + &self.organization, + &self.workspace, + )?; let method = match path { "challenge" => "CreateCodeChallenge", "enroll" => "EnrollCodeDevice", @@ -297,7 +319,13 @@ pub async fn enroll() -> Result { ); } let generated = helper(json!({"command":"generate"})).await?; - crate::init_cli::perform_code_authority_login().await?; + if crate::safety::disconnected_authority() + .map_err(anyhow::Error::msg)? + .is_none() + { + crate::safety::require_vendor_network()?; + crate::init_cli::perform_code_authority_login().await?; + } let context = Context::load()?; let key = required(&generated, "keyId")?; let challenge = context.challenge(key, None).await?; diff --git a/packages/local-host-rs/src/credential_mode.rs b/packages/local-host-rs/src/credential_mode.rs index c5ee062e6..1326ad04a 100644 --- a/packages/local-host-rs/src/credential_mode.rs +++ b/packages/local-host-rs/src/credential_mode.rs @@ -50,7 +50,7 @@ const IDENTITY_INTROSPECTION_TIMEOUT: std::time::Duration = std::time::Duration: /// caller-provided `MAESTRO_EVALOPS_ORG_ID` / workspace values are never /// trusted for a user-facing model turn. #[derive(Debug, Deserialize)] -struct IdentityIntrospection { +pub(crate) struct IdentityIntrospection { #[serde(default)] active: bool, #[serde(default)] @@ -309,6 +309,34 @@ pub fn require_ready(model: &str) -> Result { require_ready_with_identity(model).map(|(mode, _identity)| mode) } +/// Construct only the signed customer route; shared by native turns and auxiliary model calls. +pub fn disconnected_client(model: &str) -> Result> { + let Some(route) = crate::safety::disconnected_route(model).map_err(anyhow::Error::msg)? else { + return Ok(None); + }; + if let Some(reason) = crate::safety::check_model_allowed(model) { + anyhow::bail!(reason); + } + let credential = match route.credential_env { + Some(name) => std::env::var(&name) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Disconnected route credential {name} is missing"))?, + None => String::new(), + }; + anyhow::ensure!( + !credential.starts_with("op://"), + "Disconnected routes do not execute credential helpers" + ); + let client = + crate::ai::UnifiedClient::OpenAI(crate::ai::OpenAiClient::with_disconnected_endpoint( + credential, + route.endpoint, + model.split_once('/').expect("validated model route").0, + )?); + Ok(Some(client)) +} + /// Resolve a ready provider mode together with the live Identity session that /// authorized it, when one exists. /// @@ -321,6 +349,12 @@ pub fn require_ready(model: &str) -> Result { /// turn to its originating tenant rather than rediscovering whatever account /// happens to be active during a later retry. pub fn require_ready_with_identity(model: &str) -> Result<(DetectedMode, Option)> { + if crate::safety::disconnected_route(model) + .map_err(anyhow::Error::msg)? + .is_some() + { + return Ok((DetectedMode::Byok, None)); + } let env = std::env::vars().collect::>(); if !hosted_runner_mode(&env) && crate::local_models::is_local_model_route(model) @@ -370,6 +404,7 @@ fn ready_mode_from_verified_identity( /// service credentials and independently reauthorizes the exact tenant. /// Human-only capture and provider admission use their separate typed guards. pub fn current_verified_identity_session() -> Result { + crate::safety::require_vendor_network()?; current_verified_identity_session_with_env() .map(|(identity, _env)| identity.into_telemetry_session()) } @@ -403,6 +438,7 @@ fn current_verified_identity_session_with_env() /// Load and verify the current human Identity session for a product-owned /// child operation such as Session History ingestion. pub(crate) fn verified_current_identity_session() -> Result { + crate::safety::require_vendor_network()?; let env = std::env::vars().collect::>(); let snapshot = if platform_session_from(None, &env).is_some() { None @@ -656,8 +692,16 @@ fn verified_runtime_identity( } fn verified_platform_session( + session: PlatformSession, + introspection: IdentityIntrospection, +) -> Result { + verified_platform_session_for_scope(session, introspection, IDENTITY_REQUIRED_SCOPE) +} + +pub(crate) fn verified_platform_session_for_scope( mut session: PlatformSession, introspection: IdentityIntrospection, + required_scope: &str, ) -> Result { let organization_id = introspection .organization_id @@ -674,7 +718,7 @@ fn verified_platform_session( .iter() .map(String::as_str) .chain(introspection.scope.split_whitespace()) - .any(|scope| scope == IDENTITY_REQUIRED_SCOPE); + .any(|scope| scope == required_scope); if !introspection.active || introspection.subject.trim().is_empty() || introspection.token_type != "access" diff --git a/packages/local-host-rs/src/evalops_cli.rs b/packages/local-host-rs/src/evalops_cli.rs index 91153fbce..69e81e4ed 100644 --- a/packages/local-host-rs/src/evalops_cli.rs +++ b/packages/local-host-rs/src/evalops_cli.rs @@ -94,6 +94,7 @@ struct HostedOrbSmokeCredential { const DEFAULT_SESSION_HISTORY_URL: &str = "https://api.evalops.dev"; pub async fn run_evalops(args: &[String]) -> Result { + crate::safety::require_vendor_network()?; match args.first().map(String::as_str) { Some("device-revoke") => crate::code_authority::revoke().await, Some("device-enroll") => crate::code_authority::enroll().await, diff --git a/packages/local-host-rs/src/headless_server.rs b/packages/local-host-rs/src/headless_server.rs index 6852d79f1..b3822c581 100644 --- a/packages/local-host-rs/src/headless_server.rs +++ b/packages/local-host-rs/src/headless_server.rs @@ -1509,6 +1509,7 @@ fn init_headless_tracing() -> Option { } pub async fn run_headless_server(model_override: Option) -> Result { + crate::safety::disconnected_policy().map_err(anyhow::Error::msg)?; let _telemetry = init_headless_tracing(); let mut state = HeadlessState::new(model_override); prepare_headless_local_model_with( diff --git a/packages/local-host-rs/src/hooks/integration.rs b/packages/local-host-rs/src/hooks/integration.rs index 7b3a4ed75..5e4e61a76 100644 --- a/packages/local-host-rs/src/hooks/integration.rs +++ b/packages/local-host-rs/src/hooks/integration.rs @@ -127,6 +127,9 @@ impl MaestroSessionHistoryHook { transcript_path: Option<&str>, transcript_size_before: Option, ) { + if crate::safety::vendor_network_disabled() { + return; + } let Some(session_id) = session_id else { return; }; @@ -1533,6 +1536,9 @@ impl IntegratedHookSystem { endpoint: Option, state_dir: PathBuf, ) { + if crate::safety::vendor_network_disabled() { + return; + } self.set_identity_context(Some(organization_id.clone()), Some(workspace_id.clone())); let hook = Arc::new(MaestroSessionHistoryHook { organization_id, diff --git a/packages/local-host-rs/src/hosted_runner/tests.rs b/packages/local-host-rs/src/hosted_runner/tests.rs index 4609dac1c..1092417bf 100644 --- a/packages/local-host-rs/src/hosted_runner/tests.rs +++ b/packages/local-host-rs/src/hosted_runner/tests.rs @@ -14,6 +14,8 @@ use crate::headless::messages::{CodexSubagentContinuityEdge, ToolRetryDecisionAc use crate::headless::{NativeToolCapability, PendingApproval, RemoteTransportConfig}; use crate::hosted_runner::rendezvous_protocol::RendezvousMode; +#[cfg(unix)] +mod deferred_rejection; mod env_aliases; mod transport_flush; @@ -11211,304 +11213,6 @@ async fn correlated_protocol_rejection_rolls_back_ownership_and_allows_retry() { supervisor.lock().expect("supervisor").shutdown(); } -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn delayed_protocol_rejection_after_queued_return_is_rolled_back_by_event_pump() { - let workspace = tempdir().expect("workspace"); - let fixtures = tempdir().expect("fixtures"); - let log_path = fixtures.path().join("delayed-rejection.log"); - let script = create_reject_then_accept_script(fixtures.path(), &log_path, Some("0.65")); - let supervisor = connected_supervisor_for_script(&script).await; - let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( - &supervisor, - ))); - let handle = start_hosted_runner_with_message_executor( - test_config(workspace.path().to_path_buf()), - executor.clone(), - ) - .await - .expect("hosted runner"); - let client = reqwest::Client::new(); - let (capability, subscription_id) = - attach_thread_controller(&client, &handle.base_url(), "conn_delayed_rejection").await; - let headers = response_headers( - "conn_delayed_rejection", - &subscription_id, - &capability, - "delayed-rejected-key", - ); - let response = ToAgentMessage::ToolResponse { - call_id: "retry-call".to_string(), - tool_execution_id: Some("delayed-retry-execution".to_string()), - approved: true, - result: None, - }; - - let queued = handle_message( - handle.shared.clone(), - "sess_test", - headers.clone(), - response.clone(), - ) - .await - .expect("response is queued before delayed rejection"); - let ResponseBody::Json { body, .. } = queued else { - panic!("queued response must return JSON"); - }; - assert!( - body["message"] - .as_str() - .is_some_and(|message| message.contains("pending native consumption")) - ); - tokio::time::sleep(Duration::from_millis(400)).await; - { - let state = handle - .shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!( - !state - .pending_response_idempotency - .contains_key("delayed-rejected-key") - ); - } - assert!( - !executor - .queued_responses - .lock() - .expect("queued responses") - .contains_key("delayed-rejected-key") - ); - assert!( - !executor - .memory_completed_responses - .lock() - .expect("memory completion") - .contains_key("delayed-rejected-key") - ); - assert!( - !load_executor_response_ledger(workspace.path(), "sess_test") - .expect("response ledger") - .iter() - .any(|(key, _)| key == "delayed-rejected-key") - ); - - handle_message(handle.shared.clone(), "sess_test", headers, response) - .await - .expect("corrected same-key retry dispatches after delayed rollback"); - assert_eq!( - std::fs::read_to_string(&log_path) - .expect("delayed rejection log") - .lines() - .count(), - 2 - ); - handle.shutdown().await; - supervisor.lock().expect("supervisor").shutdown(); -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn delayed_rejection_rollback_survives_thread_persistence_failure() { - let workspace = tempdir().expect("workspace"); - let fixtures = tempdir().expect("fixtures"); - let log_path = fixtures.path().join("delayed-rejection-persistence.log"); - let script = create_reject_then_accept_script(fixtures.path(), &log_path, Some("0.65")); - let supervisor = connected_supervisor_for_script(&script).await; - let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( - &supervisor, - ))); - let handle = start_hosted_runner_with_message_executor( - test_config(workspace.path().to_path_buf()), - executor.clone(), - ) - .await - .expect("hosted runner"); - let client = reqwest::Client::new(); - let (capability, subscription_id) = - attach_thread_controller(&client, &handle.base_url(), "conn_rejection_persist").await; - let headers = response_headers( - "conn_rejection_persist", - &subscription_id, - &capability, - "rejection-persist-key", - ); - let response = ToAgentMessage::ToolResponse { - call_id: "retry-call".to_string(), - tool_execution_id: Some("rejection-persist-execution".to_string()), - approved: true, - result: None, - }; - - handle_message( - handle.shared.clone(), - "sess_test", - headers.clone(), - response.clone(), - ) - .await - .expect("response is queued before delayed rejection"); - // The pump's rejection tick persists twice: publishing the correlated - // protocol Error is a lifecycle boundary, then the rejection rollback - // persists the removed pending records. Fail both so the rollback path - // itself observes a journal write failure. - handle.shared.fail_next_thread_persistences(2); - tokio::time::sleep(Duration::from_millis(900)).await; - - // The in-memory rollback happened even though the journal write failed, - // and the event pump must survive to retry the persistence. - { - let state = handle - .shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!( - !state - .pending_response_idempotency - .contains_key("rejection-persist-key") - ); - } - assert!( - !executor - .queued_responses - .lock() - .expect("queued responses") - .contains_key("rejection-persist-key") - ); - assert!( - !handle - .shared - .event_pump_task - .lock() - .await - .as_ref() - .expect("event pump task") - .is_finished() - ); - tokio::time::timeout(Duration::from_secs(2), async { - loop { - if !handle - .shared - .thread_persistence_retry_pending - .load(Ordering::Acquire) - { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - }) - .await - .expect("event pump retries the thread journal persistence"); - assert_eq!( - std::fs::read_to_string(&log_path) - .expect("delayed rejection log") - .lines() - .count(), - 1, - "only the rejected dispatch reaches the native child in this test" - ); - handle.shutdown().await; - supervisor.lock().expect("supervisor").shutdown(); -} - -#[cfg(unix)] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn delayed_rejection_survives_ledger_cleanup_failure() { - let workspace = tempdir().expect("workspace"); - let fixtures = tempdir().expect("fixtures"); - let log_path = fixtures.path().join("delayed-rejection-ledger.log"); - let script = create_reject_then_accept_script(fixtures.path(), &log_path, Some("0.65")); - let supervisor = connected_supervisor_for_script(&script).await; - let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( - &supervisor, - ))); - let handle = start_hosted_runner_with_message_executor( - test_config(workspace.path().to_path_buf()), - executor.clone(), - ) - .await - .expect("hosted runner"); - let client = reqwest::Client::new(); - let (capability, subscription_id) = - attach_thread_controller(&client, &handle.base_url(), "conn_rejection_ledger_pump").await; - let headers = response_headers( - "conn_rejection_ledger_pump", - &subscription_id, - &capability, - "pump-ledger-key", - ); - let response = ToAgentMessage::ToolResponse { - call_id: "retry-call".to_string(), - tool_execution_id: Some("pump-ledger-execution".to_string()), - approved: true, - result: None, - }; - - handle_message( - handle.shared.clone(), - "sess_test", - headers.clone(), - response.clone(), - ) - .await - .expect("response is queued before delayed rejection"); - executor.fail_next_ledger_persistences(1); - tokio::time::sleep(Duration::from_millis(900)).await; - - // The executor drain observed the rejection; the ledger cleanup failure - // must not fail the drain and kill the event pump. - assert!( - !handle - .shared - .event_pump_task - .lock() - .await - .as_ref() - .expect("event pump task") - .is_finished() - ); - { - let state = handle - .shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - assert!( - !state - .pending_response_idempotency - .contains_key("pump-ledger-key") - ); - } - assert!( - !executor - .queued_responses - .lock() - .expect("queued responses") - .contains_key("pump-ledger-key") - ); - assert!( - load_executor_response_ledger(workspace.path(), "sess_test") - .expect("response ledger") - .iter() - .any(|(key, dispatched)| key == "pump-ledger-key" && !dispatched) - ); - - handle_message(handle.shared.clone(), "sess_test", headers, response) - .await - .expect("corrected same-key retry dispatches despite the stale pending entry"); - assert_eq!( - std::fs::read_to_string(&log_path) - .expect("delayed rejection ledger log") - .lines() - .count(), - 2 - ); - handle.shutdown().await; - supervisor.lock().expect("supervisor").shutdown(); -} - #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn rejected_response_releases_ownership_when_ledger_cleanup_fails() { diff --git a/packages/local-host-rs/src/hosted_runner/tests/deferred_rejection.rs b/packages/local-host-rs/src/hosted_runner/tests/deferred_rejection.rs new file mode 100644 index 000000000..307f1e946 --- /dev/null +++ b/packages/local-host-rs/src/hosted_runner/tests/deferred_rejection.rs @@ -0,0 +1,378 @@ +use super::*; + +#[cfg(unix)] +fn gated_rejection_script(directory: &Path, log_path: &Path) -> (PathBuf, PathBuf) { + let script = create_reject_then_accept_script(directory, log_path, None); + let release = directory.join("release-rejection"); + let quoted_release = format!("'{}'", release.to_string_lossy().replace('\'', "'\\''")); + let contents = std::fs::read_to_string(&script).expect("fixture script"); + let boundary = "if [ \"$count\" -eq 1 ]; then\n"; + assert_eq!(contents.matches(boundary).count(), 1); + std::fs::write( + &script, + contents.replacen( + boundary, + &format!("{boundary} while [ ! -f {quoted_release} ]; do sleep 0.01; done\n"), + 1, + ), + ) + .expect("gated rejection fixture"); + (script, release) +} + +#[cfg(unix)] +async fn await_rollback(complete: impl Fn() -> bool) { + tokio::time::timeout(Duration::from_secs(2), async { + while !complete() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("event pump rolls back the released rejection"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delayed_protocol_rejection_after_queued_return_is_rolled_back_by_event_pump() { + let workspace = tempdir().expect("workspace"); + let fixtures = tempdir().expect("fixtures"); + let log_path = fixtures.path().join("delayed-rejection.log"); + let (script, release_rejection) = gated_rejection_script(fixtures.path(), &log_path); + let supervisor = connected_supervisor_for_script(&script).await; + let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( + &supervisor, + ))); + let handle = start_hosted_runner_with_message_executor( + test_config(workspace.path().to_path_buf()), + executor.clone(), + ) + .await + .expect("hosted runner"); + let client = reqwest::Client::new(); + let (capability, subscription_id) = + attach_thread_controller(&client, &handle.base_url(), "conn_delayed_rejection").await; + let headers = response_headers( + "conn_delayed_rejection", + &subscription_id, + &capability, + "delayed-rejected-key", + ); + let response = ToAgentMessage::ToolResponse { + call_id: "retry-call".to_string(), + tool_execution_id: Some("delayed-retry-execution".to_string()), + approved: true, + result: None, + }; + + let queued = handle_message( + handle.shared.clone(), + "sess_test", + headers.clone(), + response.clone(), + ) + .await + .expect("response is queued before delayed rejection"); + let ResponseBody::Json { body, .. } = queued else { + panic!("queued response must return JSON"); + }; + assert!( + body["message"] + .as_str() + .is_some_and(|message| message.contains("pending native consumption")) + ); + std::fs::write(&release_rejection, b"reject").expect("release native rejection"); + await_rollback(|| { + let pending = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_response_idempotency + .contains_key("delayed-rejected-key"); + let queued = executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("delayed-rejected-key"); + !pending && !queued + }) + .await; + { + let state = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + !state + .pending_response_idempotency + .contains_key("delayed-rejected-key") + ); + } + assert!( + !executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("delayed-rejected-key") + ); + assert!( + !executor + .memory_completed_responses + .lock() + .expect("memory completion") + .contains_key("delayed-rejected-key") + ); + assert!( + !load_executor_response_ledger(workspace.path(), "sess_test") + .expect("response ledger") + .iter() + .any(|(key, _)| key == "delayed-rejected-key") + ); + + handle_message(handle.shared.clone(), "sess_test", headers, response) + .await + .expect("corrected same-key retry dispatches after delayed rollback"); + assert_eq!( + std::fs::read_to_string(&log_path) + .expect("delayed rejection log") + .lines() + .count(), + 2 + ); + handle.shutdown().await; + supervisor.lock().expect("supervisor").shutdown(); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delayed_rejection_rollback_survives_thread_persistence_failure() { + let workspace = tempdir().expect("workspace"); + let fixtures = tempdir().expect("fixtures"); + let log_path = fixtures.path().join("delayed-rejection-persistence.log"); + let (script, release_rejection) = gated_rejection_script(fixtures.path(), &log_path); + let supervisor = connected_supervisor_for_script(&script).await; + let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( + &supervisor, + ))); + let handle = start_hosted_runner_with_message_executor( + test_config(workspace.path().to_path_buf()), + executor.clone(), + ) + .await + .expect("hosted runner"); + let client = reqwest::Client::new(); + let (capability, subscription_id) = + attach_thread_controller(&client, &handle.base_url(), "conn_rejection_persist").await; + let headers = response_headers( + "conn_rejection_persist", + &subscription_id, + &capability, + "rejection-persist-key", + ); + let response = ToAgentMessage::ToolResponse { + call_id: "retry-call".to_string(), + tool_execution_id: Some("rejection-persist-execution".to_string()), + approved: true, + result: None, + }; + + handle_message( + handle.shared.clone(), + "sess_test", + headers.clone(), + response.clone(), + ) + .await + .expect("response is queued before delayed rejection"); + // The pump's rejection tick persists twice: publishing the correlated + // protocol Error is a lifecycle boundary, then the rejection rollback + // persists the removed pending records. Fail both so the rollback path + // itself observes a journal write failure. + handle.shared.fail_next_thread_persistences(2); + std::fs::write(&release_rejection, b"reject").expect("release native rejection"); + await_rollback(|| { + let pending = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_response_idempotency + .contains_key("rejection-persist-key"); + let queued = executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("rejection-persist-key"); + !pending && !queued + }) + .await; + + // The in-memory rollback happened even though the journal write failed, + // and the event pump must survive to retry the persistence. + { + let state = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + !state + .pending_response_idempotency + .contains_key("rejection-persist-key") + ); + } + assert!( + !executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("rejection-persist-key") + ); + assert!( + !handle + .shared + .event_pump_task + .lock() + .await + .as_ref() + .expect("event pump task") + .is_finished() + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if !handle + .shared + .thread_persistence_retry_pending + .load(Ordering::Acquire) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("event pump retries the thread journal persistence"); + assert_eq!( + std::fs::read_to_string(&log_path) + .expect("delayed rejection log") + .lines() + .count(), + 1, + "only the rejected dispatch reaches the native child in this test" + ); + handle.shutdown().await; + supervisor.lock().expect("supervisor").shutdown(); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn delayed_rejection_survives_ledger_cleanup_failure() { + let workspace = tempdir().expect("workspace"); + let fixtures = tempdir().expect("fixtures"); + let log_path = fixtures.path().join("delayed-rejection-ledger.log"); + let (script, release_rejection) = gated_rejection_script(fixtures.path(), &log_path); + let supervisor = connected_supervisor_for_script(&script).await; + let executor = Arc::new(AgentSupervisorHostedRunnerMessageExecutor::new(Arc::clone( + &supervisor, + ))); + let handle = start_hosted_runner_with_message_executor( + test_config(workspace.path().to_path_buf()), + executor.clone(), + ) + .await + .expect("hosted runner"); + let client = reqwest::Client::new(); + let (capability, subscription_id) = + attach_thread_controller(&client, &handle.base_url(), "conn_rejection_ledger_pump").await; + let headers = response_headers( + "conn_rejection_ledger_pump", + &subscription_id, + &capability, + "pump-ledger-key", + ); + let response = ToAgentMessage::ToolResponse { + call_id: "retry-call".to_string(), + tool_execution_id: Some("pump-ledger-execution".to_string()), + approved: true, + result: None, + }; + + handle_message( + handle.shared.clone(), + "sess_test", + headers.clone(), + response.clone(), + ) + .await + .expect("response is queued before delayed rejection"); + executor.fail_next_ledger_persistences(1); + std::fs::write(&release_rejection, b"reject").expect("release native rejection"); + await_rollback(|| { + let pending = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_response_idempotency + .contains_key("pump-ledger-key"); + let queued = executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("pump-ledger-key"); + !pending && !queued + }) + .await; + + // The executor drain observed the rejection; the ledger cleanup failure + // must not fail the drain and kill the event pump. + assert!( + !handle + .shared + .event_pump_task + .lock() + .await + .as_ref() + .expect("event pump task") + .is_finished() + ); + { + let state = handle + .shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + !state + .pending_response_idempotency + .contains_key("pump-ledger-key") + ); + } + assert!( + !executor + .queued_responses + .lock() + .expect("queued responses") + .contains_key("pump-ledger-key") + ); + assert!( + load_executor_response_ledger(workspace.path(), "sess_test") + .expect("response ledger") + .iter() + .any(|(key, dispatched)| key == "pump-ledger-key" && !dispatched) + ); + + handle_message(handle.shared.clone(), "sess_test", headers, response) + .await + .expect("corrected same-key retry dispatches despite the stale pending entry"); + assert_eq!( + std::fs::read_to_string(&log_path) + .expect("delayed rejection ledger log") + .lines() + .count(), + 2 + ); + handle.shutdown().await; + supervisor.lock().expect("supervisor").shutdown(); +} diff --git a/packages/local-host-rs/src/hosted_runner_cli.rs b/packages/local-host-rs/src/hosted_runner_cli.rs index 67d17e332..638943f65 100644 --- a/packages/local-host-rs/src/hosted_runner_cli.rs +++ b/packages/local-host-rs/src/hosted_runner_cli.rs @@ -482,6 +482,7 @@ async fn start_resolved_hosted_runner_cli_runtime( mut config: HostedRunnerLaunchConfig, resolved_env: HashMap, ) -> Result { + crate::safety::require_vendor_network()?; let runtime_boundary = config.runtime_boundary()?; let launch_spec = config.launch_spec(&resolved_env)?; for alias in &config.runner.deprecated_env_aliases { @@ -578,6 +579,7 @@ where I: IntoIterator, T: Into + Clone, { + crate::safety::require_vendor_network()?; let _telemetry = init_hosted_runner_tracing(); let env = std::env::vars().collect::>(); // Register before startup can expose readiness. Constructing an async diff --git a/packages/local-host-rs/src/init_cli.rs b/packages/local-host-rs/src/init_cli.rs index 513f7395a..471cd5cf5 100644 --- a/packages/local-host-rs/src/init_cli.rs +++ b/packages/local-host-rs/src/init_cli.rs @@ -260,6 +260,7 @@ struct InitResult { } pub async fn run_init(args: &[String]) -> Result { + crate::safety::require_vendor_network()?; if args .iter() .any(|arg| matches!(arg.as_str(), "--help" | "-h")) @@ -2304,6 +2305,7 @@ async fn load_current_evalops_snapshot_async() -> Result Result> { + crate::safety::require_vendor_network()?; std::thread::spawn(|| -> Result> { tokio::runtime::Builder::new_current_thread() .enable_all() @@ -2431,6 +2433,7 @@ pub(crate) async fn perform_code_authority_login() -> Result<()> { } pub async fn perform_evalops_login() -> Result<()> { + crate::safety::require_vendor_network()?; let client = Client::builder() .timeout(Duration::from_secs(30)) .build() diff --git a/packages/local-host-rs/src/lib.rs b/packages/local-host-rs/src/lib.rs index 4e38a23b4..1ca2b5b62 100644 --- a/packages/local-host-rs/src/lib.rs +++ b/packages/local-host-rs/src/lib.rs @@ -53,6 +53,7 @@ pub mod path_utils; pub mod pending_decisions; pub mod plan_mode; pub mod plugins; +mod private_code_authority; pub mod rlm; pub mod safety; pub mod sandbox_policy; diff --git a/packages/local-host-rs/src/local_models.rs b/packages/local-host-rs/src/local_models.rs index 264f3c63b..5cbed5acc 100644 --- a/packages/local-host-rs/src/local_models.rs +++ b/packages/local-host-rs/src/local_models.rs @@ -25,6 +25,9 @@ pub struct LocalRuntimeEndpoint { /// discovery, setup, and request routing share the same override precedence. #[must_use] pub fn local_runtime_endpoints(env: &HashMap) -> Vec { + if crate::safety::vendor_network_disabled() { + return Vec::new(); + } LOCAL_RUNTIME_IDS .into_iter() .filter_map(|provider| { @@ -48,6 +51,10 @@ pub fn local_runtime_endpoints(env: &HashMap) -> Vec anyhow::Result { let mut parsed = url::Url::parse(base_url)?; + anyhow::ensure!( + matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), + "runtime URL must use HTTP or HTTPS with a host" + ); let base_path = parsed.path().trim_end_matches('/'); parsed.set_path(&format!("{base_path}/models")); Ok(parsed.to_string()) @@ -57,6 +64,31 @@ pub fn local_metadata_url(base_url: &str) -> anyhow::Result { pub struct LocalDiscoveryBatch { pub generation: u64, pub models: Vec, + pub runtimes: Vec, +} + +/// Discovery evidence only; this never grants inference or tool permission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalRuntimeDiscovery { + pub provider: &'static str, + pub display_name: &'static str, + pub status: LocalRuntimeStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LocalRuntimeStatus { + Ready { models: usize }, + Empty, + Unreachable, + TimedOut, + HttpError(u16), + InvalidEndpoint, + InvalidResponse, +} + +struct EndpointDiscovery { + models: Vec, + runtime: LocalRuntimeDiscovery, } /// Replace the runtime model metadata available to agent-side model resolution. @@ -148,6 +180,7 @@ pub async fn discover_local_model(route: &str) -> anyhow::Result reqwest::blocking::Client { fn discover_endpoints_with_client( client: &reqwest::blocking::Client, endpoints: &[LocalRuntimeEndpoint], -) -> Vec { +) -> (Vec, Vec) { let results = std::thread::scope(|scope| { endpoints .iter() - .map(|endpoint| scope.spawn(|| discover_endpoint(client, endpoint))) + .map(|endpoint| { + ( + endpoint, + scope.spawn(|| discover_endpoint(client, endpoint)), + ) + }) .collect::>() .into_iter() - .flat_map(|handle| handle.join().unwrap_or_default()) + .map(|(endpoint, handle)| { + handle.join().unwrap_or_else(|_| { + endpoint_failure(endpoint, LocalRuntimeStatus::InvalidResponse) + }) + }) .collect::>() }); let mut seen = HashSet::new(); - results - .into_iter() - .filter(|model| seen.insert((model.provider.clone(), model.id.clone()))) - .collect() + let mut models = Vec::new(); + let mut runtimes = Vec::new(); + for result in results { + models.extend( + result + .models + .into_iter() + .filter(|model| seen.insert((model.provider.clone(), model.id.clone()))), + ); + runtimes.push(result.runtime); + } + (models, runtimes) +} + +fn endpoint_failure( + endpoint: &LocalRuntimeEndpoint, + status: LocalRuntimeStatus, +) -> EndpointDiscovery { + EndpointDiscovery { + models: Vec::new(), + runtime: LocalRuntimeDiscovery { + provider: endpoint.provider, + display_name: endpoint.display_name, + status, + }, + } } fn discover_endpoint( client: &reqwest::blocking::Client, endpoint: &LocalRuntimeEndpoint, -) -> Vec { +) -> EndpointDiscovery { let started = Instant::now(); let Ok(url) = local_metadata_url(&endpoint.base_url) else { - return Vec::new(); + return endpoint_failure(endpoint, LocalRuntimeStatus::InvalidEndpoint); }; - let Ok(response) = client.get(url).send() else { - return Vec::new(); + let response = match client.get(url).send() { + Ok(response) => response, + Err(error) => { + return endpoint_failure( + endpoint, + if error.is_timeout() { + LocalRuntimeStatus::TimedOut + } else { + LocalRuntimeStatus::Unreachable + }, + ); + } }; - let Ok(response) = response.error_for_status() else { - return Vec::new(); + if !response.status().is_success() { + return endpoint_failure( + endpoint, + LocalRuntimeStatus::HttpError(response.status().as_u16()), + ); + } + let payload: serde_json::Value = match response.json() { + Ok(payload) => payload, + Err(error) => { + return endpoint_failure( + endpoint, + if error.is_timeout() { + LocalRuntimeStatus::TimedOut + } else { + LocalRuntimeStatus::InvalidResponse + }, + ); + } }; - let Ok(payload) = response.json() else { - return Vec::new(); + let Some(entries) = payload.get("data").and_then(serde_json::Value::as_array) else { + return endpoint_failure(endpoint, LocalRuntimeStatus::InvalidResponse); }; let mut models = parse_models_response(endpoint.provider, &payload); - if endpoint.provider == "ollama" { + if !entries.is_empty() && models.is_empty() { + return endpoint_failure(endpoint, LocalRuntimeStatus::InvalidResponse); + } + if endpoint.provider == "ollama" && !models.is_empty() { let remaining = DISCOVERY_TOTAL_TIMEOUT.saturating_sub(started.elapsed()); if !remaining.is_zero() { let running = ollama_running_contexts(client, &endpoint.base_url, remaining); apply_ollama_running_contexts(&mut models, &running); } } - models + let status = if models.is_empty() { + LocalRuntimeStatus::Empty + } else { + LocalRuntimeStatus::Ready { + models: models.len(), + } + }; + EndpointDiscovery { + models, + runtime: LocalRuntimeDiscovery { + provider: endpoint.provider, + display_name: endpoint.display_name, + status, + }, + } } fn ollama_running_contexts( @@ -821,7 +932,8 @@ mod tests { *released.lock().unwrap() = true; wake.notify_all(); } - let models = discovery.join().unwrap(); + let (models, runtimes) = discovery.join().unwrap(); + assert_eq!(runtimes[2].status, LocalRuntimeStatus::InvalidResponse); slow_a_server.join().unwrap(); slow_b_server.join().unwrap(); @@ -841,12 +953,100 @@ mod tests { } #[test] - fn discovery_actor_coalesces_refreshes_and_increments_completed_generations() { - let first = serve_models_after_requests( - Duration::from_millis(25), - r#"{"data":[{"id":"model-a"}]}"#, - 2, + fn discovery_distinguishes_empty_invalid_and_unreachable_runtimes() { + let client = discovery_client(); + for (body, expected) in [ + (r#"{"data":[]}"#, LocalRuntimeStatus::Empty), + ( + r#"{"data":[{"id":"qwen"}]}"#, + LocalRuntimeStatus::Ready { models: 1 }, + ), + ( + r#"{"error":"unavailable"}"#, + LocalRuntimeStatus::InvalidResponse, + ), + (r#"{"data":[{}]}"#, LocalRuntimeStatus::InvalidResponse), + ("not-json", LocalRuntimeStatus::InvalidResponse), + ] { + let endpoint = LocalRuntimeEndpoint { + provider: "llamacpp", + display_name: "llama.cpp", + base_url: serve_models_after(Duration::ZERO, body), + }; + let result = discover_endpoint(&client, &endpoint); + assert_eq!(result.runtime.status, expected); + assert_eq!(result.runtime.provider, "llamacpp"); + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let mut endpoint = LocalRuntimeEndpoint { + provider: "lmstudio", + display_name: "LM Studio", + base_url, + }; + assert_eq!( + discover_endpoint(&client, &endpoint).runtime.status, + LocalRuntimeStatus::Unreachable + ); + endpoint.base_url = "invalid URL".to_owned(); + assert_eq!( + discover_endpoint(&client, &endpoint).runtime.status, + LocalRuntimeStatus::InvalidEndpoint ); + } + + #[test] + fn discovery_timeout_is_distinct_from_an_empty_model_list() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let (release, gate) = mpsc::channel(); + let server = std::thread::spawn(move || { + let (_stream, _) = listener.accept().unwrap(); + gate.recv().unwrap(); + }); + let endpoint = LocalRuntimeEndpoint { + provider: "llamacpp", + display_name: "llama.cpp", + base_url, + }; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_millis(100)) + .build() + .unwrap(); + let result = discover_endpoint(&client, &endpoint); + release.send(()).unwrap(); + server.join().unwrap(); + assert_eq!(result.runtime.status, LocalRuntimeStatus::TimedOut); + assert!(result.models.is_empty()); + } + + #[test] + fn discovery_reports_http_error_without_server_body_or_credentials() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request); + stream.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 6\r\nConnection: close\r\n\r\nsecret").unwrap(); + }); + let endpoint = LocalRuntimeEndpoint { + provider: "llamacpp", + display_name: "llama.cpp", + base_url, + }; + let result = discover_endpoint(&discovery_client(), &endpoint); + assert_eq!(result.runtime.status, LocalRuntimeStatus::HttpError(401)); + assert!(result.models.is_empty()); + assert!(!format!("{:?}", result.runtime).contains("secret")); + server.join().unwrap(); + } + + #[test] + fn discovery_actor_coalesces_refreshes_and_increments_completed_generations() { + let first = + serve_models_after_requests(Duration::ZERO, r#"{"data":[{"id":"model-a"}]}"#, 2); let endpoints = vec![LocalRuntimeEndpoint { provider: "llamacpp", display_name: "llama.cpp", @@ -856,11 +1056,18 @@ mod tests { // loading during HTTP client construction. Production still builds its // client asynchronously inside the actor. let client = discovery_client(); + // Hold actor startup until both refreshes have been submitted, so + // coalescing does not depend on the HTTP server's scheduling or a sleep. + let (release, startup) = std::sync::mpsc::channel(); let (handle, events) = - spawn_local_model_discovery_with_client_factory(endpoints, move || client); + spawn_local_model_discovery_with_client_factory(endpoints, move || { + startup.recv().expect("release discovery actor startup"); + client + }); handle.refresh(); handle.refresh(); + release.send(()).expect("start queued discovery"); let first = events.recv_timeout(Duration::from_secs(1)).unwrap(); assert_eq!(first.generation, 1); assert_eq!(first.models[0].id, "model-a"); diff --git a/packages/local-host-rs/src/managed_setup/mod.rs b/packages/local-host-rs/src/managed_setup/mod.rs index afbf63ebe..99b72a219 100644 --- a/packages/local-host-rs/src/managed_setup/mod.rs +++ b/packages/local-host-rs/src/managed_setup/mod.rs @@ -499,6 +499,9 @@ impl ManagedSetupClient { where F: FnOnce(&PlatformSession) -> Result, { + if crate::safety::vendor_network_disabled() { + return Self::offline_local(session); + } let Some(session) = session else { return Self::unmanaged(); }; @@ -905,6 +908,8 @@ pub fn platform_base_url() -> Option { /// token. The workspace the session is bound to selects the document; an /// organization-only session reads the organization document. pub fn fetch_managed_setup(session: &PlatformSession) -> Result { + crate::safety::require_vendor_network() + .map_err(|error| ManagedSetupError::Request(error.to_string()))?; let base_url = platform_base_url().ok_or(ManagedSetupError::NotConfigured)?; fetch_managed_setup_from(session, &base_url) } diff --git a/packages/local-host-rs/src/mission_cli.rs b/packages/local-host-rs/src/mission_cli.rs index 9fb8561d7..be5e9586e 100644 --- a/packages/local-host-rs/src/mission_cli.rs +++ b/packages/local-host-rs/src/mission_cli.rs @@ -2630,6 +2630,7 @@ mod tests { readiness_requirements: vec!["test".into()], authorized_skips: vec![], authorized_dispositions: vec![], + output_paths: Vec::new(), }; let review = CodingValidationReport { child_id: "review-child".into(), @@ -2689,6 +2690,7 @@ mod tests { review: Some(review), behavior: Some(behavior), handoff_items: vec![], + outputs: Vec::new(), }; let feature = json!({ "id": contract.task_id, diff --git a/packages/local-host-rs/src/model_catalog.rs b/packages/local-host-rs/src/model_catalog.rs index 46dd3b35a..153fb2bcd 100644 --- a/packages/local-host-rs/src/model_catalog.rs +++ b/packages/local-host-rs/src/model_catalog.rs @@ -544,6 +544,9 @@ fn load_cache(path: &Path) -> Option { /// older than the TTL. No-ops without a tokio runtime. A long-lived process /// may refresh again after the TTL; startup paths never block or fail on it. fn maybe_spawn_background_refresh() { + if crate::safety::vendor_network_disabled() { + return; + } static LAST_REFRESH_STARTED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -577,6 +580,7 @@ fn maybe_spawn_background_refresh() { /// atomically replace the cache. On failure only `checked_at` is bumped so /// the old data stays in use without hammering the upstream on every start. async fn refresh_catalog_cache() -> anyhow::Result<()> { + crate::safety::require_vendor_network()?; let Some(path) = catalog_cache_path() else { return Ok(()); }; diff --git a/packages/local-host-rs/src/orb_connection.rs b/packages/local-host-rs/src/orb_connection.rs index 21829471d..ea148582d 100644 --- a/packages/local-host-rs/src/orb_connection.rs +++ b/packages/local-host-rs/src/orb_connection.rs @@ -311,6 +311,7 @@ pub fn validate_managed_mcp_connection(connection: &ServiceConnection) -> Result /// request. The MCP config is a runtime snapshot, so a revoked/rotated /// connection must not keep using the old endpoint or credential reference. pub fn validate_hosted_orb_runtime_binding(config: &McpServerConfig) -> Result<()> { + crate::safety::require_vendor_network()?; if config.scope != McpConfigScope::Managed || config.name != HOSTED_ORB_MCP_SERVER_NAME || config.transport != McpTransport::Http diff --git a/packages/local-host-rs/src/private_code_authority.rs b/packages/local-host-rs/src/private_code_authority.rs new file mode 100644 index 000000000..3cc1a71be --- /dev/null +++ b/packages/local-host-rs/src/private_code_authority.rs @@ -0,0 +1,418 @@ +//! Customer-cell transport for the existing Identity Code authority. +//! Signed machine policy chooses the endpoint and tenant; Identity still +//! verifies tokens, device proofs, scope, revocation, and tool decisions. +use anyhow::{Context as _, Result, bail}; +use std::{ + collections::HashMap, + io::Read as _, + net::{IpAddr, SocketAddr, ToSocketAddrs as _}, + time::Duration, +}; + +use crate::{ + credential_mode::{ + IdentityIntrospection, PlatformSession, verified_platform_session_for_scope, + }, + safety::{DisconnectedAuthority, ManagedPolicyMetadata}, +}; + +const TIMEOUT: Duration = Duration::from_secs(10); +const MAX_RESPONSE_BYTES: u64 = 64 * 1024; + +pub(crate) struct CustomerAuthority { + pub http: reqwest::Client, + pub base: String, + pub session: PlatformSession, +} + +pub(crate) fn load() -> Result> { + let Some((config, metadata)) = + crate::safety::disconnected_authority().map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + // No snapshot, vendor token, browser login, refresh, or tenant hint is used. + let env = std::env::vars().collect(); + let (session, pinned) = verify(&config, &metadata, &env)?; + let mut builder = reqwest::Client::builder() + .resolve_to_addrs(&pinned.host, &pinned.addresses) + .no_proxy() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(TIMEOUT); + if let Some(certificates) = certificates(&config)? { + builder = builder.use_rustls_tls().tls_built_in_root_certs(false); + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + } + Ok(Some(CustomerAuthority { + http: builder.build()?, + base: config.endpoint.trim_end_matches('/').to_owned(), + session, + })) +} + +/// Recheck signed policy immediately before every authority exchange, including +/// the exchange after a potentially long hardware-attestation prompt. +pub(crate) fn validate_request_target( + base: &str, + organization: &str, + workspace: &str, +) -> Result<()> { + match crate::safety::disconnected_authority().map_err(anyhow::Error::msg)? { + Some((config, metadata)) => { + if config.endpoint.trim_end_matches('/') != base + || metadata.org_id != organization + || metadata.workspace_id.as_deref() != Some(workspace) + { + bail!("Code authority no longer matches signed customer policy"); + } + Ok(()) + } + None => crate::safety::require_vendor_network(), + } +} + +#[derive(Debug, Clone)] +struct PinnedAuthority { + host: String, + addresses: Vec, +} + +fn allowed_authority_ip(ip: IpAddr) -> bool { + // AWS reserves this ULA range for instance-local infrastructure, including + // IMDS at fd00:ec2::254. A general ULA exception must not authorize it. + if matches!(ip, IpAddr::V6(v6) if v6.segments()[..2] == [0xfd00, 0x0ec2]) { + return false; + } + !crate::tools::net_guard::is_blocked_ip(ip) + || match ip { + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(), + IpAddr::V6(v6) => v6.is_unique_local() || v6.is_loopback(), + } +} + +fn checked_addresses(addresses: Vec, port: u16) -> Result> { + if addresses.is_empty() + || addresses + .iter() + .any(|addr| addr.port() != port || !allowed_authority_ip(addr.ip())) + { + bail!("Customer Identity resolved to a forbidden address"); + } + Ok(addresses) +} + +fn resolve_authority(config: &DisconnectedAuthority) -> Result { + let url = reqwest::Url::parse(&config.endpoint)?; + let host = url + .host_str() + .context("Customer Identity host is missing")? + .trim_matches(['[', ']']) + .to_owned(); + let port = url + .port_or_known_default() + .context("Customer Identity port is missing")?; + let addresses = if let Ok(ip) = host.parse::() { + vec![SocketAddr::new(ip, port)] + } else { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let resolver_host = host.clone(); + std::thread::spawn(move || { + let result = (resolver_host.as_str(), port) + .to_socket_addrs() + .map(Iterator::collect); + let _ = sender.send(result); + }); + receiver + .recv_timeout(TIMEOUT) + .context("Customer Identity DNS resolution timed out")?? + }; + Ok(PinnedAuthority { + host, + addresses: checked_addresses(addresses, port)?, + }) +} + +fn certificates(config: &DisconnectedAuthority) -> Result>> { + let Some(pem) = &config.ca_pem else { + return Ok(None); + }; + if pem.len() > MAX_RESPONSE_BYTES as usize { + bail!("Customer Identity CA exceeds size limit"); + } + let certificates = reqwest::Certificate::from_pem_bundle(pem.as_bytes()) + .context("parse signed customer Identity CA")?; + if certificates.is_empty() { + bail!("Customer Identity CA contains no certificates"); + } + Ok(Some(certificates)) +} + +fn verify( + config: &DisconnectedAuthority, + metadata: &ManagedPolicyMetadata, + env: &HashMap, +) -> Result<(PlatformSession, PinnedAuthority)> { + let workspace = metadata + .workspace_id + .as_deref() + .filter(|v| !v.trim().is_empty()) + .context("Customer Code authority requires signed workspace scope")?; + if metadata.org_id.trim().is_empty() { + bail!("Customer Code authority requires signed organization scope"); + } + let token = env + .get(&config.credential_env) + .filter(|v| !v.trim().is_empty()) + .context("Customer Code authority credential is unavailable")? + .clone(); + let pinned = resolve_authority(config)?; + let config = config.clone(); + let expected_org = metadata.org_id.clone(); + let expected_workspace = workspace.to_owned(); + std::thread::spawn(move || { + let mut builder = reqwest::blocking::Client::builder() + .resolve_to_addrs(&pinned.host, &pinned.addresses) + .no_proxy() + .https_only(true) + .redirect(reqwest::redirect::Policy::none()) + .timeout(TIMEOUT); + if let Some(certificates) = certificates(&config)? { + builder = builder.use_rustls_tls().tls_built_in_root_certs(false); + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + } + let response = builder + .build()? + .post(format!( + "{}/v1/tokens/introspect", + config.endpoint.trim_end_matches('/') + )) + .bearer_auth(&token) + .send() + .context("verify customer Identity credential")?; + if !response.status().is_success() { + bail!("Customer Identity rejected credential verification"); + } + let mut body = Vec::new(); + response + .take(MAX_RESPONSE_BYTES + 1) + .read_to_end(&mut body)?; + if body.len() > MAX_RESPONSE_BYTES as usize { + bail!("Customer Identity response exceeds size limit"); + } + let introspection: IdentityIntrospection = serde_json::from_slice(&body)?; + let session = PlatformSession { + access_token: token, + organization_id: expected_org.clone(), + workspace_id: Some(expected_workspace.clone()), + provider_ref: serde_json::Value::Null, + email: None, + user_id: None, + }; + let verified = + verified_platform_session_for_scope(session, introspection, "code:tools:execute")?; + if verified.organization_id != expected_org + || verified.workspace_id.as_deref() != Some(expected_workspace.as_str()) + { + bail!("Customer Identity token does not match signed policy tenant scope"); + } + Ok((verified, pinned)) + }) + .join() + .map_err(|_| anyhow::anyhow!("Customer Identity verification worker failed"))? +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::{io::Write as _, sync::Arc}; + + fn metadata() -> ManagedPolicyMetadata { + ManagedPolicyMetadata { + org_id: "customer-org".into(), + workspace_id: Some("customer-workspace".into()), + policy_version: 1, + issued_at: 1, + expires_at: u64::MAX, + key_id: "operator-key".into(), + policy_hash: "verified-by-policy-owner".into(), + kill_switch: false, + } + } + + fn active() -> serde_json::Value { + json!({"active":true,"subject":"customer-user","token_type":"access", + "organization_id":"customer-org","workspace_id":"customer-workspace","scope":"code:tools:execute"}) + } + + fn fixture( + body: String, + status: &str, + trusted: bool, + ) -> (DisconnectedAuthority, std::thread::JoinHandle<()>) { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let other = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let authority = DisconnectedAuthority { + endpoint: format!("https://localhost:{port}"), + credential_env: "CUSTOMER_CODE_TOKEN".into(), + ca_pem: Some(if trusted { + cert.cert.pem() + } else { + other.cert.pem() + }), + }; + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![cert.cert.der().clone()], + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der()).into(), + ) + .unwrap(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nLocation: https://identity.evalops.dev/v1/tokens/introspect\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let server = std::thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + TIMEOUT; + let stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fixture accept: {error}"), + } + }; + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + let connection = rustls::ServerConnection::new(Arc::new(tls)).unwrap(); + let mut stream = rustls::StreamOwned::new(connection, stream); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => { + assert!(!trusted); + return; + } + Ok(count) => request.extend_from_slice(&buffer[..count]), + } + if request.windows(4).any(|part| part == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("post /v1/tokens/introspect ")); + assert!(request.contains("authorization: bearer customer-token")); + assert!(!request.contains("vendor-token")); + stream.write_all(response.as_bytes()).unwrap(); + stream.flush().unwrap(); + }); + (authority, server) + } + + #[test] + fn private_code_authority_verifies_tls_token_scope_and_signed_tenant() { + let env = HashMap::from([ + ("CUSTOMER_CODE_TOKEN".into(), "customer-token".into()), + ("MAESTRO_EVALOPS_ACCESS_TOKEN".into(), "vendor-token".into()), + ]); + let mut cases = vec![(active(), true)]; + for (field, value) in [ + ("active", json!(false)), + ("token_type", json!("service")), + ("scope", json!("llm_gateway:invoke")), + ("organization_id", json!("other-org")), + ("workspace_id", json!("other-workspace")), + ("workspace_id", serde_json::Value::Null), + ] { + let mut response = active(); + response[field] = value; + cases.push((response, false)); + } + for (response, expected) in cases { + let (config, server) = fixture(response.to_string(), "200 OK", true); + assert_eq!(verify(&config, &metadata(), &env).is_ok(), expected); + server.join().unwrap(); + } + for (status, trusted, body) in [ + ("302 Found", true, active().to_string()), + ("200 OK", false, active().to_string()), + ("200 OK", true, "malformed".into()), + ] { + let (config, server) = fixture(body, status, trusted); + assert!(verify(&config, &metadata(), &env).is_err()); + server.join().unwrap(); + } + } + + #[test] + fn private_code_authority_rejects_every_forbidden_dns_answer() { + for ip in [ + "169.254.169.254", + "100.100.100.200", + "0.0.0.0", + "224.0.0.1", + "192.0.2.1", + "::", + "fe80::1", + "ff02::1", + "fd00:ec2::254", + "::ffff:169.254.169.254", + "64:ff9b::a9fe:a9fe", + ] { + let good = "10.1.2.3:443".parse().unwrap(); + let forbidden = SocketAddr::new(ip.parse().unwrap(), 443); + assert!( + checked_addresses(vec![good, forbidden], 443).is_err(), + "{ip}" + ); + } + for ip in ["10.1.2.3", "127.0.0.1", "::1", "fd12::1", "8.8.8.8"] { + assert!( + checked_addresses(vec![SocketAddr::new(ip.parse().unwrap(), 443)], 443).is_ok() + ); + } + assert!(checked_addresses(vec![], 443).is_err()); + assert!(checked_addresses(vec!["10.1.2.3:80".parse().unwrap()], 443).is_err()); + } + + #[test] + fn private_code_authority_never_falls_back_to_vendor_credentials_or_tenant_hints() { + let config = DisconnectedAuthority { + endpoint: "https://customer.invalid".into(), + credential_env: "CUSTOMER_CODE_TOKEN".into(), + ca_pem: None, + }; + let env = HashMap::from([ + ("MAESTRO_EVALOPS_ACCESS_TOKEN".into(), "vendor-token".into()), + ("MAESTRO_EVALOPS_ORG_ID".into(), "customer-org".into()), + ]); + assert!( + verify(&config, &metadata(), &env) + .unwrap_err() + .to_string() + .contains("credential is unavailable") + ); + let mut scope = metadata(); + scope.workspace_id = None; + assert!( + verify(&config, &scope, &env) + .unwrap_err() + .to_string() + .contains("signed workspace") + ); + } +} diff --git a/packages/local-host-rs/src/safety/guardian.rs b/packages/local-host-rs/src/safety/guardian.rs index e402a9ea4..fa8a60415 100644 --- a/packages/local-host-rs/src/safety/guardian.rs +++ b/packages/local-host-rs/src/safety/guardian.rs @@ -398,8 +398,13 @@ fn llm_evaluator(model: String) -> EvaluatorFn { Arc::new(move |context| { let model = model.clone(); Box::pin(async move { - let client = create_client_for_model(&model) - .map_err(|error| GuardianError::Llm(error.to_string()))?; + let client = match crate::credential_mode::disconnected_client(&model) + .map_err(|error| GuardianError::Llm(error.to_string()))? + { + Some(client) => client, + None => create_client_for_model(&model) + .map_err(|error| GuardianError::Llm(error.to_string()))?, + }; let messages = vec![Message { role: Role::User, content: MessageContent::text(context.user_prompt()), diff --git a/packages/local-host-rs/src/safety/mod.rs b/packages/local-host-rs/src/safety/mod.rs index 152687451..c12ca8b88 100644 --- a/packages/local-host-rs/src/safety/mod.rs +++ b/packages/local-host-rs/src/safety/mod.rs @@ -84,11 +84,13 @@ pub use path_containment::{ PathContainment, has_path_traversal, is_path_contained, is_system_path, }; pub use policy::{ - ManagedPolicyAuditEvent, ManagedPolicyEnvelope, ManagedPolicyMetadata, - ManagedPolicyPublishResult, ManagedPolicyStatus, check_model_allowed, check_path_allowed, - check_session_limits, get_policy_limits, managed_policy_audit, managed_policy_metadata, - managed_policy_status, publish_managed_policy, record_managed_policy_audit, - refresh_managed_policy, + DisconnectedAuthority, DisconnectedPolicy, DisconnectedRoute, ManagedPolicyAuditEvent, + ManagedPolicyEnvelope, ManagedPolicyMetadata, ManagedPolicyPublishResult, ManagedPolicyStatus, + check_model_allowed, check_path_allowed, check_session_limits, disconnected_authority, + disconnected_policy, disconnected_route, get_policy_limits, managed_policy_audit, + managed_policy_metadata, managed_policy_status, publish_managed_policy, + record_managed_policy_audit, refresh_managed_policy, require_vendor_network, + vendor_network_disabled, }; #[cfg(any(test, feature = "test-support"))] pub use safe_mode::PlanModeOverride; diff --git a/packages/local-host-rs/src/safety/policy.rs b/packages/local-host-rs/src/safety/policy.rs index 4f0933640..46882650f 100644 --- a/packages/local-host-rs/src/safety/policy.rs +++ b/packages/local-host-rs/src/safety/policy.rs @@ -60,6 +60,9 @@ pub struct LimitsPolicy { #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct EnterprisePolicy { + /// Signed customer routes; never read from repository or local policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub disconnected: Option, #[allow(dead_code)] pub org_id: Option, pub tools: Option, @@ -70,6 +73,32 @@ pub struct EnterprisePolicy { pub limits: Option, } +/// A disconnected installation admits only these exact OpenAI-compatible routes. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DisconnectedPolicy { + pub routes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authority: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DisconnectedRoute { + pub model: String, + pub endpoint: String, + /// Optional process credential name. No credential helpers or cloud auth. + pub credential_env: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DisconnectedAuthority { + pub endpoint: String, + pub credential_env: String, + pub ca_pem: Option, +} + /// A v1 organization-managed policy bundle. /// /// The signature covers every field except policyHash and signature, in the @@ -272,12 +301,290 @@ static PIP_INSTALL_PATTERN: std::sync::LazyLock = std::sync::LazyLock::ne .expect("Invalid pip install regex") }); +#[cfg(test)] +thread_local! { + static TEST_MACHINE_POLICY: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Machine installation is discovered independently of user environment and cwd. +/// The directory itself is the enrollment marker, so deleting a policy fails closed. +fn system_policy_path() -> Option { + #[cfg(test)] + if let Some(path) = TEST_MACHINE_POLICY.with(|value| value.borrow().clone()) { + return Some(path); + } + + #[cfg(target_os = "macos")] + let directory = Path::new("/Library/Application Support/Deixic Code"); + #[cfg(not(target_os = "macos"))] + let directory = Path::new("/etc/deixic-code"); + static ENROLLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + machine_policy_path_at(directory, &ENROLLED) +} + +fn machine_policy_path_at( + directory: &Path, + enrolled: &std::sync::atomic::AtomicBool, +) -> Option { + let absent = std::fs::symlink_metadata(directory) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound); + if !absent { + enrolled.store(true, std::sync::atomic::Ordering::Relaxed); + } + (!absent || enrolled.load(std::sync::atomic::Ordering::Relaxed)) + .then(|| directory.join("managed-policy.json")) +} + +fn validate_protected_file(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let anchor = PathBuf::from("/"); + let owner = 0; + #[cfg(test)] + let (anchor, owner) = TEST_MACHINE_POLICY.with(|value| { + value + .borrow() + .as_ref() + .map(|fixture| { + let anchor = fixture.parent().unwrap().to_path_buf(); + let owner = std::fs::metadata(&anchor).unwrap().uid(); + (anchor, owner) + }) + .unwrap_or((anchor, owner)) + }); + for (index, component) in path.ancestors().enumerate() { + let metadata = std::fs::symlink_metadata(component).map_err(|_| { + format!( + "Machine policy path is unavailable: {}", + component.display() + ) + })?; + if metadata.file_type().is_symlink() + || metadata.uid() != owner + || metadata.mode() & 0o022 != 0 + || (index == 0 && !metadata.is_file()) + || (index > 0 && !metadata.is_dir()) + { + return Err(format!( + "Machine policy requires root-owned files and directories without symlinks or group/world writes: {}", + component.display() + )); + } + if component == anchor { + break; + } + } + Ok(()) + } + #[cfg(not(unix))] + { + let _ = path; + Err("Machine policy protection is supported only on Unix".into()) + } +} + +/// Returns errors for expired, missing or invalid enrolled policy before networking. +pub fn disconnected_policy() -> Result, String> { + Ok(disconnected_policy_verified()?.map(|(profile, _)| profile)) +} + +pub fn disconnected_authority() +-> Result, String> { + Ok(disconnected_policy_verified()? + .and_then(|(profile, metadata)| profile.authority.map(|authority| (authority, metadata)))) +} + +fn disconnected_policy_verified() +-> Result, String> { + let Some(managed) = load_managed_policy(false)? else { + return Ok(None); + }; + let Some(profile) = managed.policy.disconnected else { + return Ok(None); + }; + if system_policy_path().is_none() { + return Err("Disconnected policy requires protected machine installation".into()); + } + if std::env::var("MAESTRO_HOSTED_RUNNER_MODE").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) { + return Err("Disconnected Code cannot replace hosted Runner Host authority".into()); + } + if profile.routes.is_empty() { + return Err("Disconnected policy requires approved model routes".into()); + } + let mut models = std::collections::HashSet::new(); + for route in &profile.routes { + validate_disconnected_route(route)?; + if !models.insert(&route.model) { + return Err("Disconnected model routes must be unique".into()); + } + } + if let Some(authority) = &profile.authority { + validate_disconnected_authority(authority, &managed.metadata)?; + } + Ok(Some((profile, managed.metadata))) +} + +fn validate_disconnected_authority( + authority: &DisconnectedAuthority, + metadata: &ManagedPolicyMetadata, +) -> Result<(), String> { + let endpoint = + Url::parse(&authority.endpoint).map_err(|_| "Invalid disconnected authority endpoint")?; + let host = endpoint.host_str().unwrap_or("").trim_end_matches('.'); + if endpoint.scheme() != "https" + || host.is_empty() + || !matches!(endpoint.path(), "" | "/") + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || ["evalops.dev", "deixic.com"] + .iter() + .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))) + { + return Err("Disconnected authority requires a customer HTTPS origin".into()); + } + let normalized_host = host.trim_end_matches('.'); + if matches!( + normalized_host, + "metadata.google.internal" | "metadata.goog" | "instance-data" | "metadata.azure.internal" + ) || normalized_host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| match ip { + IpAddr::V4(ip) => { + ip.is_unspecified() || ip.is_multicast() || ip.is_link_local() || ip.is_broadcast() + } + IpAddr::V6(ip) => { + ip.is_unspecified() || ip.is_multicast() || ip.is_unicast_link_local() + } + }) + { + return Err( + "Disconnected authority cannot use metadata or reserved network destinations".into(), + ); + } + let name = &authority.credential_env; + if name.is_empty() + || !name + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') + || name.starts_with("MAESTRO_") + || name.starts_with("EVALOPS_") + || name.starts_with("DEIXIC_") + { + return Err( + "Disconnected authority requires a dedicated customer credential environment name" + .into(), + ); + } + if metadata.org_id.trim().is_empty() + || metadata + .workspace_id + .as_ref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err( + "Disconnected authority requires explicit signed organization and workspace scope" + .into(), + ); + } + if let Some(pem) = &authority.ca_pem { + if pem.len() > 64 * 1024 || pem.contains("PRIVATE KEY") { + return Err("Disconnected authority CA must be a public certificate bundle no larger than 64 KiB".into()); + } + let certificates = reqwest::Certificate::from_pem_bundle(pem.as_bytes()) + .map_err(|_| "Invalid disconnected authority CA certificate bundle")?; + if certificates.is_empty() { + return Err("Disconnected authority CA bundle must contain certificates".into()); + } + } + Ok(()) +} + +fn validate_disconnected_route(route: &DisconnectedRoute) -> Result<(), String> { + let descriptor = crate::ai::ProviderRegistry::resolve_descriptor(&route.model) + .map_err(|_| "Disconnected model must name an explicit OpenAI-compatible provider")?; + if !route.model.starts_with(&format!("{}/", descriptor.id)) + || !matches!(descriptor.id, "openai" | "ollama" | "llamacpp" | "lmstudio") + { + return Err( + "Disconnected models must use openai, ollama, llamacpp or lmstudio routes".into(), + ); + } + let endpoint = + Url::parse(&route.endpoint).map_err(|_| "Invalid disconnected model endpoint")?; + if !matches!(endpoint.scheme(), "https" | "http") + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return Err("Disconnected endpoint must be an absolute HTTP(S) URL without credentials, query or fragment".into()); + } + let loopback = endpoint.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + if route.credential_env.is_some() && endpoint.scheme() != "https" && !loopback { + return Err( + "Credential-bearing disconnected endpoints require HTTPS except on loopback".into(), + ); + } + if route.credential_env.as_ref().is_some_and(|name| { + name.is_empty() + || !name + .bytes() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_') + }) { + return Err("Invalid disconnected credential environment name".into()); + } + Ok(()) +} + +pub fn disconnected_route(model: &str) -> Result, String> { + let Some(profile) = disconnected_policy()? else { + return Ok(None); + }; + profile + .routes + .into_iter() + .find(|route| route.model == model) + .map(Some) + .ok_or_else(|| format!("Model {model} is not approved by the disconnected policy")) +} + +/// Background workers suppress requests on both disconnected and invalid policy. +pub fn vendor_network_disabled() -> bool { + !matches!(disconnected_policy(), Ok(None)) +} + +pub fn require_vendor_network() -> anyhow::Result<()> { + anyhow::ensure!( + !vendor_network_disabled(), + "Vendor connections are disabled by machine policy" + ); + Ok(()) +} + fn managed_policy_path() -> Option { - env_path("MAESTRO_MANAGED_POLICY_PATH") + system_policy_path().or_else(|| env_path("MAESTRO_MANAGED_POLICY_PATH")) } fn managed_policy_state_path(policy_path: &Path) -> PathBuf { - if let Some(path) = env_path("MAESTRO_MANAGED_POLICY_STATE_PATH") { - return path; + if system_policy_path().as_deref() != Some(policy_path) { + if let Some(path) = env_path("MAESTRO_MANAGED_POLICY_STATE_PATH") { + return path; + } } let mut state = policy_path.as_os_str().to_os_string(); state.push(".state"); @@ -286,6 +593,9 @@ fn managed_policy_state_path(policy_path: &Path) -> PathBuf { fn managed_policy_trust_fingerprint(policy_path: &Path) -> String { let fingerprint = [ + system_policy_path() + .and_then(|path| std::fs::read_to_string(path.with_extension("pub")).ok()) + .unwrap_or_default(), std::env::var("MAESTRO_MANAGED_POLICY_PUBLIC_KEY").unwrap_or_default(), std::env::var("MAESTRO_MANAGED_POLICY_KEY_ID").unwrap_or_default(), std::env::var("MAESTRO_ORG_ID").unwrap_or_default(), @@ -362,6 +672,13 @@ fn decode_encoded_bytes(value: &str, expected_len: usize, label: &str) -> Result } fn configured_managed_public_key() -> Result, String> { + if let Some(path) = system_policy_path() { + let key_path = path.with_extension("pub"); + validate_protected_file(&key_path)?; + let key = + std::fs::read_to_string(key_path).map_err(|_| "Machine policy key is unreadable")?; + return decode_encoded_bytes(&key, 32, "public key"); + } let value = std::env::var("MAESTRO_MANAGED_POLICY_PUBLIC_KEY") .map_err(|_| "Managed policy public key is not configured".to_string())?; decode_encoded_bytes(&value, 32, "public key") @@ -453,6 +770,15 @@ fn verify_managed_policy(envelope: ManagedPolicyEnvelope) -> Result std::io::Result<()> { + use std::io::Write; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o644); + } + let mut file = options.open(path)?; + file.write_all(bytes) +} + fn persist_managed_policy_watermark( policy_path: &Path, policy_version: u64, @@ -493,7 +832,7 @@ fn persist_managed_policy_watermark( }) .map_err(|_| "Managed policy rollback state could not be serialized".to_string())?; let temp_path = state_path.with_extension(format!("tmp-{}", std::process::id())); - std::fs::write(&temp_path, serialized) + write_policy_file(&temp_path, &serialized) .map_err(|error| format!("Managed policy rollback state could not be written: {error}"))?; if let Err(error) = std::fs::rename(&temp_path, &state_path) { let _ = std::fs::remove_file(&temp_path); @@ -557,7 +896,7 @@ pub fn publish_managed_policy( .map(|duration| duration.as_nanos()) .unwrap_or(0); let temp_path = path.with_extension(format!("publish-tmp-{}-{unique}", std::process::id())); - if let Err(error) = std::fs::write(&temp_path, serialized) { + if let Err(error) = write_policy_file(&temp_path, &serialized) { let _ = std::fs::remove_file(&temp_path); return Err(format!( "Managed policy envelope could not be written: {error}" @@ -651,6 +990,11 @@ fn load_managed_policy(force: bool) -> Result, Str return Err("Managed policy file is missing or is not a regular file".to_string()); } + if system_policy_path().as_ref() == Some(&path) { + validate_protected_file(&path)?; + validate_protected_file(&path.with_extension("pub"))?; + validate_protected_file(&managed_policy_state_path(&path))?; + } let mtime = std::fs::metadata(&path) .map_err(|_| "Managed policy file metadata is unavailable".to_string())? .modified() @@ -698,6 +1042,17 @@ fn load_managed_policy(force: bool) -> Result, Str if let Ok(cache) = MANAGED_POLICY_CACHE.read() { if cache.path.as_ref() == Some(&path) { + // Live clients pin their approved endpoint. Changing/removing the profile + // must not let an existing client keep using a now-unapproved route. + if cache.policy.as_ref().is_some_and(|previous| { + previous.policy.disconnected.is_some() + && previous.policy.disconnected != verified.policy.disconnected + }) { + return Err( + "Disconnected routes changed; restart Code to adopt the new signed policy" + .into(), + ); + } if let Some(previous_version) = cache.accepted_version { if verified.metadata.policy_version < previous_version { return Err("Managed policy rollback was rejected".to_string()); @@ -1661,6 +2016,9 @@ pub fn check_url_allowed(url: &str) -> Option { } pub fn check_model_allowed(model_id: &str) -> Option { + if let Err(error) = disconnected_route(model_id) { + return Some(error); + } if let Some(reason) = crate::stealth_models::check_model_allowed(model_id) { return Some(reason); } @@ -1796,6 +2154,441 @@ mod tests { } } + struct MachineFixture { + directory: tempfile::TempDir, + } + impl MachineFixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)) + .unwrap(); + } + let path = directory.path().join("managed-policy.json"); + TEST_MACHINE_POLICY.with(|value| *value.borrow_mut() = Some(path)); + Self { directory } + } + fn install(&self, profile: DisconnectedPolicy) { + let key = ring::signature::Ed25519KeyPair::from_seed_unchecked(&[17; 32]).unwrap(); + let mut envelope = signed_test_envelope(&key, 1, false); + envelope.policy.disconnected = Some(profile); + let payload = canonical_managed_policy_payload(&envelope); + envelope.policy_hash = sha256_hex(&payload); + envelope.signature = URL_SAFE_NO_PAD.encode(key.sign(&payload).as_ref()); + let path = self.directory.path().join("managed-policy.json"); + write_policy_file( + &path.with_extension("pub"), + URL_SAFE_NO_PAD.encode(key.public_key().as_ref()).as_bytes(), + ) + .unwrap(); + write_policy_file(&path, &serde_json::to_vec(&envelope).unwrap()).unwrap(); + persist_managed_policy_watermark(&path, envelope.policy_version, &envelope.policy_hash) + .unwrap(); + } + } + impl Drop for MachineFixture { + fn drop(&mut self) { + TEST_MACHINE_POLICY.with(|value| *value.borrow_mut() = None); + } + } + fn private_profile() -> DisconnectedPolicy { + DisconnectedPolicy { + authority: None, + routes: vec![DisconnectedRoute { + model: "ollama/customer-model".into(), + endpoint: "http://127.0.0.1:11434/v1".into(), + credential_env: None, + }], + } + } + + #[test] + fn disconnected_authority_requires_customer_origin_and_signed_tenant_scope() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + let mut profile = private_profile(); + profile.authority = Some(DisconnectedAuthority { + endpoint: "https://identity.customer.example".into(), + credential_env: "CUSTOMER_CODE_ACCESS_TOKEN".into(), + ca_pem: None, + }); + fixture.install(profile); + let mut metadata = load_managed_policy(false).unwrap().unwrap().metadata; + let mut authority = DisconnectedAuthority { + endpoint: "https://identity.customer.example".into(), + credential_env: "CUSTOMER_CODE_ACCESS_TOKEN".into(), + ca_pem: None, + }; + assert!(validate_disconnected_authority(&authority, &metadata).is_ok()); + let workspace = metadata.workspace_id.as_deref().unwrap(); + assert!( + crate::private_code_authority::validate_request_target( + &authority.endpoint, + &metadata.org_id, + workspace + ) + .is_ok() + ); + assert!( + crate::private_code_authority::validate_request_target( + &authority.endpoint, + "other", + workspace + ) + .is_err() + ); + assert!( + crate::private_code_authority::validate_request_target( + "https://other.customer.example", + &metadata.org_id, + workspace + ) + .is_err() + ); + for endpoint in [ + "http://10.0.0.1", + "https://identity.evalops.dev", + "https://identity.evalops.dev.", + "https://deixic.com", + "https://identity.customer.example/path", + "https://user@identity.customer.example", + "https://identity.customer.example?query", + "https://169.254.169.254", + "https://[fe80::1]", + "https://metadata.google.internal", + ] { + authority.endpoint = endpoint.into(); + assert!( + validate_disconnected_authority(&authority, &metadata).is_err(), + "{endpoint}" + ); + } + authority.endpoint = "https://identity.customer.example".into(); + authority.credential_env = "MAESTRO_EVALOPS_ACCESS_TOKEN".into(); + assert!(validate_disconnected_authority(&authority, &metadata).is_err()); + authority.credential_env = "CUSTOMER_CODE_ACCESS_TOKEN".into(); + let certificate = + rcgen::generate_simple_self_signed(vec!["customer.example".into()]).unwrap(); + authority.ca_pem = Some(certificate.cert.pem()); + assert!(validate_disconnected_authority(&authority, &metadata).is_ok()); + for bundle in [ + String::new(), + "x".repeat(64 * 1024 + 1), + format!( + "{}{}", + certificate.cert.pem(), + certificate.signing_key.serialize_pem() + ), + ] { + authority.ca_pem = Some(bundle); + assert!(validate_disconnected_authority(&authority, &metadata).is_err()); + } + let path = fixture.directory.path().join("managed-policy.json"); + let installed = std::fs::read(&path).unwrap(); + let mut envelope: ManagedPolicyEnvelope = serde_json::from_slice(&installed).unwrap(); + envelope.policy_version += 1; + envelope + .policy + .disconnected + .as_mut() + .unwrap() + .authority + .as_mut() + .unwrap() + .ca_pem = Some(format!( + "{}{}", + certificate.cert.pem(), + certificate.signing_key.serialize_pem() + )); + let key = ring::signature::Ed25519KeyPair::from_seed_unchecked(&[17; 32]).unwrap(); + let payload = canonical_managed_policy_payload(&envelope); + envelope.policy_hash = sha256_hex(&payload); + envelope.signature = URL_SAFE_NO_PAD.encode(key.sign(&payload).as_ref()); + assert!(publish_managed_policy(envelope).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), installed); + authority.ca_pem = None; + metadata.workspace_id = None; + assert!(validate_disconnected_authority(&authority, &metadata).is_err()); + } + + #[test] + fn disconnected_credentials_require_encrypted_nonlocal_transport() { + let mut route = private_profile().routes.remove(0); + route.credential_env = Some("CUSTOMER_MODEL_API_KEY".into()); + for endpoint in ["http://10.0.0.2/v1", "http://models.customer.example/v1"] { + route.endpoint = endpoint.into(); + assert!( + validate_disconnected_route(&route) + .unwrap_err() + .contains("HTTPS") + ); + } + for endpoint in [ + "https://10.0.0.2/v1", + "http://127.0.0.1:8080/v1", + "http://[::1]:8080/v1", + ] { + route.endpoint = endpoint.into(); + assert!(validate_disconnected_route(&route).is_ok()); + } + } + + #[test] + fn disconnected_policy_uses_signed_machine_routes_and_rejects_tampering() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + assert!(vendor_network_disabled()); + assert!( + disconnected_route("ollama/customer-model") + .unwrap() + .is_some() + ); + assert!(disconnected_route("openai/gpt-5.5").is_err()); + assert!(require_vendor_network().is_err()); + let path = fixture.directory.path().join("managed-policy.json"); + let mut envelope: ManagedPolicyEnvelope = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + envelope.policy.disconnected.as_mut().unwrap().routes[0].endpoint = + "https://api.openai.com/v1".into(); + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(disconnected_policy().is_err()); + assert!(vendor_network_disabled()); + assert!(check_tool_allowed("read").is_some()); + } + + #[test] + fn disconnected_admission_never_verifies_stored_identity() { + let _env = crate::config::test_process_env_lock(); + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let names = [ + "MAESTRO_HOME", + "MAESTRO_IDENTITY_URL", + "OLLAMA_BASE_URL", + "MAESTRO_DISABLE_KEYCHAIN", + "MAESTRO_OAUTH_STORAGE_MODE", + ]; + let previous: Vec<_> = names + .iter() + .map(|name| (*name, std::env::var(name).ok())) + .collect(); + std::env::set_var("MAESTRO_HOME", fixture.directory.path()); + std::env::set_var("MAESTRO_IDENTITY_URL", &endpoint); + std::env::set_var("OLLAMA_BASE_URL", &endpoint); + std::env::set_var("MAESTRO_DISABLE_KEYCHAIN", "1"); + std::env::set_var("MAESTRO_OAUTH_STORAGE_MODE", "file"); + for expiry in [None, Some(1_i64), Some(i64::MAX)] { + if let Some(expires) = expiry { + std::fs::write(fixture.directory.path().join("oauth.json"), serde_json::to_vec(&serde_json::json!({ + "evalops": {"type":"oauth", "access":"stored-test-token", "refresh":"stored-test-refresh", "expires":expires, + "metadata":{"identityBaseUrl": endpoint}} + })).unwrap()).unwrap(); + } + let (mode, identity) = + crate::credential_mode::require_ready_with_identity("ollama/customer-model") + .unwrap(); + assert!(matches!(mode, crate::credential_mode::DetectedMode::Byok)); + assert!(identity.is_none()); + assert!(crate::credential_mode::verified_current_identity_session().is_err()); + assert!(crate::init_cli::load_current_evalops_snapshot().is_err()); + } + assert!(crate::credential_mode::require_ready_with_identity("evalops/default").is_err()); + assert!( + crate::local_models::local_runtime_endpoints(&std::collections::HashMap::new()) + .is_empty() + ); + assert!(crate::telemetry::experiments_telemetry_disabled()); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + for (name, value) in previous { + restore_env_var(name, value); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn disconnected_enrolled_tool_authority_does_not_fall_back() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + let record = fixture.directory.path().join("code-device.json"); + std::fs::write(&record, r#"{"key_id":"enrolled-device"}"#).unwrap(); + let authority = crate::code_authority::CodeToolAuthority::configured_at(record) + .expect("enrollment remains authoritative despite unavailable Platform"); + let error = authority + .authorize(serde_json::json!({"tool":"read"})) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("Vendor connections are disabled") + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn disconnected_native_and_subagent_admission_preserve_policy() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + let config = crate::agent::NativeAgentConfig { + model: "ollama/customer-model".into(), + cwd: fixture.directory.path().to_string_lossy().into_owned(), + ..Default::default() + }; + let (agent, _) = + crate::agent::NativeAgent::new(config.clone()).expect("private native agent"); + let (child, _) = crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( + config.clone(), + crate::agent::CredentialVault::new(), + "parent-scope".into(), + ) + .expect("private child agent"); + drop(child); + drop(agent); + let forbidden = crate::agent::NativeAgentConfig { + model: "evalops/default".into(), + ..config.clone() + }; + assert!(crate::agent::NativeAgent::new(forbidden.clone()).is_err()); + assert!( + crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( + forbidden, + crate::agent::CredentialVault::new(), + "parent-scope".into() + ) + .is_err() + ); + let injected = + crate::ai::UnifiedClient::OpenAI(crate::ai::OpenAiClient::new("fixture").unwrap()); + assert!(crate::agent::NativeAgent::new_with_client(config, injected).is_err()); + } + + #[test] + fn disconnected_managed_setup_never_fetches_or_reopens_enrolled_mcp() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + let session = crate::credential_mode::PlatformSession { + access_token: "expired-stored-token".into(), + organization_id: "customer".into(), + workspace_id: Some("workspace".into()), + provider_ref: serde_json::Value::Null, + email: None, + user_id: None, + }; + let setup = crate::managed_setup::ManagedSetupClient::resolve_with( + Some(&session), + None, + 0, + Duration::ZERO, + |_| panic!("disconnected setup must not fetch"), + ); + assert_eq!( + setup.origin(), + crate::managed_setup::ManagedSetupOrigin::FailedClosed + ); + assert_eq!( + setup.mcp_policy(), + &crate::managed_setup::McpPolicy::deny_all() + ); + assert!(crate::managed_setup::fetch_managed_setup(&session).is_err()); + } + + #[test] + fn disconnected_route_update_requires_restart_for_pinned_clients() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + assert!(disconnected_policy().unwrap().is_some()); + let path = fixture.directory.path().join("managed-policy.json"); + let mut envelope: ManagedPolicyEnvelope = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + envelope.policy_version += 1; + envelope.policy.disconnected.as_mut().unwrap().routes[0].endpoint = + "http://127.0.0.1:1234/v1".into(); + let key = ring::signature::Ed25519KeyPair::from_seed_unchecked(&[17; 32]).unwrap(); + let payload = canonical_managed_policy_payload(&envelope); + envelope.policy_hash = sha256_hex(&payload); + envelope.signature = URL_SAFE_NO_PAD.encode(key.sign(&payload).as_ref()); + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + persist_managed_policy_watermark(&path, envelope.policy_version, &envelope.policy_hash) + .unwrap(); + assert!(disconnected_policy().unwrap_err().contains("restart")); + assert!(vendor_network_disabled()); + assert!(check_model_allowed("ollama/customer-model").is_some()); + } + + #[test] + fn disconnected_expired_policy_blocks_model_and_tool_admission() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + assert!(disconnected_policy().unwrap().is_some()); + let path = fixture.directory.path().join("managed-policy.json"); + let mut envelope: ManagedPolicyEnvelope = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + envelope.issued_at = 1; + envelope.expires_at = 2; + std::fs::write(&path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + assert!(disconnected_policy().unwrap_err().contains("expired")); + assert!(vendor_network_disabled()); + assert!(check_model_allowed("ollama/customer-model").is_some()); + assert!(check_tool_allowed("read").is_some()); + assert!( + crate::private_code_authority::validate_request_target( + "https://identity.customer.example", + "org-1", + "workspace-1" + ) + .is_err() + ); + } + + #[test] + fn disconnected_machine_uninstall_requires_process_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_owned(); + let enrolled = std::sync::atomic::AtomicBool::new(false); + assert_eq!( + machine_policy_path_at(&path, &enrolled), + Some(path.join("managed-policy.json")) + ); + directory.close().unwrap(); + assert_eq!( + machine_policy_path_at(&path, &enrolled), + Some(path.join("managed-policy.json")) + ); + assert!( + machine_policy_path_at(&path, &std::sync::atomic::AtomicBool::new(false)).is_none() + ); + } + + #[test] + fn disconnected_missing_or_writable_machine_policy_fails_closed() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + assert!(disconnected_policy().is_err()); + assert!(vendor_network_disabled()); + fixture.install(private_profile()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = fixture.directory.path().join("managed-policy.json"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap(); + assert!(disconnected_policy().is_err()); + assert!(vendor_network_disabled()); + } + } + // ======================================================================== // Private IP Detection Tests // ======================================================================== @@ -2028,6 +2821,7 @@ mod tests { } fn managed_test_policy() -> EnterprisePolicy { EnterprisePolicy { + disconnected: None, org_id: Some("org-1".to_string()), tools: Some(PolicyList { allowed: Some(vec!["bash".to_string(), "read".to_string()]), diff --git a/packages/local-host-rs/src/subagents/mod.rs b/packages/local-host-rs/src/subagents/mod.rs index 989b73369..bfaaeeca0 100644 --- a/packages/local-host-rs/src/subagents/mod.rs +++ b/packages/local-host-rs/src/subagents/mod.rs @@ -2223,6 +2223,11 @@ impl SubagentManager { .with_details(details) } + /// Runtime-held records use the same byte-preserving path encoding as persisted children. + pub(crate) fn coding_validator_working_directory(record: &SubagentRecord) -> PathBuf { + deserialize_repository_path(&record.cwd) + } + pub(crate) fn coding_validator_record(&self, id: &str) -> Result { self.coding_validator_receipts .lock() diff --git a/packages/local-host-rs/src/telemetry/journal.rs b/packages/local-host-rs/src/telemetry/journal.rs index 90b9715ec..08f83fb9f 100644 --- a/packages/local-host-rs/src/telemetry/journal.rs +++ b/packages/local-host-rs/src/telemetry/journal.rs @@ -12,6 +12,17 @@ pub(crate) struct TurnJournal { records: HashMap, } +impl Drop for TurnJournal { + fn drop(&mut self) { + // A forked child can retain this open file description until exec. + // Closing only our descriptor leaves its flock held by that child, + // even though this producer has finished. Release the lock explicitly. + if let Err(error) = self._lease.unlock() { + tracing::warn!(%error, "telemetry producer lease could not be released"); + } + } +} + impl TurnJournal { pub(crate) fn open() -> Option { if first_party_telemetry_disabled() { @@ -47,8 +58,9 @@ impl TurnJournal { /// description into the child. `O_CLOEXEC` closes the child's copy only at /// `exec`, so any process this host spawns -- a bash tool, an MCP server, /// an LSP, a subagent -- holds every open journal lease for the width of - /// its own fork-to-exec window. During that window a dropped producer's - /// lease still reports `WouldBlock`, and recovery used to read that as "a + /// its own fork-to-exec window. Normal producer Drop explicitly unlocks + /// the lease. If a producer exits without running destructors, the child's + /// inherited lease still reports `WouldBlock`, and recovery used to read that as "a /// live producer owns this journal" and abandon the pending record. A probe /// against this API with eight spawning threads lost the record in 245 of /// 300 trials. The window is microseconds wide, so re-reading it over a @@ -288,6 +300,23 @@ mod tests { assert!(producer.write(&event).is_none()); } + #[test] + #[cfg(unix)] + fn producer_drop_releases_lease_even_while_an_inherited_description_remains_open() { + let root = tempfile::tempdir().unwrap(); + let outbox = root.path().join("outbox"); + let mut producer = TurnJournal::open_at(outbox.clone()).unwrap(); + let event = super::super::tests::canonical_event(TurnStatus::Success); + producer.write(&event).unwrap(); + // dup and fork preserve the same open file description and flock. + // Retain it throughout recovery instead of depending on exec timing. + let inherited = producer._lease.try_clone().unwrap(); + drop(producer); + TurnJournal::recover(&outbox, &outbox.join("pending")); + assert_eq!(outbox_paths(&outbox).len(), 1); + assert!(inherited.metadata().is_ok()); + } + #[test] fn recovery_reclaims_a_lease_a_forked_child_transiently_inherited() { use std::sync::Arc; diff --git a/packages/local-host-rs/src/telemetry/staged_rollout.rs b/packages/local-host-rs/src/telemetry/staged_rollout.rs index aa1ea7cc5..3db84f482 100644 --- a/packages/local-host-rs/src/telemetry/staged_rollout.rs +++ b/packages/local-host-rs/src/telemetry/staged_rollout.rs @@ -1168,7 +1168,8 @@ fn first_party_delivery_session() -> Option { } fn first_party_telemetry_disabled() -> bool { - true_flag("MAESTRO_INTERNAL_TELEMETRY_DISABLED") + crate::safety::vendor_network_disabled() + || true_flag("MAESTRO_INTERNAL_TELEMETRY_DISABLED") || true_flag("EVALOPS_INTERNAL_TELEMETRY_DISABLED") || telemetry_flag() == Some(false) } @@ -1352,6 +1353,9 @@ pub fn test_record_visibility_with_session( /// only a restriction: collection still requires matching live Identity authority. /// An absent origin cannot be filled in by a later login. pub fn onboarding_identity_scope() -> Option { + if first_party_telemetry_disabled() { + return None; + } let env = std::env::vars().collect(); let snapshot = crate::init_cli::load_evalops_snapshot().ok().flatten(); let session = crate::credential_mode::platform_session_from(snapshot.as_ref(), &env)?; diff --git a/packages/local-host-rs/src/tools/bash/mod.rs b/packages/local-host-rs/src/tools/bash/mod.rs index ecea1a6f7..9b98c03d0 100644 --- a/packages/local-host-rs/src/tools/bash/mod.rs +++ b/packages/local-host-rs/src/tools/bash/mod.rs @@ -3637,6 +3637,8 @@ mod tests { let tool = BashTool::new(workspace.path().display().to_string()); let result = tool .execute(BashArgs { + // Publish the complete PID atomically: file existence must + // not race the shell redirection before printf writes bytes. // The detached child records its PID immediately but delays // the workspace mutation for 30s. A 1s delay raced the // assertion path below on loaded CI runners: if the pid-file @@ -3648,7 +3650,7 @@ mod tests { // process-group assertions below remain the kill-correctness // proof. command: format!( - "setsid sh -c 'printf %s \"$$\" > \"{}\"; sleep 30; printf leaked > \"{}\"' &", + "setsid sh -c 'printf %s \"$$\" > \"{0}.tmp\"; mv -- \"{0}.tmp\" \"{0}\"; sleep 30; printf leaked > \"{1}\"' &", detached_pid_path.display(), sentinel.display() ), diff --git a/packages/local-host-rs/src/tools/registry/coding_task.rs b/packages/local-host-rs/src/tools/registry/coding_task.rs index 8b6773831..5c4e360f3 100644 --- a/packages/local-host-rs/src/tools/registry/coding_task.rs +++ b/packages/local-host-rs/src/tools/registry/coding_task.rs @@ -10,8 +10,10 @@ use crate::tools::details::BashDetails; use maestro_runtime::coding_acceptance::*; use serde::Deserialize; use serde_json::{Value, json}; +use std::fmt::Write as _; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use tokio_util::sync::CancellationToken; #[derive(Clone)] @@ -33,8 +35,10 @@ pub(super) struct CodingTaskState { completed: Option<(CodingCompletionSubmission, Vec)>, } -fn git(cwd: &Path, args: &[&str]) -> Result { +fn git_raw(cwd: &Path, args: &[&str]) -> Result, String> { let output = Command::new("git") + .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_NO_LAZY_FETCH", "1") .args(args) .current_dir(cwd) .output() @@ -42,7 +46,12 @@ fn git(cwd: &Path, args: &[&str]) -> Result { if !output.status.success() { return Err("Cannot establish coding repository identity".into()); } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned()) + Ok(output.stdout) +} +fn git(cwd: &Path, args: &[&str]) -> Result { + Ok(String::from_utf8_lossy(&git_raw(cwd, args)?) + .trim() + .to_owned()) } fn checkout(cwd: &Path) -> Result<(PathBuf, String), String> { Ok(( @@ -124,9 +133,288 @@ fn ensure_revision(state: &CodingTaskState) -> Result<(), String> { if root != state.root || revision != state.revision { return Err("Coding evidence is stale: run coding_task readiness at current HEAD, then rerun validation".into()); } - if !git( - &root, - &["status", "--porcelain", "--untracked-files=normal"], + verify_physical_checkout(&root, &revision)?; + Ok(()) +} +// Authenticate each object against its content address before trusting any links. +// Git's ordinary object reads do not detect a loose object's contents replaced +// under an unchanged filename. Replace refs must never rewrite this proof chain. +fn verified_git_object( + root: &Path, + kind: &str, + oid: &str, + limit: usize, +) -> Result, String> { + if !matches!(oid.len(), 40 | 64) || !oid.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("Invalid coding Git object identity".into()); + } + let size: usize = git(root, &["cat-file", "-s", oid])? + .parse() + .map_err(|_| "Invalid coding Git object size")?; + if size > limit { + return Err("Coding Git object exceeds verification bound".into()); + } + let bytes = git_raw(root, &["cat-file", kind, oid])?; + if bytes.len() != size { + return Err("Coding Git object size changed".into()); + } + let mut child = Command::new("git") + .env("GIT_NO_REPLACE_OBJECTS", "1") + .env("GIT_NO_LAZY_FETCH", "1") + .args(["hash-object", "-t", kind, "--stdin"]) + .current_dir(root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + child + .stdin + .take() + .ok_or("Missing Git hash input")? + .write_all(&bytes) + .map_err(|e| e.to_string())?; + let hash = child.wait_with_output().map_err(|e| e.to_string())?; + if !hash.status.success() || hash.stdout != format!("{oid}\n").as_bytes() { + return Err("Coding Git object content does not match its identity".into()); + } + Ok(bytes) +} + +fn verified_path_blob(root: &Path, revision: &str, path: &str) -> Result, String> { + let commit = verified_git_object(root, "commit", revision, 1_048_576)?; + let tree_line = commit + .split(|b| *b == b'\n') + .next() + .ok_or("Missing commit tree")?; + let mut tree = std::str::from_utf8(tree_line) + .map_err(|_| "Invalid commit tree")? + .strip_prefix("tree ") + .ok_or("Missing commit tree")? + .to_owned(); + let parts = path.split('/').collect::>(); + for (index, part) in parts.iter().enumerate() { + let bytes = verified_git_object(root, "tree", &tree, 16_777_216)?; + let width = tree.len() / 2; + let mut remaining = bytes.as_slice(); + let mut selected = None; + while !remaining.is_empty() { + let nul = remaining + .iter() + .position(|b| *b == 0) + .ok_or("Invalid Git tree entry")?; + let header = &remaining[..nul]; + let space = header + .iter() + .position(|b| *b == b' ') + .ok_or("Invalid Git tree mode")?; + let raw_id = remaining + .get(nul + 1..nul + 1 + width) + .ok_or("Invalid Git tree object")?; + if &header[space + 1..] == part.as_bytes() { + if selected.is_some() { + return Err("Duplicate Git tree path".into()); + } + let mut oid = String::with_capacity(width * 2); + for byte in raw_id { + write!(&mut oid, "{byte:02x}").map_err(|_| "Invalid Git object identity")?; + } + selected = Some((header[..space].to_vec(), oid)); + } + remaining = &remaining[nul + 1 + width..]; + } + let Some((mode, oid)) = selected else { + return Ok(None); + }; + if index + 1 == parts.len() { + if mode != b"100644" && mode != b"100755" { + return Err("Coding output must be a regular Git blob".into()); + } + return Ok(Some(oid)); + } + if mode != b"40000" { + return Err("Coding output parent must be a Git tree".into()); + } + tree = oid; + } + Err("Invalid coding output path".into()) +} + +// Validate the physical candidate independently of index stat hints, filters, +// replace refs and promisor fetches. Bounds apply to the verification operation; +// the smaller output publication limit is enforced separately. +fn verify_physical_checkout(root: &Path, revision: &str) -> Result<(), String> { + let commit = verified_git_object(root, "commit", revision, 1_048_576)?; + let tree = std::str::from_utf8( + commit + .split(|b| *b == b'\n') + .next() + .ok_or("Missing commit tree")?, + ) + .map_err(|_| "Invalid commit tree")? + .strip_prefix("tree ") + .ok_or("Missing commit tree")?; + let mut pending = vec![(tree.to_owned(), String::new(), 0usize)]; + let mut expected = std::collections::BTreeMap::new(); + let mut directories = std::collections::BTreeSet::from([String::new()]); + let mut symlinks = Vec::new(); + let mut total = 0usize; + let mut trees = 0usize; + while let Some((tree, prefix, depth)) = pending.pop() { + trees += 1; + if depth > 256 || trees > 100_000 { + return Err("Coding tree verification bound exceeded".into()); + } + let bytes = verified_git_object(root, "tree", &tree, 16_777_216)?; + let width = tree.len() / 2; + let mut remaining = bytes.as_slice(); + let mut names = std::collections::BTreeSet::new(); + while !remaining.is_empty() { + let nul = remaining + .iter() + .position(|b| *b == 0) + .ok_or("Invalid Git tree entry")?; + let header = &remaining[..nul]; + let space = header + .iter() + .position(|b| *b == b' ') + .ok_or("Invalid Git tree mode")?; + let mode = + std::str::from_utf8(&header[..space]).map_err(|_| "Invalid Git tree mode")?; + let name = std::str::from_utf8(&header[space + 1..]) + .map_err(|_| "Coding paths must be UTF-8")?; + if name.is_empty() + || matches!(name, "." | "..") + || name.eq_ignore_ascii_case(".git") + || name.contains('/') + || name.contains('\\') + || !names.insert(name.to_owned()) + { + return Err("Invalid coding tree path".into()); + } + let raw_id = remaining + .get(nul + 1..nul + 1 + width) + .ok_or("Invalid Git tree object")?; + let mut oid = String::with_capacity(width * 2); + for byte in raw_id { + write!(&mut oid, "{byte:02x}").map_err(|_| "Invalid Git object identity")?; + } + remaining = &remaining[nul + 1 + width..]; + let path = if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}/{name}") + }; + let physical = root.join(&path); + let metadata = std::fs::symlink_metadata(&physical).map_err(|e| e.to_string())?; + match mode { + "40000" => { + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Coding tree directory differs physically".into()); + } + directories.insert(path.clone()); + pending.push((oid, path, depth + 1)); + } + "100644" | "100755" | "120000" => { + let blob = verified_git_object(root, "blob", &oid, 67_108_864)?; + total = total + .checked_add(blob.len()) + .ok_or("Coding verification size overflow")?; + if total > 536_870_912 || expected.len() >= 100_000 { + return Err("Coding checkout verification bound exceeded".into()); + } + if mode == "120000" { + if !metadata.file_type().is_symlink() + || std::fs::read_link(&physical) + .map_err(|e| e.to_string())? + .as_os_str() + .as_encoded_bytes() + != blob + { + return Err("Coding symlink differs from committed target".into()); + } + symlinks.push(physical.clone()); + } else { + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() != blob.len() as u64 + || std::fs::read(&physical).map_err(|e| e.to_string())? != blob + { + return Err( + "Coding tracked physical bytes differ from committed candidate" + .into(), + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if (metadata.permissions().mode() & 0o111 != 0) != (mode == "100755") { + return Err("Coding tracked executable mode differs from committed candidate".into()); + } + } + } + expected.insert(path, (mode.to_owned(), oid)); + } + "160000" => return Err("Coding acceptance does not support submodules".into()), + _ => return Err("Unsupported coding Git tree mode".into()), + } + } + } + // A committed link may point only into this authenticated tracked tree. + // Dangling, external and ignored-file targets cannot stand in for source. + for link in symlinks { + let target = dunce::canonicalize(&link).map_err(|e| e.to_string())?; + let relative = target + .strip_prefix(root) + .map_err(|_| "Coding symlink leaves the candidate tree")? + .to_str() + .ok_or("Coding symlink target must be UTF-8")?; + if !expected.contains_key(relative) && !directories.contains(relative) { + return Err("Coding symlink target is not tracked source".into()); + } + } + let index = git_raw( + root, + &["-c", "core.fsmonitor=false", "ls-files", "--stage", "-z"], + )?; + let mut observed = std::collections::BTreeMap::new(); + for entry in index.split(|b| *b == 0).filter(|e| !e.is_empty()) { + let tab = entry + .iter() + .position(|b| *b == b'\t') + .ok_or("Invalid coding index")?; + let header = std::str::from_utf8(&entry[..tab]) + .map_err(|_| "Invalid coding index")? + .split(' ') + .collect::>(); + let path = + std::str::from_utf8(&entry[tab + 1..]).map_err(|_| "Invalid coding index path")?; + if header.len() != 3 + || header[2] != "0" + || observed + .insert( + path.to_owned(), + (header[0].to_owned(), header[1].to_owned()), + ) + .is_some() + { + return Err("Coding index contains unresolved entries".into()); + } + } + if observed != expected { + return Err("Coding index differs from committed candidate".into()); + } + if !git_raw( + root, + &[ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "ls-files", + "--others", + "--exclude-standard", + "-z", + ], )? .is_empty() { @@ -134,6 +422,56 @@ fn ensure_revision(state: &CodingTaskState) -> Result<(), String> { } Ok(()) } + +// Capture only authenticated changed regular Git blobs. The complete tool has +// no content argument; caller/model bytes cannot enter this path. +fn capture_outputs(state: &CodingTaskState) -> Result, String> { + let mut outputs = Vec::new(); + let mut total = 0usize; + for path in &state.contract.output_paths { + if !valid_output_path(path) { + return Err("Invalid coding output path".into()); + } + let oid = verified_path_blob(&state.root, &state.revision, path)? + .ok_or("Coding output is absent from the accepted revision")?; + let previous = verified_path_blob(&state.root, &state.base_revision, path)?; + if previous.as_ref() == Some(&oid) { + return Err("Coding output content must differ from the baseline".into()); + } + // The authenticated baseline tree binds its blob ID; no old content is + // consumed. Only newly published output bytes carry the 64 KiB bound. + let bytes = verified_git_object(&state.root, "blob", &oid, 65_536)?; + let mut physical = state.root.clone(); + for component in path.split('/') { + physical.push(component); + let metadata = std::fs::symlink_metadata(&physical).map_err(|e| e.to_string())?; + if metadata.file_type().is_symlink() { + return Err("Coding output physical path must not traverse symlinks".into()); + } + } + let metadata = std::fs::symlink_metadata(&physical).map_err(|e| e.to_string())?; + if !metadata.is_file() + || metadata.len() != bytes.len() as u64 + || std::fs::read(&physical).map_err(|e| e.to_string())? != bytes + { + return Err("Coding output physical bytes differ from the committed blob".into()); + } + + total = total + .checked_add(bytes.len()) + .ok_or("Coding output size overflow")?; + if bytes.is_empty() || total > 65_536 { + return Err("Coding outputs exceed the 64 KiB bound or are empty".into()); + } + outputs.push(CodingOutputFile { + path: path.clone(), + content: String::from_utf8(bytes).map_err(|_| "Coding output must be UTF-8")?, + }); + } + ensure_revision(state)?; + Ok(outputs) +} + fn text_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> { args.get(key) .and_then(Value::as_str) @@ -490,6 +828,7 @@ impl ToolExecutor { review: Some(review), behavior: Some(behavior), handoff_items: state.handoffs.clone(), + outputs: capture_outputs(state)?, }; let children = vec![review_record, behavior_record]; let decision = evaluate_coding_acceptance( @@ -669,7 +1008,9 @@ impl ToolExecutor { "Validator profile, original attempt, or assigned revision mismatch".into(), ); } - let (child_root, child_head) = checkout(Path::new(&record.cwd))?; + let child_directory = + crate::tools::subagents::SubagentManager::coding_validator_working_directory(&record); + let (child_root, child_head) = checkout(&child_directory)?; if child_head != state.revision || !git( &child_root, @@ -882,6 +1223,7 @@ mod tests { readiness_requirements: vec!["test".into()], authorized_skips: vec![], authorized_dispositions: vec![], + output_paths: Vec::new(), }, work_id: "work".into(), session_id: "implementation".into(), @@ -898,6 +1240,295 @@ mod tests { completed: None, } } + #[test] + fn coding_revision_refuses_filters_and_hidden_dependency_edits_before_status() { + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + git(&root, &["init", "-q"]).unwrap(); + std::fs::write(root.join("dependency.py"), "trusted\n").unwrap(); + std::fs::write(root.join("output.py"), "new\n").unwrap(); + git(&root, &["add", "."]).unwrap(); + let commit = || { + git( + &root, + &[ + "-c", + "user.name=test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ], + ) + .unwrap() + }; + commit(); + let mut current = state(); + current.root = root.clone(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + current.contract.repository_id = root.to_string_lossy().into_owned(); + assert!(ensure_revision(¤t).is_ok()); + for (set, unset) in [ + ("--assume-unchanged", "--no-assume-unchanged"), + ("--skip-worktree", "--no-skip-worktree"), + ] { + git(&root, &["update-index", set, "dependency.py"]).unwrap(); + std::fs::write(root.join("dependency.py"), "forged dependency\n").unwrap(); + assert!(git(&root, &["status", "--porcelain"]).unwrap().is_empty()); + assert!(ensure_revision(¤t).is_err()); + std::fs::write(root.join("dependency.py"), "trusted\n").unwrap(); + git(&root, &["update-index", unset, "dependency.py"]).unwrap(); + } + git(&root, &["config", "core.trustctime", "false"]).unwrap(); + git(&root, &["config", "core.checkstat", "minimal"]).unwrap(); + let dependency = root.join("dependency.py"); + // Avoid Git's racily-clean timestamp window: the cached file time must + // precede the refreshed index, even on a fast or heavily loaded runner. + let cached_mtime = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000); + std::fs::File::open(&dependency) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(cached_mtime)) + .unwrap(); + assert!(git(&root, &["status", "--porcelain"]).unwrap().is_empty()); + let original_mtime = std::fs::metadata(&dependency).unwrap().modified().unwrap(); + std::fs::write(&dependency, "forged!\n").unwrap(); + std::fs::File::open(&dependency) + .unwrap() + .set_times(std::fs::FileTimes::new().set_modified(original_mtime)) + .unwrap(); + assert!(git(&root, &["status", "--porcelain"]).unwrap().is_empty()); + assert!(ensure_revision(¤t).is_err()); + std::fs::write(&dependency, "trusted\n").unwrap(); + assert!(ensure_revision(¤t).is_ok()); + #[cfg(unix)] + { + let link = root.join("tracked-link"); + std::os::unix::fs::symlink("dependency.py", &link).unwrap(); + git(&root, &["add", "tracked-link"]).unwrap(); + commit(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + assert!(ensure_revision(¤t).is_ok()); + let external = tempfile::tempdir().unwrap(); + let outside = external.path().join("outside.py"); + std::fs::write(&outside, "outside\n").unwrap(); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&outside, &link).unwrap(); + git(&root, &["add", "tracked-link"]).unwrap(); + commit(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + assert_eq!( + ensure_revision(¤t).unwrap_err(), + "Coding symlink leaves the candidate tree" + ); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink("dependency.py", &link).unwrap(); + git(&root, &["add", "tracked-link"]).unwrap(); + commit(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + } + std::fs::write( + root.join(".gitattributes"), + "output.py filter=qualification\n", + ) + .unwrap(); + git( + &root, + &[ + "config", + "filter.qualification.clean", + "cat >/dev/null; touch .git/filter-ran; printf 'new\n'", + ], + ) + .unwrap(); + git(&root, &["add", "."]).unwrap(); + commit(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + std::fs::write(root.join("output.py"), "bad\n").unwrap(); + assert!(git(&root, &["status", "--porcelain"]).unwrap().is_empty()); + assert!(root.join(".git/filter-ran").exists()); + std::fs::remove_file(root.join(".git/filter-ran")).unwrap(); + assert!(ensure_revision(¤t).is_err()); + assert!(!root.join(".git/filter-ran").exists()); + std::fs::write(root.join("output.py"), "new\n").unwrap(); + assert!(ensure_revision(¤t).is_ok()); + std::fs::create_dir(root.join("module")).unwrap(); + git( + &root, + &[ + "update-index", + "--add", + "--cacheinfo", + &format!("160000,{},module", current.revision), + ], + ) + .unwrap(); + commit(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + assert_eq!( + ensure_revision(¤t).unwrap_err(), + "Coding acceptance does not support submodules" + ); + } + + #[test] + fn coding_outputs_capture_exact_changed_regular_blobs_and_reject_unsafe_files() { + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + let commit = |root: &Path| { + git(root, &["add", "."]).unwrap(); + git( + root, + &[ + "-c", + "user.name=test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ], + ) + .unwrap(); + git(root, &["rev-parse", "HEAD"]).unwrap() + }; + git(&root, &["init", "-q"]).unwrap(); + // A large existing file may shrink to a bounded accepted output. + std::fs::write(root.join("output.py"), "x".repeat(65_537)).unwrap(); + let base = commit(&root); + std::fs::write(root.join("output.py"), "new\n").unwrap(); + let revision = commit(&root); + let mut current = state(); + current.root = root.clone(); + current.base_revision = base; + current.revision = revision; + current.contract.repository_id = root.to_string_lossy().into_owned(); + current.contract.output_paths = vec!["output.py".into()]; + assert_eq!(capture_outputs(¤t).unwrap()[0].content, "new\n"); + std::fs::write(root.join("output.py"), "bad\n").unwrap(); + assert_eq!( + capture_outputs(¤t).unwrap_err(), + "Coding output physical bytes differ from the committed blob" + ); + std::fs::write(root.join("output.py"), "new\n").unwrap(); + + // Git ordinarily accepts corrupted loose objects under the original OID. + // Neither unchanged HEAD nor a clean physical checkout authenticates them. + let blob = git(&root, &["rev-parse", "HEAD:output.py"]).unwrap(); + let tree = git(&root, &["rev-parse", "HEAD^{tree}"]).unwrap(); + for (kind, oid) in [ + ("blob", blob.clone()), + ("tree", tree), + ("commit", current.revision.clone()), + ] { + let original = git_raw(&root, &["cat-file", kind, &oid]).unwrap(); + let mut forged = original.clone(); + forged[0] ^= 1; + let input = root.join(".git/forged-object"); + std::fs::write(&input, &forged).unwrap(); + let forged_oid = git( + &root, + &[ + "hash-object", + "-w", + "--literally", + "-t", + kind, + input.to_str().unwrap(), + ], + ) + .unwrap(); + let object_path = |id: &str| root.join(".git/objects").join(&id[..2]).join(&id[2..]); + let target = object_path(&oid); + let encoded = std::fs::read(&target).unwrap(); + std::fs::remove_file(&target).unwrap(); + std::fs::copy(object_path(&forged_oid), &target).unwrap(); + assert_eq!(git_raw(&root, &["cat-file", kind, &oid]).unwrap(), forged); + assert!( + capture_outputs(¤t).is_err(), + "accepted corrupted {kind}" + ); + std::fs::remove_file(&target).unwrap(); + std::fs::write(target, encoded).unwrap(); + } + let missing = root.join(".git/objects").join(&blob[..2]).join(&blob[2..]); + let encoded = std::fs::read(&missing).unwrap(); + std::fs::remove_file(&missing).unwrap(); + assert!(verified_git_object(&root, "blob", &blob, 65_536).is_err()); + std::fs::write(&missing, encoded).unwrap(); + let replacement_file = root.join(".git/replacement"); + std::fs::write(&replacement_file, "forged replacement\n").unwrap(); + let replacement = git( + &root, + &["hash-object", "-w", replacement_file.to_str().unwrap()], + ) + .unwrap(); + git(&root, &["replace", &blob, &replacement]).unwrap(); + assert_eq!(capture_outputs(¤t).unwrap()[0].content, "new\n"); + git(&root, &["replace", "-d", &blob]).unwrap(); + + current.base_revision = current.revision.clone(); + git(&root, &["update-index", "--chmod=+x", "output.py"]).unwrap(); + git( + &root, + &[ + "-c", + "user.name=test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "mode only", + ], + ) + .unwrap(); + current.revision = git(&root, &["rev-parse", "HEAD"]).unwrap(); + assert_eq!( + capture_outputs(¤t).unwrap_err(), + "Coding output content must differ from the baseline" + ); + for path in [ + "é.py", + " leading.py", + "trailing.py ", + ":literal[1]*.py", + "glob[1]*.py", + ] { + current.base_revision = current.revision.clone(); + std::fs::write(root.join(path), "literal output\n").unwrap(); + // A glob-shaped path must not accidentally select this neighbor. + std::fs::write(root.join("literal1neighbor.py"), "not selected\n").unwrap(); + current.revision = commit(&root); + current.contract.output_paths = vec![path.into()]; + let output = capture_outputs(¤t).unwrap(); + assert_eq!(output.len(), 1); + assert_eq!(output[0].path, path); + assert_eq!(output[0].content, "literal output\n"); + } + current.contract.output_paths = vec!["output.py".into()]; + current.base_revision = current.revision.clone(); + assert!(capture_outputs(¤t).is_err()); + current.contract.output_paths = vec!["../outside".into()]; + assert!(capture_outputs(¤t).is_err()); + current.contract.output_paths = vec!["big.py".into()]; + std::fs::write(root.join("big.py"), vec![b'x'; 65_537]).unwrap(); + current.revision = commit(&root); + assert!(capture_outputs(¤t).is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink("output.py", root.join("link.py")).unwrap(); + current.contract.output_paths = vec!["link.py".into()]; + current.revision = commit(&root); + assert!(capture_outputs(¤t).is_err()); + } + } + fn result(exit_code: i32) -> (ToolResult, BashDetails) { let details = BashDetails { command: "cargo test".into(), diff --git a/packages/local-host-rs/src/tools/registry/coding_task/dispatcher_tests.rs b/packages/local-host-rs/src/tools/registry/coding_task/dispatcher_tests.rs index 9a1022d8c..db6049f0b 100644 --- a/packages/local-host-rs/src/tools/registry/coding_task/dispatcher_tests.rs +++ b/packages/local-host-rs/src/tools/registry/coding_task/dispatcher_tests.rs @@ -299,7 +299,18 @@ async fn dispatcher_completes_with_actual_terminal_producer_receipts_only_after_ timeout_ms: 10000, max_tokens: 1000, isolation: SubagentIsolation::Worktree, - cwd: child_root.to_string_lossy().into_owned(), + // Production records encode path bytes. Exercise both that wire + // representation and legacy plain UTF-8 records through completion. + cwd: if name == "behavior" { + use std::fmt::Write as _; + let mut encoded = String::new(); + for byte in child_root.to_string_lossy().as_bytes() { + write!(&mut encoded, "{byte:02x}").unwrap(); + } + format!("\0maestro-path-v1:{encoded}") + } else { + child_root.to_string_lossy().into_owned() + }, worktree_path: Some(child_root.to_string_lossy().into_owned()), worktree_cleaned: false, initial_files: vec![], @@ -348,11 +359,15 @@ async fn dispatcher_completes_with_actual_terminal_producer_receipts_only_after_ assert!(fixture.executor.coding_completion().is_err()); drop(unavailable); - let completed = fixture.action(json!({"action":"complete"})).await; + let completed = fixture.action(json!({"action":"complete", "outputs":[{"path":"forged.py","content":"model supplied bytes"}]})).await; assert!(completed.success, "completion failed: {completed:?}"); assert_eq!(completed.details.unwrap()["accepted"], true); let (_, proof, children) = fixture.executor.coding_completion().unwrap().unwrap(); assert_eq!(proof.revision, head); + assert!( + proof.outputs.is_empty(), + "model arguments cannot fabricate native captured outputs" + ); assert_eq!(children.len(), 2); assert_ne!(children[0].session_id, children[1].session_id); let store = MissionStore::load(MISSION, MissionStoreConfig::default()).unwrap(); diff --git a/packages/local-host-rs/src/tools/registry/tests.rs b/packages/local-host-rs/src/tools/registry/tests.rs index 57ae72b99..333e3aeba 100644 --- a/packages/local-host-rs/src/tools/registry/tests.rs +++ b/packages/local-host-rs/src/tools/registry/tests.rs @@ -1,4 +1,6 @@ use super::*; + +mod streaming_order; use std::path::Path; use crate::agent::{ @@ -2915,37 +2917,6 @@ async fn typed_bash_streaming_caps_live_output() { ); } -#[tokio::test] -async fn typed_bash_streaming_releases_low_volume_output_before_completion() { - let dir = tempfile::tempdir().unwrap(); - let executor = ToolExecutor::new(dir.path().to_str().unwrap()); - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let args = serde_json::json!({ - "command": "printf 'server ready'; sleep 1; printf ' done'" - }); - let execution = executor.execute_with_receipt("bash", &args, Some(&tx), "stream-low-volume"); - tokio::pin!(execution); - let deadline = tokio::time::sleep(std::time::Duration::from_millis(500)); - tokio::pin!(deadline); - - let first_output = loop { - tokio::select! { - result = &mut execution => panic!("Bash completed before streaming output: {result:?}"), - Some(event) = rx.recv() => { - if let FromAgent::ToolOutput { content, .. } = event { - break content; - } - } - () = &mut deadline => panic!("low-volume Bash output was buffered until completion"), - } - }; - assert!( - first_output.contains("server ready"), - "first output: {first_output:?}" - ); - let _ = execution.await; -} - #[test] fn cache_dependencies_follow_effective_search_cwd_and_lsp_inputs() { let search = cache_dependency_paths( diff --git a/packages/local-host-rs/src/tools/registry/tests/streaming_order.rs b/packages/local-host-rs/src/tools/registry/tests/streaming_order.rs new file mode 100644 index 000000000..be64d48ac --- /dev/null +++ b/packages/local-host-rs/src/tools/registry/tests/streaming_order.rs @@ -0,0 +1,59 @@ +use super::*; + +#[tokio::test] +async fn typed_bash_streaming_releases_low_volume_output_before_completion() { + let dir = tempfile::tempdir().unwrap(); + let executor = ToolExecutor::new(dir.path().to_str().unwrap()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let args = serde_json::json!({ + "command": "touch stream-start; while [ ! -f stream-emit ]; do sleep 0.01; done; printf 'server ready'; while [ ! -f stream-finish ]; do sleep 0.01; done; printf ' done'" + }); + let execution = executor.execute_with_receipt("bash", &args, Some(&tx), "stream-low-volume"); + tokio::pin!(execution); + + // Measure streaming after the real child starts; cold process startup is + // separate from the existing 500ms low-volume output deadline. + let started = async { + while !dir.path().join("stream-start").exists() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }; + tokio::select! { + result = &mut execution => panic!("Bash completed before fixture startup: {result:?}"), + result = tokio::time::timeout(std::time::Duration::from_secs(5), started) => { + result.expect("Bash fixture starts"); + } + } + std::fs::write(dir.path().join("stream-emit"), b"emit").unwrap(); + let deadline = tokio::time::sleep(std::time::Duration::from_millis(500)); + tokio::pin!(deadline); + + let first_output = loop { + tokio::select! { + biased; + Some(event) = rx.recv() => { + if let FromAgent::ToolOutput { content, .. } = event { + break content; + } + } + result = &mut execution => panic!("Bash completed before streaming output: {result:?}"), + () = &mut deadline => panic!("low-volume Bash output was buffered until completion"), + } + }; + assert!( + first_output.contains("server ready"), + "first output: {first_output:?}" + ); + assert!(!dir.path().join("stream-finish").exists()); + // Completion is impossible until the observer has received streamed output. + std::fs::write(dir.path().join("stream-finish"), b"finish").unwrap(); + let result = execution.await; + assert!(matches!(result.outcome, ToolOutcome::Succeeded { .. })); + let output = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + FromAgent::ToolOutput { content, .. } => Some(content), + _ => None, + }) + .collect::(); + assert!(output.contains(" done"), "final output: {output:?}"); +} diff --git a/packages/local-host-rs/src/ui_prefs.rs b/packages/local-host-rs/src/ui_prefs.rs index e59b2415d..fae7d1b21 100644 --- a/packages/local-host-rs/src/ui_prefs.rs +++ b/packages/local-host-rs/src/ui_prefs.rs @@ -17,7 +17,7 @@ pub struct UiPrefs { /// Display locale; unknown future locales fall back without losing other preferences. #[serde(default)] pub display_language: Option, - /// Suppresses the introductory walkthrough only; never proves runtime readiness. + /// Suppresses automatic first-run setup; never proves runtime readiness. #[serde(default)] pub onboarding_seen: bool, /// The user's choice for structured onboarding information collection. diff --git a/packages/maestro-rs/Cargo.toml b/packages/maestro-rs/Cargo.toml index 1f379c237..8127523c4 100644 --- a/packages/maestro-rs/Cargo.toml +++ b/packages/maestro-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maestro" -version = "0.10.90" +version = "0.10.91" edition = "2021" license = "MIT" description = "Canonical native Rust CLI for Deixic Code" diff --git a/packages/runtime-rs/src/agent/native.rs b/packages/runtime-rs/src/agent/native.rs index f05aa7970..06da98fb3 100644 --- a/packages/runtime-rs/src/agent/native.rs +++ b/packages/runtime-rs/src/agent/native.rs @@ -397,6 +397,7 @@ fn closed_tool_response_failure(call_id: &str) -> anyhow::Error { }) } mod attachments; +mod cancellation; mod codex; mod commands; mod context; @@ -813,6 +814,10 @@ fn goal_tools_visible_from_execution(execution: &ToolExecution) -> Option /// This enum is private to the module - external code interacts through /// `NativeAgent` methods which create and send these commands. enum AgentCommand { + /// Acknowledge only from the outer loop, after active-turn cleanup. + AwaitIdle { + reply: oneshot::Sender<()>, + }, ApplySelectiveSummary { messages: Vec, digest: String, @@ -1704,51 +1709,6 @@ impl NativeAgent { Ok(()) } - /// Cancel the current operation - pub fn cancel(&self) { - self.cancel_with_options(true); - } - - /// Cancel all queued and active work, close the command channel, and wait - /// for the background runner to exit. - /// - /// This is a lifecycle barrier: - /// buffered work must be preempted, active tool cleanup must finish, and - /// the runner task must return before this future completes. The external - /// repeat-signal monitor remains the hard escape hatch if platform cleanup - /// itself wedges. - pub async fn shutdown(mut self) { - self.shutdown_token.cancel(); - self.cancel_with_options(true); - let runner_handle = self.runner_handle.take(); - drop(self.command_tx); - if let Some(runner_handle) = runner_handle { - let _ = runner_handle.await; - } - } - - /// Cancel the current operation but keep any queued prompts. - pub fn cancel_keep_queue(&self) { - self.cancel_with_options(false); - } - - pub fn cancel_queued(&self, id: u64) { - let _ = self.command_tx.send(AgentCommand::CancelQueued { id }); - } - - pub fn reorder_queued(&self, id: u64, placement: QueuePlacement) { - let _ = self - .command_tx - .send(AgentCommand::ReorderQueued { id, placement }); - } - - fn cancel_with_options(&self, clear_pending: bool) { - // Preserve channel order before synchronously waking the runner. The - // runner can then drain every prompt queued before this cancellation. - let _ = self.command_tx.send(AgentCommand::Cancel { clear_pending }); - cancel_active_operation(&self.active_cancellation); - } - /// Clear conversation history pub fn clear_history(&self) { let _ = self.command_tx.send(AgentCommand::ClearHistory); diff --git a/packages/runtime-rs/src/agent/native/cancellation.rs b/packages/runtime-rs/src/agent/native/cancellation.rs new file mode 100644 index 000000000..cef6ee2a7 --- /dev/null +++ b/packages/runtime-rs/src/agent/native/cancellation.rs @@ -0,0 +1,65 @@ +//! Ordered cancellation and native actor cleanup barriers. +use super::*; + +impl NativeAgent { + /// Cancel the current operation + pub fn cancel(&self) { + self.cancel_with_options(true); + } + + /// Cancel all queued and active work, close the command channel, and wait + /// for the background runner to exit. + /// + /// This is a lifecycle barrier: + /// buffered work must be preempted, active tool cleanup must finish, and + /// the runner task must return before this future completes. The external + /// repeat-signal monitor remains the hard escape hatch if platform cleanup + /// itself wedges. + pub async fn shutdown(mut self) { + self.shutdown_token.cancel(); + self.cancel_with_options(true); + let runner_handle = self.runner_handle.take(); + drop(self.command_tx); + if let Some(runner_handle) = runner_handle { + let _ = runner_handle.await; + } + } + + /// Cancel active and queued work and return a receiver for the cleanup barrier. + /// A dropped receiver or closed command channel never authorizes a transition. + pub fn cancel_for_session_transition(&self) -> Result> { + let (reply, settled) = oneshot::channel(); + self.command_tx + .send(AgentCommand::Cancel { + clear_pending: true, + }) + .map_err(|error| anyhow::anyhow!("Failed to cancel session work: {error}"))?; + self.command_tx + .send(AgentCommand::AwaitIdle { reply }) + .map_err(|error| anyhow::anyhow!("Failed to await session cleanup: {error}"))?; + cancel_active_operation(&self.active_cancellation); + Ok(settled) + } + + /// Cancel the current operation but keep any queued prompts. + pub fn cancel_keep_queue(&self) { + self.cancel_with_options(false); + } + + pub fn cancel_queued(&self, id: u64) { + let _ = self.command_tx.send(AgentCommand::CancelQueued { id }); + } + + pub fn reorder_queued(&self, id: u64, placement: QueuePlacement) { + let _ = self + .command_tx + .send(AgentCommand::ReorderQueued { id, placement }); + } + + fn cancel_with_options(&self, clear_pending: bool) { + // Preserve channel order before synchronously waking the runner. The + // runner can then drain every prompt queued before this cancellation. + let _ = self.command_tx.send(AgentCommand::Cancel { clear_pending }); + cancel_active_operation(&self.active_cancellation); + } +} diff --git a/packages/runtime-rs/src/agent/native/commands.rs b/packages/runtime-rs/src/agent/native/commands.rs index e0d133026..6fe1fb256 100644 --- a/packages/runtime-rs/src/agent/native/commands.rs +++ b/packages/runtime-rs/src/agent/native/commands.rs @@ -413,6 +413,10 @@ impl NativeAgentRunner { break; }; match cmd { + AgentCommand::AwaitIdle { reply } => { + // Active-turn command draining defers this until cleanup completes. + let _ = reply.send(()); + } #[cfg(test)] AgentCommand::InspectSession { capture, reply } => { let _ = reply.send(tests::session_scenarios::inspect(&self, capture)); diff --git a/packages/runtime-rs/src/agent/native/tests.rs b/packages/runtime-rs/src/agent/native/tests.rs index 68a91a1f8..1e33efa99 100644 --- a/packages/runtime-rs/src/agent/native/tests.rs +++ b/packages/runtime-rs/src/agent/native/tests.rs @@ -4812,105 +4812,8 @@ async fn shutdown_drops_an_in_flight_side_question() { ); } -#[test] -fn agent_cancel_interrupts_an_approval_wait_without_dropping_the_request() { - let request_token = CancellationToken::new(); - let approval_token = CancellationToken::new(); - let active = Arc::new(Mutex::new(ActiveCancellation { - request: Some(request_token.clone()), - tool: None, - approval: Some(approval_token.clone()), - tool_batch_active: true, - terminal_drain_required: false, - operation_interrupted: false, - })); - let (command_tx, mut command_rx) = mpsc::unbounded_channel(); - let (tool_response_tx, _tool_response_rx) = mpsc::unbounded_channel(); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); - let agent = super::NativeAgent { - managed_authorization: Arc::new(crate::agent::ManagedAuthorizationCoordinator::new( - event_tx.clone(), - )), - host: runtime_test_host_handle(), - external_tool_schema_policy: ExternalToolSchemaPolicy::Eager, - managed_run_id: "test-run".to_owned(), - command_tx, - tool_response_tx, - active_cancellation: active.clone(), - event_tx, - model_name: "test-model".to_string(), - provider_name: "test-provider".to_string(), - runtime_audit: empty_runtime_audit(), - shutdown_token: CancellationToken::new(), - runner_handle: None, - }; - - agent.cancel_keep_queue(); - - assert!(approval_token.is_cancelled()); - assert!(!request_token.is_cancelled()); - assert!( - active - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .operation_interrupted - ); - assert!(matches!( - command_rx.try_recv(), - Ok(AgentCommand::Cancel { - clear_pending: false - }) - )); -} - -#[tokio::test] -async fn agent_cancel_keeps_tool_batch_cleanup_alive_between_operations() { - let request_token = CancellationToken::new(); - let active = Arc::new(Mutex::new(ActiveCancellation { - request: Some(request_token.clone()), - tool: None, - approval: None, - tool_batch_active: true, - terminal_drain_required: false, - operation_interrupted: false, - })); - let (command_tx, mut command_rx) = mpsc::unbounded_channel(); - let (tool_response_tx, _tool_response_rx) = mpsc::unbounded_channel(); - let (event_tx, _event_rx) = mpsc::unbounded_channel(); - let agent = super::NativeAgent { - managed_authorization: Arc::new(crate::agent::ManagedAuthorizationCoordinator::new( - event_tx.clone(), - )), - host: runtime_test_host_handle(), - external_tool_schema_policy: ExternalToolSchemaPolicy::Eager, - managed_run_id: "test-run".to_owned(), - command_tx, - tool_response_tx, - active_cancellation: active.clone(), - event_tx, - model_name: "test-model".to_string(), - provider_name: "test-provider".to_string(), - runtime_audit: empty_runtime_audit(), - shutdown_token: CancellationToken::new(), - runner_handle: None, - }; - - agent.cancel_keep_queue(); - - assert!(!request_token.is_cancelled()); - assert!( - active - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .operation_interrupted - ); - assert!(matches!( - command_rx.try_recv(), - Ok(AgentCommand::Cancel { - clear_pending: false - }) - )); -} +#[path = "tests/session_transition.rs"] +mod session_transition; #[test] fn every_main_request_prompt_kind_uses_atomic_activation() { diff --git a/packages/runtime-rs/src/agent/native/tests/session_transition.rs b/packages/runtime-rs/src/agent/native/tests/session_transition.rs new file mode 100644 index 000000000..f6a5ca5f6 --- /dev/null +++ b/packages/runtime-rs/src/agent/native/tests/session_transition.rs @@ -0,0 +1,159 @@ +use super::*; + +#[test] +fn agent_cancel_interrupts_an_approval_wait_without_dropping_the_request() { + let request_token = CancellationToken::new(); + let approval_token = CancellationToken::new(); + let active = Arc::new(Mutex::new(ActiveCancellation { + request: Some(request_token.clone()), + tool: None, + approval: Some(approval_token.clone()), + tool_batch_active: true, + terminal_drain_required: false, + operation_interrupted: false, + })); + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + let (tool_response_tx, _tool_response_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let agent = super::super::NativeAgent { + managed_authorization: Arc::new(crate::agent::ManagedAuthorizationCoordinator::new( + event_tx.clone(), + )), + host: runtime_test_host_handle(), + external_tool_schema_policy: ExternalToolSchemaPolicy::Eager, + managed_run_id: "test-run".to_owned(), + command_tx, + tool_response_tx, + active_cancellation: active.clone(), + event_tx, + model_name: "test-model".to_string(), + provider_name: "test-provider".to_string(), + runtime_audit: empty_runtime_audit(), + shutdown_token: CancellationToken::new(), + runner_handle: None, + }; + + agent.cancel_keep_queue(); + + assert!(approval_token.is_cancelled()); + assert!(!request_token.is_cancelled()); + assert!( + active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .operation_interrupted + ); + assert!(matches!( + command_rx.try_recv(), + Ok(AgentCommand::Cancel { + clear_pending: false + }) + )); +} + +#[tokio::test] +async fn agent_cancel_keeps_tool_batch_cleanup_alive_between_operations() { + let request_token = CancellationToken::new(); + let active = Arc::new(Mutex::new(ActiveCancellation { + request: Some(request_token.clone()), + tool: None, + approval: None, + tool_batch_active: true, + terminal_drain_required: false, + operation_interrupted: false, + })); + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + let (tool_response_tx, _tool_response_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let agent = super::super::NativeAgent { + managed_authorization: Arc::new(crate::agent::ManagedAuthorizationCoordinator::new( + event_tx.clone(), + )), + host: runtime_test_host_handle(), + external_tool_schema_policy: ExternalToolSchemaPolicy::Eager, + managed_run_id: "test-run".to_owned(), + command_tx, + tool_response_tx, + active_cancellation: active.clone(), + event_tx, + model_name: "test-model".to_string(), + provider_name: "test-provider".to_string(), + runtime_audit: empty_runtime_audit(), + shutdown_token: CancellationToken::new(), + runner_handle: None, + }; + + agent.cancel_keep_queue(); + + assert!(!request_token.is_cancelled()); + assert!( + active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .operation_interrupted + ); + assert!(matches!( + command_rx.try_recv(), + Ok(AgentCommand::Cancel { + clear_pending: false + }) + )); +} + +#[tokio::test] +async fn session_transition_cancel_waits_for_an_explicit_cleanup_acknowledgement() { + let request_token = CancellationToken::new(); + let active = Arc::new(Mutex::new(ActiveCancellation { + request: Some(request_token.clone()), + tool: None, + approval: None, + tool_batch_active: true, + terminal_drain_required: false, + operation_interrupted: false, + })); + let (command_tx, mut command_rx) = mpsc::unbounded_channel(); + let (tool_response_tx, _tool_response_rx) = mpsc::unbounded_channel(); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let agent = super::super::NativeAgent { + managed_authorization: Arc::new(crate::agent::ManagedAuthorizationCoordinator::new( + event_tx.clone(), + )), + host: runtime_test_host_handle(), + external_tool_schema_policy: ExternalToolSchemaPolicy::Eager, + managed_run_id: "test-run".to_owned(), + command_tx, + tool_response_tx, + active_cancellation: active.clone(), + event_tx, + model_name: "test-model".to_string(), + provider_name: "test-provider".to_string(), + runtime_audit: empty_runtime_audit(), + shutdown_token: CancellationToken::new(), + runner_handle: None, + }; + + let mut settled = agent.cancel_for_session_transition().unwrap(); + + assert!(!request_token.is_cancelled()); + assert!( + active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .operation_interrupted + ); + assert!(matches!( + command_rx.try_recv(), + Ok(AgentCommand::Cancel { + clear_pending: true + }) + )); + assert!(matches!( + settled.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + let AgentCommand::AwaitIdle { reply } = command_rx.try_recv().unwrap() else { + panic!("cancellation must be followed by its idle barrier"); + }; + reply.send(()).unwrap(); + settled.await.unwrap(); +} diff --git a/packages/session-rs/src/session/index.rs b/packages/session-rs/src/session/index.rs index dd830468d..539ac6d04 100644 --- a/packages/session-rs/src/session/index.rs +++ b/packages/session-rs/src/session/index.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use super::entries::SessionEntry; use super::reader::SessionReader; -const CACHE_SCHEMA_VERSION: u32 = 1; +const CACHE_SCHEMA_VERSION: u32 = 2; /// Maximum characters kept for the first-user-message preview. const MAX_PREVIEW_CHARS: usize = 160; @@ -30,6 +30,9 @@ const MAX_PREVIEW_CHARS: usize = 160; pub struct SessionIndexEntry { /// Session id from the file header. pub id: String, + /// Persisted fork lineage from the session header. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_session: Option, /// Working directory the session was started in. pub cwd: String, /// RFC 3339 session start timestamp from the file header. @@ -231,6 +234,7 @@ fn entry_from_file(path: &Path) -> Option { let (header, stats, meta) = SessionReader::read_header(path).ok()?; Some(SessionIndexEntry { id: header.id, + parent_session: header.parent_session, cwd: header.cwd, started_at: header.timestamp, preview: first_user_message_preview(path), @@ -356,6 +360,35 @@ mod tests { assert_eq!(second[0].entry.preview.as_deref(), Some("first prompt")); } + #[test] + fn fork_parent_survives_cache_reload_and_legacy_cache_upgrade() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("sessions"); + let path = write_session(&root.join("project"), "child.jsonl", "child", "opening"); + let text = fs::read_to_string(&path).unwrap().replace( + "\"id\":\"child\"", + "\"id\":\"child\",\"parentSession\":\"parent\"", + ); + fs::write(&path, text).unwrap(); + let cache = temp.path().join("session-index.json"); + let first = collect_sessions(&root, Some(&cache)); + assert_eq!(first[0].entry.parent_session.as_deref(), Some("parent")); + let again = collect_sessions(&root, Some(&cache)); + assert_eq!(again[0].entry.parent_session.as_deref(), Some("parent")); + let mut legacy: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&cache).unwrap()).unwrap(); + legacy["version"] = 1.into(); + for file in legacy["files"].as_object_mut().unwrap().values_mut() { + file["entry"] + .as_object_mut() + .unwrap() + .remove("parentSession"); + } + fs::write(&cache, serde_json::to_string(&legacy).unwrap()).unwrap(); + let upgraded = collect_sessions(&root, Some(&cache)); + assert_eq!(upgraded[0].entry.parent_session.as_deref(), Some("parent")); + } + #[test] fn index_prunes_entries_for_deleted_files() { let temp = TempDir::new().unwrap(); diff --git a/packages/session-rs/src/session/manager.rs b/packages/session-rs/src/session/manager.rs index 70b02175e..68661a55b 100644 --- a/packages/session-rs/src/session/manager.rs +++ b/packages/session-rs/src/session/manager.rs @@ -326,6 +326,9 @@ pub struct SessionInfo { /// Full session ID (typically a UUID). pub id: String, + /// Persisted parent session for fork navigation. + pub parent_session: Option, + /// Absolute path to the session JSONL file. pub path: PathBuf, @@ -565,6 +568,7 @@ impl SessionManager { Ok((header, stats, meta)) => { let modified = entry.metadata().ok().and_then(|m| m.modified().ok()); sessions.push(SessionInfo { + parent_session: header.parent_session, id: header.id, path, cwd: header.cwd, @@ -1934,25 +1938,6 @@ mod tests { assert_eq!(session.id(), "abc123"); } - #[test] - fn session_info_title() { - let info = SessionInfo { - id: "abc123".to_string(), - path: PathBuf::from("/tmp/test.jsonl"), - cwd: "/tmp".to_string(), - model: "anthropic/claude-3".to_string(), - thinking_level: ThinkingLevel::Medium, - timestamp: "2024-01-15T10:30:00Z".to_string(), - stats: SessionStats::default(), - meta: None, - preview: None, - modified: None, - }; - - assert!(info.title().contains("abc123")); - assert_eq!(info.short_id(), "abc123"); - } - // ============================================================ // Session ID Validation Tests // ============================================================ @@ -2036,6 +2021,7 @@ mod tests { #[test] fn test_session_info_title_with_meta() { let info = SessionInfo { + parent_session: None, id: "abc123".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2064,6 +2050,7 @@ mod tests { #[test] fn test_session_info_title_from_summary() { let info = SessionInfo { + parent_session: None, id: "abc123".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2093,6 +2080,7 @@ mod tests { fn test_session_info_title_truncates_long_summary() { let long_summary = "a".repeat(100); let info = SessionInfo { + parent_session: None, id: "abc123".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2123,6 +2111,7 @@ mod tests { #[test] fn test_session_info_is_favorite() { let mut info = SessionInfo { + parent_session: None, id: "abc123".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2155,6 +2144,7 @@ mod tests { #[test] fn test_session_info_short_id_truncation() { let info = SessionInfo { + parent_session: None, id: "abcdefghijklmnop".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2174,6 +2164,7 @@ mod tests { #[test] fn test_session_info_short_id_short_string() { let info = SessionInfo { + parent_session: None, id: "abc".to_string(), path: PathBuf::from("/tmp/test.jsonl"), cwd: "/tmp".to_string(), @@ -2454,49 +2445,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn test_session_info_clone() { - let info = SessionInfo { - id: "test".to_string(), - path: PathBuf::from("/test"), - cwd: "/cwd".to_string(), - model: "model".to_string(), - thinking_level: ThinkingLevel::Medium, - timestamp: "2024".to_string(), - stats: SessionStats::default(), - meta: None, - preview: None, - modified: None, - }; - - let cloned = info.clone(); - assert_eq!(cloned.id, info.id); - assert_eq!(cloned.cwd, info.cwd); - } - - #[test] - fn test_session_info_debug() { - let info = SessionInfo { - id: "test".to_string(), - path: PathBuf::from("/test"), - cwd: "/cwd".to_string(), - model: "model".to_string(), - thinking_level: ThinkingLevel::Medium, - timestamp: "2024".to_string(), - stats: SessionStats::default(), - meta: None, - preview: None, - modified: None, - }; - - let debug = format!("{:?}", info); - assert!(debug.contains("test")); - } - - // ============================================================ - // Prune Sessions Tests - // ============================================================ - #[test] fn test_prune_sessions_noop_when_zero_limits() { let dir = TempDir::new().unwrap(); @@ -2724,6 +2672,7 @@ mod tests { auxiliary_cleanup: noop_auxiliary_cleanup, }; let session = SessionInfo { + parent_session: None, id: id.to_string(), path: session_path.clone(), cwd: "/tmp".to_string(), @@ -2986,6 +2935,7 @@ mod tests { auxiliary_cleanup: noop_auxiliary_cleanup, }; let session = SessionInfo { + parent_session: None, id: id.to_string(), path: session_path.clone(), cwd: "/tmp".to_string(), @@ -3039,6 +2989,7 @@ mod tests { auxiliary_cleanup: noop_auxiliary_cleanup, }; let session = SessionInfo { + parent_session: None, id: id.to_string(), path: session_path, cwd: "/tmp".to_string(), @@ -3103,6 +3054,7 @@ mod tests { auxiliary_cleanup: noop_auxiliary_cleanup, }; let victim = SessionInfo { + parent_session: None, id: "victim".to_string(), path: victim_path, cwd: "/tmp".to_string(), @@ -3944,3 +3896,7 @@ mod tests { assert!(SessionWriter::open_existing(&child_path).is_err()); } } + +#[cfg(test)] +#[path = "manager/info_tests.rs"] +mod info_tests; diff --git a/packages/session-rs/src/session/manager/info_tests.rs b/packages/session-rs/src/session/manager/info_tests.rs new file mode 100644 index 000000000..4b99ce488 --- /dev/null +++ b/packages/session-rs/src/session/manager/info_tests.rs @@ -0,0 +1,66 @@ +use super::*; + +#[test] +fn session_info_title() { + let info = SessionInfo { + parent_session: None, + id: "abc123".to_string(), + path: PathBuf::from("/tmp/test.jsonl"), + cwd: "/tmp".to_string(), + model: "anthropic/claude-3".to_string(), + thinking_level: ThinkingLevel::Medium, + timestamp: "2024-01-15T10:30:00Z".to_string(), + stats: SessionStats::default(), + meta: None, + preview: None, + modified: None, + }; + + assert!(info.title().contains("abc123")); + assert_eq!(info.short_id(), "abc123"); +} + +#[test] +fn test_session_info_clone() { + let info = SessionInfo { + parent_session: None, + id: "test".to_string(), + path: PathBuf::from("/test"), + cwd: "/cwd".to_string(), + model: "model".to_string(), + thinking_level: ThinkingLevel::Medium, + timestamp: "2024".to_string(), + stats: SessionStats::default(), + meta: None, + preview: None, + modified: None, + }; + + let cloned = info.clone(); + assert_eq!(cloned.id, info.id); + assert_eq!(cloned.cwd, info.cwd); +} + +#[test] +fn test_session_info_debug() { + let info = SessionInfo { + parent_session: None, + id: "test".to_string(), + path: PathBuf::from("/test"), + cwd: "/cwd".to_string(), + model: "model".to_string(), + thinking_level: ThinkingLevel::Medium, + timestamp: "2024".to_string(), + stats: SessionStats::default(), + meta: None, + preview: None, + modified: None, + }; + + let debug = format!("{:?}", info); + assert!(debug.contains("test")); +} + +// ============================================================ +// Prune Sessions Tests +// ============================================================ diff --git a/packages/tui-rs/docs/user-guide/11-sessions.md b/packages/tui-rs/docs/user-guide/11-sessions.md index 01e1f19b9..ebb85b290 100644 --- a/packages/tui-rs/docs/user-guide/11-sessions.md +++ b/packages/tui-rs/docs/user-guide/11-sessions.md @@ -54,7 +54,13 @@ Native helpers also expose `maestro sessions` early-exit subcommands. | `/history …` | Prompt history | | `/compact` | Compact older context | -Keyboard: `Ctrl+O` opens the session switcher by default. +Keyboard: `Ctrl+Alt+R` opens the session switcher. + +You can also open `/sessions` or `/resume` while startup is still preparing +cloud access. The list loads in the background, and closing it restores your +draft. Choosing a saved local-model session does not wait for cloud setup. +Reading a saved conversation does not authorize managed inference: cloud +models still require verified access when you use them. --- diff --git a/packages/tui-rs/src/app.rs b/packages/tui-rs/src/app.rs index 80df6080a..f0c3672c1 100644 --- a/packages/tui-rs/src/app.rs +++ b/packages/tui-rs/src/app.rs @@ -140,6 +140,8 @@ pub enum ActiveModal { FileSearch, /// Session history browser SessionSwitcher, + /// Confirm a session transition, then await runtime cleanup. + SessionTransition, /// Read-only persisted tool execution browser Operations, /// Interactive MCP server lifecycle manager @@ -693,6 +695,8 @@ pub struct App { /// Session history browser modal. session_switcher: SessionSwitcher, + session_transition: Option, + session_cleanup: session_transition::SessionCleanupState, /// Recent persisted tool executions. operations: OperationsModal, @@ -1147,7 +1151,7 @@ impl App { } else { None }; - let (prepared, draft) = match startup::prepare_with_composer( + let startup = match startup::prepare_with_composer( &mut terminal, &mut capabilities, &mut terminal_events, @@ -1158,9 +1162,38 @@ impl App { return Err(error); } }; + let mut prepared = startup.prepared; + prepared.local_discovery = + Some((startup.local_discovery_handle, startup.local_discovery_rx)); let mut app = Self::new_with_prepared_startup(terminal, capabilities, initial_prompt, true, prepared); - app.state.textarea = draft; + app.state.model = Some(startup.model.clone()); + app.model_selector + .set_current_model(Some(startup.model.clone())); + if startup.model_changed { + app.current_model = startup.model.clone(); + app.current_model_user_set = true; + let thinking = crate::model_dynamics::normalize_thinking( + &startup.model, + app.current_thinking_level, + ); + app.current_thinking_level = thinking; + app.state.thinking_level = thinking; + } + if let Some(batch) = startup.local_discovery_batch { + if app.model_selector.apply_discovery(&batch) { + crate::local_models::replace_discovered_models( + batch.generation, + &batch.models, + Some(&startup.model), + ); + } + } + if let Some(session) = &startup.resume_session { + app.initial_prompt = None; + app.resume_session_path(&session.path, &session.id); + } + app.state.textarea = startup.textarea; // Keep the reader and its buffered input across the startup handoff. app.terminal_events = terminal_events; app.initialize_terminal_events(); @@ -1450,6 +1483,7 @@ impl App { prepared: startup::PreparedStartup, ) -> Self { let startup::PreparedStartup { + local_discovery, config: app_config, plugin_registry, loaded_skills, @@ -1512,8 +1546,11 @@ impl App { } let (model_monitor, model_verification_rx) = crate::model_monitor::spawn_model_monitor(); let (local_model_discovery, local_model_discovery_rx) = - crate::local_models::spawn_local_model_discovery(); - local_model_discovery.refresh(); + local_discovery.unwrap_or_else(|| { + let (handle, rx) = crate::local_models::spawn_local_model_discovery(); + handle.refresh(); + (handle, rx) + }); let initial_thinking = crate::model_dynamics::configured_thinking( &app_config, @@ -1682,6 +1719,8 @@ impl App { workspace_scan_rx: None, workspace_refresh_pending: false, session_switcher: SessionSwitcher::new(&cwd), + session_transition: None, + session_cleanup: session_transition::SessionCleanupState::Ready, operations: OperationsModal::new(&cwd), mcp_manager: McpManager::new(), approval_controller: ApprovalController::new(), @@ -2246,6 +2285,14 @@ Always use tools when they would be helpful. Be concise and direct in your respo needs_redraw = true; } + if self.poll_session_transition().await? { + needs_redraw = true; + } + + if self.session_switcher.poll_refresh() { + needs_redraw = true; + } + if self.poll_local_model_discovery() { needs_redraw = true; } @@ -2381,7 +2428,7 @@ Always use tools when they would be helpful. Be concise and direct in your respo // Fire a due /loop prompt. Loops never interrupt a running turn: // a due prompt waits for idle and then submits. - let loop_prompt = if self.state.busy { + let loop_prompt = if self.state.busy || self.session_cleanup_pending() { None } else { self.loop_schedule.as_mut().and_then(|schedule| { @@ -2407,7 +2454,8 @@ Always use tools when they would be helpful. Be concise and direct in your respo // active (worker did not call update_goal complete|blocked), inject // a continuation prompt. No second model. Do not burn the safety // counter or spin when no agent is available (e.g. missing API key). - let queue_or_loop_busy = self.loop_schedule.is_some() + let queue_or_loop_busy = self.session_cleanup_pending() + || self.loop_schedule.is_some() || !self.queued_prompts.is_empty() || self.queued_prompt_inflight.is_some() || self.queued_prompt_active.is_some(); @@ -3133,82 +3181,6 @@ Always use tools when they would be helpful. Be concise and direct in your respo } } - /// Point the subagent scope at `session_id`'s conversation. - /// - /// Call this wherever the active conversation changes. The tool executor - /// lives behind an `Arc` for the whole process, so without a rotation a - /// child started by an earlier conversation reported its completion into - /// whichever conversation happened to be active when it finished, and that - /// summary went on to the next model request. - /// - /// The scope is derived from the session id rather than random, so resuming - /// a session re-adopts the scope its own children were stamped with and - /// their parked completions surface then. `None` means no session id exists - /// yet; the placeholder is unique so it can never collide with another - /// conversation, and it is replaced when the session file is created. - /// Point both the subagent scope and the hook system at `session_id`. - /// - /// `reason` is published to `SessionStart` / `SessionEnd` hooks as their - /// `source` / `reason` field. The hook system lives in the runner, so the - /// session id and the lifecycle dispatch both travel as a command; the - /// runner compares against the session it holds and fires the transition. - pub(super) fn adopt_session_context(&mut self, session_id: Option<&str>, reason: &str) { - self.adopt_session_context_inner(session_id, reason, false); - } - - pub(super) fn adopt_compacted_session_context(&mut self, session_id: &str) { - self.adopt_session_context_inner(Some(session_id), "summarize", true); - } - - fn adopt_session_context_inner( - &mut self, - session_id: Option<&str>, - reason: &str, - preserve_compacted_checkpoint: bool, - ) { - let scope = subagent_scope_for_session(session_id); - self.tool_executor.set_subagent_parent_scope(scope.clone()); - let Some(agent) = &self.native_agent else { - return; - }; - if let Err(e) = agent.set_subagent_parent_scope(scope) { - self.state.error = Some( - self.state - .locale - .format("Failed to rotate subagent scope: {0}", &[(e).to_string()]), - ); - } - let owns_persistent_tool_spills = - session_id.is_some() && self.session_manager.writer().is_some(); - let transcript_path = session_id.and_then(|_| { - self.session_manager - .current_session_path() - .map(|path| path.to_string_lossy().into_owned()) - }); - let transition = - if let Some(session_id) = session_id.filter(|_| preserve_compacted_checkpoint) { - agent.set_compacted_session_context_with_transcript( - session_id.to_owned(), - transcript_path, - owns_persistent_tool_spills, - ) - } else { - agent.set_session_context_with_transcript( - session_id.map(str::to_owned), - transcript_path, - reason, - owns_persistent_tool_spills, - ) - }; - if let Err(e) = transition { - self.state.error = Some( - self.state - .locale - .format("Failed to update session context: {0}", &[e.to_string()]), - ); - } - } - fn clear_active_skills(&mut self) { let active_ids: Vec = self .skill_registry @@ -3244,6 +3216,11 @@ Always use tools when they would be helpful. Be concise and direct in your respo } fn maybe_open_first_run_setup(&mut self) -> bool { + // Returning users keep the composer when cloud admission fails. Setup + // remains available explicitly and this preference grants no readiness. + if self.ui_prefs.onboarding_seen { + return false; + } let credentials_missing = should_open_first_run_setup( self.native_agent.is_some(), default_model_credentials_ready(), @@ -3372,9 +3349,7 @@ Always use tools when they would be helpful. Be concise and direct in your respo fn poll_local_model_discovery(&mut self) -> bool { let mut changed = false; while let Ok(batch) = self.local_model_discovery_rx.try_recv() { - let accepted = self - .model_selector - .replace_discovered_models(batch.generation, batch.models.clone()); + let accepted = self.model_selector.apply_discovery(&batch); if accepted { crate::local_models::replace_discovered_models( batch.generation, @@ -3564,6 +3539,11 @@ Always use tools when they would be helpful. Be concise and direct in your respo /// Returns true if badge counts or status text changed. async fn refresh_mcp_badges_with_force(&mut self, force: bool) -> bool { + // mcp_status initializes configured transports. Passive UI polling + // must not start MCP work before the selected agent is admitted. + if self.native_agent.is_none() { + return false; + } let now = Instant::now(); if !force && self @@ -4388,6 +4368,14 @@ Always use tools when they would be helpful. Be concise and direct in your respo args.clone(), *requires_approval, ); + if self.session_cleanup_pending() && *requires_approval { + // Cancellation is already authorized; late requests must not + // start a fresh guardian review or acquire UI input focus. + self.state.handle_agent_message(msg.clone()); + self.handle_tool_approval(call_id.clone(), tool.clone(), args.clone(), false) + .await?; + return Ok(()); + } // Unknown tool name -> deny immediately if !self.tool_executor.has_tool(tool) && approval_inline_env.is_none() { let note = self.state.locale.format("Skipped unknown tool '{0}' (not in registry); denied call. Retry with a supported tool (bash/read/write/glob/grep) and valid args.", std::slice::from_ref(tool)); @@ -4601,7 +4589,10 @@ Always use tools when they would be helpful. Be concise and direct in your respo } } - if needs_post_interrupt_queue && allow_post_interrupt_queue { + if needs_post_interrupt_queue + && allow_post_interrupt_queue + && !self.session_cleanup_pending() + { let _ = self.maybe_handle_post_interrupt_queue().await?; } Ok(()) @@ -4863,6 +4854,7 @@ Always use tools when they would be helpful. Be concise and direct in your respo let slash_state = &mut self.slash_state; let file_search = &mut self.file_search; let session_switcher = &mut self.session_switcher; + let session_transition = &self.session_transition; let operations = &mut self.operations; let mcp_manager = &self.mcp_manager; self.command_palette.set_locale(state.locale); @@ -4989,6 +4981,11 @@ Always use tools when they would be helpful. Be concise and direct in your respo ActiveModal::SessionSwitcher => { session_switcher.render(frame, area); } + ActiveModal::SessionTransition => { + if let Some(dialog) = session_transition { + dialog.render(frame, area); + } + } ActiveModal::Operations => { operations.render(frame, area); } @@ -5649,7 +5646,9 @@ mod exec_commands; mod input_handlers; mod prompt_audit; mod prompt_queue; +mod session_commands; mod session_recording; +mod session_transition; mod startup; #[cfg(test)] diff --git a/packages/tui-rs/src/app/command_handlers.rs b/packages/tui-rs/src/app/command_handlers.rs index b2825984b..1c12ca4e6 100644 --- a/packages/tui-rs/src/app/command_handlers.rs +++ b/packages/tui-rs/src/app/command_handlers.rs @@ -933,6 +933,11 @@ impl App { SessionAction::Fork => { self.fork_session(); } + SessionAction::BrowseBranches => { + self.session_switcher + .show_branches(self.state.session_id.as_deref()); + self.active_modal = ActiveModal::SessionSwitcher; + } SessionAction::Rewind { turns, dry_run } => { self.rewind_turns(turns, dry_run); } @@ -1258,6 +1263,12 @@ impl App { /// Prepare and read the selected transcript once, while retaining the /// current writer until in-memory adoption succeeds. pub(crate) fn resume_session_path(&mut self, path: &std::path::Path, target_session_id: &str) { + if self.session_cleanup_pending() { + self.state.error = Some(self.state.locale.translate( + "Could not confirm that the response stopped. The current conversation was kept.", + ).to_owned()); + return; + } let same_active_session = self .session_manager .current_session_path() @@ -1502,181 +1513,6 @@ impl App { self.state.mark_alerts_seen(); } - fn start_new_session(&mut self, status: &str) { - if self.state.busy { - self.state.status = Some( - self.state - .locale - .translate( - "Wait for the active response to finish before starting a new session.", - ) - .to_string(), - ); - return; - } - self.last_esc_at = None; - self.credential_vault.clear(); - // A child still running under the previous conversation must not report - // into this one. The scope rotates now; the session id does not exist - // until the first message creates the session file, which rotates it - // again to the id-derived scope. - self.adopt_session_context(None, "new"); - self.dex_terminal = None; - self.dex_delight = Default::default(); - self.state.messages.clear(); - self.state.clear_focus_turn_state(); - self.plan_review_comments.clear(); - self.state.scroll_offset = 0; - self.state.alerts.clear(); - self.state.unseen_alerts = 0; - // Drop any lingering error surface and force a full viewport repaint - // so the previous session's frames cannot linger on screen. - self.reset_rendered_viewport(); - self.session_manager.reset_session(); - self.state.session_id = None; - self.pending_agent_tool_notes.clear(); - self.pending_agent_note_applications.clear(); - self.pending_agent_note_consumptions.clear(); - self.pending_consumed_agent_tool_notes.clear(); - self.ready_consumed_agent_tool_notes.clear(); - self.active_turn_assistant_messages_persisted = true; - self.ephemeral_lifecycle_applications.clear(); - crate::plan_mode::set_active_session_id(None); - crate::tools::tool_call_contract::clear_pending_contracts(); - self.session_started_at = SystemTime::now(); - self.session_resume_failed = false; - self.usage_tracker = crate::usage::UsageTracker::new(); - if !self.current_model.is_empty() { - self.usage_tracker.set_model(self.current_model.clone()); - } - self.clear_active_skills(); - if let Some(agent) = &self.native_agent { - agent.clear_history(); - } - self.state.status = Some(status.to_string()); - self.state.add_system_message(status.to_string()); - } - - pub(super) fn fork_session(&mut self) { - use crate::session::BranchPoint; - - let fork_index = self.state.messages.len().saturating_sub(1); - let fork_id = self - .state - .messages - .last() - .map(|m| m.id.clone()) - .unwrap_or_else(|| "start".to_string()); - let branch = BranchPoint::new(fork_id, fork_index) - .with_description(self.state.locale.translate("Forked via /fork")); - if let Err(error) = self.ensure_session_started() { - self.state.error = Some(self.state.locale.format( - "Failed to start session before fork: {0}", - &[(error).to_string()], - )); - return; - } - match self.session_manager.fork_session_snapshot() { - Ok((fork_session_id, path)) => { - let activity = if self.state.busy { - self.state - .locale - .translate(" The parent remains active and its current response continues.") - } else { - self.state.locale.translate(" The parent remains selected.") - }; - self.state.status = Some(self.state.locale.format( - "Fork {0} created without switching sessions.", - &[fork_session_id[..8.min(fork_session_id.len())].to_string()], - )); - self.state.add_system_message(self.state.locale.format( - "Forked at message {0} (branch {1}) into session {2} at {3}.{4}", - &[ - (branch.fork_index + 1).to_string(), - branch.id[..8.min(branch.id.len())].to_string(), - (fork_session_id).clone(), - (path.display()).to_string(), - (activity).to_string(), - ], - )); - } - Err(error) => { - self.state.error = Some( - self.state - .locale - .format("Failed to fork session: {0}", &[(error).to_string()]), - ); - } - } - } - - pub(super) fn rewind_turns(&mut self, turns: usize, dry_run: bool) { - self.rewind_saved_turns(turns, dry_run, false); - } - - pub(super) fn rewind_saved_turns(&mut self, turns: usize, dry_run: bool, files: bool) { - if self.state.busy { - self.state.status = Some( - self.state - .locale - .translate("Wait for the active response to finish before rewinding.") - .to_string(), - ); - return; - } - let result = (|| -> anyhow::Result<()> { - self.session_manager.flush()?; - let source = self - .session_manager - .current_session_path() - .ok_or_else(|| anyhow::anyhow!("No saved session to rewind."))?; - let (boundary, saved_turns) = - crate::session::rewind_boundary_with_turn_count(&source, turns)?; - let kept_turns = saved_turns.saturating_sub(turns); - if dry_run { - self.state.add_system_message(self.state.locale.format("Rewind before the last {0} user turn(s) into a new saved session. The original remains available.", &[(turns).to_string()])); - if files { - self.preview_rewind_files(kept_turns)?; - } - return Ok(()); - } - if files { - self.preview_rewind_files(kept_turns)?; - } - // Publish the branch before changing active history or files. - let fork = crate::session::fork_session_prefix(&source, Some(boundary))?; - let source_id = self.state.session_id.clone(); - if let Some(source_id) = source_id.as_deref() { - let sessions = self.session_manager.sessions_dir(); - crate::checkpoints::fork_before_turn( - &crate::checkpoints::CheckpointStore::new(sessions, source_id), - &crate::checkpoints::CheckpointStore::new(sessions, &fork.id), - kept_turns, - )?; - } - - self.resume_session_path(&fork.path, &fork.id); - if self.session_manager.current_session_id() != Some(fork.id.as_str()) { - anyhow::bail!("The saved branch could not be opened."); - } - self.state.status = Some(self.state.locale.format( - "Rewound into saved session {0}.", - std::slice::from_ref(&(fork.id)), - )); - if files { - self.restore_rewind_files(source_id.as_deref(), kept_turns)?; - } - Ok(()) - })(); - if let Err(error) = result { - self.state.error = Some( - self.state - .locale - .format("Rewind failed: {0}", &[(error).to_string()]), - ); - } - } - pub(super) fn cycle_interaction_mode(&mut self) { let next = self.state.interaction_mode.next(); self.apply_interaction_mode(next); diff --git a/packages/tui-rs/src/app/input_handlers.rs b/packages/tui-rs/src/app/input_handlers.rs index 16651a7c0..072e6f074 100644 --- a/packages/tui-rs/src/app/input_handlers.rs +++ b/packages/tui-rs/src/app/input_handlers.rs @@ -36,6 +36,10 @@ impl App { code: KeyCode, modifiers: CrosstermModifiers, ) -> Result<()> { + // A late tool approval cannot take the confirmation dialog's keystrokes. + if self.session_transition.is_some() { + self.active_modal = ActiveModal::SessionTransition; + } if code != KeyCode::Esc || self.active_modal != ActiveModal::None || self.history_search.is_some() @@ -112,6 +116,9 @@ impl App { } ActiveModal::DexAppearance => return self.handle_dex_appearance_key(code), ActiveModal::FileSearch => return self.handle_file_search_key(code, ctrl).await, + ActiveModal::SessionTransition => { + return self.handle_session_transition_key(code, ctrl).await; + } ActiveModal::SessionSwitcher => { return self.handle_session_switcher_key(code, ctrl).await; } @@ -730,6 +737,7 @@ impl App { self.state.error = Some(e); } } + KeyCode::Char('f') if ctrl => self.session_switcher.toggle_branches(), KeyCode::Char(c) if !ctrl => { self.session_switcher.insert_char(c); } @@ -1039,6 +1047,26 @@ impl App { self.active_modal = ActiveModal::None; if let Some(resource) = self.command_palette.confirm() { match resource.kind { + PaletteResourceKind::Command + if matches!(resource.id.as_str(), "model" | "model select") => + { + // Browsing models must preserve the composer, including + // its Unicode cursor and folded paste state. + let cwd = self.state.cwd.as_deref().unwrap_or("."); + let output = + crate::localization::with_locale(self.state.locale, || { + self.command_registry.execute( + &format!("/{}", resource.id), + cwd, + self.state.session_id.as_deref(), + self.state.model.as_deref(), + ) + }); + match output { + Ok(output) => self.handle_command_output(output).await, + Err(error) => self.state.error = Some(error.to_string()), + } + } PaletteResourceKind::Command => { self.state.set_input(&format!("/{}", resource.id)); self.execute_slash_command().await?; @@ -1438,6 +1466,10 @@ impl App { ctrl: bool, ) -> Result<()> { match code { + KeyCode::Char('r') if ctrl => { + self.local_model_discovery.refresh(); + self.model_selector.mark_local_refreshing(); + } KeyCode::Esc => { self.model_selector.hide(); self.active_modal = ActiveModal::None; diff --git a/packages/tui-rs/src/app/session_commands.rs b/packages/tui-rs/src/app/session_commands.rs new file mode 100644 index 000000000..6b07a0aed --- /dev/null +++ b/packages/tui-rs/src/app/session_commands.rs @@ -0,0 +1,252 @@ +//! Session creation, saved branches, and actor context adoption. +use super::*; + +impl App { + /// Point the subagent scope at `session_id`'s conversation. + /// + /// Call this wherever the active conversation changes. The tool executor + /// lives behind an `Arc` for the whole process, so without a rotation a + /// child started by an earlier conversation reported its completion into + /// whichever conversation happened to be active when it finished, and that + /// summary went on to the next model request. + /// + /// The scope is derived from the session id rather than random, so resuming + /// a session re-adopts the scope its own children were stamped with and + /// their parked completions surface then. `None` means no session id exists + /// yet; the placeholder is unique so it can never collide with another + /// conversation, and it is replaced when the session file is created. + /// Point both the subagent scope and the hook system at `session_id`. + /// + /// `reason` is published to `SessionStart` / `SessionEnd` hooks as their + /// `source` / `reason` field. The hook system lives in the runner, so the + /// session id and the lifecycle dispatch both travel as a command; the + /// runner compares against the session it holds and fires the transition. + pub(super) fn adopt_session_context(&mut self, session_id: Option<&str>, reason: &str) { + self.adopt_session_context_inner(session_id, reason, false); + } + + pub(super) fn adopt_compacted_session_context(&mut self, session_id: &str) { + self.adopt_session_context_inner(Some(session_id), "summarize", true); + } + + fn adopt_session_context_inner( + &mut self, + session_id: Option<&str>, + reason: &str, + preserve_compacted_checkpoint: bool, + ) { + let scope = subagent_scope_for_session(session_id); + self.tool_executor.set_subagent_parent_scope(scope.clone()); + let Some(agent) = &self.native_agent else { + return; + }; + if let Err(e) = agent.set_subagent_parent_scope(scope) { + self.state.error = Some( + self.state + .locale + .format("Failed to rotate subagent scope: {0}", &[(e).to_string()]), + ); + } + let owns_persistent_tool_spills = + session_id.is_some() && self.session_manager.writer().is_some(); + let transcript_path = session_id.and_then(|_| { + self.session_manager + .current_session_path() + .map(|path| path.to_string_lossy().into_owned()) + }); + let transition = + if let Some(session_id) = session_id.filter(|_| preserve_compacted_checkpoint) { + agent.set_compacted_session_context_with_transcript( + session_id.to_owned(), + transcript_path, + owns_persistent_tool_spills, + ) + } else { + agent.set_session_context_with_transcript( + session_id.map(str::to_owned), + transcript_path, + reason, + owns_persistent_tool_spills, + ) + }; + if let Err(e) = transition { + self.state.error = Some( + self.state + .locale + .format("Failed to update session context: {0}", &[e.to_string()]), + ); + } + } + + pub(super) fn start_new_session(&mut self, status: &str) { + if self.session_change_requires_cleanup() { + self.request_session_transition(session_transition::TransitionAction::New( + status.to_owned(), + )); + return; + } + self.last_esc_at = None; + self.credential_vault.clear(); + // A child still running under the previous conversation must not report + // into this one. The scope rotates now; the session id does not exist + // until the first message creates the session file, which rotates it + // again to the id-derived scope. + self.adopt_session_context(None, "new"); + self.dex_terminal = None; + self.dex_delight = Default::default(); + self.state.messages.clear(); + self.state.clear_focus_turn_state(); + self.plan_review_comments.clear(); + self.state.scroll_offset = 0; + self.state.alerts.clear(); + self.state.unseen_alerts = 0; + // Drop any lingering error surface and force a full viewport repaint + // so the previous session's frames cannot linger on screen. + self.reset_rendered_viewport(); + self.session_manager.reset_session(); + self.state.session_id = None; + self.pending_agent_tool_notes.clear(); + self.pending_agent_note_applications.clear(); + self.pending_agent_note_consumptions.clear(); + self.pending_consumed_agent_tool_notes.clear(); + self.ready_consumed_agent_tool_notes.clear(); + self.active_turn_assistant_messages_persisted = true; + self.ephemeral_lifecycle_applications.clear(); + crate::plan_mode::set_active_session_id(None); + crate::tools::tool_call_contract::clear_pending_contracts(); + self.session_started_at = SystemTime::now(); + self.session_resume_failed = false; + self.usage_tracker = crate::usage::UsageTracker::new(); + if !self.current_model.is_empty() { + self.usage_tracker.set_model(self.current_model.clone()); + } + self.clear_active_skills(); + if let Some(agent) = &self.native_agent { + agent.clear_history(); + } + self.state.status = Some(status.to_string()); + self.state.add_system_message(status.to_string()); + } + + pub(super) fn fork_session(&mut self) { + use crate::session::BranchPoint; + + let fork_index = self.state.messages.len().saturating_sub(1); + let fork_id = self + .state + .messages + .last() + .map(|m| m.id.clone()) + .unwrap_or_else(|| "start".to_string()); + let branch = BranchPoint::new(fork_id, fork_index) + .with_description(self.state.locale.translate("Forked via /fork")); + if let Err(error) = self.ensure_session_started() { + self.state.error = Some(self.state.locale.format( + "Failed to start session before fork: {0}", + &[(error).to_string()], + )); + return; + } + match self.session_manager.fork_session_snapshot() { + Ok((fork_session_id, path)) => { + let activity = if self.state.busy { + self.state + .locale + .translate(" The parent remains active and its current response continues.") + } else { + self.state.locale.translate(" The parent remains selected.") + }; + self.state.status = Some(self.state.locale.format( + "Fork {0} created without switching sessions.", + &[fork_session_id[..8.min(fork_session_id.len())].to_string()], + )); + self.state.add_system_message(self.state.locale.format( + "Forked at message {0} (branch {1}) into session {2} at {3}.{4}", + &[ + (branch.fork_index + 1).to_string(), + branch.id[..8.min(branch.id.len())].to_string(), + (fork_session_id).clone(), + (path.display()).to_string(), + (activity).to_string(), + ], + )); + self.state.add_system_message(self.state.locale.format( + "Resume fork: deixic-code --resume-session {0}\nBrowse branches: /tree or /sessions, then Ctrl+F.", + &[fork_session_id], + )); + } + Err(error) => { + self.state.error = Some( + self.state + .locale + .format("Failed to fork session: {0}", &[(error).to_string()]), + ); + } + } + } + + pub(super) fn rewind_turns(&mut self, turns: usize, dry_run: bool) { + self.rewind_saved_turns(turns, dry_run, false); + } + + pub(super) fn rewind_saved_turns(&mut self, turns: usize, dry_run: bool, files: bool) { + if self.session_change_requires_cleanup() && !dry_run { + self.request_session_transition(session_transition::TransitionAction::Rewind { + turns, + files, + }); + return; + } + let result = (|| -> anyhow::Result<()> { + self.session_manager.flush()?; + let source = self + .session_manager + .current_session_path() + .ok_or_else(|| anyhow::anyhow!("No saved session to rewind."))?; + let (boundary, saved_turns) = + crate::session::rewind_boundary_with_turn_count(&source, turns)?; + let kept_turns = saved_turns.saturating_sub(turns); + if dry_run { + self.state.add_system_message(self.state.locale.format("Rewind before the last {0} user turn(s) into a new saved session. The original remains available.", &[(turns).to_string()])); + if files { + self.preview_rewind_files(kept_turns)?; + } + return Ok(()); + } + if files { + self.preview_rewind_files(kept_turns)?; + } + // Publish the branch before changing active history or files. + let fork = crate::session::fork_session_prefix(&source, Some(boundary))?; + let source_id = self.state.session_id.clone(); + if let Some(source_id) = source_id.as_deref() { + let sessions = self.session_manager.sessions_dir(); + crate::checkpoints::fork_before_turn( + &crate::checkpoints::CheckpointStore::new(sessions, source_id), + &crate::checkpoints::CheckpointStore::new(sessions, &fork.id), + kept_turns, + )?; + } + + self.resume_session_path(&fork.path, &fork.id); + if self.session_manager.current_session_id() != Some(fork.id.as_str()) { + anyhow::bail!("The saved branch could not be opened."); + } + self.state.status = Some(self.state.locale.format( + "Rewound into saved session {0}.", + std::slice::from_ref(&(fork.id)), + )); + if files { + self.restore_rewind_files(source_id.as_deref(), kept_turns)?; + } + Ok(()) + })(); + if let Err(error) = result { + self.state.error = Some( + self.state + .locale + .format("Rewind failed: {0}", &[(error).to_string()]), + ); + } + } +} diff --git a/packages/tui-rs/src/app/session_transition.rs b/packages/tui-rs/src/app/session_transition.rs new file mode 100644 index 000000000..682a09f39 --- /dev/null +++ b/packages/tui-rs/src/app/session_transition.rs @@ -0,0 +1,648 @@ +//! Confirm session changes, then await the existing actor's cleanup barrier. +use super::*; +use maestro_ui::Modal; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Paragraph, Wrap}, +}; +use tokio::sync::oneshot::{self, error::TryRecvError}; + +pub(super) enum TransitionAction { + New(String), + Rewind { turns: usize, files: bool }, +} + +pub(super) enum SessionCleanupState { + Ready, + // The screen's busy flag is not an actor cleanup receipt. A kept parent + // retains its receiver; a closed receiver keeps confirmation required. + Awaiting(Option>), +} + +enum Stage { + Confirm, + Waiting { + settled: oneshot::Receiver<()>, + started: Instant, + }, +} + +pub(super) struct TransitionDialog { + action: TransitionAction, + stage: Stage, + previous_modal: ActiveModal, +} + +impl TransitionDialog { + pub(super) fn render(&self, frame: &mut Frame, area: Rect) { + let theme = crate::themes::current_ui_theme(); + let inner = Modal::new( + maestro_ui::localization::tr("Change conversation"), + 80, + area.height.saturating_sub(2).min(18), + ) + .theme(theme) + .render(frame, area); + let text = match &self.stage { + Stage::Waiting { .. } => maestro_ui::localization::tr( + "Stopping the response and finishing tool cleanup… Esc keeps this conversation.", + ) + .to_owned(), + Stage::Confirm => { + let question = match &self.action { + TransitionAction::New(_) => maestro_ui::localization::tr( + "Stop the response and start a new conversation? The saved conversation stays available.", + ).to_owned(), + TransitionAction::Rewind { turns, files } => maestro_ui::localization::format( + if *files { + "Stop the response and rewind the last {0} turn(s), including their file changes? The original conversation stays available." + } else { + "Stop the response and rewind the last {0} turn(s)? The original conversation stays available. Files stay unchanged." + }, &[turns.to_string()], + ), + }; + maestro_ui::localization::format( + "{0}\n\nQueued prompts return to the composer.\nEnter: stop and continue · Esc: keep working", + &[question], + ) + } + }; + frame.render_widget( + Paragraph::new(text) + .wrap(Wrap { trim: false }) + .style(theme.on_panel().text_style()), + inner, + ); + } +} + +impl App { + pub(super) fn session_cleanup_pending(&self) -> bool { + matches!(self.session_cleanup, SessionCleanupState::Awaiting(_)) + } + + pub(super) fn session_change_requires_cleanup(&self) -> bool { + self.state.busy || self.session_cleanup_pending() + } + + async fn finish_session_cleanup_receipt(&mut self) -> Result { + // Final snapshots and events belong to the parent even when the + // transition dialog was dismissed while the actor was cleaning up. + self.poll_agent().await?; + self.goal_auto_continue_armed = false; + if let Err(error) = self.session_manager.flush() { + self.state.error = Some( + self.state + .locale + .format("Failed to save session: {0}", &[error.to_string()]), + ); + return Ok(false); + } + self.session_cleanup = SessionCleanupState::Ready; + Ok(true) + } + + async fn poll_kept_session_cleanup(&mut self) -> Result { + let SessionCleanupState::Awaiting(Some(settled)) = &mut self.session_cleanup else { + return Ok(false); + }; + match settled.try_recv() { + Ok(()) => { + self.session_cleanup = SessionCleanupState::Awaiting(None); + self.finish_session_cleanup_receipt().await?; + Ok(true) + } + Err(TryRecvError::Empty) => Ok(false), + Err(TryRecvError::Closed) => { + self.session_cleanup = SessionCleanupState::Awaiting(None); + Ok(true) + } + } + } + + pub(super) fn request_session_transition(&mut self, action: TransitionAction) { + if let TransitionAction::Rewind { turns, files } = &action { + let preview = (|| -> anyhow::Result<()> { + self.session_manager.flush()?; + let path = self + .session_manager + .current_session_path() + .ok_or_else(|| anyhow::anyhow!("No saved session to rewind."))?; + let (_, total) = crate::session::rewind_boundary_with_turn_count(&path, *turns)?; + if *files { + self.preview_rewind_files(total.saturating_sub(*turns))?; + } + Ok(()) + })(); + if let Err(error) = preview { + self.state.error = Some( + self.state + .locale + .format("Rewind failed: {0}", &[error.to_string()]), + ); + return; + } + } + self.session_transition = Some(TransitionDialog { + action, + stage: Stage::Confirm, + previous_modal: self.active_modal, + }); + self.active_modal = ActiveModal::SessionTransition; + } + + pub(super) fn session_transition_waiting(&self) -> bool { + self.session_transition + .as_ref() + .is_some_and(|dialog| matches!(dialog.stage, Stage::Waiting { .. })) + } + + fn keep_current_session_after_transition(&mut self) { + let waiting = self.session_transition_waiting(); + let previous = if let Some(dialog) = self.session_transition.take() { + if let Stage::Waiting { settled, .. } = dialog.stage { + self.session_cleanup = SessionCleanupState::Awaiting(Some(settled)); + } + dialog.previous_modal + } else { + ActiveModal::None + }; + if waiting { + let prompts = self.drain_queued_prompts_for_restore(); + self.restore_queued_prompts_to_input(prompts); + } + self.active_modal = if self.approval_controller.is_visible() && !waiting { + ActiveModal::Approval + } else if previous == ActiveModal::SessionTransition || waiting { + ActiveModal::None + } else { + previous + }; + } + + pub(super) async fn handle_session_transition_key( + &mut self, + code: KeyCode, + ctrl: bool, + ) -> Result<()> { + if code == KeyCode::Esc || (ctrl && code == KeyCode::Char('c')) { + self.keep_current_session_after_transition(); + return Ok(()); + } + if code != KeyCode::Enter || self.session_transition_waiting() { + return Ok(()); + } + let Some(agent) = &self.native_agent else { + self.keep_current_session_after_transition(); + self.state.error = Some(self.state.locale.translate( + "Could not confirm that the response stopped. The current conversation was kept.", + ).to_owned()); + return Ok(()); + }; + let settled = match agent.cancel_for_session_transition() { + Ok(settled) => settled, + Err(error) => { + self.keep_current_session_after_transition(); + self.state.error = Some(error.to_string()); + return Ok(()); + } + }; + self.session_cleanup = SessionCleanupState::Awaiting(None); + self.cancel_pending_guardian_reviews(); + self.approval_controller.clear(); + self.submit_queued_steering_after_interrupt = false; + self.restore_queued_prompts_after_interrupt = false; + self.goal_auto_continue_armed = false; + if let Some(dialog) = &mut self.session_transition { + dialog.stage = Stage::Waiting { + settled, + started: Instant::now(), + }; + } + Ok(()) + } + + pub(super) async fn poll_session_transition(&mut self) -> Result { + let cleanup_changed = self.poll_kept_session_cleanup().await?; + let Some(dialog) = &mut self.session_transition else { + return Ok(cleanup_changed); + }; + self.active_modal = ActiveModal::SessionTransition; + let Stage::Waiting { settled, started } = &mut dialog.stage else { + return Ok(cleanup_changed); + }; + // A completion racing cancellation can re-arm goal continuation. + self.goal_auto_continue_armed = false; + let confirmed = match settled.try_recv() { + Ok(()) => true, + Err(TryRecvError::Empty) if started.elapsed() < Duration::from_secs(30) => { + return Ok(cleanup_changed); + } + Err(_) => false, + }; + if !confirmed { + self.keep_current_session_after_transition(); + self.state.error = Some(self.state.locale.translate( + "Could not confirm that the response stopped. The current conversation was kept.", + ).to_owned()); + return Ok(true); + } + if !self.finish_session_cleanup_receipt().await? { + self.keep_current_session_after_transition(); + return Ok(true); + } + self.state.busy = false; + self.cancel_pending_guardian_reviews(); + self.approval_controller.clear(); + let Some(dialog) = self.session_transition.take() else { + return Ok(false); + }; + self.active_modal = ActiveModal::None; + let prompts = self.drain_queued_prompts_for_restore(); + match dialog.action { + TransitionAction::New(status) => self.start_new_session(&status), + TransitionAction::Rewind { turns, files } => { + self.rewind_saved_turns(turns, false, files); + } + } + self.restore_queued_prompts_to_input(prompts); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> App { + let mut app = super::super::tests::new_test_app(); + // Start these conversation fixtures independently of host diagnostics. + app.state.messages.clear(); + app + } + + fn waiting(app: &mut App, receiver: oneshot::Receiver<()>, started: Instant) { + app.session_cleanup = SessionCleanupState::Awaiting(None); + app.session_transition = Some(TransitionDialog { + action: TransitionAction::New("New session started.".into()), + stage: Stage::Waiting { + settled: receiver, + started, + }, + previous_modal: ActiveModal::None, + }); + app.active_modal = ActiveModal::SessionTransition; + } + + #[tokio::test] + async fn busy_rewind_requires_confirmation_and_keeps_the_saved_parent() { + let mut app = app(); + let saved = tempfile::tempdir().unwrap(); + app.session_manager = + SessionManager::with_sessions_dir("rewind-confirm-test", saved.path()); + app.state.add_user_message("keep me".into()); + app.record_user_message("keep me"); + app.session_manager.flush().unwrap(); + let parent = app.session_manager.current_session_path().unwrap(); + let before = std::fs::read(&parent).unwrap(); + app.state.busy = true; + app.rewind_turns(1, false); + assert_eq!(app.active_modal, ActiveModal::SessionTransition); + assert!(!app.session_transition_waiting()); + assert!(app.state.busy); + assert_eq!(app.state.messages[0].content, "keep me"); + assert_eq!( + app.session_manager.current_session_path(), + Some(parent.clone()) + ); + assert_eq!(std::fs::read(&parent).unwrap(), before); + app.handle_session_transition_key(KeyCode::Esc, false) + .await + .unwrap(); + assert_eq!(app.active_modal, ActiveModal::None); + assert!(app.state.busy); + assert_eq!(app.state.messages[0].content, "keep me"); + assert_eq!( + app.session_manager.current_session_path(), + Some(parent.clone()) + ); + assert_eq!(std::fs::read(&parent).unwrap(), before); + } + + #[tokio::test] + async fn dismissal_preserves_running_response_draft_and_queued_work() { + let mut app = app(); + app.state.busy = true; + app.state.add_user_message("parent prompt".into()); + app.state.set_input("draft é 日本語"); + app.queued_prompts.push_back(QueuedPrompt { + id: 1, + content: "queued work".into(), + kind: PromptKind::FollowUp, + }); + app.start_new_session("New session started."); + assert_eq!(app.active_modal, ActiveModal::SessionTransition); + app.handle_session_transition_key(KeyCode::Esc, false) + .await + .unwrap(); + assert!(app.state.busy); + assert_eq!(app.state.input(), "draft é 日本語"); + assert_eq!(app.queued_prompts.len(), 1); + assert_eq!(app.state.messages[0].content, "parent prompt"); + } + + #[tokio::test] + async fn late_approval_cannot_take_confirmation_keys_or_start_during_cleanup() { + let mut app = app(); + app.state.busy = true; + app.state.set_input("retained draft"); + let (_reply, settled) = oneshot::channel(); + waiting(&mut app, settled, Instant::now()); + let (decisions, mut denied) = mpsc::unbounded_channel(); + app.tool_response_tx = Some(decisions); + app.handle_agent_message(FromAgent::ToolCall { + call_id: "late-tool".into(), + tool: "bash".into(), + args: serde_json::json!({"command":"touch late-approval-marker"}), + requires_approval: true, + approval_inline_env: None, + }) + .await + .unwrap(); + assert!(app.approval_controller.current().is_none()); + assert!(app.pending_guardian_reviews.is_empty()); + let (id, approved, _, _, _) = denied.try_recv().unwrap(); + assert_eq!(id, "late-tool"); + assert!(!approved); + app.active_modal = ActiveModal::Approval; + app.handle_key(KeyCode::Enter, CrosstermModifiers::NONE) + .await + .unwrap(); + assert_eq!(app.active_modal, ActiveModal::SessionTransition); + assert!(app.session_transition_waiting()); + assert_eq!(app.state.input(), "retained draft"); + app.handle_session_transition_key(KeyCode::Esc, false) + .await + .unwrap(); + app.handle_agent_message(FromAgent::ToolCall { + call_id: "late-tool-after-keep".into(), + tool: "bash".into(), + args: serde_json::json!({"command":"touch late-approval-marker"}), + requires_approval: true, + approval_inline_env: None, + }) + .await + .unwrap(); + let (id, approved, _, _, _) = denied.try_recv().unwrap(); + assert_eq!(id, "late-tool-after-keep"); + assert!(!approved); + assert!(app.approval_controller.current().is_none()); + assert!(app.pending_guardian_reviews.is_empty()); + assert_eq!(app.active_modal, ActiveModal::None); + } + + #[tokio::test] + async fn missing_actor_cannot_authorize_switching() { + let mut app = app(); + app.state.busy = true; + app.state.add_user_message("parent prompt".into()); + app.start_new_session("New session started."); + app.handle_session_transition_key(KeyCode::Enter, false) + .await + .unwrap(); + assert!(app.state.busy); + assert_eq!(app.state.messages[0].content, "parent prompt"); + assert!( + app.state + .error + .as_deref() + .unwrap() + .contains("current conversation was kept") + ); + } + + #[tokio::test] + async fn closed_barrier_and_timeout_keep_parent_and_restore_queued_draft() { + for closed in [true, false] { + let mut app = app(); + app.state.busy = true; + app.state.add_user_message("parent prompt".into()); + app.state.set_input("draft é"); + app.queued_prompts.push_back(QueuedPrompt { + id: 1, + content: "queued work".into(), + kind: PromptKind::FollowUp, + }); + let (reply, settled) = oneshot::channel(); + let started = Instant::now() + .checked_sub(if closed { + Duration::ZERO + } else { + Duration::from_secs(31) + }) + .unwrap(); + waiting(&mut app, settled, started); + let _held_reply = if closed { + drop(reply); + None + } else { + Some(reply) + }; + assert!(app.poll_session_transition().await.unwrap()); + assert_eq!(app.state.messages[0].content, "parent prompt"); + assert!(app.state.input().contains("queued work")); + assert!(app.state.input().contains("draft é")); + assert!( + app.state + .error + .as_deref() + .unwrap() + .contains("current conversation was kept") + ); + } + } + + #[tokio::test] + async fn failed_cleanup_cannot_be_bypassed_by_an_idle_retry() { + let mut app = app(); + app.state.busy = true; + app.state.add_user_message("parent prompt".into()); + let (reply, settled) = oneshot::channel(); + waiting(&mut app, settled, Instant::now()); + app.handle_agent_message(FromAgent::TurnInterrupted { + response_id: "old".into(), + reason: "user".into(), + }) + .await + .unwrap(); + assert!( + !app.state.busy, + "terminal events can precede the cleanup receipt" + ); + drop(reply); + assert!(app.poll_session_transition().await.unwrap()); + let other = tempfile::tempdir().unwrap(); + app.resume_session_path(&other.path().join("unread-target.jsonl"), "other"); + assert!( + app.state + .error + .as_deref() + .unwrap() + .contains("Could not confirm"), + "unknown cleanup must reject adoption before reading another transcript" + ); + app.start_new_session("New session started."); + assert_eq!(app.active_modal, ActiveModal::SessionTransition); + assert_eq!(app.state.messages[0].content, "parent prompt"); + } + + #[tokio::test] + async fn dismissed_cleanup_still_requires_the_retained_actor_receipt() { + let mut app = app(); + let saved = tempfile::tempdir().unwrap(); + app.session_manager = SessionManager::with_sessions_dir("kept-cleanup-test", saved.path()); + app.state.add_user_message("parent prompt".into()); + app.handle_agent_message(FromAgent::ResponseStart { + response_id: "old".into(), + }) + .await + .unwrap(); + app.ensure_session_started().unwrap(); + let parent_path = app.session_manager.current_session_path().unwrap(); + app.state.busy = false; + let (events, rx) = mpsc::unbounded_channel(); + app.native_event_rx = Some(rx); + let (reply, settled) = oneshot::channel(); + waiting(&mut app, settled, Instant::now()); + app.handle_session_transition_key(KeyCode::Esc, false) + .await + .unwrap(); + assert!(app.session_cleanup_pending()); + app.start_new_session("New session started."); + assert_eq!(app.active_modal, ActiveModal::SessionTransition); + assert_eq!(app.state.messages[0].content, "parent prompt"); + app.handle_session_transition_key(KeyCode::Esc, false) + .await + .unwrap(); + events + .send(FromAgent::ResponseChunk { + response_id: "old".into(), + content: "late kept-parent chunk".into(), + is_thinking: false, + }) + .unwrap(); + events + .send(FromAgent::ResponseEnd { + response_id: "old".into(), + usage: None, + }) + .unwrap(); + events + .send(FromAgent::TurnInterrupted { + response_id: "old".into(), + reason: "user".into(), + }) + .unwrap(); + reply.send(()).unwrap(); + assert!(app.poll_session_transition().await.unwrap()); + assert!(!app.session_cleanup_pending()); + assert_eq!(app.state.messages[0].content, "parent prompt"); + app.start_new_session("New session started."); + assert_eq!(app.active_modal, ActiveModal::None); + assert_eq!(app.state.messages[0].content, "New session started."); + assert!( + std::fs::read_to_string(parent_path) + .unwrap() + .contains("late kept-parent chunk"), + "the retained receipt must drain and save the parent's final events" + ); + } + + #[tokio::test] + async fn acknowledgement_drains_old_events_before_starting_new_session() { + let mut app = app(); + app.goal_store = crate::goal::GoalStore::default(); + app.goal_store + .create("parent goal", None, false, None, None) + .unwrap(); + app.goal_store.set_auto_continue(true).unwrap(); + let saved = tempfile::tempdir().unwrap(); + app.session_manager = + SessionManager::with_sessions_dir("transition-drain-test", saved.path()); + app.state.add_user_message("parent prompt".into()); + app.handle_agent_message(FromAgent::ResponseStart { + response_id: "old".into(), + }) + .await + .unwrap(); + app.ensure_session_started().unwrap(); + let parent_path = app.session_manager.current_session_path().unwrap(); + app.state.busy = true; + app.state.set_input("draft é"); + let (events, rx) = mpsc::unbounded_channel(); + app.native_event_rx = Some(rx); + let (reply, settled) = oneshot::channel(); + waiting(&mut app, settled, Instant::now()); + app.goal_auto_continue_armed = true; + assert!(!app.poll_session_transition().await.unwrap()); + assert!(!app.goal_auto_continue_armed); + assert_eq!(app.state.messages[0].content, "parent prompt"); + events + .send(FromAgent::ResponseChunk { + response_id: "old".into(), + content: "late parent chunk".into(), + is_thinking: false, + }) + .unwrap(); + events + .send(FromAgent::ResponseEnd { + response_id: "old".into(), + usage: None, + }) + .unwrap(); + events + .send(FromAgent::TurnCompleted { + response_id: "old".into(), + coding_completion: None, + coding_child_records: Vec::new(), + }) + .unwrap(); + reply.send(()).unwrap(); + assert!(app.poll_session_transition().await.unwrap()); + assert!(!app.state.busy); + assert!( + !app.goal_auto_continue_armed, + "late completion must not continue a parent goal in the new session" + ); + assert!( + app.state + .messages + .iter() + .all(|message| !message.content.contains("late parent chunk")) + ); + assert_eq!(app.state.input(), "draft é"); + assert!(app.session_transition.is_none()); + let parent = std::fs::read_to_string(parent_path).unwrap(); + assert!( + parent.contains("late parent chunk"), + "the old response must be saved in its parent: {parent}" + ); + } + + #[tokio::test] + async fn rewind_preview_refuses_missing_saved_session_before_cancellation() { + let mut app = app(); + app.state.busy = true; + app.rewind_saved_turns(1, false, false); + assert!(app.session_transition.is_none()); + assert!(app.state.busy); + assert!( + app.state + .error + .as_deref() + .unwrap() + .contains("No saved session") + ); + } +} diff --git a/packages/tui-rs/src/app/startup.rs b/packages/tui-rs/src/app/startup.rs index 476e687f9..67d1f5fb1 100644 --- a/packages/tui-rs/src/app/startup.rs +++ b/packages/tui-rs/src/app/startup.rs @@ -1,12 +1,16 @@ //! Keep network verification and extension discovery off the terminal thread. -//! Only the composer exists while preparation is pending: no command dispatcher, -//! agent, tool executor, or MCP connection can run before policy is resolved. +//! The normal shell and model picker remain usable while preparation is pending, +//! but no dispatcher, agent, tool executor, or MCP connection can run before +//! policy is resolved. use super::*; -use crate::state::InteractionMode; use std::sync::mpsc::{Receiver, TryRecvError}; pub(super) struct PreparedStartup { + pub local_discovery: Option<( + crate::local_models::LocalDiscoveryHandle, + Receiver, + )>, pub config: crate::config::ComposerConfig, pub plugin_registry: PluginRegistry, pub loaded_skills: Vec, @@ -19,6 +23,10 @@ pub(super) struct PreparedStartup { impl PreparedStartup { pub(super) fn load(session: PlatformSessionResolution) -> Self { + Self::load_for_model(session, &crate::codex_auth::resolve_default_model()) + } + + fn load_for_model(session: PlatformSessionResolution, model: &str) -> Self { let workspace = std::env::current_dir().unwrap_or_else(|_| ".".into()); let config = crate::config::load_config(&workspace, None); let plugin_registry = PluginRegistry::discover(); @@ -29,7 +37,7 @@ impl PreparedStartup { let exec_commands = crate::exec_commands::discover_with_plugin_dirs(&workspace, &dirs); let (managed_setup, managed_setup_identity_scope) = match session { PlatformSessionResolution::Detect => resolve_startup_managed_setup( - &crate::codex_auth::resolve_default_model(), + model, crate::credential_mode::current_verified_identity_session, || match crate::credential_mode::detect() { Ok(crate::credential_mode::DetectedMode::Platform(session)) => Some(session), @@ -42,6 +50,7 @@ impl PreparedStartup { } }; Self { + local_discovery: None, config, plugin_registry, loaded_skills, @@ -54,6 +63,23 @@ impl PreparedStartup { } } +pub(super) struct StartupHandoff { + pub prepared: PreparedStartup, + pub textarea: crate::components::textarea::TextArea, + pub model: String, + pub model_changed: bool, + pub resume_session: Option, + pub local_discovery_handle: crate::local_models::LocalDiscoveryHandle, + pub local_discovery_rx: Receiver, + pub local_discovery_batch: Option, +} + +#[derive(Debug)] +struct StartupModelChoice { + model: String, + persist_default: bool, +} + fn resolve_startup_managed_setup( model: &str, verified: impl FnOnce() -> anyhow::Result, @@ -62,7 +88,9 @@ fn resolve_startup_managed_setup( crate::managed_setup::ManagedSetupClient, Option, ) { - if crate::local_models::is_local_model_route(model) { + if maestro_local_host::safety::vendor_network_disabled() + || crate::local_models::is_local_model_route(model) + { let stored = unverified(); return ( crate::managed_setup::ManagedSetupClient::offline_local(stored.as_ref()), @@ -90,43 +118,331 @@ pub(super) fn prepare_with_composer( terminal: &mut terminal::Terminal, capabilities: &mut TerminalCapabilities, events: &mut Option, -) -> Result<(PreparedStartup, crate::components::textarea::TextArea)> { - let rx = preparation(|| PreparedStartup::load(PlatformSessionResolution::Detect))?; +) -> Result { + let initial_model = crate::codex_auth::resolve_default_model(); + let mut selected_model = initial_model.clone(); + let mut rx = start_preparation(&selected_model)?; + let (local_discovery, local_discovery_rx) = crate::local_models::spawn_local_model_discovery(); + local_discovery.refresh(); + let mut latest_local_discovery = None; + let mut resume_session = None; + let mut pending_session: Option>> = None; + let mut sessions = SessionSwitcher::new(std::env::current_dir()?.to_string_lossy().as_ref()); + let terminal_info = crate::terminal_info::TerminalInfo::get(); + let model_binding = + load_rust_tui_keybindings(&terminal_info.name, std::env::var_os("TMUX").is_some()) + .cycle_model; let mut state = AppState::new(); state.locale = crate::ui_prefs::UiPrefs::load_default().locale(); - let configured_model = crate::codex_auth::resolve_default_model(); - if crate::local_models::is_local_model_route(&configured_model) { - state.model = Some(configured_model); + state.model = Some(selected_model.clone()); + let mut model_selector = ModelSelector::new(); + model_selector.set_current_model(Some(selected_model.clone())); + update_startup_status(&mut state, None, &selected_model, &model_binding.display()); + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + + loop { + if let Some(pending) = &pending_session { + match pending.try_recv() { + Ok(Ok(session)) => { + selected_model = session.model.clone(); + state.model = Some(selected_model.clone()); + model_selector.set_current_model(Some(selected_model.clone())); + rx = start_preparation(&selected_model)?; + resume_session = Some(session); + pending_session = None; + } + Ok(Err(error)) => { + sessions.show_error( + state + .locale + .format("Failed to load session: {0}", &[error.to_string()]), + ); + pending_session = None; + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + } + Err(TryRecvError::Disconnected) => { + sessions.show_error( + state + .locale + .translate("Session loading stopped. Try again.") + .to_owned(), + ); + pending_session = None; + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + } + Err(TryRecvError::Empty) => {} + } + } + if sessions.poll_refresh() { + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + } + while let Ok(batch) = local_discovery_rx.try_recv() { + if model_selector.apply_discovery(&batch) { + latest_local_discovery = Some(batch); + update_startup_status( + &mut state, + latest_local_discovery.as_ref(), + &selected_model, + &model_binding.display(), + ); + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + } + } + + if !model_selector.is_visible() && !sessions.is_visible() && pending_session.is_none() { + match rx.try_recv() { + Ok(prepared) => { + return Ok(StartupHandoff { + prepared, + textarea: state.textarea, + model: selected_model.clone(), + model_changed: selected_model != initial_model, + resume_session, + local_discovery_handle: local_discovery, + local_discovery_rx, + local_discovery_batch: latest_local_discovery, + }); + } + Err(TryRecvError::Disconnected) => bail!("Startup preparation failed"), + Err(TryRecvError::Empty) => {} + } + } + + let next = if let Some(reader) = events { + reader + .poll(Duration::from_millis(16)) + .map_err(anyhow::Error::from)? + } else if event::poll(Duration::from_millis(16))? { + AppTerminalEvent::from_crossterm(event::read()?) + } else { + None + }; + let Some(event) = next else { + continue; + }; + if !model_selector.is_visible() + && handle_startup_sessions(&mut state, &mut sessions, &event)? + { + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + continue; + } + if sessions.is_visible() { + // Enter selects metadata only. The full app reopens the transcript + // under its existing lock and admission path before resuming it. + if let AppTerminalEvent::Key(key) = &event { + if should_handle_key_event(key.kind) && key.code == KeyCode::Enter { + if let Some(session) = sessions.selected_session().cloned() { + pending_session = Some(preparation(move || load_startup_session(session))?); + sessions.hide(); + } + } + } + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + continue; + } + if matches!(&event, AppTerminalEvent::Key(key) if should_handle_key_event(key.kind) && key.code == KeyCode::Char('r') && key.modifiers.contains(CrosstermModifiers::CONTROL)) + { + local_discovery.refresh(); + model_selector.mark_local_refreshing(); + } + if let Some(choice) = + edit_startup_shell(&mut state, &mut model_selector, model_binding, event)? + { + if choice.persist_default { + if let Err(error) = crate::config_cli::persist_user_model_default(&choice.model) { + state.status = Some( + state + .locale + .format("Failed to save default model: {0}", &[(error).to_string()]), + ); + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; + continue; + } + } + if choice.model != selected_model { + // Replacing the receiver closes the old preparation channel. + // A late cloud result cannot overwrite the visible selection. + resume_session = None; + pending_session = None; + selected_model = choice.model; + state.model = Some(selected_model.clone()); + state.thinking_level = crate::model_dynamics::normalize_thinking( + &selected_model, + state.thinking_level, + ); + model_selector.set_current_model(Some(selected_model.clone())); + rx = start_preparation(&selected_model)?; + } + update_startup_status( + &mut state, + latest_local_discovery.as_ref(), + &selected_model, + &model_binding.display(), + ); + } + render_startup_shell( + terminal, + capabilities, + &state, + &mut model_selector, + &mut sessions, + )?; } - state.status = Some( +} + +fn load_startup_session( + mut session: crate::session::SessionInfo, +) -> Result { + // The index deliberately omits model metadata. Read the selected header off + // the input thread; resume_session_path later rereads under the writer lock. + // This value selects a preparation route, never grants authority. + let (header, _, _) = crate::session::SessionReader::read_header(&session.path)?; + session.model = header.model; + session.thinking_level = header.thinking_level; + Ok(session) +} + +fn start_preparation(model: &str) -> Result> { + let model = model.to_owned(); + preparation(move || PreparedStartup::load_for_model(PlatformSessionResolution::Detect, &model)) +} + +fn update_startup_status( + state: &mut AppState, + discovery: Option<&crate::local_models::LocalDiscoveryBatch>, + selected_model: &str, + model_binding: &str, +) { + let local_status = match discovery { + None => state + .locale + .translate("checking local runtimes") + .to_string(), + Some(batch) if batch.models.is_empty() => state + .locale + .translate("no local models detected") + .to_string(), + Some(batch) => state.locale.format( + "{0} local models available", + std::slice::from_ref(&batch.models.len().to_string()), + ), + }; + let route_status = if crate::local_models::is_local_model_route(selected_model) { state .locale - .translate("Starting… You can type while setup finishes.") - .into(), - ); - wait_with_composer( - &rx, - &mut state, - |state| { - let size = terminal.size()?; - let (top, height) = terminal::calculate_viewport(size.height); - if capabilities.viewport_top != top || capabilities.viewport_height != height { - *terminal = terminal::recreate_with_viewport(height)?; - capabilities.viewport_top = top; - capabilities.viewport_height = height; + .translate("preparing a local session") + .to_string() + } else { + state + .locale + .translate("preparing secure access") + .to_string() + }; + state.status = Some(state.locale.format( + "Starting… {0} · {1} · {2} or /model to choose", + &[route_status, local_status, model_binding.to_string()], + )); +} + +fn render_startup_shell( + terminal: &mut terminal::Terminal, + capabilities: &mut TerminalCapabilities, + state: &AppState, + model_selector: &mut ModelSelector, + sessions: &mut SessionSwitcher, +) -> Result<()> { + let size = terminal.size()?; + let (top, height) = terminal::calculate_viewport(size.height); + if capabilities.viewport_top != top || capabilities.viewport_height != height { + *terminal = terminal::recreate_with_viewport(height)?; + capabilities.viewport_top = top; + capabilities.viewport_height = height; + } + terminal.draw(|frame| { + crate::localization::with_locale(state.locale, || { + let area = frame.area(); + frame.render_widget( + ChatView::new(state).with_footer_style(FooterStyle::Rich), + area, + ); + let status_height = u16::from(!state.zen_mode); + let input_height = calculate_input_height(state, area); + let input_area = Rect { + x: area.x, + y: area + .y + .saturating_add(area.height.saturating_sub(status_height + input_height)), + width: area.width, + height: input_height, + }; + if input_area.y > area.y { + let notice_area = Rect::new( + area.x.saturating_add(1), + input_area.y - 1, + area.width.saturating_sub(2), + 1, + ); + frame.render_widget(ratatui::widgets::Clear, notice_area); + frame.render_widget( + ratatui::widgets::Paragraph::new(state.status.as_deref().unwrap_or_default()) + .style(crate::themes::current_ui_theme().muted_style()), + notice_area, + ); } - terminal.draw(|frame| { - let area = frame.area(); - let input_height = - calculate_input_height(state, area).min(area.height.saturating_sub(1)); - let input_area = Rect { - y: area - .y - .saturating_add(area.height.saturating_sub(1 + input_height)), - height: input_height, - ..area - }; - let mut input = ChatInputWidget::new( + if sessions.is_visible() { + sessions.render(frame, area); + } else if model_selector.is_visible() { + model_selector.render(frame, area); + } else { + let input = ChatInputWidget::new( &state.textarea, ChatInputWidgetOptions { busy: false, @@ -134,43 +450,75 @@ pub(super) fn prepare_with_composer( ghost_text: None, }, ); - if state.model.is_some() { - input = input.with_runtime_footer( - state.model.as_deref(), - state.thinking_level, - InteractionMode::Normal, - ); - } - let cursor = input.cursor_pos(input_area); - frame.render_widget(input, input_area); - if input_area.y > area.y { - frame.render_widget( - ratatui::widgets::Paragraph::new( - state.status.as_deref().unwrap_or_default(), - ), - Rect::new(area.x, input_area.y - 1, area.width, 1), - ); - } - if let Some(cursor) = cursor { + if let Some(cursor) = input.cursor_pos(input_area) { frame.set_cursor_position(cursor); } - })?; - Ok(()) - }, - || { - if let Some(reader) = events { - return reader.poll(Duration::from_millis(16)).map_err(Into::into); } - if event::poll(Duration::from_millis(16))? { - Ok(AppTerminalEvent::from_crossterm(event::read()?)) - } else { - Ok(None) + }); + })?; + Ok(()) +} + +/// Returns true when a history navigation event was consumed. Enter is left +/// to the caller so selection never creates an executable dispatcher here. +fn handle_startup_sessions( + state: &mut AppState, + sessions: &mut SessionSwitcher, + event: &AppTerminalEvent, +) -> Result { + if let AppTerminalEvent::Resize { width, .. } = event { + state.set_input_width(crate::components::composer_editor_width(*width)); + } + if let AppTerminalEvent::Key(key) = event { + if !should_handle_key_event(key.kind) { + return Ok(sessions.is_visible()); + } + let ctrl = key.modifiers.contains(CrosstermModifiers::CONTROL); + let alt = key.modifiers.contains(CrosstermModifiers::ALT); + if ctrl && key.code == KeyCode::Char('c') { + bail!("Startup cancelled"); + } + if !sessions.is_visible() { + if (ctrl && alt && key.code == KeyCode::Char('r')) + || (key.code == KeyCode::Enter + && key.modifiers.is_empty() + && matches!(state.input().trim(), "/sessions" | "/resume")) + { + if key.code == KeyCode::Enter { + state.set_input(""); + } + sessions.show(); + return Ok(true); } - }, - ) - .map(|prepared| (prepared, state.textarea)) + return Ok(false); + } + match key.code { + KeyCode::Enter => return Ok(false), + KeyCode::Esc => sessions.hide(), + KeyCode::Up => sessions.move_up(), + KeyCode::Down => sessions.move_down(), + KeyCode::Backspace => sessions.backspace(), + KeyCode::Char('r') if ctrl => sessions.refresh_async(), + KeyCode::Delete => { + if let Err(error) = sessions.delete_selected() { + state.status = Some(error); + } + } + KeyCode::Char(c) if !ctrl && !alt => sessions.insert_char(c), + _ => {} + } + return Ok(true); + } + if let AppTerminalEvent::Paste(text) = event { + if sessions.is_visible() { + sessions.insert_str(text); + return Ok(true); + } + } + Ok(sessions.is_visible()) } +#[cfg(test)] fn wait_with_composer( rx: &Receiver, state: &mut AppState, @@ -191,13 +539,68 @@ fn wait_with_composer( } } -fn edit_draft(state: &mut AppState, event: AppTerminalEvent) -> Result<()> { +fn edit_startup_shell( + state: &mut AppState, + model_selector: &mut ModelSelector, + model_binding: crate::key_hints::KeyBinding, + event: AppTerminalEvent, +) -> Result> { + // Cancellation and resize apply even when a modal owns keyboard focus. + if let AppTerminalEvent::Key(key) = &event { + if should_handle_key_event(key.kind) + && key.code == KeyCode::Char('c') + && key.modifiers.contains(CrosstermModifiers::CONTROL) + { + bail!("Startup cancelled"); + } + } + if let AppTerminalEvent::Resize { width, .. } = &event { + state.set_input_width(crate::components::composer_editor_width(*width)); + } + if model_selector.is_visible() { + match event { + AppTerminalEvent::Paste(text) => model_selector.insert_str(&text), + AppTerminalEvent::Key(key) if should_handle_key_event(key.kind) => { + let ctrl = key.modifiers.contains(CrosstermModifiers::CONTROL); + match key.code { + KeyCode::Esc => model_selector.hide(), + KeyCode::Enter => { + return Ok(model_selector.confirm().map(|model| StartupModelChoice { + model, + persist_default: false, + })); + } + KeyCode::Char('d') if ctrl => { + return Ok(model_selector.confirm().map(|model| StartupModelChoice { + model, + persist_default: true, + })); + } + KeyCode::Tab => model_selector.toggle_show_all(), + KeyCode::Up => model_selector.move_up(), + KeyCode::Down => model_selector.move_down(), + KeyCode::Left => model_selector.move_left(), + KeyCode::Right => model_selector.move_right(), + KeyCode::Backspace => model_selector.backspace(), + KeyCode::Char(c) if !ctrl => model_selector.insert_char(c), + _ => {} + } + } + _ => {} + } + return Ok(None); + } + match event { AppTerminalEvent::Paste(text) => state.insert_paste(&text), AppTerminalEvent::Resize { width, .. } => { state.set_input_width(crate::components::composer_editor_width(width)); } AppTerminalEvent::Key(key) if should_handle_key_event(key.kind) => { + if model_binding.matches(&key) { + model_selector.show(); + return Ok(None); + } match (key.code, key.modifiers) { (KeyCode::Char('c' | 'd'), m) if m.contains(CrosstermModifiers::CONTROL) => { bail!("Startup cancelled"); @@ -218,6 +621,10 @@ fn edit_draft(state: &mut AppState, event: AppTerminalEvent) -> Result<()> { (KeyCode::Enter, m) if m.contains(CrosstermModifiers::SHIFT) => { state.insert_char('\n'); } + (KeyCode::Enter, _) if state.input().trim() == "/model" => { + state.set_input(""); + model_selector.show(); + } (KeyCode::Enter, _) => { state.status = Some( state @@ -233,7 +640,19 @@ fn edit_draft(state: &mut AppState, event: AppTerminalEvent) -> Result<()> { } _ => {} } - Ok(()) + Ok(None) +} + +#[cfg(test)] +fn edit_draft(state: &mut AppState, event: AppTerminalEvent) -> Result<()> { + let mut selector = ModelSelector::new(); + edit_startup_shell( + state, + &mut selector, + crate::key_hints::ctrl(KeyCode::Char('p')), + event, + ) + .map(|_| ()) } #[cfg(test)] @@ -315,6 +734,108 @@ mod tests { assert!(state.status.as_deref().unwrap().contains("press Enter")); } + #[test] + fn startup_model_picker_preserves_unicode_draft_and_cursor() { + let binding = crate::key_hints::ctrl(KeyCode::Char('p')); + let mut state = AppState::new(); + state.set_input("draft é"); + state.move_left(); + let original_cursor = state.cursor(); + let mut selector = ModelSelector::new(); + selector.set_current_model(Some("ollama/qwen3".to_owned())); + + let opened = edit_startup_shell( + &mut state, + &mut selector, + binding, + AppTerminalEvent::Key(event::KeyEvent::new( + KeyCode::Char('p'), + CrosstermModifiers::CONTROL, + )), + ) + .unwrap(); + assert!(opened.is_none()); + assert!(selector.is_visible()); + assert_eq!(state.input(), "draft é"); + assert_eq!(state.cursor(), original_cursor); + + let choice = edit_startup_shell( + &mut state, + &mut selector, + binding, + AppTerminalEvent::Key(event::KeyEvent::new( + KeyCode::Enter, + CrosstermModifiers::NONE, + )), + ) + .unwrap() + .expect("active model should be selectable"); + assert_eq!(choice.model, "ollama/qwen3"); + assert!(!choice.persist_default); + assert_eq!(state.input(), "draft é"); + assert_eq!(state.cursor(), original_cursor); + } + + #[test] + fn startup_history_shortcut_preserves_the_draft_and_escape_restores_focus() { + let mut state = AppState::new(); + state.set_input("draft 東京"); + state.move_left(); + let cursor = state.cursor(); + let mut sessions = SessionSwitcher::new("/tmp"); + let open = AppTerminalEvent::Key(event::KeyEvent::new( + KeyCode::Char('r'), + CrosstermModifiers::CONTROL | CrosstermModifiers::ALT, + )); + assert!(handle_startup_sessions(&mut state, &mut sessions, &open).unwrap()); + assert!(sessions.is_visible()); + assert_eq!(state.input(), "draft 東京"); + assert_eq!(state.cursor(), cursor); + let close = + AppTerminalEvent::Key(event::KeyEvent::new(KeyCode::Esc, CrosstermModifiers::NONE)); + assert!(handle_startup_sessions(&mut state, &mut sessions, &close).unwrap()); + assert!(!sessions.is_visible()); + assert_eq!(state.cursor(), cursor); + } + + #[test] + fn startup_cancel_works_while_model_picker_has_focus() { + let mut state = AppState::new(); + let mut selector = ModelSelector::new(); + selector.show(); + let error = edit_startup_shell( + &mut state, + &mut selector, + crate::key_hints::ctrl(KeyCode::Char('p')), + AppTerminalEvent::Key(event::KeyEvent::new( + KeyCode::Char('c'), + CrosstermModifiers::CONTROL, + )), + ) + .unwrap_err(); + assert!(error.to_string().contains("Startup cancelled")); + } + + #[test] + fn startup_model_command_opens_picker_without_submitting() { + let mut state = AppState::new(); + state.set_input("/model"); + let mut selector = ModelSelector::new(); + let choice = edit_startup_shell( + &mut state, + &mut selector, + crate::key_hints::ctrl(KeyCode::Char('p')), + AppTerminalEvent::Key(event::KeyEvent::new( + KeyCode::Enter, + CrosstermModifiers::NONE, + )), + ) + .unwrap(); + assert!(choice.is_none()); + assert!(selector.is_visible()); + assert!(state.input().is_empty()); + } + #[test] fn startup_cancellation_does_not_wait_for_preparation() { let (release_tx, release_rx) = std::sync::mpsc::channel(); diff --git a/packages/tui-rs/src/app/tests.rs b/packages/tui-rs/src/app/tests.rs index 63ddec996..b8f6b4850 100644 --- a/packages/tui-rs/src/app/tests.rs +++ b/packages/tui-rs/src/app/tests.rs @@ -1348,7 +1348,7 @@ fn find_session_jsonl(dir: &std::path::Path) -> std::path::PathBuf { .expect("directory should contain a session .jsonl file") } -fn new_test_app() -> App { +pub(super) fn new_test_app() -> App { new_test_app_with_platform_resolution(PlatformSessionResolution::UseNoPlatformSession) } @@ -1406,6 +1406,7 @@ async fn startup_prompt_waits_for_initial_local_discovery() { std::thread::sleep(Duration::from_millis(25)); tx.send(crate::local_models::LocalDiscoveryBatch { generation: 99, + runtimes: Vec::new(), models: vec![crate::model_catalog::ModelInfo { id: "startup-budget-model".to_owned(), name: "startup-budget-model".to_owned(), @@ -2962,12 +2963,53 @@ fn test_format_mcp_server_transition_status_for_error_change() { ); } +#[tokio::test] +async fn passive_mcp_refresh_waits_for_native_agent_admission() { + let mut app = new_test_app(); + assert!(app.native_agent.is_none()); + for force in [false, true] { + assert!(!app.refresh_mcp_badges_with_force(force).await); + assert!(!app.mcp_status_refresh_in_flight); + assert!(app.last_mcp_status_refresh.is_none()); + assert!(app.mcp_status_rx.try_recv().is_err()); + } +} + +#[test] +fn returning_user_without_an_agent_keeps_the_composer_and_pending_prompt() { + let mut app = new_test_app(); + app.ui_prefs.onboarding_seen = true; + app.state.set_input("offline draft é stays"); + app.initial_prompt = Some("explicit pending request".into()); + assert!(app.native_agent.is_none()); + assert!(!app.maybe_open_first_run_setup()); + assert_eq!(app.active_modal, ActiveModal::None); + assert_eq!(app.state.input(), "offline draft é stays"); + assert_eq!( + app.initial_prompt.as_deref(), + Some("explicit pending request") + ); + assert!(app.native_agent.is_none()); +} + #[tokio::test] async fn test_handle_config_event_forces_mcp_badge_refresh() { let temp = tempfile::tempdir().expect("tempdir"); std::fs::create_dir_all(temp.path().join(".composer")).expect("create config dir"); let mut app = new_test_app(); + let (agent, _events) = crate::agent::NativeAgent::new_with_test_client( + crate::agent::NativeAgentConfig { + model: "openai/gpt-4o".into(), + cwd: temp.path().display().to_string(), + ..Default::default() + }, + crate::ai::UnifiedClient::OpenAI( + crate::ai::OpenAiClient::with_base_url("fixture", "http://127.0.0.1:1/v1").unwrap(), + ), + ) + .unwrap(); + app.native_agent = Some(agent); app.tool_executor = Arc::new(ToolExecutor::new(temp.path().display().to_string())); app.last_mcp_status_refresh = Some(Instant::now()); app.state.mcp_connected = 7; @@ -3293,32 +3335,6 @@ fn open_plan_comments_block_approval() { ); } -#[test] -fn rewind_is_blocked_while_busy() { - let mut app = new_test_app(); - app.state.add_user_message("keep me".into()); - app.state.busy = true; - app.rewind_turns(1, false); - // App construction may prepend system messages of its own (e.g. the - // sandbox-unavailable notice when a concurrently running config test - // sets MAESTRO_SANDBOX_MODE/MAESTRO_INTERNAL_TUI_SANDBOX_DEFAULT - // process-wide, or an untrusted-workspace notice), so an absolute - // message count is not stable. Assert the semantic invariant instead: - // the user message survives the blocked rewind and the busy status is - // shown. - assert!( - app.state - .messages - .iter() - .any(|message| message.content == "keep me"), - "a blocked rewind must not remove the user message" - ); - assert_eq!( - app.state.status.as_deref(), - Some("Wait for the active response to finish before rewinding.") - ); -} - #[test] fn restore_side_questions_by_timestamp_without_model_history_entries() { let mut state = AppState::new(); @@ -6783,7 +6799,17 @@ fn opening_control_panels_preserves_approval_and_preferences() { #[tokio::test] async fn control_panel_enter_keeps_child_model_picker_open() { let mut app = new_test_app(); - app.show_control_panel(crate::commands::ControlPanel::Model); + app.state.set_input("draft é stays"); + app.state.move_left(); + let cursor = app.state.cursor(); + app.show_command_palette(); + app.command_palette.insert_str(">model"); + app.handle_command_palette_key(crossterm::event::KeyCode::Enter, false) + .await + .unwrap(); + assert_eq!(app.active_modal, ActiveModal::CommandPalette); + assert_eq!(app.state.input(), "draft é stays"); + assert_eq!(app.state.cursor(), cursor); assert_eq!( app.command_palette.selected_resource().unwrap().id, "model select" @@ -6792,6 +6818,13 @@ async fn control_panel_enter_keeps_child_model_picker_open() { .await .unwrap(); assert_eq!(app.active_modal, ActiveModal::ModelSelector); + assert_eq!(app.state.input(), "draft é stays"); + assert_eq!(app.state.cursor(), cursor); + app.handle_model_selector_key(crossterm::event::KeyCode::Esc, false) + .await + .unwrap(); + assert_eq!(app.state.input(), "draft é stays"); + assert_eq!(app.state.cursor(), cursor); } #[tokio::test] diff --git a/packages/tui-rs/src/cli_commands.rs b/packages/tui-rs/src/cli_commands.rs index 62b9c50f1..d30994659 100644 --- a/packages/tui-rs/src/cli_commands.rs +++ b/packages/tui-rs/src/cli_commands.rs @@ -30,6 +30,21 @@ pub async fn run_cli_command(args: &[String]) -> Result { ); }; + if matches!( + cmd, + "doctor" + | "setup" + | "login" + | "evalops" + | "openai" + | "codex" + | "remote" + | "computer" + | "orb" + | "operating-plane" + ) { + maestro_local_host::safety::require_vendor_network()?; + } match cmd { "sessions" => run_sessions(&args[1..]), "search" => crate::search_cli::run_search(&args[1..]), diff --git a/packages/tui-rs/src/commands/registry.rs b/packages/tui-rs/src/commands/registry.rs index 8745e4d08..8a0045755 100644 --- a/packages/tui-rs/src/commands/registry.rs +++ b/packages/tui-rs/src/commands/registry.rs @@ -69,6 +69,8 @@ //! //! See `CommandArgument` in `types.rs` for argument definition details. +mod session_navigation; + use std::collections::HashMap; use std::path::Path; use std::sync::Arc; @@ -1494,24 +1496,8 @@ fn build_builtin_registry() -> CommandRegistry { .primary(0), ); - // Fork session - registry.register( - Command::new( - "fork", - maestro_ui::localization::tr("Fork the conversation into a new session branch"), - CommandCategory::Session, - Box::new(|_| { - Ok(CommandOutput::Action(CommandAction::Session( - SessionAction::Fork, - ))) - }), - ) - .localized() - .usage("/fork") - .primary(2), - ); + session_navigation::register(&mut registry); - // Rewind turns registry.register( Command::new( "rewind", diff --git a/packages/tui-rs/src/commands/registry/session_navigation.rs b/packages/tui-rs/src/commands/registry/session_navigation.rs new file mode 100644 index 000000000..e522b2f13 --- /dev/null +++ b/packages/tui-rs/src/commands/registry/session_navigation.rs @@ -0,0 +1,34 @@ +use super::*; + +pub(super) fn register(registry: &mut CommandRegistry) { + // Persisted branch navigation and fork creation. + registry.register( + Command::new( + "tree", + maestro_ui::localization::tr("Browse and resume session branches"), + CommandCategory::Session, + Box::new(|_| { + Ok(CommandOutput::Action(CommandAction::Session( + SessionAction::BrowseBranches, + ))) + }), + ) + .localized(), + ); + + registry.register( + Command::new( + "fork", + maestro_ui::localization::tr("Fork the conversation into a new session branch"), + CommandCategory::Session, + Box::new(|_| { + Ok(CommandOutput::Action(CommandAction::Session( + SessionAction::Fork, + ))) + }), + ) + .localized() + .usage("/fork") + .primary(2), + ); +} diff --git a/packages/tui-rs/src/commands/types.rs b/packages/tui-rs/src/commands/types.rs index d4135493e..70afdb441 100644 --- a/packages/tui-rs/src/commands/types.rs +++ b/packages/tui-rs/src/commands/types.rs @@ -546,6 +546,8 @@ pub enum SessionAction { New, /// Fork current conversation into a new session branch Fork, + /// Browse and resume persisted forks. + BrowseBranches, /// Rewind last N user turns (default 1) Rewind { turns: usize, dry_run: bool }, /// Rewind saved conversation and corresponding file changes. diff --git a/packages/tui-rs/src/components/model_selector.rs b/packages/tui-rs/src/components/model_selector.rs index 4027441df..2a93f33a0 100644 --- a/packages/tui-rs/src/components/model_selector.rs +++ b/packages/tui-rs/src/components/model_selector.rs @@ -248,6 +248,8 @@ pub struct ModelSelector { models: Vec, /// Latest applied local discovery generation. discovery_generation: u64, + local_runtimes: Vec, + local_refreshing: bool, /// Shared transient query, selection, and scrolling over ordered result rows. picker: ActionPicker, /// Product-owned focused/search result ordering. @@ -280,6 +282,8 @@ impl ModelSelector { catalog_models: models.clone(), models, discovery_generation: 0, + local_runtimes: Vec::new(), + local_refreshing: true, picker: ActionPicker::new(Vec::new()) .identified_by(|row: &ModelRow| row.id.as_str()) .expect("empty model rows are unique") @@ -364,6 +368,44 @@ impl ModelSelector { true } + /// Apply model rows and their availability evidence as one generation. + pub fn apply_discovery(&mut self, batch: &crate::local_models::LocalDiscoveryBatch) -> bool { + if !self.replace_discovered_models(batch.generation, batch.models.clone()) { + return false; + } + self.local_runtimes.clone_from(&batch.runtimes); + self.local_refreshing = false; + true + } + + pub fn mark_local_refreshing(&mut self) { + self.local_refreshing = true; + } + + fn local_runtime_lines(&self) -> Vec> { + use crate::local_models::LocalRuntimeStatus; + use maestro_ui::localization::{format as translated, tr}; + let mut lines = Vec::new(); + if self.local_refreshing { + lines.push(Line::from(tr("Checking local runtimes…").to_owned())); + } + for runtime in &self.local_runtimes { + let status = match runtime.status { + LocalRuntimeStatus::Ready { models } => { + translated("Models detected: {0}", &[models.to_string()]) + } + LocalRuntimeStatus::Empty => tr("No models reported").to_owned(), + LocalRuntimeStatus::Unreachable => tr("Not responding").to_owned(), + LocalRuntimeStatus::TimedOut => tr("Timed out").to_owned(), + LocalRuntimeStatus::HttpError(code) => translated("HTTP {0}", &[code.to_string()]), + LocalRuntimeStatus::InvalidEndpoint => tr("Check the runtime URL").to_owned(), + LocalRuntimeStatus::InvalidResponse => tr("Invalid model list").to_owned(), + }; + lines.push(Line::from(format!("{} · {}", runtime.display_name, status))); + } + lines + } + /// Replace the complete discovered-model snapshot. Older batches are /// ignored, catalog duplicates are replaced in place, and the selected /// route remains stable when it still exists. @@ -766,6 +808,30 @@ impl ModelSelector { .theme(theme) .render(frame, area); + // Reserve a small status area without hiding the search or key hints. + // At very short heights the picker itself takes priority. + let mut lines = self.local_runtime_lines(); + lines.push(Line::from(vec![ + Span::styled(maestro_ui::localization::tr("Ctrl+R"), theme.text_style()), + Span::raw(" "), + Span::raw(maestro_ui::localization::tr("refresh").to_owned()), + ])); + let status_height = (lines.len() as u16).min(inner.height.saturating_sub(6)); + let list_area = Rect { + height: inner.height.saturating_sub(status_height), + ..inner + }; + if status_height > 0 { + frame.render_widget( + ratatui::widgets::Paragraph::new(lines).style(theme.muted_style()), + Rect::new( + inner.x, + inner.y + list_area.height, + inner.width, + status_height, + ), + ); + } // Model list let canonical_current = self .current_model @@ -780,7 +846,7 @@ impl ModelSelector { .count(); self.picker.render( frame, - inner, + list_area, theme, PickerOptions { placeholder: maestro_ui::localization::tr("Type to filter models..."), @@ -911,6 +977,100 @@ fn capability_summary(model: &ModelInfo) -> String { mod tests { use super::*; + #[test] + fn runtime_status_is_visible_at_supported_terminal_widths() { + use crate::local_models::{LocalDiscoveryBatch, LocalRuntimeDiscovery, LocalRuntimeStatus}; + use ratatui::{Terminal, backend::TestBackend}; + for width in [40, 60, 100] { + let mut selector = ModelSelector::with_models(vec![test_model("qwen", "ollama")]); + selector.set_current_model(Some("ollama/qwen".to_owned())); + let mut batch = LocalDiscoveryBatch { + generation: 1, + models: Vec::new(), + runtimes: vec![ + LocalRuntimeDiscovery { + provider: "llamacpp", + display_name: "llama.cpp", + status: LocalRuntimeStatus::Empty, + }, + LocalRuntimeDiscovery { + provider: "lmstudio", + display_name: "LM Studio", + status: LocalRuntimeStatus::Unreachable, + }, + LocalRuntimeDiscovery { + provider: "ollama", + display_name: "Ollama", + status: LocalRuntimeStatus::Ready { models: 1 }, + }, + ], + }; + selector.apply_discovery(&batch); + selector.show(); + let before = selector.selected_model_id(); + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + terminal + .draw(|frame| selector.render(frame, frame.area())) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("No models reported"), "{width}: {text}"); + assert!(text.contains("Not responding"), "{width}: {text}"); + assert!(text.contains("Models detected: 1"), "{width}: {text}"); + assert_eq!(selector.selected_model_id(), before); + assert!(text.contains("Ctrl+R refresh"), "{width}: {text}"); + batch.generation += 1; + batch.runtimes[0].status = LocalRuntimeStatus::HttpError(401); + assert!(selector.apply_discovery(&batch)); + terminal + .draw(|frame| selector.render(frame, frame.area())) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("llama.cpp · HTTP 401"), "{width}: {text}"); + } + } + + #[test] + fn runtime_status_and_models_reject_stale_batches_together() { + use crate::local_models::{LocalDiscoveryBatch, LocalRuntimeDiscovery, LocalRuntimeStatus}; + let mut selector = ModelSelector::with_models(Vec::new()); + let mut batch = LocalDiscoveryBatch { + generation: 2, + models: Vec::new(), + runtimes: vec![LocalRuntimeDiscovery { + provider: "ollama", + display_name: "Ollama", + status: LocalRuntimeStatus::Empty, + }], + }; + assert!(selector.apply_discovery(&batch)); + assert!(!selector.local_refreshing); + selector.mark_local_refreshing(); + batch.generation = 1; + batch.runtimes[0].status = LocalRuntimeStatus::Unreachable; + assert!(!selector.apply_discovery(&batch)); + assert!(selector.local_refreshing); + assert_eq!(selector.local_runtimes[0].status, LocalRuntimeStatus::Empty); + batch.generation = 3; + assert!(selector.apply_discovery(&batch)); + assert!(!selector.local_refreshing); + assert_eq!( + selector.local_runtimes[0].status, + LocalRuntimeStatus::Unreachable + ); + } + #[test] fn stealth_models_are_hidden_until_consent_and_keep_a_warning_badge() { use ratatui::{Terminal, backend::TestBackend}; diff --git a/packages/tui-rs/src/components/session_switcher.rs b/packages/tui-rs/src/components/session_switcher.rs index 2abfdce8e..837a66ad4 100644 --- a/packages/tui-rs/src/components/session_switcher.rs +++ b/packages/tui-rs/src/components/session_switcher.rs @@ -2,6 +2,7 @@ //! //! Provides a UI for listing and switching between sessions. +use crate::search_cli::{SearchMatch, collect_documents, search_documents}; use maestro_ui::{KeyHint, Modal, ModalSize, NoticeTone, Picker, key_hints}; use ratatui::{ Frame, @@ -10,8 +11,9 @@ use ratatui::{ text::{Line, Span}, widgets::{ListItem, ListState}, }; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::time::{Duration, UNIX_EPOCH}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use crate::session::{ IndexedSession, SessionInfo, SessionManager, SessionMeta, SessionReadError, SessionStats, @@ -34,8 +36,15 @@ pub struct SessionSwitcher { query: String, /// Filtered sessions filtered: Vec, + /// Non-authoritative content results, computed through the existing search owner. + content_matches: HashMap, + content_pending: Option)>>, + content_query: Option, + query_changed_at: Instant, + branch_root: Option, /// Loading state loading: bool, + pending: Option, String>>>, /// Error message error: Option, /// List state for scrolling @@ -53,7 +62,13 @@ impl SessionSwitcher { visible: false, query: String::new(), filtered: Vec::new(), + content_matches: HashMap::new(), + content_pending: None, + content_query: None, + query_changed_at: Instant::now(), + branch_root: None, loading: false, + pending: None, error: None, list_state: ListState::default(), } @@ -63,10 +78,21 @@ impl SessionSwitcher { pub fn show(&mut self) { self.visible = true; self.query.clear(); + self.content_matches.clear(); + self.content_query = None; + self.branch_root = None; self.selected = 0; self.loading = true; self.error = None; - self.refresh(); + self.refresh_async(); + } + + /// Keep a failed selection actionable without replacing the existing list. + pub fn show_error(&mut self, error: String) { + self.visible = true; + self.pending = None; + self.loading = false; + self.error = Some(error); } /// Hide the modal @@ -80,8 +106,81 @@ impl SessionSwitcher { self.visible } + /// Refresh metadata off the input thread, coalescing repeated opens. + pub fn refresh_async(&mut self) { + if self.pending.is_some() { + return; + } + let mut loader = Self::new(self.manager.cwd()); + loader.manager = + SessionManager::with_sessions_dir(self.manager.cwd(), self.manager.sessions_dir()); + loader.index_path.clone_from(&self.index_path); + self.begin_load(move || loader.load_sessions().map_err(|error| error.to_string())); + } + + fn begin_load( + &mut self, + load: impl FnOnce() -> Result, String> + Send + 'static, + ) { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + self.loading = true; + self.error = None; + match std::thread::Builder::new() + .name("maestro-session-list".into()) + .spawn(move || { + let _ = tx.send(load()); + }) { + Ok(_) => self.pending = Some(rx), + Err(error) => { + self.loading = false; + self.error = Some(error.to_string()); + } + } + } + + /// Adopt only this refresh's result, retaining search and stable selection. + pub fn poll_refresh(&mut self) -> bool { + let content_changed = self.poll_content_search(); + use std::sync::mpsc::TryRecvError; + let Some(rx) = &self.pending else { + return content_changed; + }; + let result = match rx.try_recv() { + Ok(result) => result, + Err(TryRecvError::Empty) => return content_changed, + Err(TryRecvError::Disconnected) => { + Err(maestro_ui::localization::tr("Session loading stopped. Try again.").to_owned()) + } + }; + self.pending = None; + self.loading = false; + match result { + Ok(sessions) => { + let selected = self.selected_session().map(|session| session.id.clone()); + self.sessions = sessions; + self.filter(); + if let Some(index) = self + .filtered + .iter() + .position(|&i| Some(&self.sessions[i].id) == selected.as_ref()) + { + self.selected = index; + self.list_state.select(Some(index)); + } + } + Err(error) => { + self.error = Some(maestro_ui::localization::format( + "Failed to load sessions: {0}", + &[error], + )); + } + } + true + } + /// Refresh session list pub fn refresh(&mut self) { + self.pending = None; match self.load_sessions() { Ok(sessions) => { self.sessions = sessions; @@ -127,44 +226,59 @@ impl SessionSwitcher { /// Insert a character in filter pub fn insert_char(&mut self, c: char) { self.query.push(c); - self.filter(); + self.query_changed(); } /// Insert a string into the filter (e.g. pasted text). pub fn insert_str(&mut self, s: &str) { self.query.push_str(s); - self.filter(); + self.query_changed(); } /// Delete character from filter pub fn backspace(&mut self) { self.query.pop(); - self.filter(); + self.query_changed(); } /// Clear filter pub fn clear_filter(&mut self) { self.query.clear(); - self.filter(); + self.query_changed(); } /// Filter sessions based on query fn filter(&mut self) { - if self.query.is_empty() { - self.filtered = (0..self.sessions.len()).collect(); - } else { - let query = self.query.to_lowercase(); - self.filtered = self - .sessions + let query = self.query.to_lowercase(); + let branch_root = self.branch_root.as_ref().map(|anchor| { + self.sessions .iter() - .enumerate() - .filter(|(_, s)| { - s.id.to_lowercase().contains(&query) - || s.title().to_lowercase().contains(&query) - || s.cwd.to_lowercase().contains(&query) - }) - .map(|(i, _)| i) - .collect(); + .find(|session| session.id == *anchor) + .and_then(|session| self.branch_path(session).first().cloned()) + .unwrap_or_else(|| anchor.clone()) + }); + self.filtered = self + .sessions + .iter() + .enumerate() + .filter(|(_, session)| { + let in_branch = branch_root + .as_ref() + .is_none_or(|root| self.branch_path(session).first() == Some(root)); + in_branch + && (query.is_empty() + || session.id.to_lowercase().contains(&query) + || session.title().to_lowercase().contains(&query) + || session.cwd.to_lowercase().contains(&query) + || self.content_matches.contains_key(&session.id)) + }) + .map(|(index, _)| index) + .collect(); + if self.branch_root.is_some() { + let sessions = &self.sessions; + let mut ordered = self.filtered.clone(); + ordered.sort_by_cached_key(|&index| self.branch_path(&sessions[index])); + self.filtered = ordered; } // Reset selection if out of bounds if self.selected >= self.filtered.len() { @@ -178,6 +292,117 @@ impl SessionSwitcher { } } + fn query_changed(&mut self) { + self.content_matches.clear(); + self.content_query = None; + self.query_changed_at = Instant::now(); + self.filter(); + } + + /// Switch between all sessions and the selected session's persisted fork family. + pub fn toggle_branches(&mut self) { + self.branch_root = if self.branch_root.is_some() { + None + } else { + self.selected_session() + .and_then(|session| self.branch_path(session).first().cloned()) + }; + self.clear_filter(); + self.selected = 0; + self.filter(); + } + + /// Open a persisted family even while its metadata is loading. + pub fn show_branches(&mut self, session_id: Option<&str>) { + self.show(); + self.branch_root = session_id.map(str::to_owned); + self.filter(); + } + + fn branch_path(&self, session: &SessionInfo) -> Vec { + let mut path = vec![session.id.clone()]; + let mut seen = HashSet::from([session.id.as_str()]); + let mut parent = session.parent_session.as_deref(); + while let Some(id) = parent { + if !seen.insert(id) { + // Corrupt cyclic lineage must remain usable and cannot hang input. + path.sort(); + return path; + } + path.push(id.to_owned()); + parent = self + .sessions + .iter() + .find(|candidate| candidate.id == id) + .and_then(|candidate| candidate.parent_session.as_deref()); + } + path.reverse(); + path + } + + fn poll_content_search(&mut self) -> bool { + use std::sync::mpsc::TryRecvError; + let mut changed = false; + if let Some(rx) = &self.content_pending { + match rx.try_recv() { + Ok((query, results)) => { + self.content_pending = None; + if self.visible && query == self.query { + self.content_query = Some(query); + for result in results { + self.content_matches + .entry(result.session_id.clone()) + .or_insert(result); + } + self.filter(); + } + changed = true; + } + Err(TryRecvError::Disconnected) => { + self.content_pending = None; + self.content_query = Some(self.query.clone()); + self.error = Some( + maestro_ui::localization::tr("Conversation search stopped. Try again.") + .to_owned(), + ); + changed = true; + } + Err(TryRecvError::Empty) => {} + } + } + if self.visible + && !self.query.trim().is_empty() + && self.content_pending.is_none() + && self.content_query.as_deref() != Some(&self.query) + && self.query_changed_at.elapsed() >= Duration::from_millis(150) + { + let Some(root) = self.manager.sessions_dir().parent().map(PathBuf::from) else { + return changed; + }; + let cache = self + .index_path + .as_ref() + .map(|path| path.with_file_name("search-index.json")); + let query = self.query.clone(); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + match std::thread::Builder::new() + .name("maestro-session-search".into()) + .spawn(move || { + let documents = collect_documents(&root, cache.as_deref()); + let results = search_documents(&documents, &query, None, documents.len()); + let _ = tx.send((query, results)); + }) { + Ok(_) => self.content_pending = Some(rx), + Err(error) => { + self.content_query = Some(self.query.clone()); + self.error = Some(error.to_string()); + } + } + changed = true; + } + changed + } + /// Move selection up pub fn move_up(&mut self) { if self.selected > 0 { @@ -247,8 +472,14 @@ impl SessionSwitcher { } else { format!("{}/{}", self.filtered.len(), self.sessions.len()) }; - let title = - maestro_ui::localization::format("Sessions ({0})", std::slice::from_ref(&(count))); + let title = maestro_ui::localization::format( + if self.branch_root.is_some() { + "Session branches ({0})" + } else { + "Sessions ({0})" + }, + std::slice::from_ref(&count), + ); let inner = Modal::sized(title, ModalSize::Wide) .theme(theme) .render(frame, area); @@ -256,11 +487,49 @@ impl SessionSwitcher { .filtered .iter() .filter_map(|&idx| self.sessions.get(idx)) - .map(Self::render_session_item) + .map(|session| { + let item = Self::render_session_item(session); + if let Some(result) = self.content_matches.get(&session.id) { + let snippet: String = result + .snippet + .text + .chars() + .filter(|c| !c.is_control() || c.is_whitespace()) + .collect(); + let snippet = snippet.split_whitespace().collect::>().join(" "); + ListItem::new(vec![ + Self::session_line(session), + Line::from(Span::styled(format!(" {snippet}"), theme.muted_style())), + ]) + } else { + item + } + }) .collect(); + let compact = inner.width < 70; + let hints = if compact { + key_hints( + &[ + KeyHint::new("Enter", "select"), + KeyHint::new("Esc", "cancel"), + ], + theme, + ) + } else { + key_hints( + &[ + KeyHint::new("Ctrl+F", "branches"), + KeyHint::new("Enter", "select"), + KeyHint::new("Esc", "cancel"), + KeyHint::new("↑↓", "navigate"), + KeyHint::new("Del", "delete"), + ], + theme, + ) + }; let mut picker = Picker::new( &self.query, - maestro_ui::localization::tr("Type to filter..."), + maestro_ui::localization::tr("Search names, messages, and tool results..."), items, theme, ) @@ -269,27 +538,39 @@ impl SessionSwitcher { } else { maestro_ui::localization::tr("No matching sessions") }) - .help(key_hints( - &[ - KeyHint::new("↑↓", "navigate"), - KeyHint::new("Enter", "select"), - KeyHint::new("Esc", "cancel"), - KeyHint::new("Del", "delete"), - ], - theme, - )); + .help(hints); if self.loading { picker = picker.notice( maestro_ui::localization::tr("Loading sessions..."), NoticeTone::Busy, ); + } else if self.content_pending.is_some() { + picker = picker.notice( + maestro_ui::localization::tr("Searching conversations..."), + NoticeTone::Busy, + ); } else if let Some(error) = &self.error { picker = picker.notice(error.as_str(), NoticeTone::Error); } - picker.render(frame, inner, &mut self.list_state); + let mut picker_area = inner; + if compact && inner.height > 1 { + picker_area.height -= 1; + frame.render_widget( + ratatui::widgets::Paragraph::new(key_hints( + &[KeyHint::new("Ctrl+F", "branches")], + theme, + )), + Rect::new(inner.x, inner.bottom() - 1, inner.width, 1), + ); + } + picker.render(frame, picker_area, &mut self.list_state); } fn render_session_item(session: &SessionInfo) -> ListItem<'static> { + ListItem::new(Self::session_line(session)) + } + + fn session_line(session: &SessionInfo) -> Line<'static> { let theme = crate::themes::current_ui_theme(); let mut spans = Vec::new(); @@ -298,6 +579,15 @@ impl SessionSwitcher { spans.push(Span::styled("★ ", Style::default().fg(theme.attention))); } + if let Some(parent) = &session.parent_session { + spans.push(Span::styled( + maestro_ui::localization::format( + "Fork of {0} · ", + &[parent.chars().take(8).collect()], + ), + theme.muted_style(), + )); + } // Title let title: String = session.title().chars().take(30).collect(); spans.push(Span::styled( @@ -327,7 +617,7 @@ impl SessionSwitcher { .unwrap_or("/"); spans.push(Span::styled(format!(" [{cwd}]"), theme.muted_style())); - ListItem::new(Line::from(spans)) + Line::from(spans) } } @@ -337,6 +627,162 @@ impl Default for SessionSwitcher { } } +#[cfg(test)] +mod continuity_tests { + use super::*; + use std::fmt::Write as _; + + fn write_session( + dir: &std::path::Path, + id: &str, + parent: Option<&str>, + role: &str, + text: &str, + ) { + std::fs::create_dir_all(dir).unwrap(); + let content = if role == "assistant" { + serde_json::json!([{"type":"text","text":text}]) + } else { + serde_json::json!(text) + }; + let entries = [ + serde_json::json!({"type":"session","id":id,"parentSession":parent,"timestamp":"2024-01-15T10:30:00Z","cwd":"/project","model":"ollama/qwen","thinkingLevel":"medium"}), + serde_json::json!({"type":"message","timestamp":"2024-01-15T10:30:01Z","message":{"role":"user","content":"unrelated opening","timestamp":0}}), + serde_json::json!({"type":"message","timestamp":"2024-01-15T10:30:02Z","message":{"role":role,"toolCallId":"call-1","toolName":"bash","isError":false,"content":content,"timestamp":1}}), + ]; + let mut encoded = String::new(); + for entry in entries { + writeln!(encoded, "{entry}").unwrap(); + } + std::fs::write(dir.join(format!("{id}.jsonl")), encoded).unwrap(); + } + + fn switcher(dir: &std::path::Path) -> SessionSwitcher { + let mut picker = SessionSwitcher::new("/project"); + picker.manager = SessionManager::with_sessions_dir("/project", dir); + picker.index_path = None; + picker.visible = true; + picker.refresh(); + picker + } + + #[test] + fn content_search_finds_reply_and_tool_result_and_ignores_malformed_session() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("sessions/project"); + write_session( + &dir, + "reply-session", + None, + "assistant", + "A 日本語 needle only in the reply", + ); + write_session( + &dir, + "tool-session", + None, + "toolResult", + "Another 日本語 needle from the tool", + ); + std::fs::write(dir.join("broken.jsonl"), "not json\n").unwrap(); + let mut picker = switcher(&dir); + picker.insert_str("日本語"); + assert!( + picker.filtered.is_empty(), + "metadata alone cannot match later content" + ); + picker.query_changed_at = Instant::now().checked_sub(Duration::from_secs(1)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while picker.content_query.is_none() { + picker.poll_refresh(); + assert!(Instant::now() < deadline, "content search did not complete"); + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(picker.filtered.len(), 2); + assert!( + picker.content_matches["reply-session"] + .snippet + .text + .contains("日本語") + ); + assert!( + picker.content_matches["tool-session"] + .snippet + .text + .contains("日本語") + ); + } + + #[test] + fn stale_content_results_do_not_replace_a_new_query() { + let mut picker = SessionSwitcher::new("/project"); + picker.visible = true; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + picker.content_pending = Some(rx); + picker.insert_str("new query"); + tx.send(( + "old query".into(), + vec![SearchMatch { + session_id: "old".into(), + project: "/project".into(), + timestamp_ms: 1, + kind: crate::search_cli::SearchKind::Assistant, + score: 1, + snippet: crate::search_cli::Snippet { + text: "old query".into(), + highlights: vec![], + }, + }], + )) + .unwrap(); + picker.poll_refresh(); + assert_eq!(picker.query, "new query"); + assert!(picker.content_matches.is_empty()); + } + + #[test] + fn branch_navigation_uses_persisted_lineage_and_keeps_orphans_accessible() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("sessions/project"); + write_session(&dir, "parent", None, "assistant", "parent reply"); + write_session(&dir, "child", Some("parent"), "assistant", "child reply"); + write_session( + &dir, + "grandchild", + Some("child"), + "assistant", + "grandchild reply", + ); + write_session(&dir, "orphan", Some("missing"), "assistant", "orphan reply"); + let mut picker = switcher(&dir); + picker.branch_root = Some("child".into()); + picker.filter(); + let ids: Vec<_> = picker + .filtered + .iter() + .map(|&index| picker.sessions[index].id.as_str()) + .collect(); + assert_eq!(ids, ["parent", "child", "grandchild"]); + picker.branch_root = Some("orphan".into()); + picker.filter(); + assert_eq!(picker.selected_session().unwrap().id, "orphan"); + picker.toggle_branches(); + assert_eq!(picker.filtered.len(), 4); + } + + #[test] + fn cyclic_lineage_cannot_hang_the_picker() { + let temp = tempfile::tempdir().unwrap(); + let dir = temp.path().join("sessions/project"); + write_session(&dir, "a", Some("b"), "assistant", "reply"); + write_session(&dir, "b", Some("a"), "assistant", "reply"); + let mut picker = switcher(&dir); + picker.branch_root = Some("a".into()); + picker.filter(); + assert_eq!(picker.filtered.len(), 2); + } +} + /// Map a session-index entry onto the switcher's list model. /// /// The index stores neither the model nor the per-role message breakdown, so @@ -346,6 +792,7 @@ impl Default for SessionSwitcher { fn indexed_session_info(indexed: &IndexedSession) -> SessionInfo { let entry = &indexed.entry; SessionInfo { + parent_session: entry.parent_session.clone(), id: entry.id.clone(), path: indexed.path.clone(), cwd: entry.cwd.clone(), @@ -397,6 +844,64 @@ fn format_relative_time(timestamp: &str) -> String { #[cfg(test)] mod tests { use super::*; + fn wait_for_refresh(switcher: &mut SessionSwitcher) { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while switcher.pending.is_some() && std::time::Instant::now() < deadline { + switcher.poll_refresh(); + std::thread::sleep(Duration::from_millis(1)); + } + assert!(!switcher.loading); + } + + #[test] + fn failed_history_refresh_retains_the_existing_selection() { + let mut switcher = SessionSwitcher::new("/tmp"); + switcher.sessions.push(SessionInfo { + parent_session: None, + id: "saved".into(), + path: "/tmp/saved.jsonl".into(), + cwd: "/tmp".into(), + model: "ollama/qwen".into(), + thinking_level: ThinkingLevel::default(), + timestamp: String::new(), + stats: SessionStats::default(), + meta: None, + preview: Some("existing conversation".into()), + modified: None, + }); + switcher.filter(); + switcher.begin_load(|| Err("disk unavailable".into())); + wait_for_refresh(&mut switcher); + assert_eq!(switcher.selected_session().unwrap().id, "saved"); + assert!( + switcher + .error + .as_deref() + .unwrap() + .contains("disk unavailable") + ); + } + + #[test] + fn slow_history_loading_allows_filtering_and_dismissal() { + let mut switcher = SessionSwitcher::new("/tmp"); + let (release, gate) = std::sync::mpsc::channel(); + switcher.begin_load(move || { + gate.recv().unwrap(); + Ok(Vec::new()) + }); + switcher.show(); // must coalesce rather than replace the blocked loader + switcher.insert_str("my draft"); + assert!(!switcher.poll_refresh()); + assert_eq!(switcher.query, "my draft"); + switcher.hide(); + assert!(!switcher.is_visible()); + release.send(()).unwrap(); + wait_for_refresh(&mut switcher); + assert_eq!(switcher.query, "my draft"); + assert!(!switcher.is_visible()); + } + #[test] fn session_switcher_notices_use_busy_and_error_colors() { use ratatui::{Terminal, backend::TestBackend}; @@ -477,11 +982,18 @@ mod tests { #[test] fn session_switcher_basics() { + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join("sessions/project"); let mut switcher = SessionSwitcher::new("/tmp"); + switcher.manager = SessionManager::with_sessions_dir("/tmp", &sessions); + switcher.index_path = Some(temp.path().join("session-index.json")); assert!(!switcher.is_visible()); switcher.show(); + wait_for_refresh(&mut switcher); assert!(switcher.is_visible()); + assert!(switcher.error.is_none()); + assert!(switcher.sessions.is_empty()); switcher.hide(); assert!(!switcher.is_visible()); @@ -519,6 +1031,7 @@ mod tests { switcher.index_path = Some(index_path.clone()); switcher.show(); + wait_for_refresh(&mut switcher); assert_eq!(switcher.sessions.len(), 1); let session = &switcher.sessions[0]; @@ -571,6 +1084,7 @@ mod tests { switcher.index_path = None; switcher.show(); + wait_for_refresh(&mut switcher); assert_eq!(switcher.sessions.len(), 1); assert_eq!(switcher.sessions[0].model, "openai/gpt-5.2"); @@ -608,6 +1122,7 @@ mod tests { switcher.index_path = None; switcher.show(); + wait_for_refresh(&mut switcher); assert_eq!(switcher.sessions.len(), 2); switcher.insert_str("/tmp/two"); diff --git a/packages/tui-rs/src/entrypoint.rs b/packages/tui-rs/src/entrypoint.rs index 58886daee..a42b4c95e 100644 --- a/packages/tui-rs/src/entrypoint.rs +++ b/packages/tui-rs/src/entrypoint.rs @@ -712,6 +712,8 @@ fn provider_api_key_env(provider: &str) -> &'static str { /// returns an error, return that error from this function; otherwise, unwrap /// the Ok value and continue." pub async fn run_cli(raw_args: Vec) -> Result<()> { + // Machine policy is checked before updates, Identity, configuration or command dispatch. + maestro_local_host::safety::disconnected_policy().map_err(anyhow::Error::msg)?; // maestro-tui remains a Cargo target for compatibility, but it is not a // public product name. Normalize argv[0] before Clap renders help or // diagnostics so both binaries expose the same Deixic Code surface. @@ -738,6 +740,7 @@ pub async fn run_cli(raw_args: Vec) -> Result<()> { .and_then(|arg| arg.to_str()) .is_some_and(|arg| arg == "hosted-runner") { + maestro_local_host::safety::require_vendor_network()?; let mut hosted_args = vec![std::ffi::OsString::from("deixic-code hosted-runner")]; hosted_args.extend(raw_args.into_iter().skip(2)); run_hosted_runner_cli_from_env(hosted_args).await?; diff --git a/packages/tui-rs/src/onboarding_checks.rs b/packages/tui-rs/src/onboarding_checks.rs index 1defd4b6a..8f959aab8 100644 --- a/packages/tui-rs/src/onboarding_checks.rs +++ b/packages/tui-rs/src/onboarding_checks.rs @@ -284,6 +284,9 @@ async fn managed_policy_check(model: &str, cwd: &Path) -> OnboardingCheck { } async fn model_probe(model: &str) -> anyhow::Result<()> { + if let Some(client) = crate::credential_mode::disconnected_client(model)? { + return probe_client(&client, model, PROBE_TIMEOUT).await; + } let source_env: HashMap = std::env::vars().collect(); let mode = crate::credential_mode::require_ready(model)?; let (route, mut env) = match mode { diff --git a/packages/tui-rs/src/orb_cli.rs b/packages/tui-rs/src/orb_cli.rs index 7106cdd7f..fdb707e9b 100644 --- a/packages/tui-rs/src/orb_cli.rs +++ b/packages/tui-rs/src/orb_cli.rs @@ -13,6 +13,7 @@ use crate::tools::orb_delegation::OrbConsoleAction; /// Run `maestro computer ...` (`maestro orb` remains a compatibility alias). pub async fn run_orb(args: &[String]) -> Result { + maestro_local_host::safety::require_vendor_network()?; let mut args = args.iter().map(String::as_str).collect::>(); let json = args.contains(&"--json"); args.retain(|arg| *arg != "--json"); diff --git a/packages/tui-rs/src/print_mode.rs b/packages/tui-rs/src/print_mode.rs index 87777b5ec..9474fa233 100644 --- a/packages/tui-rs/src/print_mode.rs +++ b/packages/tui-rs/src/print_mode.rs @@ -365,7 +365,12 @@ pub async fn run_print_mode(options: PrintModeOptions) -> Result { .or_else(|| specialist.as_ref().and_then(|p| p.model.clone())) .filter(|m| !m.trim().is_empty()) .unwrap_or_else(crate::codex_auth::resolve_default_model); - if crate::local_models::is_local_model_route(&model) { + // A signed exact route is administrator-supplied metadata, not a discovered + // user override; do not probe ordinary local-runtime environment URLs. + let disconnected = maestro_local_host::safety::disconnected_route(&model) + .map_err(anyhow::Error::msg)? + .is_some(); + if crate::local_models::is_local_model_route(&model) && !disconnected { let discovered = crate::local_models::discover_local_model(&model) .await? .with_context(|| { diff --git a/packages/tui-rs/src/remote_cli.rs b/packages/tui-rs/src/remote_cli.rs index 90325ab1f..91bdc48f2 100644 --- a/packages/tui-rs/src/remote_cli.rs +++ b/packages/tui-rs/src/remote_cli.rs @@ -190,6 +190,7 @@ struct Minted { } pub async fn run_remote(args: &[String]) -> Result { + maestro_local_host::safety::require_vendor_network()?; let (cmd, rest) = match args.first().map(String::as_str) { None | Some("help" | "--help" | "-h") => { println!("{USAGE}"); diff --git a/packages/tui-rs/src/update_cli.rs b/packages/tui-rs/src/update_cli.rs index 1fff2b475..91b4a98aa 100644 --- a/packages/tui-rs/src/update_cli.rs +++ b/packages/tui-rs/src/update_cli.rs @@ -2164,6 +2164,9 @@ fn startup_update_enabled() -> bool { /// state, and install failures fail open so an unavailable update service can /// never prevent Maestro from starting. pub async fn run_startup_update(raw_args: &[std::ffi::OsString]) -> Option { + if maestro_local_host::safety::vendor_network_disabled() { + return None; + } if !startup_update_enabled() { return None; } @@ -2782,6 +2785,7 @@ fn write_update_success(output: &mut impl Write, latest: &str) -> io::Result<()> } pub async fn run_update(args: &[String]) -> Result { + maestro_local_host::safety::require_vendor_network()?; let parsed = match parse_args(args) { Ok(parsed) => parsed, Err(error) => { diff --git a/packages/tui-rs/src/value_cli.rs b/packages/tui-rs/src/value_cli.rs index 7c6332d1d..c513062e0 100644 --- a/packages/tui-rs/src/value_cli.rs +++ b/packages/tui-rs/src/value_cli.rs @@ -1610,6 +1610,7 @@ mod tests { #[test] fn session_timestamp_falls_back_to_modified() { let mut info = SessionInfo { + parent_session: None, id: "abc".into(), path: PathBuf::from("/tmp/s.jsonl"), cwd: "/tmp".into(), diff --git a/packages/tui-rs/tests/pty_e2e.rs b/packages/tui-rs/tests/pty_e2e.rs index 5990a1a2f..ef5be04ef 100644 --- a/packages/tui-rs/tests/pty_e2e.rs +++ b/packages/tui-rs/tests/pty_e2e.rs @@ -345,11 +345,30 @@ impl PtySession { /// Fork is a fast-path subcommand and therefore cannot use the regular /// interactive flags prepended by [`Self::spawn`]. fn spawn_with_args(mock: &MockOpenAiServer, workdir: &std::path::Path, args: &[&str]) -> Self { + Self::spawn_with_args_and_env(mock, workdir, args, &[]) + } + + fn spawn_with_args_and_env( + mock: &MockOpenAiServer, + workdir: &std::path::Path, + args: &[&str], + extra_env: &[(&str, &str)], + ) -> Self { + Self::spawn_with_size_and_env(mock, workdir, args, extra_env, 120) + } + + fn spawn_with_size_and_env( + mock: &MockOpenAiServer, + workdir: &std::path::Path, + args: &[&str], + extra_env: &[(&str, &str)], + columns: u16, + ) -> Self { let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { rows: 36, - cols: 120, + cols: columns, pixel_width: 0, pixel_height: 0, }) @@ -400,6 +419,9 @@ impl PtySession { "MAESTRO_PROMPT_HISTORY_FILE", workdir.join("prompt-history.json"), ); + for (name, value) in extra_env { + command.env(name, value); + } let child = pair .slave @@ -407,7 +429,7 @@ impl PtySession { .expect("spawn maestro-tui"); drop(pair.slave); - let output = Arc::new(Mutex::new(TerminalCapture::new(36, 120))); + let output = Arc::new(Mutex::new(TerminalCapture::new(36, columns))); let mut reader = pair.master.try_clone_reader().expect("clone PTY reader"); let writer = Arc::new(Mutex::new( pair.master.take_writer().expect("take PTY writer") as Box, @@ -714,6 +736,332 @@ fn write_fork_fixture(workdir: &std::path::Path, session_id: &str) -> std::path: path } +fn wait_continuity_frame(session: &PtySession, needle: &str) { + let deadline = Instant::now() + TURN_TIMEOUT; + loop { + let frame = session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()) + .current_text(); + if frame.contains(needle) { + return; + } + assert!( + Instant::now() < deadline, + "current terminal frame never contained {needle:?}: {frame}" + ); + std::thread::sleep(Duration::from_millis(50)); + } +} + +fn send_continuity_bytes_until(session: &mut PtySession, bytes: &[u8], needle: &str) { + let deadline = Instant::now() + TURN_TIMEOUT; + loop { + session.send_bytes(bytes); + let retry = Instant::now() + Duration::from_secs(1); + while Instant::now() < retry { + if session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()) + .current_text() + .contains(needle) + { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + Instant::now() < deadline, + "current terminal frame never contained {needle:?}: {}", + session.screen_text() + ); + } +} + +fn save_continuity_frame(session: &PtySession, name: &str) { + if let Some(dir) = std::env::var_os("MAESTRO_PTY_EVIDENCE_DIR") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + let frame = session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()); + std::fs::write(dir.join(format!("{name}.txt")), frame.current_text()).unwrap(); + std::fs::write(dir.join(format!("{name}.ansi")), frame.current_formatted()).unwrap(); + } +} + +fn submit_continuity_prompt(session: &mut PtySession, prompt: &str, result: &str) { + let input = format!("\x15{prompt}"); + send_continuity_bytes_until(session, input.as_bytes(), &format!("> {prompt}")); + send_continuity_bytes_until(session, b"\r", result); +} + +fn close_continuity_dialog(session: &mut PtySession, key: &[u8], title: &str) { + let deadline = Instant::now() + TURN_TIMEOUT; + loop { + session.send_bytes(key); + let retry = Instant::now() + Duration::from_secs(1); + while Instant::now() < retry { + if !session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()) + .current_text() + .contains(title) + { + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + assert!(Instant::now() < deadline, "dialog {title:?} did not close"); + } +} + +fn approve_continuity_sleep(session: &mut PtySession) -> Vec { + session.wait_for_text("Action Approval Required", READY_TIMEOUT); + let deadline = Instant::now() + TURN_TIMEOUT; + while !session.has_running_tool("sleep 6") { + session.send_bytes(b"y"); + std::thread::sleep(Duration::from_millis(300)); + assert!(Instant::now() < deadline, "sleep tool never started"); + } + let table = process_table(); + let root = session.child.process_id().unwrap(); + let processes: Vec<_> = table + .iter() + .filter(|(pid, _, args)| args.contains("sleep 6") && is_descendant(&table, *pid, root)) + .map(|(pid, _, _)| *pid) + .collect(); + assert!(!processes.is_empty(), "capture the running tool processes"); + session.send_bytes(b"\x15"); + processes +} + +fn assert_continuity_processes_stopped(processes: &[u32]) { + for &pid in processes { + // SAFETY: signal zero only probes existence; it cannot signal any process. + let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; + let error = std::io::Error::last_os_error(); + assert!( + result == -1 && error.raw_os_error() == Some(libc::ESRCH), + "tool process {pid} must be gone before switching, including after reparenting" + ); + } +} + +#[test] +fn pty_session_content_search_and_persisted_fork_navigation_preserve_draft() { + let _serial = PTY_TEST_SERIAL + .lock() + .unwrap_or_else(|error| error.into_inner()); + for columns in [40, 120] { + let mock = MockOpenAiServer::start(vec![]); + let workdir = tempfile::tempdir().unwrap(); + write_fork_fixture(workdir.path(), "search-parent"); + let path = write_fork_fixture(workdir.path(), "search-child"); + let text = std::fs::read_to_string(&path).unwrap(); + let mut lines = text.lines(); + let mut header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + header["parentSession"] = "search-parent".into(); + let tool = serde_json::json!({"type":"message","timestamp":"2026-07-29T00:00:02Z","message":{"role":"toolResult","toolCallId":"tool-search","toolName":"bash","content":"TOOL_CONTENT_ONLY_NEEDLE","isError":false,"timestamp":2}}); + std::fs::write( + &path, + format!( + "{header}\n{}\n{tool}\n", + lines.collect::>().join("\n") + ), + ) + .unwrap(); + let mut session = PtySession::spawn_with_size_and_env( + &mock, + workdir.path(), + &["--model", "gpt-4o", "--api-key", "pty-e2e-key"], + &[], + columns, + ); + session.wait_for_text("Mode: Act", READY_TIMEOUT); + session.send_bytes("draft é stays".as_bytes()); + session.wait_for_text("draft é stays", TURN_TIMEOUT); + send_continuity_bytes_until(&mut session, b"\x1b\x12", "Sessions (2)"); + session.send_bytes(b"TOOL_CONTENT_ONLY_NEEDLE"); + session.wait_for_text("Sessions (1/2)", TURN_TIMEOUT); + let visible = session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()) + .current_text(); + assert!( + visible.contains("Enter") && visible.contains("Esc") && visible.contains("Ctrl+F"), + "selection, cancellation, and branch controls must remain visible: {visible}" + ); + save_continuity_frame(&session, &format!("content-search-{columns}")); + session.send_bytes(b"\r"); + wait_continuity_frame(&session, "› You"); + session.wait_for_text("PTY_FORK_SOURCE_READY", TURN_TIMEOUT); + assert_eq!( + mock.request_count(), + 0, + "search and resume must not submit a model prompt" + ); + session.wait_for_text("draft é stays", TURN_TIMEOUT); + send_continuity_bytes_until(&mut session, b"\x1b\x12", "Sessions (2)"); + send_continuity_bytes_until(&mut session, b"\x06", "Session branches (2)"); + save_continuity_frame(&session, &format!("fork-tree-{columns}")); + close_continuity_dialog(&mut session, b"\x1b", "Session branches"); + submit_continuity_prompt(&mut session, "/fork", "Resume fork:"); + save_continuity_frame(&session, &format!("fork-resume-{columns}")); + let forks: Vec<_> = std::fs::read_dir(path.parent().unwrap()) + .unwrap() + .filter_map(Result::ok) + .filter_map(|entry| { + let text = std::fs::read_to_string(entry.path()).ok()?; + let header: serde_json::Value = serde_json::from_str(text.lines().next()?).ok()?; + (header["parentSession"] == "search-child") + .then(|| header["id"].as_str().unwrap().to_owned()) + }) + .collect(); + assert_eq!(forks.len(), 1); + let mut resumed = + PtySession::spawn_with_args(&mock, workdir.path(), &["--resume-session", &forks[0]]); + resumed.wait_for_text("PTY_FORK_SOURCE_READY", READY_TIMEOUT); + assert_eq!(mock.request_count(), 0); + resumed.shutdown(); + session.shutdown(); + } +} + +#[test] +fn pty_confirmed_new_session_stops_tool_and_excludes_parent_history() { + let _serial = PTY_TEST_SERIAL + .lock() + .unwrap_or_else(|error| error.into_inner()); + for columns in [40, 120] { + let mock = MockOpenAiServer::start(vec![ + tool_call_turn("bash", &serde_json::json!({"command":"sleep 600"})), + text_turn("NEW_SESSION_READY"), + ]); + let workdir = tempfile::tempdir().unwrap(); + let mut session = PtySession::spawn_with_size_and_env( + &mock, + workdir.path(), + &[ + "--model", + "gpt-4o", + "--api-key", + "pty-e2e-key", + "PARENT_LONG_TOOL_PROMPT", + ], + &[], + columns, + ); + let processes = approve_continuity_sleep(&mut session); + submit_continuity_prompt(&mut session, "/fork", "Resume fork:"); + assert!( + session.has_running_tool("sleep 6"), + "fork must leave the parent running" + ); + assert_eq!(mock.request_count(), 1, "fork must not submit a prompt"); + save_continuity_frame(&session, &format!("busy-fork-{columns}")); + submit_continuity_prompt(&mut session, "/new", "Change conversation"); + save_continuity_frame(&session, &format!("new-confirm-{columns}")); + let visible = session + .output + .lock() + .unwrap_or_else(|error| error.into_inner()) + .current_text(); + assert!( + visible.contains("Enter:") && visible.contains("Esc:"), + "confirmation controls must fit narrow terminals: {visible}" + ); + close_continuity_dialog(&mut session, b"\x1b", "Change conversation"); + std::thread::sleep(Duration::from_millis(300)); + assert!( + session.has_running_tool("sleep 6"), + "dismissal must leave the tool running" + ); + submit_continuity_prompt(&mut session, "/new", "Change conversation"); + session.send_bytes_until(b"\r", "New session started.", TURN_TIMEOUT); + assert!( + !session.has_running_tool("sleep 6"), + "switching must await process cleanup" + ); + assert_continuity_processes_stopped(&processes); + submit_continuity_prompt(&mut session, "CHILD_NEW_PROMPT", "NEW_SESSION_READY"); + let requests = mock.state.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(requests.requests.len(), 2); + assert!(!requests.requests[1].contains("PARENT_LONG_TOOL_PROMPT")); + assert!(!requests.requests[1].contains("sleep 600")); + drop(requests); + save_continuity_frame(&session, &format!("new-completed-{columns}")); + session.shutdown(); + } +} + +#[test] +fn pty_confirmed_rewind_preserves_earlier_turn_and_persists_child_lineage() { + let _serial = PTY_TEST_SERIAL + .lock() + .unwrap_or_else(|error| error.into_inner()); + let mock = MockOpenAiServer::start(vec![ + text_turn("EARLIER_REPLY_KEEP"), + tool_call_turn("bash", &serde_json::json!({"command":"sleep 600"})), + text_turn("REWIND_CHILD_READY"), + ]); + let workdir = tempfile::tempdir().unwrap(); + let mut session = PtySession::spawn(&mock, workdir.path(), "EARLIER_PROMPT_KEEP"); + session.wait_for_text("EARLIER_REPLY_KEEP", READY_TIMEOUT); + submit_continuity_prompt( + &mut session, + "REWIND_RUNNING_PROMPT_DROP", + "Action Approval Required", + ); + let processes = approve_continuity_sleep(&mut session); + submit_continuity_prompt(&mut session, "/rewind 1", "Change conversation"); + save_continuity_frame(&session, "rewind-confirm-120"); + close_continuity_dialog(&mut session, b"\r", "Change conversation"); + wait_continuity_frame(&session, "EARLIER_REPLY_KEEP"); + assert!(!session.has_running_tool("sleep 6")); + assert_continuity_processes_stopped(&processes); + submit_continuity_prompt(&mut session, "REWIND_NEW_PROMPT", "REWIND_CHILD_READY"); + let requests = mock.state.lock().unwrap_or_else(|error| error.into_inner()); + assert_eq!(requests.requests.len(), 3); + assert!(requests.requests[2].contains("EARLIER_PROMPT_KEEP")); + assert!(requests.requests[2].contains("EARLIER_REPLY_KEEP")); + assert!(!requests.requests[2].contains("REWIND_RUNNING_PROMPT_DROP")); + drop(requests); + session.shutdown(); + let root = workdir.path().join(".composer/agent/sessions"); + let headers: Vec = std::fs::read_dir(root) + .unwrap() + .filter_map(Result::ok) + .flat_map(|entry| std::fs::read_dir(entry.path()).unwrap()) + .filter_map(Result::ok) + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|extension| extension == "jsonl") + }) + .map(|entry| { + let text = std::fs::read_to_string(entry.path()).unwrap(); + serde_json::from_str(text.lines().next().unwrap()).unwrap() + }) + .collect(); + assert_eq!(headers.len(), 2); + let child = headers + .iter() + .find(|header| header["parentSession"].is_string()) + .unwrap(); + assert!( + headers + .iter() + .any(|parent| parent["id"] == child["parentSession"]) + ); +} + // ───────────────────────────────────────────────────────────────────────────── // Scenarios // ───────────────────────────────────────────────────────────────────────────── @@ -754,7 +1102,7 @@ fn pty_startup_composer_edits_before_managed_setup_finishes() { let started = Instant::now(); let mut session = PtySession::spawn_with_args(&mock, workdir.path(), &["--model", "openai/gpt-4o"]); - session.wait_for_text("You can type while setup finishes", Duration::from_secs(5)); + session.wait_for_text("Starting…", Duration::from_secs(5)); let first_frame = started.elapsed(); session.send_bytes(b"startup draft survives"); session.wait_for_text("startup draft survives", Duration::from_secs(2)); @@ -804,6 +1152,171 @@ fn pty_local_model_loads_when_identity_is_unreachable() { session.shutdown(); } +/// A cloud-default launch must expose the real model picker before managed +/// setup completes. Selecting a discovered local route abandons the stale +/// cloud preparation and opens the full local shell without releasing it. +#[test] +fn pty_cloud_startup_can_switch_to_discovered_local_model_before_identity() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + for (provider, base_url_env) in [ + ("ollama", "OLLAMA_BASE_URL"), + ("lmstudio", "LM_STUDIO_BASE_URL"), + ("llamacpp", "LLAMA_CPP_BASE_URL"), + ] { + let (release, gate) = std::sync::mpsc::channel(); + let mut mock = MockOpenAiServer::start(vec![text_turn("LOCAL_DRAFT_ACCEPTED")]); + mock.managed_setup_base_url = start_mock_managed_setup_server_with_gate(Some(gate)); + let workdir = tempfile::tempdir().expect("temp workdir"); + let mut session = PtySession::spawn_with_args_and_env( + &mock, + workdir.path(), + &["--model", "openai/gpt-4o"], + &[(base_url_env, mock.base_url.as_str())], + ); + session.wait_for_text("local models available", Duration::from_secs(5)); + session.send_bytes("draft é survives".as_bytes()); + session.wait_for_text("draft é survives", Duration::from_secs(2)); + session.send_bytes(b"\x10"); // configured default Ctrl+P + session.wait_for_text("Select Model", Duration::from_secs(2)); + // A provider search also matches catalog recommendations. Select the + // exact discovered route so the assertion exercises this mock runtime. + session.send_bytes(format!("{provider}/gpt-4o").as_bytes()); + session.wait_for_text(&format!("gpt-4o ({provider})"), Duration::from_secs(2)); + session.send_bytes(b"\r"); + session.wait_for_text("gpt-4o · Local", Duration::from_secs(5)); + session.wait_for_text("draft é survives", Duration::from_secs(2)); + assert_eq!( + mock.request_count(), + 0, + "choosing a route must not submit the draft" + ); + session.send_bytes_until(b"\r", "LOCAL_DRAFT_ACCEPTED", TURN_TIMEOUT); + assert_eq!(mock.request_count(), 1, "local draft submits exactly once"); + release.send(()).unwrap(); + session.shutdown(); + } +} + +/// Passive MCP initialization must wait for cloud or local agent admission. +#[test] +fn pty_cloud_without_admission_does_not_initialize_configured_mcp() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let mock = MockOpenAiServer::start(Vec::new()); + let workdir = tempfile::tempdir().expect("temp workdir"); + let config = workdir.path().join("mcp.json"); + std::fs::write( + &config, + serde_json::json!({ + "mcpServers": { + "pre-admission-probe": {"url": format!("{}/mcp", mock.base_url), "timeout": 500} + } + }) + .to_string(), + ) + .unwrap(); + let mut session = PtySession::spawn_with_args_and_env( + &mock, + workdir.path(), + &["--model", "openai/gpt-4o"], + &[ + ("OPENAI_API_KEY", ""), + (maestro_tui::credential_mode::ACCESS_TOKEN_ENV, ""), + (maestro_tui::credential_mode::ORG_ID_ENV, ""), + (maestro_tui::credential_mode::WORKSPACE_ID_ENV, ""), + ("MAESTRO_USER_MCP_PATH", config.to_str().unwrap()), + ("OLLAMA_BASE_URL", "http://127.0.0.1:9"), + ("LM_STUDIO_BASE_URL", "http://127.0.0.1:9/v1"), + ("LLAMA_CPP_BASE_URL", "http://127.0.0.1:9/v1"), + ], + ); + // The denied returning-user shell can suppress the setup error text; + // wait for its composer rather than a label in the dismissed walkthrough. + session.wait_for_text("> ", Duration::from_secs(5)); + session.send_bytes("offline draft é stays".as_bytes()); + session.wait_for_text("offline draft é stays", Duration::from_secs(2)); + assert!(!session.screen_text().contains("Guided setup")); + session.send_bytes(b"\x0b"); // Ctrl+K, keeping the composer intact + session.wait_for_text("Search commands, files, sessions", Duration::from_secs(2)); + session.send_bytes(b">model"); + session.wait_for_text("/model", Duration::from_secs(2)); + session.send_bytes(b"\r"); + session.wait_for_text("Model and effort", Duration::from_secs(2)); + session.send_bytes(b"\r"); + session.wait_for_text("Select Model", Duration::from_secs(2)); + session.send_bytes(b"\x1b"); + session.wait_for_text("offline draft é stays", Duration::from_secs(2)); + std::thread::sleep(Duration::from_millis(250)); + assert_eq!( + mock.request_count(), + 0, + "passive MCP initialization must wait for admission" + ); + session.shutdown(); +} + +/// Browsing local history must remain possible while cloud setup is held. +#[test] +fn pty_startup_history_preserves_draft_and_stays_open_after_preparation() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let (release, gate) = std::sync::mpsc::channel(); + let mut mock = MockOpenAiServer::start(Vec::new()); + mock.managed_setup_base_url = start_mock_managed_setup_server_with_gate(Some(gate)); + let workdir = tempfile::tempdir().expect("temp workdir"); + let mut session = + PtySession::spawn_with_args(&mock, workdir.path(), &["--model", "openai/gpt-4o"]); + session.wait_for_text("Starting…", Duration::from_secs(5)); + session.send_bytes(b"retained history draft"); + session.wait_for_text("retained history draft", Duration::from_secs(2)); + session.send_bytes(b"\x1b\x12"); // Ctrl+Alt+R, matching the normal shell + session.wait_for_text("Sessions (", Duration::from_secs(2)); + release.send(()).unwrap(); + session.send_bytes(b"nonexistent history"); + session.wait_for_text("No matching sessions", Duration::from_secs(2)); + assert!(session.screen_text().contains("Sessions (")); + session.send_bytes(b"\x1b"); + session.wait_for_text("retained history draft", Duration::from_secs(5)); + assert_eq!(mock.request_count(), 0); + session.shutdown(); +} + +#[test] +fn pty_startup_resumes_local_history_without_cloud_preparation() { + let _serial = PTY_TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let (release, gate) = std::sync::mpsc::channel(); + let mut mock = MockOpenAiServer::start(vec![text_turn("RESUMED_LOCAL_DRAFT")]); + mock.managed_setup_base_url = start_mock_managed_setup_server_with_gate(Some(gate)); + let workdir = tempfile::tempdir().expect("temp workdir"); + let path = write_fork_fixture(workdir.path(), "airplane-history"); + let contents = std::fs::read_to_string(&path).unwrap(); + let mut lines = contents.lines(); + let mut header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap(); + header["model"] = serde_json::json!("ollama/gpt-4o"); + std::fs::write( + &path, + format!("{header}\n{}\n", lines.collect::>().join("\n")), + ) + .unwrap(); + let mut session = PtySession::spawn_with_args_and_env( + &mock, + workdir.path(), + &["--model", "openai/gpt-4o"], + &[("OLLAMA_BASE_URL", mock.base_url.as_str())], + ); + session.wait_for_text("Starting…", Duration::from_secs(5)); + session.send_bytes(b"resume draft"); + session.wait_for_text("resume draft", Duration::from_secs(2)); + session.send_bytes(b"\x1b\x12"); + session.wait_for_text("PTY_FORK_SOURCE_READY", Duration::from_secs(5)); + session.send_bytes(b"\r"); + session.wait_for_text("gpt-4o · Local", Duration::from_secs(5)); + session.wait_for_text("resume draft", Duration::from_secs(2)); + assert_eq!(mock.request_count(), 0); + session.send_bytes_until(b"\r", "RESUMED_LOCAL_DRAFT", TURN_TIMEOUT); + assert_eq!(mock.request_count(), 1); + release.send(()).unwrap(); + session.shutdown(); +} + /// The grouped `/model` menu must open its child selector and let Escape /// return to chat without issuing a provider request. A follow-up turn proves /// the modal stack was actually dismissed rather than only painted away. diff --git a/packages/tui-rs/tests/support/terminal_capture.rs b/packages/tui-rs/tests/support/terminal_capture.rs index a74cf460a..cc401bd6d 100644 --- a/packages/tui-rs/tests/support/terminal_capture.rs +++ b/packages/tui-rs/tests/support/terminal_capture.rs @@ -35,6 +35,11 @@ impl TerminalCapture { screen_rows(self.parser.screen()).join("\n") } + /// Formatted current frame for image evidence, preserving actual cell colors. + pub(super) fn current_formatted(&self) -> Vec { + self.parser.screen().contents_formatted() + } + pub(super) fn text(&self) -> String { let mut text = self .parser diff --git a/packages/ui-rs/src/translations.rs b/packages/ui-rs/src/translations.rs index 78c9b999d..1a9af10ed 100644 --- a/packages/ui-rs/src/translations.rs +++ b/packages/ui-rs/src/translations.rs @@ -5520,6 +5520,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "浏览并恢复已保存的对话", ], ), + ( + "Browse and resume session branches", + [ + "Explorar y reanudar ramas de sesión", + "Parcourir et reprendre les branches de session", + "Sitzungszweige durchsuchen und fortsetzen", + "セッションの分岐を表示して再開", + "세션 분기를 찾아 재개", + "浏览并恢复会话分支", + ], + ), ( "Browse saved conversations", [ @@ -5880,6 +5891,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "更改颜色主题", ], ), + ( + "Change conversation", + [ + "Cambiar conversación", + "Changer de conversation", + "Unterhaltung wechseln", + "会話を変更", + "대화 변경", + "切换对话", + ], + ), ( "Change status-bar footer style (rich|solo|history|clear)", [ @@ -5968,6 +5990,28 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "ChatGPT/Codex 订阅", ], ), + ( + "Check the runtime URL", + [ + "Comprueba la URL del motor", + "Vérifiez l’URL du moteur", + "URL der Laufzeitumgebung prüfen", + "ランタイムの URL を確認してください", + "런타임 URL을 확인하세요", + "请检查运行时 URL", + ], + ), + ( + "Checking local runtimes…", + [ + "Comprobando motores locales…", + "Vérification des moteurs locaux…", + "Lokale Laufzeitumgebungen werden geprüft…", + "ローカルランタイムを確認中…", + "로컬 런타임 확인 중…", + "正在检查本地运行时…", + ], + ), ( "Checking your actual configuration and runtime…", [ @@ -7165,6 +7209,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "谈话紧凑", ], ), + ( + "Conversation search stopped. Try again.", + [ + "La búsqueda de conversaciones se detuvo. Inténtalo de nuevo.", + "La recherche de conversations s’est arrêtée. Réessayez.", + "Die Unterhaltungssuche wurde beendet. Erneut versuchen.", + "会話の検索が停止しました。もう一度お試しください。", + "대화 검색이 중단되었습니다. 다시 시도하세요.", + "对话搜索已停止。请重试。", + ], + ), ( "Conversation too short to compact", [ @@ -7221,6 +7276,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "无法应用主题 : {0}", ], ), + ( + "Could not confirm that the response stopped. The current conversation was kept.", + [ + "No se pudo confirmar que la respuesta se detuvo. Se conservó la conversación actual.", + "Impossible de confirmer l’arrêt de la réponse. La conversation actuelle a été conservée.", + "Das Ende der Antwort konnte nicht bestätigt werden. Die aktuelle Unterhaltung wurde beibehalten.", + "応答の停止を確認できませんでした。現在の会話を保持しました。", + "응답이 중단되었는지 확인할 수 없습니다. 현재 대화를 유지했습니다.", + "无法确认响应已停止。已保留当前对话。", + ], + ), ( "Could not list memory dir: {0}\n", [ @@ -7570,6 +7636,10 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "Ctrl+C 中断并立即应用", ], ), + ( + "Ctrl+R", + ["Ctrl+R", "Ctrl+R", "Ctrl+R", "Ctrl+R", "Ctrl+R", "Ctrl+R"], + ), ( "Current", ["Actual", "Actuel", "Aktuell", "現在", "현재", "当前"], @@ -10201,6 +10271,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "叉子对话", ], ), + ( + "Fork of {0} · ", + [ + "Rama de {0} · ", + "Branche de {0} · ", + "Zweig von {0} · ", + "{0} の分岐 · ", + "{0}의 분기 · ", + "{0} 的分支 · ", + ], + ), ( "Fork the conversation into a new session branch", [ @@ -10829,6 +10910,12 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "HTTP MCP URL必须使用 HTTPS (允许回转 HTTP)", ], ), + ( + "HTTP {0}", + [ + "HTTP {0}", "HTTP {0}", "HTTP {0}", "HTTP {0}", "HTTP {0}", "HTTP {0}", + ], + ), ( "Hand work to the default peer and follow its response", [ @@ -11897,6 +11984,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "无效的间隔 '{0}' (尝试 30s, 5m, 1h, 或分钟作为空数)", ], ), + ( + "Invalid model list", + [ + "Lista de modelos no válida", + "Liste de modèles invalide", + "Ungültige Modellliste", + "無効なモデル一覧", + "잘못된 모델 목록", + "模型列表无效", + ], + ), ( "Invalid npm package source", [ @@ -13325,6 +13423,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "模型和数据集", ], ), + ( + "Models detected: {0}", + [ + "Modelos detectados: {0}", + "Modèles détectés : {0}", + "Erkannte Modelle: {0}", + "検出されたモデル: {0}", + "감지된 모델: {0}", + "检测到的模型:{0}", + ], + ), ( "Moderate", ["Moderado", "Modéré", "Mittel", "中程度", "보통", "中等"], @@ -13981,6 +14090,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "没有发现任务。", ], ), + ( + "No models reported", + [ + "No se informaron modelos", + "Aucun modèle signalé", + "Keine Modelle gemeldet", + "モデルの報告がありません", + "보고된 모델 없음", + "未报告任何模型", + ], + ), ( "No next step to suggest yet.", [ @@ -14455,6 +14575,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "本地运行时间未在最新刷新时报告", ], ), + ( + "Not responding", + [ + "No responde", + "Ne répond pas", + "Keine Antwort", + "応答なし", + "응답 없음", + "无响应", + ], + ), ( "Note: native cost reads local session stats (not the legacy TS usage DB).", [ @@ -17443,6 +17574,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "恢复会话", ], ), + ( + "Resume fork: deixic-code --resume-session {0}\nBrowse branches: /tree or /sessions, then Ctrl+F.", + [ + "Reanudar rama: deixic-code --resume-session {0}\nExplorar ramas: /tree o /sessions y luego Ctrl+F.", + "Reprendre la branche : deixic-code --resume-session {0}\nParcourir les branches : /tree ou /sessions, puis Ctrl+F.", + "Zweig fortsetzen: deixic-code --resume-session {0}\nZweige durchsuchen: /tree oder /sessions, dann Ctrl+F.", + "分岐を再開: deixic-code --resume-session {0}\n分岐を表示: /tree または /sessions、その後 Ctrl+F。", + "분기 재개: deixic-code --resume-session {0}\n분기 탐색: /tree 또는 /sessions 다음 Ctrl+F.", + "恢复分支:deixic-code --resume-session {0}\n浏览分支:/tree 或 /sessions,然后按 Ctrl+F。", + ], + ), ( "Resumed session: {0}", [ @@ -18439,6 +18581,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "搜索本地会话记录(用户/assistant文本和工具结果).", ], ), + ( + "Search names, messages, and tool results...", + [ + "Buscar nombres, mensajes y resultados de herramientas...", + "Rechercher des noms, messages et résultats d’outils...", + "Namen, Nachrichten und Werkzeugergebnisse suchen...", + "名前、メッセージ、ツールの結果を検索...", + "이름, 메시지 및 도구 결과 검색...", + "搜索名称、消息和工具结果...", + ], + ), ( "Search previous prompts", [ @@ -18527,6 +18680,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "已搜索网页", ], ), + ( + "Searching conversations...", + [ + "Buscando conversaciones...", + "Recherche de conversations...", + "Unterhaltungen werden durchsucht...", + "会話を検索中...", + "대화 검색 중...", + "正在搜索对话...", + ], + ), ( "Security", [ @@ -18762,6 +18926,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "服务器错误: {0}", ], ), + ( + "Server returned HTTP {0}", + [ + "El servidor devolvió HTTP {0}", + "Le serveur a renvoyé HTTP {0}", + "Server meldete HTTP {0}", + "サーバーが HTTP {0} を返しました", + "서버가 HTTP {0}을 반환했습니다", + "服务器返回 HTTP {0}", + ], + ), ( "Session", ["Sesión", "Session", "Sitzung", "セッション", "세션", "会话"], @@ -18788,6 +18963,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "会话审批策略", ], ), + ( + "Session branches ({0})", + [ + "Ramas de sesión ({0})", + "Branches de session ({0})", + "Sitzungszweige ({0})", + "セッションの分岐 ({0})", + "세션 분기 ({0})", + "会话分支 ({0})", + ], + ), ( "Session details", [ @@ -18887,6 +19073,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "会话加载失败", ], ), + ( + "Session loading stopped. Try again.", + [ + "La carga de sesiones se detuvo. Inténtalo de nuevo.", + "Le chargement des sessions s’est arrêté. Réessayez.", + "Das Laden der Sitzungen wurde beendet. Erneut versuchen.", + "セッションの読み込みが停止しました。もう一度お試しください。", + "세션 로드가 중지되었습니다. 다시 시도하세요.", + "会话加载已停止。请重试。", + ], + ), ( "Session management", [ @@ -19792,6 +19989,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "正在启动… 设置完成前,您可以继续输入。", ], ), + ( + "Starting… {0} · {1} · {2} or /model to choose", + [ + "Iniciando… {0} · {1} · {2} o /model para elegir", + "Démarrage… {0} · {1} · {2} ou /model pour choisir", + "Start… {0} · {1} · {2} oder /model zum Auswählen", + "起動中… {0} · {1} · {2} または /model で選択", + "시작 중… {0} · {1} · {2} 또는 /model 로 선택", + "正在启动… {0} · {1} · 按 {2} 或用 /model 选择", + ], + ), ( "Stash / restore / swap draft", [ @@ -19890,6 +20098,50 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "停止允许工作区扩展", ], ), + ( + "Stop the response and rewind the last {0} turn(s), including their file changes? The original conversation stays available.", + [ + "¿Detener la respuesta y retroceder los últimos {0} turnos, incluidos los cambios de archivos? La conversación original seguirá disponible.", + "Arrêter la réponse et revenir sur les {0} derniers tours, y compris leurs modifications de fichiers ? La conversation originale reste disponible.", + "Antwort stoppen und die letzten {0} Schritte samt Dateiänderungen zurückgehen? Die ursprüngliche Unterhaltung bleibt verfügbar.", + "応答を停止し、ファイル変更を含めて最後の {0} ターンを巻き戻しますか?元の会話は保持されます。", + "응답을 중단하고 파일 변경을 포함한 마지막 {0}개 턴을 되돌릴까요? 원래 대화는 유지됩니다.", + "停止响应并回退最后 {0} 轮,包括文件更改?原对话仍可使用。", + ], + ), + ( + "Stop the response and rewind the last {0} turn(s)? The original conversation stays available. Files stay unchanged.", + [ + "¿Detener la respuesta y retroceder los últimos {0} turnos? La conversación original seguirá disponible. Los archivos no cambian.", + "Arrêter la réponse et revenir sur les {0} derniers tours ? La conversation originale reste disponible. Les fichiers ne changent pas.", + "Antwort stoppen und die letzten {0} Schritte zurückgehen? Die ursprüngliche Unterhaltung bleibt verfügbar. Dateien bleiben unverändert.", + "応答を停止し、最後の {0} ターンを巻き戻しますか?元の会話は保持されます。ファイルは変更されません。", + "응답을 중단하고 마지막 {0}개 턴을 되돌릴까요? 원래 대화는 유지됩니다. 파일은 변경되지 않습니다.", + "停止响应并回退最后 {0} 轮?原对话仍可使用。文件保持不变。", + ], + ), + ( + "Stop the response and start a new conversation? The saved conversation stays available.", + [ + "¿Detener la respuesta e iniciar una conversación nueva? La conversación guardada seguirá disponible.", + "Arrêter la réponse et commencer une nouvelle conversation ? La conversation enregistrée reste disponible.", + "Antwort stoppen und eine neue Unterhaltung starten? Die gespeicherte Unterhaltung bleibt verfügbar.", + "応答を停止して新しい会話を始めますか?保存済みの会話は引き続き利用できます。", + "응답을 중단하고 새 대화를 시작할까요? 저장된 대화는 계속 사용할 수 있습니다.", + "停止响应并开始新对话?已保存的对话仍可使用。", + ], + ), + ( + "Stopping the response and finishing tool cleanup… Esc keeps this conversation.", + [ + "Deteniendo la respuesta y finalizando la limpieza de herramientas… Esc conserva esta conversación.", + "Arrêt de la réponse et nettoyage des outils… Esc conserve cette conversation.", + "Antwort wird gestoppt und Werkzeuge werden bereinigt… Esc behält diese Unterhaltung.", + "応答を停止してツールを終了処理中… Esc で現在の会話を保持します。", + "응답을 중단하고 도구를 정리하는 중… Esc를 누르면 이 대화를 유지합니다.", + "正在停止响应并完成工具清理… Esc 保留当前对话。", + ], + ), ( "Stored EvalOps credentials detected.", [ @@ -20527,6 +20779,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "时间和时区转换", ], ), + ( + "Timed out", + [ + "Tiempo de espera agotado", + "Délai dépassé", + "Zeitüberschreitung", + "タイムアウト", + "시간 초과", + "已超时", + ], + ), ( "Timed out after {0}ms waiting for remote runner session {1} to become ready (last state: {2})", [ @@ -24323,6 +24586,10 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "受阻", ], ), + ( + "branches", + ["ramas", "branches", "Zweige", "分岐", "분기", "分支"], + ), ( "built-in specialist profiles have no writable directory", [ @@ -24389,6 +24656,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "检查子剂生命周期应用程序", ], ), + ( + "checking local runtimes", + [ + "comprobando motores locales", + "vérification des moteurs locaux", + "lokale Laufzeitumgebungen werden geprüft", + "ローカルランタイムを確認中", + "로컬 런타임 확인 중", + "正在检查本地运行时", + ], + ), ( "choose either --platform or --byok", [ @@ -25909,6 +26187,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "无组件", ], ), + ( + "no local models detected", + [ + "no se detectaron modelos locales", + "aucun modèle local détecté", + "keine lokalen Modelle erkannt", + "ローカルモデルが見つかりません", + "로컬 모델이 감지되지 않음", + "未检测到本地模型", + ], + ), ( "no summary", [ @@ -26132,6 +26421,28 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "政策决定", ], ), + ( + "preparing a local session", + [ + "preparando una sesión local", + "préparation d’une session locale", + "lokale Sitzung wird vorbereitet", + "ローカルセッションを準備中", + "로컬 세션 준비 중", + "正在准备本地会话", + ], + ), + ( + "preparing secure access", + [ + "preparando acceso seguro", + "préparation de l’accès sécurisé", + "sicherer Zugriff wird vorbereitet", + "安全なアクセスを準備中", + "보안 액세스 준비 중", + "正在准备安全访问", + ], + ), ( "profile name must be 1-64 lowercase letters, numbers, or hyphens", [ @@ -26214,6 +26525,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "ready", ["Listo", "Prêt", "Bereit", "準備完了", "준비됨", "就绪"], ), + ( + "refresh", + [ + "actualizar", + "actualiser", + "aktualisieren", + "更新", + "새로 고침", + "刷新", + ], + ), ( "refresh the existing Platform heartbeat without requiring a public A2A URL", [ @@ -27393,6 +27715,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "工作空间写法", ], ), + ( + "{0}\n\nQueued prompts return to the composer.\nEnter: stop and continue · Esc: keep working", + [ + "{0}\n\nLos mensajes en cola vuelven al campo de entrada.\nEnter: detener y continuar · Esc: seguir trabajando", + "{0}\n\nLes messages en attente reviennent dans la zone de saisie.\nEnter : arrêter et continuer · Esc : continuer à travailler", + "{0}\n\nWartende Eingaben kehren ins Eingabefeld zurück.\nEnter: stoppen und fortfahren · Esc: weiterarbeiten", + "{0}\n\n待機中の入力は入力欄に戻ります。\nEnter: 停止して続行 · Esc: 作業を継続", + "{0}\n\n대기 중인 입력은 입력창으로 돌아갑니다.\nEnter: 중단하고 계속 · Esc: 작업 유지", + "{0}\n\n排队的输入将返回输入框。\nEnter:停止并继续 · Esc:继续工作", + ], + ), ( "{0}\n\nSide question failed: {1}", [ @@ -27635,6 +27968,17 @@ pub(crate) const MESSAGES: &[(&str, [&str; 6])] = &[ "{0} 现已成为其提供商的默认连接。", ], ), + ( + "{0} local models available", + [ + "{0} modelos locales disponibles", + "{0} modèles locaux disponibles", + "{0} lokale Modelle verfügbar", + "{0} 個のローカルモデルが利用可能", + "로컬 모델 {0}개 사용 가능", + "{0} 个本地模型可用", + ], + ), ( "{0} matching skill(s).", [ diff --git a/scripts/check-sync-pr-checks.mjs b/scripts/check-sync-pr-checks.mjs index dc8e51408..937279e23 100644 --- a/scripts/check-sync-pr-checks.mjs +++ b/scripts/check-sync-pr-checks.mjs @@ -27,7 +27,7 @@ import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; -const DEFAULT_REPO = "evalops/maestro"; +const DEFAULT_REPO = "dx-corp/code"; const DEFAULT_SYNC_BRANCH = "sync/public-release-mirror"; /** @@ -75,6 +75,7 @@ export function ghJson(endpoint) { const result = spawnSync("gh", ["api", endpoint], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, + timeout: 30000, }); if (result.error) { throw new SyncPrBlindSpotError(`failed to run gh: ${result.error.message}`); @@ -96,15 +97,16 @@ export function ghJson(endpoint) { * * The check-runs API returns every run, including superseded re-runs: a * cancelled attempt and its successful retry coexist under the same check - * name (observed on evalops/maestro#1000). Only the latest run per name — + * name (observed on evalops/maestro#1000). Only the latest run per producer and name — * the one GitHub surfaces on the PR — decides. */ export function classifyCheckRuns(checkRuns) { const latestByName = new Map(); for (const run of checkRuns) { - const existing = latestByName.get(run.name); + const key = `${run.app?.id ?? "unknown"}:${run.name}`; + const existing = latestByName.get(key); if (!existing || (run.id ?? 0) > (existing.id ?? 0)) { - latestByName.set(run.name, run); + latestByName.set(key, run); } } const failing = []; @@ -124,7 +126,8 @@ export function classifyCheckRuns(checkRuns) { * what makes the monitor fail closed. */ export function evaluateSyncPrChecks({ openPrs, checkRuns }) { - const pr = Array.isArray(openPrs) ? openPrs[0] : null; + if (!Array.isArray(openPrs) || !Array.isArray(checkRuns)) throw new SyncPrBlindSpotError("Malformed sync PR/check data"); + const pr = openPrs[0]; if (!pr) { return { state: "no-open-pr", failing: [] }; } @@ -135,53 +138,66 @@ export function evaluateSyncPrChecks({ openPrs, checkRuns }) { return { state: "failing", pr, failing }; } -function main() { - const options = parseArgs(process.argv.slice(2)); +export function readSyncPrHealth(options, fetch = ghJson) { const owner = options.repo.split("/")[0]; - const openPrs = ghJson( - `repos/${options.repo}/pulls?state=open&base=${encodeURIComponent(options.base)}&head=${encodeURIComponent(`${owner}:${options.branch}`)}`, - ); - const pr = Array.isArray(openPrs) ? openPrs[0] : null; - - if (!pr) { - console.log( - `No open sync PR on ${options.repo} (head ${options.branch}); nothing to watch.`, - ); - return; - } - + const openPrs = fetch(`repos/${options.repo}/pulls?state=open&base=${encodeURIComponent(options.base)}&head=${encodeURIComponent(`${owner}:${options.branch}`)}`); + if (!Array.isArray(openPrs) || openPrs.length > 1) throw new SyncPrBlindSpotError("Malformed or ambiguous sync PR list"); + const pr = openPrs[0]; + if (!pr) return { openPrs, checkRuns: [], failingStatuses: [] }; const headSha = pr.head?.sha; - if (typeof headSha !== "string" || headSha.length === 0) { - throw new SyncPrBlindSpotError( - `sync PR ${options.repo}#${pr.number} reported no head SHA; cannot read its check runs`, - ); + if (!/^[0-9a-f]{40}$/u.test(headSha ?? "")) throw new SyncPrBlindSpotError("Sync PR reported no valid head SHA"); + const checkRuns = []; + for (let page = 1; ; page++) { + if (page > 100) throw new SyncPrBlindSpotError("Check pagination limit exceeded"); + const data = fetch(`repos/${options.repo}/commits/${headSha}/check-runs?per_page=100&page=${page}`); + if (!Array.isArray(data?.check_runs) || !Number.isInteger(data.total_count) || data.total_count < 0) throw new SyncPrBlindSpotError("Malformed check-runs payload"); + for (const run of data.check_runs) { + if (!run.name || !Number.isInteger(run.id) || !["queued", "in_progress", "completed", "waiting", "pending", "requested"].includes(run.status)) throw new SyncPrBlindSpotError("Malformed check run"); + if (run.status === "completed" && ![...FAILING_CONCLUSIONS, "success", "neutral", "skipped"].includes(run.conclusion)) throw new SyncPrBlindSpotError("Unknown completed check conclusion"); + } + checkRuns.push(...data.check_runs); + if (checkRuns.length === data.total_count) break; + if (!data.check_runs.length || checkRuns.length > data.total_count) throw new SyncPrBlindSpotError("Incomplete check-runs pagination"); } - - const data = ghJson( - `repos/${options.repo}/commits/${headSha}/check-runs?per_page=100`, - ); - const checkRuns = Array.isArray(data?.check_runs) ? data.check_runs : null; - if (!checkRuns) { - throw new SyncPrBlindSpotError( - `check-runs payload for ${options.repo}@${headSha} had no check_runs array`, - ); + if (new Set(checkRuns.map(run => run.id)).size !== checkRuns.length) throw new SyncPrBlindSpotError("Duplicate check-run pages"); + // Combined statuses cover non-Actions producers as well as check runs. + const latest = new Map(); + for (let page = 1; ; page++) { + if (page > 100) throw new SyncPrBlindSpotError("Status pagination limit exceeded"); + const statuses = fetch(`repos/${options.repo}/commits/${headSha}/statuses?per_page=100&page=${page}`); + if (!Array.isArray(statuses)) throw new SyncPrBlindSpotError("Malformed commit statuses"); + for (const status of statuses) { + if (!status.context || !Number.isInteger(status.id) || !["success", "pending", "failure", "error"].includes(status.state)) throw new SyncPrBlindSpotError("Malformed commit status"); + if (!latest.has(status.context) || latest.get(status.context).id < status.id) latest.set(status.context, status); + } + if (statuses.length < 100) break; } + return { openPrs, checkRuns, failingStatuses: [...latest.values()].filter(s => ["failure", "error"].includes(s.state)) }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const { openPrs, checkRuns, failingStatuses } = readSyncPrHealth(options); + const pr = openPrs[0]; + if (!pr) { console.log(`No open sync PR on ${options.repo}; nothing to watch.`); return; } + const headSha = pr.head.sha; const { failing } = evaluateSyncPrChecks({ openPrs, checkRuns }); const pending = checkRuns.filter((run) => run.status !== "completed"); console.log( `Sync PR ${options.repo}#${pr.number} @ ${headSha}: ${checkRuns.length} check run(s), ${pending.length} still in flight, ${failing.length} failing.`, ); - if (failing.length === 0) { + if (failing.length === 0 && failingStatuses.length === 0) { return; } + for (const status of failingStatuses) console.error(`::error::sync PR status "${status.context}" is ${status.state}: ${status.target_url ?? "no url"}`); for (const run of failing) { console.error( `::error::sync PR check "${run.name}" concluded ${run.conclusion}: ${run.html_url ?? "no url"}`, ); } console.error( - `::error::Open sync PR ${options.repo}#${pr.number} has ${failing.length} failing check run(s). This is the evalops/maestro#998 failure mode: the sync workflow run stays green while the PR it maintains is red. Do not merge the sync PR until these are resolved.`, + `::error::Open sync PR ${options.repo}#${pr.number} has ${failing.length} failing check run(s) and ${failingStatuses.length} failing commit status(es). This is the evalops/maestro#998 failure mode: the sync workflow run stays green while the PR it maintains is red. Do not merge the sync PR until these are resolved.`, ); process.exitCode = 1; } diff --git a/scripts/tag-release-contract.test.mjs b/scripts/tag-release-contract.test.mjs index 1f5e29420..1e597383e 100644 --- a/scripts/tag-release-contract.test.mjs +++ b/scripts/tag-release-contract.test.mjs @@ -13,8 +13,6 @@ test("tag-release does not cancel in-progress runs", () => { test("tag-release dispatches a public release after tagging", () => { assert.match(workflow, /dispatch-public-release:/); - assert.match(workflow, /github\.repository == 'dx-corp\/code'/); - assert.doesNotMatch(workflow, /github\.repository == 'evalops\/maestro'/); }); test("internal tag-release dispatches public-release-mirror after creating a tag", () => { diff --git a/skills/mcp-builder/scripts/requirements.txt b/skills/mcp-builder/scripts/requirements.txt index e73e5d1e3..8408402c7 100644 --- a/skills/mcp-builder/scripts/requirements.txt +++ b/skills/mcp-builder/scripts/requirements.txt @@ -1,2 +1,2 @@ -anthropic>=0.39.0 -mcp>=1.1.0 +anthropic>=1.6.0 +mcp>=2.2.0