From 3ce0e388555a02647e917524a479e5849022de47 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:50:42 -0700 Subject: [PATCH 1/2] test(kimi): reject probes of a port kimi web never announced A port TraceDecay asks `kimi web` for, but that `kimi web` never bound, can belong to any local process. The new `foreign` fake mode has another server take that port while Kimi never announces, and asserts Kimi's bearer token never reaches it. Fails on master with "GET /api/v1/plugins returned HTTP 404", the CI message from #4638. Co-Authored-By: Claude Opus 5.5 --- .../kimi_web_refresh.rs | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/kimi_web_refresh.rs b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/kimi_web_refresh.rs index 3f6521a66e..7558f5dbb8 100644 --- a/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/kimi_web_refresh.rs +++ b/crates/tracedecay-cli/tests/host_journeys_suite/host_lifecycle_cli_acceptance/kimi_web_refresh.rs @@ -19,9 +19,11 @@ const PREVIOUS_VERSION: &str = "0.0.0-previous"; /// `kimi web --no-open --port

` serving Kimi's plugin routes over the /// isolated `installed.json`, the way Kimi Code 2.1 does. `mode` selects a -/// failure: `unauthorized` answers 401, `hang` never serves. +/// failure: `unauthorized` answers 401, `hang` never serves, and `foreign` +/// never serves while another local server takes port `

` and records +/// what it is sent. const FAKE_KIMI: &str = r#" -import json, os, shutil, sys, time +import json, os, shutil, sys, threading, time from http.server import BaseHTTPRequestHandler, HTTPServer args = sys.argv[1:] @@ -44,7 +46,22 @@ with open(os.path.join(state, "pid"), "w") as f: with open(os.path.join(state, "env.json"), "w") as f: json.dump({"KIMI_CODE_NO_AUTO_UPDATE": os.environ.get("KIMI_CODE_NO_AUTO_UPDATE")}, f) mode = state_file("mode", "ok") -if mode == "hang": +if mode == "foreign": + class Foreign(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + with open(os.path.join(state, "foreign-requests.jsonl"), "a") as f: + f.write(json.dumps({"path": self.path, + "authorization": self.headers.get("Authorization")}) + "\n") + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + + foreign = HTTPServer(("127.0.0.1", int(args[3])), Foreign) + threading.Thread(target=foreign.serve_forever, daemon=True).start() +if mode in ("hang", "foreign"): while True: time.sleep(1) with open(os.path.join(home, "server.token")) as f: @@ -108,8 +125,8 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): self.handle_plugins("POST") -port = int(args[3]) -server = HTTPServer(("127.0.0.1", port), Handler) +server = HTTPServer(("127.0.0.1", int(args[3])), Handler) +port = server.server_address[1] print(" Kimi server ready 2.1.1") print(" Local: http://127.0.0.1:%d/#token=%s" % (port, token), flush=True) server.serve_forever() @@ -162,6 +179,18 @@ impl FakeKimi { .collect() } + /// The `Authorization` header of every request the foreign server got. + fn foreign_authorizations(&self) -> Vec { + fs::read_to_string(self.code_home.join("fake-kimi/foreign-requests.jsonl")) + .unwrap_or_default() + .lines() + .map(|line| { + let request: Value = serde_json::from_str(line).unwrap(); + request["authorization"].as_str().unwrap_or("").to_string() + }) + .collect() + } + /// Whether the fake was started, and then whether its process is still /// alive. fn process(&self) -> Option { @@ -404,6 +433,41 @@ fn kimi_web_refresh_falls_back_to_the_operator_step_when_kimi_never_serves() { ); } +/// A port TraceDecay asked `kimi web` for, but that `kimi web` never bound, +/// can belong to any local process; Kimi's server token must never reach it. +#[test] +fn kimi_web_refresh_never_sends_the_token_to_a_server_kimi_did_not_announce() { + let cli = IsolatedCli::new(); + let kimi = FakeKimi::install(&cli); + let _ = cli.run(&["install", "--agent", "kimi"]); + kimi.install_previous_release(&cli); + kimi.set("mode", "foreign"); + + let update = cli.run(&["update-plugin"]); + let update_stderr = stderr(&update); + + assert_eq!( + update.status.code(), + Some(PENDING_OPERATOR_ACTION_EXIT), + "{update_stderr}" + ); + assert!( + update_stderr.contains( + "The automatic refresh through `kimi web` did not apply: `kimi web` did not serve \ + its plugin API within 20 seconds" + ), + "{update_stderr}" + ); + assert_eq!( + kimi.foreign_authorizations(), + Vec::::new(), + "only the server `kimi web` announces may be sent Kimi's token" + ); + assert_eq!(kimi.requests(), Vec::new()); + assert_eq!(kimi.managed_version(), PREVIOUS_VERSION); + assert_eq!(kimi.process(), Some(false)); +} + /// Without its `kimi` CLI, Kimi Code is not installed: the sweep skips it and /// exits 0 rather than waiting on an operator step nothing can reach. #[test] From 13839e89bed87a220461612ae5486e3f768b5d77 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:54:38 -0700 Subject: [PATCH 2/2] fix(kimi): only probe the endpoint kimi web announces TraceDecay picked a loopback port by binding port 0 and dropping the listener, passed it to `kimi web --port`, and polled it with Kimi's bearer token as soon as `server.token` existed. Until Kimi bound that port, any local process could hold it, and Kimi itself moves to the next free port when the requested one is busy. Either way Kimi's token went to a server that was not Kimi; in CI a parallel test's server answered 404. Start `kimi web --port 0` so only Kimi holds its port, and probe only the port and token from a complete `Local:` line of that child's ready banner, which Kimi prints after it binds. The `server.token` read goes. Co-Authored-By: Claude Opus 5.5 --- .../src/agents/kimi/web_refresh.rs | 115 +++++++++++------- docs/USER-GUIDE.md | 6 +- 2 files changed, 75 insertions(+), 46 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/agents/kimi/web_refresh.rs b/crates/tracedecay-agent-hosts/src/agents/kimi/web_refresh.rs index 289dfaf464..6e17e3763d 100644 --- a/crates/tracedecay-agent-hosts/src/agents/kimi/web_refresh.rs +++ b/crates/tracedecay-agent-hosts/src/agents/kimi/web_refresh.rs @@ -1,6 +1,7 @@ //! Refresh of an already-installed TraceDecay plugin through Kimi Code's own //! installer, reached through the token-protected local REST API that -//! `kimi web` serves (`GET`/`POST /api/v1/plugins`). +//! `kimi web` serves (`GET`/`POST /api/v1/plugins`) on the loopback endpoint +//! it announces once bound. //! //! Consent boundary: the first install is the operator's interactive //! `/plugins install`, which carries Kimi's trust confirmation. This path only @@ -9,7 +10,6 @@ //! every failure, leaves the caller's pending operator action in place. use std::ffi::OsStr; -use std::net::{Ipv4Addr, TcpListener}; use std::path::Path; use std::time::{Duration, Instant}; @@ -26,8 +26,8 @@ const KIMI_WEB_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); const READY_POLL_INTERVAL: Duration = Duration::from_millis(50); -/// Kimi's persistent server bearer token, under the Kimi Code home. -const KIMI_SERVER_TOKEN_FILE: &str = "server.token"; +/// The origin of the `Local:` URL in `kimi web`'s ready banner. +const KIMI_WEB_LOCAL_ORIGIN: &str = "http://127.0.0.1:"; /// Why the non-interactive refresh did not apply. Every variant leaves the /// interactive `/plugins install` as the operator's step. @@ -86,16 +86,14 @@ pub(crate) fn refresh_installed_plugin( })?; let kimi = require_host_cli(super::KIMI_CLI, "Kimi Code plugin refresh") .map_err(|error| KimiWebRefreshError::KimiUnavailable(error.to_string()))?; - let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) - .and_then(|listener| listener.local_addr()) - .map_err(|error| KimiWebRefreshError::Start(format!("no loopback port: {error}")))? - .port() - .to_string(); - // Without `--host`, Kimi binds 127.0.0.1 only. Its auto-update is - // disabled so a plugin refresh never upgrades Kimi itself. + // Without `--host`, Kimi binds 127.0.0.1 only. Port 0 lets the OS assign + // one that only this `kimi web` holds; a port chosen here could be taken + // by any local process before Kimi binds it, and Kimi itself moves past a + // busy one. Its auto-update is disabled so a plugin refresh never + // upgrades Kimi itself. let mut server = spawn_host_server( &kimi, - &["web", "--no-open", "--port", &port], + &["web", "--no-open", "--port", "0"], home, &[ ("KIMI_CODE_HOME", code_home.as_os_str()), @@ -103,14 +101,7 @@ pub(crate) fn refresh_installed_plugin( ], ) .map_err(|error| KimiWebRefreshError::Start(error.to_string()))?; - let refreshed = refresh_through_server( - &mut server, - &format!("http://127.0.0.1:{port}/api/v1/plugins"), - &code_home, - source, - &staged, - &staged_version, - ); + let refreshed = refresh_through_server(&mut server, source, &staged, &staged_version); let stopped = server.terminate(); let refreshed = refreshed?; stopped.map_err(|error| KimiWebRefreshError::Stop(error.to_string()))?; @@ -148,8 +139,6 @@ fn staged_bundle_version(staged: &Path) -> Result { fn refresh_through_server( server: &mut HostServerChild, - url: &str, - code_home: &Path, source: &str, staged: &Path, staged_version: &str, @@ -167,7 +156,7 @@ fn refresh_through_server( .build(), ); let deadline = Instant::now() + KIMI_WEB_READY_DEADLINE; - let (authorization, listing) = loop { + let (url, authorization, listing) = loop { if let Some(status) = server .exited() .map_err(|error| KimiWebRefreshError::Start(error.to_string()))? @@ -177,22 +166,23 @@ fn refresh_through_server( output: server.output().trim().to_string(), }); } - let pending = match server_token(code_home, &server.output()) { - Some(token) => { + let pending = match announced_endpoint(&server.output()) { + Some(KimiWebEndpoint { port, token }) => { + let url = format!("{KIMI_WEB_LOCAL_ORIGIN}{port}/api/v1/plugins"); let authorization = format!("Bearer {token}"); match envelope_data( agent - .get(url) + .get(&url) .header("Authorization", &authorization) .call(), "GET /api/v1/plugins", ) { - Ok(listing) => break (authorization, listing), + Ok(listing) => break (url, authorization, listing), Err(KimiWebRefreshError::Unreachable(detail)) => detail, Err(error) => return Err(error), } } - None => "no server token yet".to_string(), + None => "it has not announced its local URL".to_string(), }; if Instant::now() >= deadline { return Err(KimiWebRefreshError::NotReady { @@ -221,7 +211,7 @@ fn refresh_through_server( let installed = envelope_data( agent - .post(url) + .post(&url) .header("Authorization", &authorization) .send_json(json!({ "source": source })), "POST /api/v1/plugins", @@ -242,20 +232,30 @@ fn refresh_through_server( }) } -/// The persistent token Kimi writes on first boot, else the `#token=` it -/// prints in its startup banner. -fn server_token(code_home: &Path, output: &str) -> Option { - if let Ok(token) = std::fs::read_to_string(code_home.join(KIMI_SERVER_TOKEN_FILE)) { - let token = token.trim(); - if !token.is_empty() { - return Some(token.to_string()); - } - } - let (_, rest) = output.split_once("#token=")?; - let token = rest - .split(|character: char| character.is_whitespace() || character == '\x1b') - .next()?; - (!token.is_empty()).then(|| token.to_string()) +/// The loopback endpoint a running `kimi web` serves its API on. +#[derive(Debug, PartialEq, Eq)] +struct KimiWebEndpoint { + port: u16, + token: String, +} + +/// The endpoint from a complete `Local: http://127.0.0.1:/#token=` +/// line of `kimi web`'s ready banner, which Kimi prints only once it holds +/// the port. +fn announced_endpoint(output: &str) -> Option { + output + .split_inclusive('\n') + .filter_map(|line| line.strip_suffix('\n')) + .find_map(|line| { + let (_, url) = line.split_once(KIMI_WEB_LOCAL_ORIGIN)?; + let (port, fragment) = url.split_once("/#token=")?; + let port = port.parse::().ok().filter(|port| *port != 0)?; + let token = fragment.split_whitespace().next()?; + Some(KimiWebEndpoint { + port, + token: token.to_string(), + }) + }) } /// The `data` of a successful Kimi envelope (`{"code": 0, "data": ...}`). @@ -287,3 +287,32 @@ fn envelope_data( .cloned() .ok_or_else(|| KimiWebRefreshError::Unexpected(format!("{route} returned no data"))) } + +#[cfg(test)] +mod tests { + use super::{KimiWebEndpoint, announced_endpoint}; + + const BANNER: &str = "\n Kimi server ready 2.1.1\n\n Local: http://127.0.0.1:51234/#token=abc-123\n Network: off use --host to enable\n"; + + #[test] + fn announced_endpoint_reads_the_local_line_of_the_ready_banner() { + assert_eq!( + announced_endpoint(BANNER), + Some(KimiWebEndpoint { + port: 51234, + token: "abc-123".to_string(), + }) + ); + } + + #[test] + fn announced_endpoint_waits_for_the_whole_local_line() { + let partial = &BANNER[..BANNER.find("abc-123").unwrap() + "abc".len()]; + assert_eq!(announced_endpoint(partial), None); + assert_eq!( + announced_endpoint(" Local: http://127.0.0.1:51234/\n"), + None, + "a banner without a token announces nothing TraceDecay can use" + ); + } +} diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 96cb0aadf2..d7eb98fc29 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -451,9 +451,9 @@ Kimi Code's `/plugins install ` registers it after Kimi's trust prompt. Once Kimi lists the TraceDecay plugin as installed and enabled from exactly that staged path, `tracedecay update`, `update-plugin`, and a repeated `install --agent kimi` refresh it themselves. TraceDecay starts -`kimi web --no-open --port ` (loopback only, with Kimi's auto-update -disabled), authenticates with Kimi's server token from -`~/.kimi-code/server.token`, calls `POST /api/v1/plugins` with the staged path, +`kimi web --no-open --port 0` (loopback only, with Kimi's auto-update +disabled), waits for the local URL and server token that server announces once +it holds its port, calls `POST /api/v1/plugins` there with the staged path, accepts the refresh only when Kimi reports the staged version in state `ok`, and stops the server it started. A missing `kimi`, an older Kimi without `kimi web`, a refused token, a timeout, or any other answer leaves the