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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 72 additions & 43 deletions crates/tracedecay-agent-hosts/src/agents/kimi/web_refresh.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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};

Expand All @@ -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.
Expand Down Expand Up @@ -86,31 +86,22 @@ 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()),
("KIMI_CODE_NO_AUTO_UPDATE", OsStr::new("1")),
],
)
.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()))?;
Expand Down Expand Up @@ -148,8 +139,6 @@ fn staged_bundle_version(staged: &Path) -> Result<String, KimiWebRefreshError> {

fn refresh_through_server(
server: &mut HostServerChild,
url: &str,
code_home: &Path,
source: &str,
staged: &Path,
staged_version: &str,
Expand All @@ -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()))?
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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<String> {
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:<port>/#token=<token>`
/// line of `kimi web`'s ready banner, which Kimi prints only once it holds
/// the port.
fn announced_endpoint(output: &str) -> Option<KimiWebEndpoint> {
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::<u16>().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": ...}`).
Expand Down Expand Up @@ -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"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ const PREVIOUS_VERSION: &str = "0.0.0-previous";

/// `kimi web --no-open --port <p>` 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 `<p>` 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:]
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -162,6 +179,18 @@ impl FakeKimi {
.collect()
}

/// The `Authorization` header of every request the foreign server got.
fn foreign_authorizations(&self) -> Vec<String> {
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<bool> {
Expand Down Expand Up @@ -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::<String>::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]
Expand Down
6 changes: 3 additions & 3 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,9 +451,9 @@ Kimi Code's `/plugins install <staged-path>` 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 <ephemeral>` (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
Expand Down
Loading