Skip to content

fix(kimi): only probe the endpoint kimi web announces - #4648

Merged
ScriptedAlchemy merged 2 commits into
masterfrom
fix/kimi-web-announced-port
Oct 31, 2026
Merged

ScriptedAlchemy merged 2 commits into
masterfrom
fix/kimi-web-announced-port

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Root cause

refresh_installed_plugin picked a loopback port by binding port 0, reading local_addr(), and dropping the listener. It then started kimi web --no-open --port <p> and polled http://127.0.0.1:<p>/api/v1/plugins with Authorization: Bearer <token> as soon as a token existed. The token came from ~/.kimi-code/server.token, which exists before Kimi starts, so polling began immediately.

Nothing tied port <p> to the spawned kimi web:

  • Until Kimi binds <p> (or forever, if it hangs or fails to start), any local process can take it.
  • Real Kimi moves past a busy port on its own. listenWithPortRetry in MoonshotAI/kimi-code packages/kap-server/src/start.ts retries port + 1 up to 100 times on EADDRINUSE, so even a healthy Kimi can serve somewhere other than the port TraceDecay probes.

In CI (PR #4638, job 124382047562), the fake kimi web was in hang mode. A parallel test's HTTP server took the freed port and answered 404. The product reported 'kimi web' answered unexpectedly: GET /api/v1/plugins returned HTTP 404 instead of did not serve its plugin API within 20 seconds.

Security impact

On a real machine, the product sent Kimi Code's persistent bearer token to whatever local process held that port. Kimi's server API covers sessions, the filesystem, and shell access. So did any process that could win a port race, or that already held the port Kimi moved past. CI only showed the flaky symptom.

Fix

  • Start kimi web --no-open --port 0. Real Kimi accepts port 0: parsePort allows 0, and listenWithPortRetry binds the OS-assigned port and reports it as the bound port. Only that child then holds its port.
  • Probe only the port and token from a complete Local: http://127.0.0.1:<port>/#token=<token> line in this child's own output. That's the line Kimi's formatReadyBanner prints in onReady, after it has bound. Partial lines are ignored, so a split read can't produce a truncated token. The URL is always built from the fixed 127.0.0.1 origin plus the announced port.
  • Drop the server.token file read. Kimi prints the token from that same file in the banner, so the banner is the one source and carries the port with it.
  • The 20 s deadline and the typed errors (NotReady, Exited, Unauthorized, ...) are unchanged. Before the announcement, the NotReady detail is it has not announced its local URL.
  • I checked for the same pattern elsewhere. spawn_host_server has no other caller. Every other port-0 bind in the workspace is a test fixture that keeps its listener open.
  • docs/USER-GUIDE.md now describes the announced-endpoint handshake.

Evidence

The new foreign fake mode starts a separate local HTTP server on the port the product passed to --port. That server answers 404 and records each Authorization header. The fake kimi web itself never announces.

Before (commit 1 on master's product code, bazel test //crates/tracedecay-cli:host_journeys_suite --test_arg=host_lifecycle_cli_acceptance::kimi_web_refresh::):

test ...::kimi_web_refresh_never_sends_the_token_to_a_server_kimi_did_not_announce ... FAILED
WARN ... Kimi Code plugin refresh through `kimi web` failed reason=`kimi web` answered unexpectedly: GET /api/v1/plugins returned HTTP 404
test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 44 filtered out

That's the same message CI reported.

After (commit 2, full suite):

test ...::kimi_web_refresh_never_sends_the_token_to_a_server_kimi_did_not_announce ... ok
test ...::kimi_web_refresh_falls_back_to_the_operator_step_when_kimi_never_serves ... ok
test ...::update_plugin_refreshes_the_installed_kimi_plugin_through_kimi_web ... ok
test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Verification

  • bazel test //crates/tracedecay-agent-hosts:all: 3/3 targets pass. Two new unit tests cover banner parsing: the full Local: line, a partial line, and a line with no token.
  • bazel test //crates/tracedecay-cli:host_journeys_suite: 50 passed.
  • bazel build --config=clippy //crates/tracedecay-agent-hosts:all //crates/tracedecay-cli:host_journeys_suite: clean with -Dwarnings.
  • rustfmt_check_2024 for both crates: clean.
  • No manifests changed, so gen_builds --check doesn't apply.
  • Not verified: a run against a real Kimi Code install, because none is installed here. The banner format and --port 0 behavior were checked against MoonshotAI/kimi-code source (apps/kimi-code/src/cli/sub/web/{run,access-urls,shared}.ts, packages/kap-server/src/start.ts).

🤖 Generated with Claude Code


Devin Review

ScriptedAlchemy and others added 2 commits October 30, 2026 19:50
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 31, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 13839e8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) October 31, 2026 03:01

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@ScriptedAlchemy
ScriptedAlchemy merged commit c534b96 into master Oct 31, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant