diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 54c423e04..2140f60b1 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -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", diff --git a/apps/kimi-code/scripts/dev-daemon-restart.mjs b/apps/kimi-code/scripts/dev-daemon-restart.mjs new file mode 100644 index 000000000..83152238d --- /dev/null +++ b/apps/kimi-code/scripts/dev-daemon-restart.mjs @@ -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` 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(); diff --git a/apps/kimi-code/src/cli/sub/daemon.ts b/apps/kimi-code/src/cli/sub/daemon.ts index 169548b8b..3c77eb4f6 100644 --- a/apps/kimi-code/src/cli/sub/daemon.ts +++ b/apps/kimi-code/src/cli/sub/daemon.ts @@ -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/` 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 => { running.logger.info({ signal }, 'daemon shutting down'); diff --git a/packages/services/src/bridge/harness-bridge.ts b/packages/services/src/bridge/harness-bridge.ts index 2d810dd34..89cbc1bc0 100644 --- a/packages/services/src/bridge/harness-bridge.ts +++ b/packages/services/src/bridge/harness-bridge.ts @@ -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: /` + `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 | 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` 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/`). 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 `/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 | undefined { + if (options.identity === undefined) return undefined; + const homeDir = resolveKimiHome(options.homeDir); + return createKimiDefaultHeaders({ + homeDir, + ...options.identity, + }); + } } diff --git a/packages/services/test/bridge.test.ts b/packages/services/test/bridge.test.ts index 7f210a719..9c106fc88 100644 --- a/packages/services/test/bridge.test.ts +++ b/packages/services/test/bridge.test.ts @@ -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 + // `/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)', () => {