feat(native,server): embed web assets and enrich lifecycle output

- collect and embed server web assets into the native SEA binary\n- add web-assets manifest and collection scripts for native builds\n- make swagger/swagger-ui registration conditional in server start\n- enrich lifecycle commands with service URL, running state, log path, and notes\n- add --no-open flag to control auto-opening web UI after install\n- introduce ServiceUnavailableError and ProgramServiceManager\n- update service managers (launchd, systemd, schtasks) with new status fields
This commit is contained in:
haozhe.yang 2026-06-11 20:41:10 +08:00
parent 657430f721
commit 5629f91dca
26 changed files with 1061 additions and 72 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/server": patch
---
Embed server web assets in the native binary and make installed server lifecycle output show the web URL and service state.

View file

@ -16,6 +16,7 @@ import {
nativeSeaConfigPath,
targetTriple,
} from './paths.mjs';
import { collectWebAssets, webAssetManifestKey } from './web-assets.mjs';
async function ensureBundleExists() {
try {
@ -31,13 +32,19 @@ async function writeSeaConfig(target) {
appRoot,
target,
});
const web = await collectWebAssets({ appRoot, target });
const manifestPath = resolve(nativeManifestDir(target), 'manifest.json');
const webManifestPath = resolve(nativeIntermediatesDir(), 'web-assets', target, 'manifest.json');
await mkdir(dirname(manifestPath), { recursive: true });
await mkdir(dirname(webManifestPath), { recursive: true });
await writeFile(manifestPath, manifestJson);
await writeFile(webManifestPath, web.manifestJson);
const seaAssets = {
[nativeAssetManifestKey(target)]: manifestPath,
[webAssetManifestKey(target)]: webManifestPath,
...assets,
...web.assets,
};
const config = {
main: nativeJsBundlePath(),
@ -55,6 +62,9 @@ async function writeSeaConfig(target) {
for (const line of nativeAssetSummary(manifest)) {
console.log(`- ${line}`);
}
console.log(
`Collected web assets for ${web.manifest.target}: ${web.manifest.files.length} files`,
);
}
export async function runSeaBlobStep() {

View file

@ -24,6 +24,7 @@ const optionalRuntimeRequires = new Set([
]);
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
const handledNativeRuntimeRequires = new Set(['koffi']);
const disabledProductionDynamicImports = new Set(['@fastify/swagger', '@fastify/swagger-ui']);
function isAllowedSpecifier(specifier) {
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
@ -64,6 +65,7 @@ for (const line of executableLines()) {
errors.push(`relative dynamic import remains: ${specifier}`);
continue;
}
if (disabledProductionDynamicImports.has(specifier)) continue;
if (!isAllowedSpecifier(specifier)) {
errors.push(`external dynamic import remains: ${specifier}`);
}

View file

@ -1,4 +1,5 @@
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
export const WEB_ASSET_MANIFEST_VERSION = 1;
export function buildManifestKey(target) {
return `native/${target}/manifest.json`;
@ -11,3 +12,11 @@ export function isManifestVersionSupported(version) {
export function buildAssetKey(target, packageRoot, relativePath) {
return `native/${target}/${packageRoot}/${relativePath}`;
}
export function buildWebManifestKey(target) {
return `web/${target}/manifest.json`;
}
export function buildWebAssetKey(target, relativePath) {
return `web/${target}/dist-web/${relativePath}`;
}

View file

@ -0,0 +1,118 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, relative, resolve } from 'node:path';
import {
WEB_ASSET_MANIFEST_VERSION,
buildWebAssetKey,
buildWebManifestKey,
} from './manifest.mjs';
export { WEB_ASSET_MANIFEST_VERSION };
const WEB_ASSETS_DIR = 'dist-web';
function toPosixPath(path) {
return path.split('\\').join('/');
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
async function listFiles(root) {
const files = [];
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(path);
continue;
}
if (entry.isFile()) {
files.push(path);
}
}
}
await walk(root);
return files;
}
async function assertBuiltAssetRoot({ assetRoot, requiredFile, message }) {
const requiredPath = join(assetRoot, requiredFile);
try {
const info = await stat(requiredPath);
if (!info.isFile()) {
throw new Error(`${requiredFile} is not a file`);
}
} catch {
throw new Error(message);
}
}
export function webAssetManifestKey(target) {
return buildWebManifestKey(target);
}
export function webAssetKey(target, relativePath) {
return buildWebAssetKey(target, relativePath);
}
async function collectAssetRoot({
appRoot,
target,
root,
requiredFile,
missingMessage,
assetKey,
}) {
const assetRoot = resolve(appRoot, ...root.split('/'));
await assertBuiltAssetRoot({ assetRoot, requiredFile, message: missingMessage });
const files = (await listFiles(assetRoot)).sort((a, b) => a.localeCompare(b));
const manifestFiles = [];
const assets = {};
for (const file of files) {
if (!existsSync(file)) continue;
const bytes = await readFile(file);
const relativePath = toPosixPath(relative(assetRoot, file));
const key = assetKey(target, relativePath);
manifestFiles.push({
assetKey: key,
relativePath,
sha256: sha256(bytes),
});
assets[key] = file;
}
const manifest = {
version: WEB_ASSET_MANIFEST_VERSION,
target,
root,
files: manifestFiles,
};
return {
manifest,
manifestJson: `${JSON.stringify(manifest, null, 2)}\n`,
assets,
};
}
export async function collectWebAssets({ appRoot, target }) {
const buildCommand =
'pnpm --filter @moonshot-ai/kimi-web run build && pnpm --filter @moonshot-ai/kimi-code run build';
return collectAssetRoot({
appRoot,
target,
root: WEB_ASSETS_DIR,
requiredFile: 'index.html',
missingMessage: `Kimi web build output was not found at ${resolve(appRoot, WEB_ASSETS_DIR)}. Run \`${buildCommand}\` before building native SEA assets. App root: ${appRoot}`,
assetKey: webAssetKey,
});
}

View file

@ -11,6 +11,7 @@
import type { Command } from 'commander';
import {
ServiceUnavailableError,
ServiceUnsupportedError,
resolveServiceManager,
type InstallArgs,
@ -18,12 +19,15 @@ import {
type ServiceStatus,
} from '@moonshot-ai/server';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import {
DEFAULT_LOG_LEVEL,
DEFAULT_SERVER_HOST,
DEFAULT_SERVER_PORT,
parseLogLevel,
parsePort,
serverOrigin,
VALID_LOG_LEVELS,
} from './shared';
@ -32,6 +36,7 @@ export interface InstallCliOptions {
port?: string;
logLevel?: string;
force?: boolean;
open?: boolean;
json?: boolean;
}
@ -41,12 +46,14 @@ export interface JsonCliOptions {
export interface LifecycleCommandDeps {
resolveManager(): ServiceManager;
openUrl(url: string): void;
stdout: Pick<NodeJS.WriteStream, 'write'>;
stderr: Pick<NodeJS.WriteStream, 'write'>;
}
const DEFAULT_DEPS: LifecycleCommandDeps = {
resolveManager: resolveServiceManager,
openUrl: defaultOpenUrl,
stdout: process.stdout,
stderr: process.stderr,
};
@ -64,6 +71,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
DEFAULT_LOG_LEVEL,
)
.option('--force', 'Reinstall and overwrite if already installed', false)
.option('--no-open', 'Do not open the web UI after install.', true)
.option('--json', 'Output JSON', false)
.action(async (opts: InstallCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
@ -74,7 +82,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
force: opts.force === true,
};
const result = await mgr.install(args);
return {
const status = await readStatus(mgr);
const enriched = withStatusDetails({
ok: true,
action: 'install',
status: result.status,
@ -82,7 +91,11 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
unitPath: result.unitPath,
taskName: result.taskName,
message: result.message,
};
}, status, args);
if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') {
deps.openUrl(enriched.url);
}
return enriched;
});
});
@ -104,7 +117,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.start();
return { ok: result.ok, action: 'start', message: result.message };
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status);
});
});
@ -126,7 +140,8 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const result = await mgr.restart();
return { ok: result.ok, action: 'restart', message: result.message };
const status = await readStatus(mgr);
return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status);
});
});
@ -137,7 +152,7 @@ export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps
.action(async (opts: JsonCliOptions) => {
await runLifecycle(deps, opts.json === true, async (mgr) => {
const status: ServiceStatus = await mgr.status();
return { ok: true, action: 'status', ...status };
return withStatusDetails({ ok: true, action: 'status', ...status }, status);
});
});
}
@ -156,10 +171,10 @@ async function runLifecycle(
}
deps.stdout.write(formatHuman(result));
} catch (error) {
if (error instanceof ServiceUnsupportedError) {
if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) {
const payload = {
ok: false,
action: 'unsupported',
action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported',
platform: error.platform,
message: error.message,
};
@ -187,5 +202,54 @@ function formatHuman(result: Record<string, unknown>): string {
const action = typeof rawAction === 'string' ? rawAction : 'action';
const rawMessage = result['message'];
const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : '';
return `${action}${message}\n`;
const lines = [`${action}${message}`];
const url = result['url'];
if (typeof url === 'string') lines.push(`URL: ${url}`);
const running = result['running'];
if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`);
const logPath = result['logPath'];
if (typeof logPath === 'string') lines.push(`Log: ${logPath}`);
const notes = result['notes'];
if (Array.isArray(notes)) {
for (const note of notes) {
if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`);
}
}
return `${lines.join('\n')}\n`;
}
async function readStatus(mgr: ServiceManager): Promise<ServiceStatus | undefined> {
try {
return await mgr.status();
} catch {
return undefined;
}
}
function withStatusDetails(
result: Record<string, unknown>,
status: ServiceStatus | undefined,
fallback?: { host: string; port: number },
): Record<string, unknown> & { url?: string; running?: boolean } {
const host = status?.host ?? fallback?.host;
const port = status?.port ?? fallback?.port;
const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined;
return {
...result,
url,
running: status?.running,
host,
port,
logPath: status?.logPath,
notes: status?.notes,
};
}
function formatServiceUrl(host: string, port: number): string {
return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port);
}

View file

@ -15,6 +15,7 @@ import { join } from 'node:path';
import { ServerLockedError, startServer } from '@moonshot-ai/server';
import { getNativeWebAssetsDir } from '#/native/web-assets';
import { openUrl as defaultOpenUrl } from '#/utils/open-url';
import { createKimiCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version';
@ -141,7 +142,13 @@ export async function startServerForeground(
}
function serverWebAssetsDir(): string {
return join(getHostPackageRoot(), WEB_ASSETS_DIR);
return resolveServerWebAssetsDir();
}
export function resolveServerWebAssetsDir(
nativeWebAssetsDir: string | null = getNativeWebAssetsDir(),
): string {
return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR);
}
function formatAlreadyRunning(port: number, pid: number): string {

View file

@ -0,0 +1,183 @@
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { KIMI_BUILD_INFO } from '#/cli/build-info';
import {
getNativeCacheBase,
getSeaAssetSource,
type NativeAssetSource,
} from './native-assets';
import {
WEB_ASSET_MANIFEST_VERSION as MANIFEST_VERSION,
buildWebManifestKey,
} from '../../scripts/native/manifest.mjs';
export const WEB_ASSET_MANIFEST_VERSION = MANIFEST_VERSION;
export interface WebAssetFile {
readonly assetKey: string;
readonly relativePath: string;
readonly sha256: string;
}
export interface WebAssetManifest {
readonly version: typeof WEB_ASSET_MANIFEST_VERSION;
readonly target: string;
readonly root: 'dist-web';
readonly files: readonly WebAssetFile[];
}
export type WebAssetSource = NativeAssetSource;
export interface WebAssetOptions {
readonly source?: WebAssetSource | null;
readonly manifest?: WebAssetManifest | null;
readonly cacheBase?: string;
readonly env?: NodeJS.ProcessEnv;
readonly platform?: NodeJS.Platform;
readonly homeDir?: string;
readonly version?: string;
}
type RawWebAssetManifest = Omit<WebAssetManifest, 'version' | 'root'> & {
readonly version: number;
readonly root: string;
};
function currentTarget(): string {
return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`;
}
function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer {
if (Buffer.isBuffer(value)) return value;
if (typeof value === 'string') return Buffer.from(value);
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
return Buffer.from(value);
}
function sha256(bytes: Buffer | Uint8Array | string): string {
return createHash('sha256').update(bytes).digest('hex');
}
function sanitizeSegment(value: string): string {
const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
return sanitized.length > 0 ? sanitized : 'unknown';
}
function readFileSha256(path: string): string | null {
try {
return sha256(readFileSync(path));
} catch {
return null;
}
}
function ensureFile(path: string, bytes: Buffer, expectedSha256: string): void {
if (readFileSha256(path) === expectedSha256) return;
mkdirSync(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
writeFileSync(tempPath, bytes, { mode: 0o644 });
try {
renameSync(tempPath, path);
return;
} catch {
if (readFileSha256(path) === expectedSha256) {
rmSync(tempPath, { force: true });
return;
}
}
try {
rmSync(path, { force: true });
renameSync(tempPath, path);
} catch (error) {
rmSync(tempPath, { force: true });
if (readFileSha256(path) === expectedSha256) return;
throw error;
}
}
function assertSafeRelativePath(relativePath: string): void {
if (
relativePath.length === 0 ||
relativePath.startsWith('/') ||
relativePath.includes('\\') ||
relativePath.split('/').includes('..') ||
/^[A-Za-z]:/.test(relativePath)
) {
throw new Error(`Invalid web asset relative path: ${relativePath}`);
}
}
export function webAssetManifestKey(target: string = currentTarget()): string {
return buildWebManifestKey(target);
}
export function getEmbeddedWebAssetManifest(
source: WebAssetSource | null = getSeaAssetSource(),
target = currentTarget(),
): WebAssetManifest | null {
if (source === null) return null;
const key = webAssetManifestKey(target);
if (!source.getAssetKeys().includes(key)) return null;
const raw = source.getRawAsset(key);
const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawWebAssetManifest;
if (manifest.version !== WEB_ASSET_MANIFEST_VERSION) {
throw new Error(`Unsupported web asset manifest version: ${manifest.version}`);
}
if (manifest.target !== target) {
throw new Error(`Web asset manifest target mismatch: ${manifest.target} !== ${target}`);
}
if (manifest.root !== 'dist-web') {
throw new Error(`Unsupported web asset root: ${manifest.root}`);
}
return manifest as WebAssetManifest;
}
export function getWebAssetCacheRoot(
manifest: WebAssetManifest,
options: WebAssetOptions = {},
): string {
const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev');
const manifestHash = sha256(JSON.stringify(manifest));
return join(
getNativeCacheBase({
cacheBase: options.cacheBase,
env: options.env,
platform: options.platform,
homeDir: options.homeDir,
}),
'web',
version,
sanitizeSegment(manifest.target),
manifestHash,
manifest.root,
);
}
export function getNativeWebAssetsDir(options: WebAssetOptions = {}): string | null {
const source = options.source ?? getSeaAssetSource();
if (source === null) return null;
const manifest = options.manifest ?? getEmbeddedWebAssetManifest(source, currentTarget());
if (manifest === null) return null;
const cacheRoot = getWebAssetCacheRoot(manifest, options);
for (const file of manifest.files) {
assertSafeRelativePath(file.relativePath);
const bytes = toBuffer(source.getRawAsset(file.assetKey));
const actualSha256 = sha256(bytes);
if (actualSha256 !== file.sha256) {
throw new Error(
`Web asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`,
);
}
ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256);
}
return cacheRoot;
}

View file

@ -14,6 +14,7 @@ import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { registerServerCommand } from '#/cli/sub/server';
import { addLifecycleCommands } from '#/cli/sub/server/lifecycle';
function makeProgram(): Command {
// `commander` exitOverride avoids killing the test runner when --help/error fires.
@ -54,7 +55,7 @@ describe('kimi server', () => {
expect(longs).toContain('--open');
});
it('`server install` exposes --host, --port, --log-level, --force, --json', () => {
it('`server install` exposes --host, --port, --log-level, --force, --no-open, --json', () => {
const program = makeProgram();
const install = program.commands
.find((c) => c.name() === 'server')
@ -65,6 +66,7 @@ describe('kimi server', () => {
expect(longs).toContain('--port');
expect(longs).toContain('--log-level');
expect(longs).toContain('--force');
expect(longs).toContain('--no-open');
expect(longs).toContain('--json');
});
@ -95,6 +97,175 @@ describe('`kimi server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported
});
});
describe('`kimi server` lifecycle handles unavailable service managers', () => {
it('prints a friendly JSON error and exits 2', async () => {
const { ServiceUnavailableError } = await import('@moonshot-ai/server');
const program = new Command('kimi').exitOverride();
const server = program.command('server');
let stdout = '';
let stderr = '';
const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => {
throw new Error(`process.exit(${String(code)})`);
}) as typeof process.exit);
addLifecycleCommands(server, {
resolveManager: () => ({
install: async () => {
throw new ServiceUnavailableError(
'linux',
'systemd --user is not available in this environment.',
);
},
uninstall: async () => ({ ok: true, message: 'unused' }),
start: async () => ({ ok: true, message: 'unused' }),
stop: async () => ({ ok: true, message: 'unused' }),
restart: async () => ({ ok: true, message: 'unused' }),
status: async () => ({ platform: 'linux', installed: false, running: false }),
}),
openUrl: vi.fn(),
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: {
write(chunk: string | Uint8Array) {
stderr += String(chunk);
return true;
},
},
});
await expect(
program.parseAsync(['node', 'kimi', 'server', 'install', '--json']),
).rejects.toThrow('process.exit(2)');
exit.mockRestore();
expect(stderr).toBe('');
expect(JSON.parse(stdout)).toMatchObject({
ok: false,
action: 'unavailable',
platform: 'linux',
message: expect.stringContaining('server run --host 0.0.0.0'),
});
});
});
describe('`kimi server` lifecycle output', () => {
it('install passes --force/--port, prints the URL, and opens it when running', async () => {
const program = new Command('kimi').exitOverride();
const server = program.command('server');
let stdout = '';
let stderr = '';
let installArgs: unknown;
const openUrl = vi.fn();
addLifecycleCommands(server, {
resolveManager: () => ({
install: async (args) => {
installArgs = args;
return {
status: 'replaced',
message: 'Kimi server LaunchAgent replaced at /tmp/kimi.plist (port 9999).',
plistPath: '/tmp/kimi.plist',
};
},
uninstall: async () => ({ ok: true, message: 'unused' }),
start: async () => ({ ok: true, message: 'unused' }),
stop: async () => ({ ok: true, message: 'unused' }),
restart: async () => ({ ok: true, message: 'unused' }),
status: async () => ({
platform: 'darwin',
installed: true,
running: true,
host: '127.0.0.1',
port: 9999,
logPath: '/tmp/server.log',
label: 'ai.moonshot.kimi-server',
}),
}),
openUrl,
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: {
write(chunk: string | Uint8Array) {
stderr += String(chunk);
return true;
},
},
});
await program.parseAsync([
'node',
'kimi',
'server',
'install',
'--force',
'--port',
'9999',
]);
expect(stderr).toBe('');
expect(installArgs).toMatchObject({ port: 9999, force: true });
expect(stdout).toContain('URL: http://127.0.0.1:9999');
expect(stdout).toContain('Status: running');
expect(stdout).toContain('Log: /tmp/server.log');
expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999');
});
it('start prints URL and diagnostics when launchd did not keep the service running', async () => {
const program = new Command('kimi').exitOverride();
const server = program.command('server');
let stdout = '';
const openUrl = vi.fn();
addLifecycleCommands(server, {
resolveManager: () => ({
install: async () => ({ status: 'installed', message: 'unused' }),
uninstall: async () => ({ ok: true, message: 'unused' }),
start: async () => ({ ok: true, message: 'Kimi server started (ai.moonshot.kimi-server).' }),
stop: async () => ({ ok: true, message: 'unused' }),
restart: async () => ({ ok: true, message: 'unused' }),
status: async () => ({
platform: 'darwin',
installed: true,
running: false,
host: '127.0.0.1',
port: 7878,
logPath: '/tmp/server.log',
label: 'ai.moonshot.kimi-server',
notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'],
}),
}),
openUrl,
stdout: {
write(chunk: string | Uint8Array) {
stdout += String(chunk);
return true;
},
},
stderr: {
write() {
return true;
},
},
});
await program.parseAsync(['node', 'kimi', 'server', 'start']);
expect(stdout).toContain('URL: http://127.0.0.1:7878');
expect(stdout).toContain('Status: not running');
expect(stdout).toContain('launchd state: spawn scheduled');
expect(stdout).toContain('last exit code: 78 EX_CONFIG');
expect(openUrl).not.toHaveBeenCalled();
});
});
describe('`kimi server` does not register a legacy `daemon` command', () => {
it('hard-deletes the old name', () => {
const program = makeProgram();
@ -120,5 +291,17 @@ describe('shared parsers stay strict', () => {
});
});
describe('server web asset directory resolution', () => {
it('uses extracted SEA web assets when available', async () => {
const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run');
expect(resolveServerWebAssetsDir('/cache/kimi/dist-web')).toBe('/cache/kimi/dist-web');
});
it('falls back to package dist-web outside SEA mode', async () => {
const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run');
expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/);
});
});
// Silence vi import for cases where the file is built before tests reference vi.
void vi;

View file

@ -0,0 +1,113 @@
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
getNativeWebAssetsDir,
getWebAssetCacheRoot,
WEB_ASSET_MANIFEST_VERSION,
type WebAssetManifest,
type WebAssetSource,
} from '#/native/web-assets';
function sha256(bytes: Buffer | string): string {
return createHash('sha256').update(bytes).digest('hex');
}
function fakeWebAssets(files: Record<string, string>): {
manifest: WebAssetManifest;
source: WebAssetSource;
} {
const manifest: WebAssetManifest = {
version: WEB_ASSET_MANIFEST_VERSION,
target: 'test-target',
root: 'dist-web',
files: Object.entries(files).map(([relativePath, content]) => ({
assetKey: `web/test-target/dist-web/${relativePath}`,
relativePath,
sha256: sha256(content),
})),
};
const assets = new Map<string, Buffer>([
['web/test-target/manifest.json', Buffer.from(JSON.stringify(manifest))],
...Object.entries(files).map(([relativePath, content]) => [
`web/test-target/dist-web/${relativePath}`,
Buffer.from(content),
] as const),
]);
return {
manifest,
source: {
getAssetKeys: () => [...assets.keys()],
getRawAsset: (assetKey) => {
const asset = assets.get(assetKey);
if (asset === undefined) throw new Error(`missing test asset: ${assetKey}`);
return asset;
},
},
};
}
describe('web assets', () => {
it('extracts embedded web assets into a dist-web cache directory', () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-runtime-'));
try {
const { manifest, source } = fakeWebAssets({
'index.html': '<div id="app"></div>\n',
'assets/app.js': 'console.log("ok");\n',
});
const webDir = getNativeWebAssetsDir({
cacheBase: dir,
manifest,
source,
version: 'test',
});
expect(webDir).toBe(getWebAssetCacheRoot(manifest, { cacheBase: dir, version: 'test' }));
expect(readFileSync(join(webDir ?? '', 'index.html'), 'utf-8')).toBe('<div id="app"></div>\n');
expect(readFileSync(join(webDir ?? '', 'assets', 'app.js'), 'utf-8')).toBe(
'console.log("ok");\n',
);
expect(existsSync(join(dir, 'web', 'test', 'test-target'))).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('repairs corrupted extracted files on the next lookup', () => {
const dir = mkdtempSync(join(tmpdir(), 'kimi-web-assets-repair-'));
try {
const { manifest, source } = fakeWebAssets({
'index.html': '<html></html>',
});
const webDir = getNativeWebAssetsDir({
cacheBase: dir,
manifest,
source,
version: 'test',
});
writeFileSync(join(webDir ?? '', 'index.html'), 'broken');
const repairedDir = getNativeWebAssetsDir({
cacheBase: dir,
manifest,
source,
version: 'test',
});
expect(repairedDir).toBe(webDir);
expect(readFileSync(join(repairedDir ?? '', 'index.html'), 'utf-8')).toBe('<html></html>');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('returns null when no SEA web asset source is available', () => {
expect(getNativeWebAssetsDir({ source: null })).toBeNull();
});
});

View file

@ -0,0 +1,89 @@
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
collectWebAssets,
webAssetManifestKey,
WEB_ASSET_MANIFEST_VERSION,
} from '../../../scripts/native/web-assets.mjs';
function sha256(bytes: Buffer | string): string {
return createHash('sha256').update(bytes).digest('hex');
}
describe('collectWebAssets', () => {
it('collects dist-web files into deterministic SEA asset keys', async () => {
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-build-'));
try {
mkdirSync(join(appRoot, 'dist-web', 'assets'), { recursive: true });
writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<div id="app"></div>\n');
writeFileSync(join(appRoot, 'dist-web', 'assets', 'app.js'), 'console.log("ok");\n');
const { manifest, manifestJson, assets } = await collectWebAssets({
appRoot,
target: 'test-target',
});
expect(webAssetManifestKey('test-target')).toBe('web/test-target/manifest.json');
expect(manifest).toEqual({
version: WEB_ASSET_MANIFEST_VERSION,
target: 'test-target',
root: 'dist-web',
files: [
{
assetKey: 'web/test-target/dist-web/assets/app.js',
relativePath: 'assets/app.js',
sha256: sha256('console.log("ok");\n'),
},
{
assetKey: 'web/test-target/dist-web/index.html',
relativePath: 'index.html',
sha256: sha256('<div id="app"></div>\n'),
},
],
});
expect(JSON.parse(manifestJson) as unknown).toEqual(manifest);
expect(assets).toEqual({
'web/test-target/dist-web/assets/app.js': join(appRoot, 'dist-web', 'assets', 'app.js'),
'web/test-target/dist-web/index.html': join(appRoot, 'dist-web', 'index.html'),
});
} finally {
rmSync(appRoot, { recursive: true, force: true });
}
});
it('fails clearly when dist-web has not been built', async () => {
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-missing-'));
try {
await expect(collectWebAssets({ appRoot, target: 'test-target' })).rejects.toThrow(
/Kimi web build output was not found/,
);
} finally {
rmSync(appRoot, { recursive: true, force: true });
}
});
it('keeps manifest JSON parseable and stable', async () => {
const appRoot = mkdtempSync(join(tmpdir(), 'kimi-web-assets-json-'));
try {
mkdirSync(join(appRoot, 'dist-web'), { recursive: true });
writeFileSync(join(appRoot, 'dist-web', 'index.html'), '<html></html>');
const { manifestJson } = await collectWebAssets({ appRoot, target: 'test-target' });
expect(readFileSync(join(appRoot, 'dist-web', 'index.html'), 'utf-8')).toBe('<html></html>');
expect(manifestJson.endsWith('\n')).toBe(true);
expect(JSON.parse(manifestJson)).toMatchObject({
version: WEB_ASSET_MANIFEST_VERSION,
target: 'test-target',
root: 'dist-web',
});
} finally {
rmSync(appRoot, { recursive: true, force: true });
}
});
});

View file

@ -17,10 +17,15 @@ const builtins = new Set([
...builtinModules.map((name) => `node:${name}`),
]);
const optionalNativeDependencies = new Set(['cpu-features']);
const nativeExternalDependencies = new Set([
...optionalNativeDependencies,
'@fastify/swagger',
'@fastify/swagger-ui',
]);
function shouldAlwaysBundle(id: string): boolean {
if (builtins.has(id) || id.startsWith('node:')) return false;
if (optionalNativeDependencies.has(id)) return false;
if (nativeExternalDependencies.has(id)) return false;
return true;
}
@ -53,7 +58,7 @@ export default defineConfig({
},
deps: {
alwaysBundle: shouldAlwaysBundle,
neverBundle: [...optionalNativeDependencies],
neverBundle: [...nativeExternalDependencies],
onlyBundle: false,
},
outputOptions: {

View file

@ -11,7 +11,7 @@ export type {
export { acquireLock, DEFAULT_LOCK_PATH, DEFAULT_LOCK_DIR } from './lock';
export type { AcquireLockOptions, AcquireLockResult, LockContents } from './lock';
export { resolveServiceManager, ServiceUnsupportedError } from './svc';
export { resolveServiceManager, ServiceUnavailableError, ServiceUnsupportedError } from './svc';
export type {
InstallArgs,
InstallResult,

View file

@ -39,10 +39,12 @@ import {
} from '@moonshot-ai/services';
import { ErrorCode } from '@moonshot-ai/protocol';
import Fastify from 'fastify';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';
import { promises as fspPromises } from 'node:fs';
import { sep as nodePathSep, relative as nodePathRelativeNative } from 'node:path';
import {
join as nodePathJoin,
sep as nodePathSep,
relative as nodePathRelativeNative,
} from 'node:path';
import { installErrorHandler } from './error-handler';
import { transformOpenApiDocument } from './openapi/transforms';
@ -83,6 +85,10 @@ export interface ServerStartOptions {
webAssetsDir?: string;
swagger?: boolean;
swaggerUiAssetsDir?: string;
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
}
@ -123,39 +129,69 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
installErrorHandler(app);
const serverVersion = getServerVersion();
await app.register(swagger, {
openapi: {
info: {
title: 'Kimi Code Server API',
description:
'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.',
version: serverVersion,
const swaggerEnabled = opts.swagger === true;
async function registerSwagger(): Promise<void> {
const { default: swagger } = await import('@fastify/swagger');
await app.register(swagger, {
openapi: {
info: {
title: 'Kimi Code Server API',
description:
'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.',
version: serverVersion,
},
tags: [
{ name: 'meta', description: 'Server metadata' },
{ name: 'auth', description: 'Auth readiness & login state' },
{ name: 'models', description: 'Configured model aliases' },
{ name: 'providers', description: 'Configured providers' },
{ name: 'sessions', description: 'Session lifecycle' },
{ name: 'workspaces', description: 'Workspace registry + folder picker' },
{ name: 'messages', description: 'Message history' },
{ name: 'prompts', description: 'Prompt submission & abort' },
{ name: 'approvals', description: 'Approval resolution' },
{ name: 'questions', description: 'Question resolution & dismiss' },
{ name: 'tools', description: 'Tool & MCP server management' },
{ name: 'tasks', description: 'Background tasks' },
{ name: 'terminals', description: 'PTY terminal sessions' },
{ name: 'fs', description: 'Filesystem operations' },
{ name: 'files', description: 'File upload & download' },
],
},
tags: [
{ name: 'meta', description: 'Server metadata' },
{ name: 'auth', description: 'Auth readiness & login state' },
{ name: 'models', description: 'Configured model aliases' },
{ name: 'providers', description: 'Configured providers' },
{ name: 'sessions', description: 'Session lifecycle' },
{ name: 'workspaces', description: 'Workspace registry + folder picker' },
{ name: 'messages', description: 'Message history' },
{ name: 'prompts', description: 'Prompt submission & abort' },
{ name: 'approvals', description: 'Approval resolution' },
{ name: 'questions', description: 'Question resolution & dismiss' },
{ name: 'tools', description: 'Tool & MCP server management' },
{ name: 'tasks', description: 'Background tasks' },
{ name: 'terminals', description: 'PTY terminal sessions' },
{ name: 'fs', description: 'Filesystem operations' },
{ name: 'files', description: 'File upload & download' },
],
},
transformObject: (documentObject) => {
if (!('openapiObject' in documentObject)) {
return documentObject.swaggerObject;
}
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
},
});
transformObject: (documentObject) => {
if (!('openapiObject' in documentObject)) {
return documentObject.swaggerObject;
}
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
},
});
}
async function registerSwaggerUi(assetsDir: string | undefined): Promise<void> {
const { default: swaggerUi } = await import('@fastify/swagger-ui');
const logo =
assetsDir === undefined
? undefined
: {
type: 'image/svg+xml',
content: await fspPromises.readFile(nodePathJoin(assetsDir, 'logo.svg')),
};
await app.register(swaggerUi, {
routePrefix: '/documentation',
baseDir: assetsDir,
logo,
uiConfig: {
docExpansion: 'list',
deepLinking: true,
},
});
}
if (swaggerEnabled) {
await registerSwagger();
}
const envService: IEnvironmentService = {
_serviceBrand: undefined,
@ -179,13 +215,9 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
debugEndpoints: opts.debugEndpoints,
});
await app.register(swaggerUi, {
routePrefix: '/documentation',
uiConfig: {
docExpansion: 'list',
deepLinking: true,
},
});
if (swaggerEnabled) {
await registerSwaggerUi(opts.swaggerUiAssetsDir);
}
if (opts.webAssetsDir !== undefined) {
await registerWebAssetRoutes(app, opts.webAssetsDir);

View file

@ -18,6 +18,7 @@ export {
KIMI_SERVER_TASK_NAME,
} from './paths';
export {
ServiceUnavailableError,
ServiceUnsupportedError,
type InstallArgs,
type InstallResult,

View file

@ -29,6 +29,7 @@ import {
launchAgentPlistPath as defaultLaunchAgentPlistPath,
supervisorLogPath as defaultSupervisorLogPath,
} from './paths';
import { resolveSupervisorProgram } from './program';
import type {
InstallArgs,
InstallResult,
@ -55,12 +56,14 @@ export interface LaunchdManagerDeps {
const DEFAULT_DEPS: LaunchdManagerDeps = {
execLaunchctl: (args, options) => execFileUtf8('launchctl', args, options),
resolveProgram: () => process.argv[1] ?? 'kimi',
resolveProgram: () => resolveSupervisorProgram(),
plistPath: defaultLaunchAgentPlistPath,
logPath: defaultSupervisorLogPath,
guiDomain: () => defaultGuiDomain(),
};
export { resolveSupervisorProgram };
/** Construct the launchd backend. Pass `deps` overrides in tests. */
export function createLaunchdManager(
overrides: Partial<LaunchdManagerDeps> = {},
@ -217,14 +220,18 @@ export function createLaunchdManager(
};
}
const info = parseLaunchctlPrint(print.stdout);
const notes =
info.state !== undefined
? [`launchd state: ${info.state}`]
: ['launchd state: unknown'];
if (info.lastExitCode !== undefined) {
notes.push(`last exit code: ${info.lastExitCode}`);
}
return {
...base,
running: info.state === 'running' || info.pid !== undefined,
...(info.pid !== undefined ? { pid: info.pid } : {}),
notes:
info.state !== undefined
? [`launchd state: ${info.state}`]
: ['launchd state: unknown'],
notes,
};
}
@ -232,8 +239,12 @@ export function createLaunchdManager(
}
/** Pure parser for `launchctl print <domain>/<label>` output. Exported for tests. */
export function parseLaunchctlPrint(output: string): { state?: string; pid?: number } {
const result: { state?: string; pid?: number } = {};
export function parseLaunchctlPrint(output: string): {
state?: string;
pid?: number;
lastExitCode?: string;
} {
const result: { state?: string; pid?: number; lastExitCode?: string } = {};
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
const equalsIdx = line.indexOf('=');
@ -247,6 +258,8 @@ export function parseLaunchctlPrint(output: string): { state?: string; pid?: num
if (Number.isFinite(n) && n > 0) {
result.pid = n;
}
} else if ((key === 'last exit code' || key === 'last exit status') && result.lastExitCode === undefined) {
result.lastExitCode = value;
}
}
return result;

View file

@ -0,0 +1,10 @@
import { isAbsolute, resolve } from 'node:path';
export function resolveSupervisorProgram(
argv: readonly string[] = process.argv,
cwd: string = process.cwd(),
execPath: string = process.execPath,
): string {
const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath);
return isAbsolute(candidate) ? candidate : resolve(cwd, candidate);
}

View file

@ -23,6 +23,7 @@ import {
type InstallPlan,
} from './install-plan';
import { KIMI_SERVER_TASK_NAME, supervisorLogPath as defaultSupervisorLogPath } from './paths';
import { resolveSupervisorProgram } from './program';
import { buildScheduledTaskXml, parseSchtasksQuery } from './schtasks-xml';
import type {
InstallArgs,
@ -48,7 +49,7 @@ export interface SchtasksManagerDeps {
const DEFAULT_DEPS: SchtasksManagerDeps = {
execSchtasks: (args, options) =>
execFileUtf8('schtasks', args, { windowsHide: true, ...options }),
resolveProgram: () => process.argv[1] ?? 'kimi.exe',
resolveProgram: () => resolveSupervisorProgram(process.argv, process.cwd(), 'kimi.exe'),
logPath: defaultSupervisorLogPath,
writeTaskXml: defaultWriteTaskXml,
taskExists: defaultTaskExists,

View file

@ -29,7 +29,9 @@ import {
supervisorLogPath as defaultSupervisorLogPath,
systemdUnitPath as defaultSystemdUnitPath,
} from './paths';
import { resolveSupervisorProgram } from './program';
import { buildSystemdUnit, parseSystemctlShow } from './systemd-unit';
import { ServiceUnavailableError } from './types';
import type {
InstallArgs,
InstallResult,
@ -54,7 +56,7 @@ export interface SystemdManagerDeps {
const DEFAULT_DEPS: SystemdManagerDeps = {
execSystemctl: (args, options) => execFileUtf8('systemctl', ['--user', ...args], options),
resolveProgram: () => process.argv[1] ?? 'kimi',
resolveProgram: () => resolveSupervisorProgram(),
unitPath: defaultSystemdUnitPath,
logPath: defaultSupervisorLogPath,
};
@ -79,6 +81,8 @@ export function createSystemdManager(
};
}
await assertUserSystemdAvailable(deps);
writeUnit(unitPath, plan);
writeInstallPlan(plan);
@ -210,6 +214,16 @@ export function createSystemdManager(
return { install, uninstall, start, stop, restart, status };
}
async function assertUserSystemdAvailable(deps: SystemdManagerDeps): Promise<void> {
const probe = await deps.execSystemctl(['show-environment']);
if (probe.code === 0) return;
throw new ServiceUnavailableError(
'linux',
`systemd --user is not available in this environment: ${detail(probe) ?? 'systemctl --user show-environment failed'}.`,
);
}
function writeUnit(unitPath: string, plan: InstallPlan): void {
const text = buildSystemdUnit({
description: 'Kimi Code local server (managed by `kimi server install`)',

View file

@ -95,3 +95,18 @@ export class ServiceUnsupportedError extends Error {
this.platform = platform;
}
}
/** Thrown when the platform is supported but the current environment cannot host the service. */
export class ServiceUnavailableError extends Error {
override readonly name = 'ServiceUnavailableError';
readonly code = 'ESERVICE_UNAVAILABLE' as const;
readonly exitCode = 2 as const;
readonly platform: string;
constructor(platform: string, reason: string) {
super(
`${reason} Run \`kimi server run --host 0.0.0.0 --port <port>\` directly when running inside Docker or another container supervisor.`,
);
this.platform = platform;
}
}

View file

@ -157,6 +157,42 @@ describe('startServer — web assets', () => {
const health = await fetch(`${r.address}/api/v1/healthz`);
await expect(health.json()).resolves.toMatchObject({ code: 0 });
});
it('does not expose Swagger documentation by default', async () => {
const r = await startServer({
host: '127.0.0.1',
port: 0,
lockPath,
logger: silentLogger(),
coreProcessOptions: { homeDir: bridgeHome },
});
running.push(r);
const res = await fetch(`${r.address}/documentation`);
expect(res.status).toBe(404);
});
it('serves Swagger UI static assets from an explicit directory when enabled', async () => {
const staticDir = join(tmpDir, 'swagger-static');
rmSync(staticDir, { recursive: true, force: true });
mkdirSync(staticDir);
writeFileSync(join(staticDir, 'logo.svg'), '<svg id="custom-logo"></svg>', 'utf8');
const r = await startServer({
host: '127.0.0.1',
port: 0,
lockPath,
logger: silentLogger(),
coreProcessOptions: { homeDir: bridgeHome },
swagger: true,
swaggerUiAssetsDir: staticDir,
});
running.push(r);
await expect(
fetch(`${r.address}/documentation/static/logo.svg`).then((res) => res.text()),
).resolves.toBe('<svg id="custom-logo"></svg>');
});
});
describe('startServer — DI container wiring', () => {

View file

@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildLaunchAgentPlist } from '../../src/svc/launchd-plist';
import {
createLaunchdManager,
resolveSupervisorProgram,
parseLaunchctlPrint,
type LaunchdManagerDeps,
} from '../../src/svc/launchd';
@ -119,13 +120,14 @@ describe('parseLaunchctlPrint', () => {
`${'gui/501/' + KIMI_SERVER_LABEL} = {`,
'\tstate = running',
'\tpid = 4711',
'\tlast exit status = 0',
'\tlast exit code = 78: EX_CONFIG',
'\tprogram = /usr/local/bin/kimi',
'}',
].join('\n');
const parsed = parseLaunchctlPrint(sample);
expect(parsed.state).toBe('running');
expect(parsed.pid).toBe(4711);
expect(parsed.lastExitCode).toBe('78: EX_CONFIG');
});
it('returns undefined fields when keys are missing', () => {
@ -135,6 +137,12 @@ describe('parseLaunchctlPrint', () => {
});
});
describe('resolveSupervisorProgram', () => {
it('normalizes a relative executable path to an absolute path', () => {
expect(resolveSupervisorProgram(['node', './kimi'], '/tmp/kimi-bin')).toBe('/tmp/kimi-bin/kimi');
});
});
describe('launchd manager — install', () => {
it('writes the plist and bootstraps via launchctl', async () => {
const { deps, calls, plistPath } = makeDeps([{ stdout: '', stderr: '', code: 0 }], workDir);

View file

@ -21,6 +21,7 @@ import {
} from '../../src/svc/systemd-unit';
import { KIMI_SERVER_SYSTEMD_UNIT } from '../../src/svc/paths';
import { readInstallPlan, writeInstallPlan } from '../../src/svc/install-plan';
import { ServiceUnavailableError } from '../../src/svc/types';
import type { ExecOptions, ExecResult } from '../../src/svc/exec';
interface StubCall {
@ -135,6 +136,7 @@ describe('systemd manager — install', () => {
it('writes the unit, daemon-reloads, enables --now', async () => {
const { deps, calls, unitPath } = makeDeps(
[
{ stdout: '', stderr: '', code: 0 }, // show-environment
{ stdout: '', stderr: '', code: 0 }, // daemon-reload
{ stdout: '', stderr: '', code: 0 }, // enable --now
],
@ -149,9 +151,10 @@ describe('systemd manager — install', () => {
const text = readFileSync(unitPath, 'utf8');
expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --host 127.0.0.1 --port 7878 --log-level info');
expect(calls.length).toBe(2);
expect(calls[0]?.args).toEqual(['daemon-reload']);
expect(calls[1]?.args).toEqual(['enable', '--now', KIMI_SERVER_SYSTEMD_UNIT]);
expect(calls.length).toBe(3);
expect(calls[0]?.args).toEqual(['show-environment']);
expect(calls[1]?.args).toEqual(['daemon-reload']);
expect(calls[2]?.args).toEqual(['enable', '--now', KIMI_SERVER_SYSTEMD_UNIT]);
});
it('refuses to overwrite an existing install without --force', async () => {
@ -168,6 +171,7 @@ describe('systemd manager — install', () => {
it('overwrites + replaces when force=true', async () => {
const { deps, unitPath } = makeDeps(
[
{ stdout: '', stderr: '', code: 0 }, // show-environment
{ stdout: '', stderr: '', code: 0 }, // daemon-reload
{ stdout: '', stderr: '', code: 0 }, // enable --now
],
@ -183,8 +187,36 @@ describe('systemd manager — install', () => {
expect(text).toContain('ExecStart=/usr/local/bin/kimi server run --host 0.0.0.0 --port 9999 --log-level debug');
});
it('fails before writing files when user systemd is unavailable', async () => {
const { deps, calls, unitPath } = makeDeps(
[
{
stdout: '',
stderr: 'System has not been booted with systemd as init system (PID 1).',
code: 1,
},
],
workDir,
);
const mgr = createSystemdManager(deps);
await expect(
mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }),
).rejects.toBeInstanceOf(ServiceUnavailableError);
expect(calls).toEqual([{ args: ['show-environment'], options: undefined }]);
expect(existsSync(unitPath)).toBe(false);
expect(readInstallPlan()).toBeUndefined();
});
it('surfaces daemon-reload failure as a thrown error', async () => {
const { deps } = makeDeps([{ stdout: '', stderr: 'unit not loaded', code: 1 }], workDir);
const { deps } = makeDeps(
[
{ stdout: '', stderr: '', code: 0 },
{ stdout: '', stderr: 'unit not loaded', code: 1 },
],
workDir,
);
const mgr = createSystemdManager(deps);
await expect(
mgr.install({ host: '127.0.0.1', port: 7878, logLevel: 'info' }),
@ -194,6 +226,7 @@ describe('systemd manager — install', () => {
it('surfaces enable --now failure as a thrown error', async () => {
const { deps } = makeDeps(
[
{ stdout: '', stderr: '', code: 0 },
{ stdout: '', stderr: '', code: 0 },
{ stdout: '', stderr: 'unit failed to start', code: 1 },
],

View file

@ -44,6 +44,7 @@ async function bootDaemon(): Promise<RunningServer> {
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
swagger: true,
});
return server;
}

View file

@ -323,9 +323,10 @@ export class PromptService
return item;
}
await this._startPrompt(sid, state);
const item = toPromptItem(state, 'running');
this._publishSubmitted(sid, item);
await this._startPrompt(sid, state, () => {
this._publishSubmitted(sid, item);
});
return item;
}
@ -377,7 +378,11 @@ export class PromptService
return { steered: true, prompt_ids: [...promptIds] };
}
private async _startPrompt(sid: string, state: PromptState): Promise<void> {
private async _startPrompt(
sid: string,
state: PromptState,
onStarted?: () => void,
): Promise<void> {
const overridePatch = pickAgentStatePatch(state.body);
if (overridePatch !== undefined) {
await this._ensureAgentStateBootstrapped(sid);
@ -386,6 +391,7 @@ export class PromptService
this._active.set(sid, state);
const input = contentToCoreParts(state.body.content);
onStarted?.();
// Fire-and-forget. agent-core streams events via the SDK side of the
// RPC pair which lands on `BridgeClientAPI.emitEvent → IEventService.publish`.

View file

@ -114,6 +114,7 @@ interface BridgeStubOptions {
permission?: { mode: 'manual' | 'yolo' | 'auto' };
plan?: null | { id: string; content: string; path: string };
sessions?: SessionSummary[];
onPrompt?: (payload: unknown) => void | Promise<void>;
}
function makeBridge(
@ -148,6 +149,7 @@ function makeBridge(
resumeSession: vi.fn().mockResolvedValue(undefined as unknown as never),
prompt: vi.fn().mockImplementation(async (payload) => {
record.promptCalls.push(payload);
await opts.onPrompt?.(payload);
}),
steer: vi.fn().mockImplementation(async (payload) => {
record.steerCalls.push(payload);
@ -376,6 +378,34 @@ describe('PromptService.submit', () => {
});
});
it('publishes prompt.submitted before core prompt events can start the turn', async () => {
const { bus, events } = makeBus();
const { bridge } = makeBridge({
onPrompt: () => {
bus.publish({
type: 'turn.started',
turnId: 7,
origin: { kind: 'user' },
sessionId: SID,
agentId: 'main',
} as unknown as Event);
},
});
const impl = newSvc(bridge, bus);
const result = await impl.submit(SID, mkBodyMinimal());
expect(events.map((event) => event.type).slice(0, 2)).toEqual([
'prompt.submitted',
'turn.started',
]);
expect(events[0]).toMatchObject({
type: 'prompt.submitted',
promptId: result.prompt_id,
status: 'running',
});
});
it('queues a second prompt when a non-terminal prompt is already active', async () => {
const { bridge } = makeBridge();
const { bus } = makeBus();