mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-27 01:24:46 +00:00
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:
parent
c03fac8246
commit
44e85af405
5 changed files with 262 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
125
apps/kimi-code/scripts/dev-daemon-restart.mjs
Normal file
125
apps/kimi-code/scripts/dev-daemon-restart.mjs
Normal 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();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue