kimi-code/packages/server/src/start.ts
Haozhe 0886bff2bc
feat(server): add --allowed-host flag for DNS-rebinding allowlist (#1128)
- add `kimi server run --allowed-host <host...>` (repeatable or
  comma-separated; leading dot matches a domain suffix) and thread it
  through daemon spawn into startServer
- merge CLI allowed hosts with KIMI_CODE_ALLOWED_HOSTS for both the HTTP
  and WebSocket Host checks
- include the rejected host and allow guidance in the 403 error message
2026-06-26 17:06:01 +08:00

751 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { InstantiationService, resolveConfigPath, resolveKimiHome, setUnexpectedErrorHandler, IApprovalService, IAuthSummaryService, IEnvironmentService, IEventService, ICoreProcessService, IModelCatalogService, IMcpService, IMessageService, IOAuthService, IFileStore, IFsGitService, IFsSearchService, IFsService, IFsWatcher, ILogService, IPromptService, IQuestionService, ISessionService, ISkillService, ITaskService, ITerminalService, IToolService, IWorkspaceFsService, IWorkspaceRegistry, FsPathEscapesError, FsWatchLimitError, FsWatcherService, SessionNotFoundError, createConnectionLookup, resolveSafePath, type ServiceIdentifier, type CoreProcessServiceOptions } from '@moonshot-ai/agent-core';
import { ErrorCode, createAsyncApiDocument } from '@moonshot-ai/protocol';
import Fastify from 'fastify';
import { promises as fspPromises } from 'node:fs';
import {
sep as nodePathSep,
relative as nodePathRelativeNative,
} from 'node:path';
import { installErrorHandler } from './error-handler';
import { transformOpenApiDocument } from './openapi/transforms';
import { acquireLock, ServerLockedError } from './lock';
import { createAuthHook } from '#/middleware/auth';
import { createAuthFailureLimiter } from '#/middleware/rateLimit';
import {
createHostCheck,
isHostCheckDisabled,
parseAllowedHosts,
} from '#/middleware/hostnames';
import { createOriginHook, parseCorsOrigins } from '#/middleware/origin';
import {
createServerLogger,
type ServerLogLevel,
type ServerLogger,
} from './services/pinoLoggerService';
import { resolveRequestId } from './request-id';
import { registerApiV1Routes } from './routes/registerApiV1Routes';
import {
IConnectionRegistry,
IRestGateway,
IServerShutdownService,
ISessionClientsService,
IWSBroadcastService,
IWSGateway,
type WSGatewayOptions,
} from '#/services/gateway';
import { createServerServiceCollection } from '#/services/serviceCollection';
import { ISnapshotService, loadSnapshotConfig } from '#/services/snapshot';
import {
createAuthTokenService,
IAuthTokenService,
} from '#/services/auth/authTokenService';
import { classify } from '#/services/auth/bindClassify';
import { resolvePasswordHash } from '#/services/auth/password';
import { createSecurityHeadersHook } from '#/services/auth/securityHeaders';
import { createTokenStore } from '#/services/auth/tokenStore';
import { getServerVersion } from './version';
import { registerWebAssetRoutes } from './routes/webAssets';
export interface ServerStartOptions {
host: string;
port: number;
logLevel?: ServerLogLevel;
logger?: ServerLogger;
lockPath?: string;
coreProcessOptions?: CoreProcessServiceOptions;
wsGatewayOptions?: WSGatewayOptions;
debugEndpoints?: boolean;
/**
* Override the classification of a wildcard bind (`0.0.0.0` / `::` / empty).
* Default (unset) treats wildcards as `public` (most strict); set to `lan`
* to relax to LAN-tier hardening. See `services/auth/bindClassify.ts`.
*/
bindClass?: 'lan' | 'public';
/**
* Allow a non-loopback bind WITHOUT a TLS-terminating reverse proxy. Default
* false: binding beyond loopback refuses to start unless this is set, so a
* public/LAN bind is never served over plain HTTP by accident. Pass
* `--insecure-no-tls` (or set this) only when you accept the risk.
*/
insecureNoTls?: boolean;
/**
* Allow `POST /api/v1/shutdown` on a non-loopback bind. Default false: the
* shutdown route is NOT registered (404) on a public/LAN bind unless this is
* set. Loopback always mounts it.
*/
allowRemoteShutdown?: boolean;
/**
* Allow the PTY `/api/v1/terminals/*` routes on a non-loopback bind. Default
* false: terminals routes are NOT registered (404) on a public/LAN bind
* unless this is set (remote shell is the highest-risk surface). Loopback
* always mounts them.
*/
allowRemoteTerminals?: boolean;
webAssetsDir?: string;
/**
* Extra `Host` header values to allow, in addition to the default allowlist
* and `KIMI_CODE_ALLOWED_HOSTS`. A leading dot matches a domain suffix.
*/
allowedHosts?: readonly string[];
serviceOverrides?: ReadonlyArray<readonly [ServiceIdentifier<unknown>, unknown]>;
}
export interface RunningServer {
readonly address: string;
readonly logger: ServerLogger;
readonly services: InstantiationService;
close(): Promise<void>;
}
export { ServerLockedError };
export async function startServer(opts: ServerStartOptions): Promise<RunningServer> {
const pinoLogger: ServerLogger =
opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
const lockHandle = acquireLock({
port: opts.port,
host: opts.host,
lockPath: opts.lockPath,
// Record the host build identity so `kimi server status` can detect a
// build-mismatched server.
hostVersion: opts.coreProcessOptions?.identity?.version,
entry: process.argv[1],
});
const app = Fastify({
loggerInstance: pinoLogger,
disableRequestLogging: false,
genReqId: (req) => resolveRequestId(req.headers),
});
app.setValidatorCompiler(() => () => true);
app.setSerializerCompiler(() => (data) => JSON.stringify(data));
installErrorHandler(app);
// Host / Origin checks (ROADMAP M4.3). Registered before any route so they
// run ahead of every handler and ahead of the (future, M5.1) auth hook.
// Host is evaluated before Origin; both are uniform across bindings (PLAN
// D3) — even on loopback — so behavior does not depend on how the server is
// reached. The default-allow set keeps `app.inject` (`Host: localhost:80`)
// and real `fetch` to `127.0.0.1:<port>` working.
const allowedHosts = [...parseAllowedHosts(process.env), ...(opts.allowedHosts ?? [])];
const hostCheck = createHostCheck({
boundHost: opts.host,
extra: allowedHosts,
disable: isHostCheckDisabled(process.env),
});
const originHook = createOriginHook({ allowedOrigins: parseCorsOrigins(process.env) });
app.addHook('onRequest', hostCheck.onRequest);
app.addHook('onRequest', originHook);
const serverVersion = opts.coreProcessOptions?.identity?.version ?? getServerVersion();
async function registerOpenApi(): 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' },
],
},
transformObject: (documentObject) => {
if (!('openapiObject' in documentObject)) {
return documentObject.swaggerObject;
}
return transformOpenApiDocument(documentObject.openapiObject as Record<string, unknown>);
},
});
}
await registerOpenApi();
const envService: IEnvironmentService = {
_serviceBrand: undefined,
homeDir: resolveKimiHome(opts.coreProcessOptions?.homeDir),
configPath: resolveConfigPath({
homeDir: opts.coreProcessOptions?.homeDir,
configPath: opts.coreProcessOptions?.configPath,
}),
};
// Token auth (ROADMAP M5.1). The real `IAuthTokenService` needs an
// async-built `TokenStore` over the persistent `<homeDir>/server.token`
// (0600; generated once on first boot and reused across restarts) and an
// optional bcrypt password hash — both awaited here, then supplied to the
// collection via `serviceOverrides` so tests can inject a fixed-token impl
// that wins (last-wins) over this default. The store re-reads the file when
// its mtime changes, so `kimi server rotate-token` takes effect without a
// restart; the file is intentionally kept on shutdown (dispose is a no-op).
const tokenStore = await createTokenStore(envService.homeDir);
const passwordHash = await resolvePasswordHash(process.env);
const defaultAuth = createAuthTokenService({ tokenStore, passwordHash });
// Public-bind hardening gate (ROADMAP M6.3). Classify the bind host and, for
// any non-loopback tier (LAN or public), refuse to start unless the operator
// explicitly acknowledged that TLS is terminated elsewhere (`insecureNoTls`).
// Auth is bearer-token based: the persistent token is printed in the startup
// banner and reused across restarts, so a password is no longer mandatory for
// a non-loopback bind — `KIMI_CODE_PASSWORD` remains an optional additional
// credential. Failing here (before the container is built and before we
// listen) keeps a public/LAN bind from ever serving plain HTTP by accident.
// On refusal we release the lock so the operator can retry cleanly.
const bindClass = classify(opts.host, { bindClass: opts.bindClass });
if (bindClass !== 'loopback') {
const refusePublicBind = async (message: string): Promise<never> => {
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot refusal
}
lockHandle.release();
throw new Error(message);
};
if (opts.insecureNoTls !== true) {
await refusePublicBind(
'Refusing to bind a non-loopback host without TLS. ' +
'Put the server behind a TLS-terminating reverse proxy (Caddy/nginx), ' +
'or pass --insecure-no-tls to acknowledge the risk.',
);
}
if (passwordHash === undefined) {
pinoLogger.warn(
{ host: opts.host, bindClass },
'binding non-loopback host with token-only auth (no KIMI_CODE_PASSWORD) — the bearer token printed in the startup banner is the only credential protecting this server',
);
}
pinoLogger.warn(
{ host: opts.host, bindClass },
'binding non-loopback host without TLS — use a reverse proxy or tunnel in production',
);
}
const services = createServerServiceCollection({
server: {
...opts,
// WS Host/Origin defaults (ROADMAP M4.3 / M5.1): mirror the HTTP checks
// on the upgrade path. Caller-supplied values win (used by the
// host-origin e2e tests). `authTokenService` is NOT threaded here — it
// reaches the WS gateway via `setAuthTokenService` below so the
// override-aware impl enforces auth.
wsGatewayOptions: {
...opts.wsGatewayOptions,
hostCheck: opts.wsGatewayOptions?.hostCheck ?? {
boundHost: opts.host,
extra: allowedHosts,
disable: isHostCheckDisabled(process.env),
},
allowedOrigins:
opts.wsGatewayOptions?.allowedOrigins ?? parseCorsOrigins(process.env),
},
serviceOverrides: [
[IAuthTokenService, defaultAuth],
...(opts.serviceOverrides ?? []),
],
},
app,
pinoLogger,
envService,
});
const ix = new InstantiationService(services);
// Auth hook (ROADMAP M5.1). Registered after Host/Origin (above) and before
// routes, so a rejected request never reaches a handler. Resolved from the
// container so a test-injected fixed-token impl is what enforces auth.
//
// Auth-failure rate limit (ROADMAP M6.4): only on a non-loopback bind, where
// brute-force attempts are reachable from the network. Loopback keeps the
// original "no limiter" behavior so local retries are never throttled.
const authTokenService = ix.invokeFunction((a) => a.get(IAuthTokenService));
const authFailureLimiter =
bindClass !== 'loopback' ? createAuthFailureLimiter() : undefined;
app.addHook('onRequest', createAuthHook(authTokenService, { limiter: authFailureLimiter }));
// Security response headers (ROADMAP M6.6): only on a non-loopback bind.
// TLS is terminated by a reverse proxy in this phase, so HSTS is omitted
// here (`tls: false`) — the proxy is responsible for setting it.
if (bindClass !== 'loopback') {
app.addHook('onSend', createSecurityHeadersHook({ tls: false }));
}
// Bind classification (`bindClass`, computed above next to the password/TLS
// gate) drives every hardening decision from here on: debug routes now;
// rate limit, dangerous endpoints, and security headers in M6.4M6.6.
// Debug routes (ROADMAP M5.3): only mount `/api/v1/debug/*` when bound to a
// loopback interface. On a non-loopback bind these introspection/mutation
// endpoints would be reachable from the network, so suppress them even if
// the caller asked for them, and warn so the operator knows.
if (opts.debugEndpoints === true && bindClass !== 'loopback') {
pinoLogger.warn(
{ host: opts.host, bindClass },
'debug endpoints suppressed: refusing to mount /api/v1/debug/* on a non-loopback bind',
);
}
// Dangerous-endpoint downgrade (ROADMAP M6.5): on a non-loopback bind the
// shutdown + terminals routes are NOT registered (404) unless the operator
// explicitly opts in. Loopback always mounts them (backward compatible).
const allowRemoteShutdown = opts.allowRemoteShutdown === true;
const allowRemoteTerminals = opts.allowRemoteTerminals === true;
await registerApiV1Routes(app, ix, {
serverVersion,
debugEndpoints: opts.debugEndpoints === true && bindClass === 'loopback',
enableShutdown: bindClass === 'loopback' || allowRemoteShutdown,
enableTerminals: bindClass === 'loopback' || allowRemoteTerminals,
});
app.get('/asyncapi.json', async (_req, reply) => {
// Reflect the bound host, never the caller-supplied `Host` header (PLAN
// §3.6-3: Host-header reflection is an information-leak / SSRF-adjacent
// hole once the server is reachable beyond localhost).
return reply.type('application/json').send(
createAsyncApiDocument({ version: serverVersion, serverHost: opts.host }),
);
});
app.get('/openapi.json', async (_req, reply) => {
const openApiDocument = (app as unknown as { swagger(): unknown }).swagger();
return reply.type('application/json').send(openApiDocument);
});
if (opts.webAssetsDir !== undefined) {
await registerWebAssetRoutes(app, opts.webAssetsDir);
}
try {
await app.ready();
} catch (error) {
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
}
let coreProcess: ICoreProcessService;
try {
coreProcess = ix.invokeFunction((a) => {
const log = a.get(ILogService);
a.get(IRestGateway);
setUnexpectedErrorHandler((err) => {
log.error(
err instanceof Error ? { msg: err.message, stack: err.stack } : { err },
'[unexpected]',
);
});
a.get(IConnectionRegistry);
a.get(ISessionClientsService);
a.get(IEventService);
const wsBroadcast = a.get(IWSBroadcastService);
a.get(IApprovalService);
a.get(IQuestionService);
// Eagerly instantiate the snapshot reader so its event-bus subscription
// is in place before any session can publish `turn.started` events —
// lazy-loading would drop turn lifecycle state for sessions created
// before the first snapshot request.
if (loadSnapshotConfig().mode !== 'legacy') {
a.get(ISnapshotService);
}
const wsGw = a.get(IWSGateway);
// Hand the override-aware auth impl to the WS gateway so the upgrade
// path enforces the same token the HTTP hook uses (ROADMAP M5.1).
wsGw.setAuthTokenService(authTokenService);
const built = a.get(ICoreProcessService);
const sessionService = a.get(ISessionService);
a.get(IMessageService);
a.get(IAuthSummaryService);
a.get(IOAuthService);
a.get(IModelCatalogService);
const promptService = a.get(IPromptService);
const terminalService = a.get(ITerminalService);
wsGw.setAbortHandler({
abort: (sid, pid) => promptService.abort(sid, pid),
currentSeq: (sid) => wsBroadcast.currentSeq(sid),
});
wsGw.setTerminalHandler({
attach: (sessionId, terminalId, sink, options) =>
terminalService.attach(sessionId, terminalId, sink, options),
detach: (sessionId, terminalId, sinkId) =>
terminalService.detach(sessionId, terminalId, sinkId),
cleanupConnection: (sinkId) => terminalService.detachAllForSink(sinkId),
write: (sessionId, terminalId, data) =>
terminalService.write(sessionId, terminalId, data),
resize: (sessionId, terminalId, cols, rows) =>
terminalService.resize(sessionId, terminalId, cols, rows),
close: (sessionId, terminalId) => terminalService.close(sessionId, terminalId),
});
a.get(IToolService);
a.get(IMcpService);
a.get(ISkillService);
a.get(ITaskService);
a.get(IFsService);
a.get(IFsSearchService);
a.get(IFsGitService);
const registry = a.get(IConnectionRegistry);
const fsWatcher = ix.createInstance(
FsWatcherService,
createConnectionLookup((id) => registry.get(id)),
{},
);
services.set(IFsWatcher, fsWatcher);
a.get(IFsWatcher);
const fsWatchHandler = {
async add(sessionId: string, connectionId: string, wirePaths: readonly string[]) {
try {
const session = await sessionService.get(sessionId);
const realCwd = await fspPromises.realpath(session.metadata.cwd);
fsWatcher.bindSessionCwd(sessionId, realCwd);
const absPaths: string[] = [];
for (const p of wirePaths) {
const safe = await resolveSafePath(session.metadata.cwd, p);
absPaths.push(safe.absolute);
}
fsWatcher.addPaths(sessionId, connectionId, absPaths);
const watched = fsWatcher.watchedPaths(connectionId, sessionId);
const wire = watched.map((abs) => toPosixRelativeForCwd(realCwd, abs));
return {
ok: true as const,
watched_paths: wire,
current_count: fsWatcher.countForConnection(connectionId),
};
} catch (error) {
return mapFsWatchError(error);
}
},
async remove(sessionId: string, connectionId: string, wirePaths: readonly string[]) {
try {
const session = await sessionService.get(sessionId);
const realCwd = await fspPromises.realpath(session.metadata.cwd);
const absPaths: string[] = [];
for (const p of wirePaths) {
const safe = await resolveSafePath(session.metadata.cwd, p);
absPaths.push(safe.absolute);
}
fsWatcher.removePaths(sessionId, connectionId, absPaths);
const watched = fsWatcher.watchedPaths(connectionId, sessionId);
const wire = watched.map((abs) => toPosixRelativeForCwd(realCwd, abs));
return {
ok: true as const,
watched_paths: wire,
current_count: fsWatcher.countForConnection(connectionId),
};
} catch (error) {
return mapFsWatchError(error);
}
},
cleanupConnection(connectionId: string) {
fsWatcher.forgetConnection(connectionId);
},
};
wsGw.setFsWatchHandler(fsWatchHandler);
a.get(IFileStore);
a.get(IWorkspaceRegistry);
a.get(IWorkspaceFsService);
return built;
});
} catch (error) {
try {
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
}
try {
await coreProcess.ready();
} catch (error) {
try {
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
}
pinoLogger.info('core process ready');
const restGateway = ix.invokeFunction((a) => a.get(IRestGateway));
let address: string;
let boundPort: number;
try {
({ address, port: boundPort } = await listenWithPortRetry({
gateway: restGateway,
host: opts.host,
port: opts.port,
logger: pinoLogger,
}));
} catch (error) {
try {
ix.dispose();
} catch {
}
try {
await tokenStore.dispose();
} catch {
// best-effort cleanup of the token file on boot failure
}
lockHandle.release();
throw error;
}
// If we retried onto a different port, advertise the real one in the lock so
// `kimi server status` / `kill` / `ps` can find this daemon.
if (boundPort !== opts.port) {
lockHandle.updatePort(boundPort);
}
pinoLogger.info(
{ address, port: boundPort, lockPath: lockHandle.lockPath },
'server listening',
);
let closed = false;
const doClose = async (): Promise<void> => {
if (closed) return;
closed = true;
try {
ix.invokeFunction((a) => a.get(IWSGateway));
ix.invokeFunction((a) => a.get(IConnectionRegistry).closeAll('server shutting down'));
} catch {
}
try {
await app.close();
} catch {
}
try {
ix.dispose();
} catch {
}
// The persistent token is intentionally left on disk so it survives the
// next start (ROADMAP M5.1). dispose() is a no-op for the persistent store;
// the call is kept so the interface is honored uniformly and a test
// override can still observe shutdown.
try {
await tokenStore.dispose();
} catch {
// ignore — token file may already be gone
}
// Stop the auth-failure limiter's cleanup timer (ROADMAP M6.4). Only set
// on non-loopback binds; the `?.` is a no-op on loopback.
authFailureLimiter?.dispose();
lockHandle.release();
};
// Expose process-terminating shutdown to routes via DI. Respect a
// `serviceOverrides` entry so tests can observe the request without exiting.
const hasShutdownOverride = opts.serviceOverrides?.some(
([id]) => id === IServerShutdownService,
);
if (!hasShutdownOverride) {
services.set(IServerShutdownService, {
_serviceBrand: undefined,
requestShutdown: async (reason: string) => {
pinoLogger.info({ reason }, 'server shutdown requested');
await doClose();
process.exit(0);
},
});
}
return {
address,
logger: pinoLogger,
services: ix,
close: doClose,
};
}
/**
* Maximum consecutive `EADDRINUSE` retries when the requested port is busy.
* Caps the `port + 1` walk so a permanently-saturated range cannot loop
* forever; 100 matches the daemon spawner's own scan window in `resolveDaemonPort`.
*/
export const PORT_RETRY_LIMIT = 100;
export interface ListenWithPortRetryOptions {
gateway: IRestGateway;
host: string;
port: number;
logger: ServerLogger;
/** Override the retry cap — used by tests to keep the walk short. */
maxRetries?: number;
}
/**
* Bind the gateway, retrying on `port + 1` when the port is held by a
* third-party process.
*
* Why this is the right layer: {@link startServer} acquires the single-instance
* lock *before* listening, so by the time we reach `listen` a live kimi server
* would already have thrown `ServerLockedError`. Any `EADDRINUSE` here is
* therefore a third-party listener, and bumping the port is the desired policy
* ("if the port is taken by something other than kimi server itself, +1").
*
* Port `0` (OS-assigned ephemeral) is never retried: the kernel already picks a
* free port, so `EADDRINUSE` cannot arise from a specific-port conflict.
*/
export async function listenWithPortRetry(
opts: ListenWithPortRetryOptions,
): Promise<{ address: string; port: number }> {
// Ephemeral bind: the OS chooses a free port, so there is nothing to retry.
if (opts.port === 0) {
const address = await opts.gateway.listen(opts.host, 0);
return { address, port: 0 };
}
const maxRetries = opts.maxRetries ?? PORT_RETRY_LIMIT;
let port = opts.port;
for (let attempt = 0; ; attempt++) {
try {
const address = await opts.gateway.listen(opts.host, port);
if (port !== opts.port) {
opts.logger.warn(
{ requestedPort: opts.port, port, host: opts.host },
'requested port was busy; server bound to a higher port',
);
}
return { address, port };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'EADDRINUSE' || attempt >= maxRetries || port >= 65535) {
throw err;
}
const next = port + 1;
opts.logger.warn(
{ host: opts.host, port, next },
'port in use by another process, trying next port',
);
port = next;
}
}
}
function toPosixRelativeForCwd(cwd: string, abs: string): string {
if (abs === cwd) return '.';
const rel = nodePathRelativeNative(cwd, abs);
if (rel === '') return '.';
return rel.split(nodePathSep).join('/');
}
function mapFsWatchError(err: unknown):
| { ok: false; code: number; msg: string } {
if (err instanceof FsWatchLimitError) {
return {
ok: false,
code: ErrorCode.FS_WATCH_LIMIT_EXCEEDED,
msg: err.message,
};
}
if (err instanceof FsPathEscapesError) {
return {
ok: false,
code: ErrorCode.FS_PATH_ESCAPES_SESSION,
msg: err.message,
};
}
if (err instanceof SessionNotFoundError) {
return {
ok: false,
code: ErrorCode.SESSION_NOT_FOUND,
msg: 'session not found',
};
}
return {
ok: false,
code: ErrorCode.INTERNAL_ERROR,
msg: err instanceof Error ? err.message : 'fs watch error',
};
}