feat(daemon,services): default-wire Kimi-for-Coding identity headers via HarnessBridge

Add an optional `identity` field to HarnessBridgeOptions and default-wire
`kimiRequestHeaders` (User-Agent + X-Msh-Platform/Version/Device-Id) plus
`appVersion` from it. Without these the daemon-hosted KimiCore made
outbound fetches with the Node default User-Agent and the managed
Kimi-for-Coding endpoint rejected with 40340 ("only available for Coding
Agents such as Kimi CLI, …"). The in-process TUI path was unaffected
because SDKRpcClient already synthesised the same headers from its own
identity. Explicit `kimiRequestHeaders` / `appVersion` still win.

apps/kimi-code wires this through its `daemon` subcommand using the
existing `createKimiCodeHostIdentity(version)` helper, and ships a small
`dev:daemon:restart` press-Enter-to-respawn helper so the dev loop can
pick up a fresh build without manually killing the lock-holder.
This commit is contained in:
haozhe.yang 2026-06-05 18:23:30 +08:00
parent c03fac8246
commit 44e85af405
5 changed files with 262 additions and 4 deletions

View file

@ -57,6 +57,7 @@
"dev": "node scripts/dev.mjs",
"dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
"dev:daemon": "tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts daemon",
"dev:daemon:restart": "node scripts/dev-daemon-restart.mjs",
"dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs",
"build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs",
"dev:prod": "node dist/main.mjs",

View file

@ -0,0 +1,125 @@
#!/usr/bin/env node
// Press-Enter-to-restart wrapper for the daemon. No file watcher.
//
// Spawns `tsx ./src/main.ts daemon …extraArgs` once, then on each newline read
// from stdin SIGTERMs the child and respawns after it has cleanly exited.
// SIGTERM triggers the daemon's own `shutdown()` (apps/kimi-code/src/cli/sub/daemon.ts)
// which releases the port lock + closes WS conns before exit, so a fresh
// start can re-acquire 7878 without a stale-lock fight.
//
// CLI args after `--` (or any extras) are passed straight through, so:
// pnpm dev:daemon:restart -- --host 0.0.0.0 --port 7878 --log-level debug
// is equivalent to the dev:daemon script with that arg list, but with the
// restart loop on top.
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const APP_ROOT = resolve(SCRIPT_DIR, '..');
const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
const cliArgs = process.argv.slice(2);
if (cliArgs[0] === '--') cliArgs.shift();
const tsxArgs = [
'--tsconfig',
'./tsconfig.dev.json',
'--import',
'../../build/register-raw-text-loader.mjs',
'./src/main.ts',
'daemon',
...cliArgs,
];
let child = null;
let restarting = false;
let shuttingDown = false;
let killTimer = null;
function start() {
console.error('[dev:daemon:restart] starting daemon…');
child = spawn(tsxBin, tsxArgs, {
cwd: APP_ROOT,
env: process.env,
// Daemon does not read stdin; keep ours free for the Enter trigger.
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('error', (err) => {
console.error(`[dev:daemon:restart] spawn error: ${err.message}`);
});
child.on('exit', (code, signal) => {
if (killTimer !== null) {
clearTimeout(killTimer);
killTimer = null;
}
const prev = child;
child = null;
if (shuttingDown) {
process.exit(code ?? 0);
return;
}
if (restarting) {
restarting = false;
start();
return;
}
// Daemon died on its own (port conflict, runtime error, etc.). Stay alive
// so the user can fix the issue and press Enter to retry.
const tag = signal !== null ? `signal=${signal}` : `code=${code}`;
console.error(
`[dev:daemon:restart] daemon exited (${tag}). Press Enter to restart, Ctrl+C to quit.`,
);
void prev; // silence unused warning
});
}
function restart() {
if (shuttingDown) return;
if (child === null) {
// Previous run already exited; just spin up a new one.
start();
return;
}
if (restarting) return; // debounce — multiple Enters during shutdown collapse
restarting = true;
console.error('[dev:daemon:restart] restarting…');
child.kill('SIGTERM');
// Safety net: if the child ignores SIGTERM, force-kill after 5s so the
// restart loop doesn't wedge.
killTimer = setTimeout(() => {
if (child !== null && child.exitCode === null && child.signalCode === null) {
console.error('[dev:daemon:restart] SIGTERM timed out, sending SIGKILL');
child.kill('SIGKILL');
}
}, 5000);
}
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
// Any newline (Enter on most terminals) triggers a restart. Empty Enter is
// the canonical signal; typing `r<Enter>` works too.
if (chunk.includes('\n') || chunk.includes('\r')) {
restart();
}
});
const onShutdownSignal = (signal) => {
if (shuttingDown) return;
shuttingDown = true;
if (child !== null) {
child.kill(signal);
// Give the daemon a moment to flush logs / release the lock.
setTimeout(() => process.exit(0), 1000).unref();
} else {
process.exit(0);
}
};
process.on('SIGINT', () => onShutdownSignal('SIGINT'));
process.on('SIGTERM', () => onShutdownSignal('SIGTERM'));
start();

View file

@ -9,6 +9,8 @@ import type { Command } from 'commander';
import { startDaemon, type DaemonLogLevel } from '@moonshot-ai/daemon';
import { createKimiCodeHostIdentity, getVersion } from '../version';
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 7878;
const DEFAULT_LOG_LEVEL: DaemonLogLevel = 'info';
@ -44,7 +46,24 @@ export function registerDaemonCommand(parent: Command): void {
const port = parsePort(opts.port);
const logLevel = parseLogLevel(opts.logLevel);
const running = await startDaemon({ host, port, logLevel });
// Identify this process to the managed Kimi-for-Coding endpoint
// as a real Coding Agent — same `kimi-code-cli/<ver>` UA + X-Msh-*
// device-identity headers the in-process TUI path sends via
// `createKimiHarness`. Without this the upstream returns 40340
// ("only available for Coding Agents such as Kimi CLI, …")
// because HarnessBridge would otherwise forward fetch's default
// User-Agent. `HarnessBridge` reads `identity.version` for both
// the headers and KimiCore's `appVersion`, so we don't need to
// pass `appVersion` separately.
const version = getVersion();
const running = await startDaemon({
host,
port,
logLevel,
bridgeOptions: {
identity: createKimiCodeHostIdentity(version),
},
});
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
running.logger.info({ signal }, 'daemon shutting down');

View file

@ -40,6 +40,10 @@ import {
type OAuthTokenProviderResolver,
type SDKAPI,
} from '@moonshot-ai/agent-core';
import {
createKimiDefaultHeaders,
type KimiHostIdentity,
} from '@moonshot-ai/kimi-code-oauth';
import { KimiAuthFacade } from '@moonshot-ai/kimi-code-sdk';
import { BridgeClientAPI } from './bridge-client-api';
@ -48,9 +52,22 @@ import { IEventBus } from '../interfaces/event-bus';
import { IQuestionBroker } from '../interfaces/question-broker';
export interface HarnessBridgeOptions extends KimiCoreOptions {
// Future per-bridge knobs (e.g. logger handle) land here. For W3 the bridge
// forwards every option to `KimiCore` verbatim; daemon-specific extras
// (request_id prefix, audit hooks, etc.) get added by W4/Chain wiring.
/**
* Host identity (product name + version). When set and
* `kimiRequestHeaders` is omitted, the bridge default-wires
* `createKimiDefaultHeaders({ homeDir, ...identity })` into KimiCore so
* upstream sees `User-Agent: <product>/<version>` + `X-Msh-Platform: …`.
* Without this, the managed Kimi-for-Coding endpoint rejects requests
* with 40340 ("only available for Coding Agents") because the default
* fetch User-Agent doesn't match any known coding-agent product.
*
* `identity.version` also feeds `appVersion` so session records carry
* the host CLI version same wiring `SDKRpcClient` does in node-sdk.
*
* Callers can still pass explicit `kimiRequestHeaders` (or `appVersion`)
* to override; the explicit values win.
*/
readonly identity?: KimiHostIdentity;
}
/**
@ -143,10 +160,32 @@ export class HarnessBridge extends Disposable implements IHarnessBridge {
options.resolveOAuthTokenProvider ??
HarnessBridge._defaultOAuthTokenResolver(options);
// Default-wire the Kimi request headers (User-Agent + X-Msh-* device
// identity). Without this, KimiCore's outbound fetch carries the
// default Node fetch User-Agent and the managed Kimi-for-Coding
// endpoint rejects with 40340 ("only available for Coding Agents
// such as Kimi CLI, Claude Code, …"). Mirrors what `SDKRpcClient`
// does for the in-process TUI path (node-sdk's sdk-rpc-client.ts).
// Caller-supplied `kimiRequestHeaders` always wins; absent that, we
// synthesize from `options.identity`. Hosts that pass neither
// (no identity, no headers) still construct — preserves the W3
// contract — but their requests will trip the 40340 guard.
const kimiRequestHeaders: Record<string, string> | undefined =
options.kimiRequestHeaders ??
HarnessBridge._defaultKimiRequestHeaders(options);
// `appVersion` flows into Session records (`app_version`) and tool
// call ctx. Prefer explicit > identity.version so callers can pin
// a different value if they need to.
const appVersion: string | undefined =
options.appVersion ?? options.identity?.version;
// 2. Construct the core. KimiCore's ctor wires itself into `coreRpc` and
// exposes `this.sdk: Promise<SDKRPC>` for the reverse direction.
this._core = new KimiCore(coreRpc, {
...options,
kimiRequestHeaders,
appVersion,
resolveOAuthTokenProvider,
});
@ -237,4 +276,33 @@ export class HarnessBridge extends Disposable implements IHarnessBridge {
const facade = new KimiAuthFacade({ homeDir, configPath });
return facade.resolveOAuthTokenProvider;
}
/**
* Build the default `kimiRequestHeaders` from `options.identity` so the
* outbound `User-Agent` + device-identity headers identify this process
* as a real Coding Agent host (e.g. `kimi-code-cli/<ver>`). Without
* these, the managed Kimi-for-Coding endpoint rejects with 40340.
*
* Returns `undefined` when no identity is provided preserves the
* pre-fix W3 contract for hosts that pass headers explicitly via
* `options.kimiRequestHeaders` (or for legacy callers / tests that
* don't talk to the managed endpoint at all).
*
* `homeDir` resolution matches KimiCore's so the per-device id (minted
* + cached at `<homeDir>/device_id` on first call) lives in the same
* root as everything else KimiCore touches.
*
* Exposed as `static` so tests can assert the wiring without booting
* the bridge.
*/
static _defaultKimiRequestHeaders(
options: HarnessBridgeOptions,
): Record<string, string> | undefined {
if (options.identity === undefined) return undefined;
const homeDir = resolveKimiHome(options.homeDir);
return createKimiDefaultHeaders({
homeDir,
...options.identity,
});
}
}

View file

@ -220,6 +220,51 @@ describe('HarnessBridge direct construction (W3.2)', () => {
expect(tokenProvider).toBeDefined();
expect(typeof tokenProvider?.getAccessToken).toBe('function');
});
// Regression: prior to the identity-wiring fix the bridge never forwarded
// `kimiRequestHeaders` into KimiCore — the daemon-hosted KimiCore made
// upstream fetches with the Node default User-Agent, and the managed
// Kimi-for-Coding endpoint rejected with 40340 ("only available for
// Coding Agents such as Kimi CLI, …"). The in-process TUI path
// (`createKimiHarness`) was unaffected because `SDKRpcClient` already
// built these headers from `identity`. Lock down that the bridge does
// the same when given an `identity`.
it('default-wires kimiRequestHeaders from identity when caller omits headers', () => {
const headers = HarnessBridge._defaultKimiRequestHeaders({
homeDir: tmpHome,
identity: { userAgentProduct: 'kimi-code-cli', version: '9.9.9' },
});
expect(headers).toBeDefined();
expect(headers!['User-Agent']).toMatch(/^kimi-code-cli\/9\.9\.9/);
expect(headers!['X-Msh-Platform']).toBe('kimi_code_cli');
expect(headers!['X-Msh-Version']).toBe('9.9.9');
// `createKimiDeviceId` mints + caches a per-machine UUID under
// `<homeDir>/device_id`. Assert the header exists (UUID shape, not a
// literal value — we tmp-isolate the home, so the value differs every
// run).
expect(headers!['X-Msh-Device-Id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
});
it('returns undefined headers when no identity is provided (back-compat)', () => {
const headers = HarnessBridge._defaultKimiRequestHeaders({ homeDir: tmpHome });
expect(headers).toBeUndefined();
});
it('caller-supplied kimiRequestHeaders win over identity-derived defaults', () => {
// Sanity: when both are present the bridge ctor takes
// `options.kimiRequestHeaders` first. We can't observe the KimiCore
// ctor arg directly without exposing it, so we just lock down the
// helper's precedence contract — the ctor's `??` chain depends on it.
const explicit = { 'User-Agent': 'override/1.0' };
const picked =
explicit ?? HarnessBridge._defaultKimiRequestHeaders({
homeDir: tmpHome,
identity: { userAgentProduct: 'kimi-code-cli', version: '9.9.9' },
});
expect(picked).toBe(explicit);
});
});
describe('defaultServicesModule() composition (W3.2)', () => {