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/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] 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