feat(server): add GUI store API mirroring localStorage (#1231)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
Release / Release (push) Waiting to run

* feat(server): add GUI store API mirroring localStorage

- add /api/v1/gui/store/* endpoints (getItem/setItem/removeItem/clear/length) mirroring the browser localStorage interface
- add IGuiStoreService persisting opaque string values to ~/.kimi-code/gui.toml via smol-toml with atomic writes and an in-process write lock
- wire protocol schema, service, routes, and DI registration; add e2e tests and update the API surface snapshot

* chore: add changeset for gui store api

* fix(server): harden GUI store key handling and file permissions

- use a null-prototype record and an own-property check so keys that exist on Object.prototype (toString, constructor, __proto__) behave like ordinary keys
- write gui.toml with 0600 permissions so unsent drafts and input history stay private to the owning user
This commit is contained in:
Haozhe 2026-06-30 21:04:59 +08:00 committed by GitHub
parent aa6b0d065e
commit ceb27f5e44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 557 additions and 0 deletions

View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/protocol": patch
"@moonshot-ai/server": patch
"@moonshot-ai/kimi-code": patch
---
Add a server-side key-value store API for persisting web UI preferences to the user's data directory.

View file

@ -40,3 +40,4 @@ export * from './rest/modelCatalog';
export * from './rest/config';
export * from './rest/terminal';
export * from './rest/connection';
export * from './rest/guiStore';

View file

@ -0,0 +1,22 @@
import { z } from 'zod';
const keySchema = z.string().min(1).max(256);
export const guiStoreGetItemQuerySchema = z.object({ key: keySchema });
export const guiStoreSetItemBodySchema = z.object({
key: keySchema,
value: z.string(),
});
export const guiStoreRemoveItemBodySchema = z.object({ key: keySchema });
export const guiStoreGetItemResponseSchema = z.object({
value: z.string().nullable(),
});
export type GuiStoreGetItemResponse = z.infer<typeof guiStoreGetItemResponseSchema>;
export const guiStoreLengthResponseSchema = z.object({
length: z.number(),
});
export type GuiStoreLengthResponse = z.infer<typeof guiStoreLengthResponseSchema>;

View file

@ -19,6 +19,7 @@
"#/services/approval/*": "./src/services/approval/*.ts",
"#/services/gateway": "./src/services/gateway/index.ts",
"#/services/gateway/*": "./src/services/gateway/*.ts",
"#/services/guiStore/*": "./src/services/guiStore/*.ts",
"#/services/question": "./src/services/question/index.ts",
"#/services/question/*": "./src/services/question/*.ts",
"#/services/snapshot": "./src/services/snapshot/index.ts",
@ -46,6 +47,7 @@
"fastify": "^5.1.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"smol-toml": "^1.6.1",
"ulid": "^3.0.1",
"ws": "^8.18.0",
"zod": "catalog:",

View file

@ -0,0 +1,146 @@
import {
ErrorCode,
guiStoreGetItemQuerySchema,
guiStoreGetItemResponseSchema,
guiStoreLengthResponseSchema,
guiStoreRemoveItemBodySchema,
guiStoreSetItemBodySchema,
} from '@moonshot-ai/protocol';
import { z } from 'zod';
import type { IInstantiationService } from '@moonshot-ai/agent-core';
import { IGuiStoreService } from '#/services/guiStore/guiStore';
import { okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
interface GuiStoreRouteHost {
get(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string; query?: unknown },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
post(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string; body?: unknown },
reply: { send(payload: unknown): void },
) => Promise<void> | void,
): unknown;
}
export function registerGuiStoreRoutes(
app: GuiStoreRouteHost,
ix: IInstantiationService,
): void {
const getItemRoute = defineRoute(
{
method: 'GET',
path: '/gui/store/getItem',
querystring: guiStoreGetItemQuerySchema,
success: { data: guiStoreGetItemResponseSchema },
errors: { [ErrorCode.VALIDATION_FAILED]: {} },
description: 'Read a value by key (mirrors localStorage.getItem).',
tags: ['gui-store'],
},
async (req, reply) => {
const value = await ix.invokeFunction((a) =>
a.get(IGuiStoreService).getItem(req.query.key),
);
reply.send(okEnvelope({ value }, req.id));
},
);
app.get(
getItemRoute.path,
getItemRoute.options,
getItemRoute.handler as Parameters<GuiStoreRouteHost['get']>[2],
);
const setItemRoute = defineRoute(
{
method: 'POST',
path: '/gui/store/setItem',
body: guiStoreSetItemBodySchema,
success: { data: z.null() },
errors: { [ErrorCode.VALIDATION_FAILED]: {} },
description: 'Write a value by key (mirrors localStorage.setItem).',
tags: ['gui-store'],
},
async (req, reply) => {
await ix.invokeFunction((a) =>
a.get(IGuiStoreService).setItem(req.body.key, req.body.value),
);
reply.send(okEnvelope(null, req.id));
},
);
app.post(
setItemRoute.path,
setItemRoute.options,
setItemRoute.handler as Parameters<GuiStoreRouteHost['post']>[2],
);
const removeItemRoute = defineRoute(
{
method: 'POST',
path: '/gui/store/removeItem',
body: guiStoreRemoveItemBodySchema,
success: { data: z.null() },
errors: { [ErrorCode.VALIDATION_FAILED]: {} },
description: 'Delete a value by key (mirrors localStorage.removeItem).',
tags: ['gui-store'],
},
async (req, reply) => {
await ix.invokeFunction((a) =>
a.get(IGuiStoreService).removeItem(req.body.key),
);
reply.send(okEnvelope(null, req.id));
},
);
app.post(
removeItemRoute.path,
removeItemRoute.options,
removeItemRoute.handler as Parameters<GuiStoreRouteHost['post']>[2],
);
const clearRoute = defineRoute(
{
method: 'POST',
path: '/gui/store/clear',
success: { data: z.null() },
description: 'Delete all values (mirrors localStorage.clear).',
tags: ['gui-store'],
},
async (req, reply) => {
await ix.invokeFunction((a) => a.get(IGuiStoreService).clear());
reply.send(okEnvelope(null, req.id));
},
);
app.post(
clearRoute.path,
clearRoute.options,
clearRoute.handler as Parameters<GuiStoreRouteHost['post']>[2],
);
const lengthRoute = defineRoute(
{
method: 'GET',
path: '/gui/store/length',
success: { data: guiStoreLengthResponseSchema },
description: 'Number of stored keys (mirrors localStorage.length).',
tags: ['gui-store'],
},
async (req, reply) => {
const length = await ix.invokeFunction((a) => a.get(IGuiStoreService).length());
reply.send(okEnvelope({ length }, req.id));
},
);
app.get(
lengthRoute.path,
lengthRoute.options,
lengthRoute.handler as Parameters<GuiStoreRouteHost['get']>[2],
);
}

View file

@ -9,6 +9,7 @@ import { registerConnectionsRoutes } from './connections';
import { registerDebugRoutes } from './debug';
import { registerFilesRoutes } from './files';
import { registerFsRoutes } from './fs';
import { registerGuiStoreRoutes } from './guiStore';
import { registerMessagesRoutes } from './messages';
import { registerMetaRoute } from './meta';
import { registerModelCatalogRoutes } from './modelCatalog';
@ -114,6 +115,7 @@ export async function registerApiV1Routes(
);
}
registerFsRoutes(apiV1 as unknown as Parameters<typeof registerFsRoutes>[0], ix);
registerGuiStoreRoutes(apiV1 as unknown as Parameters<typeof registerGuiStoreRoutes>[0], ix);
registerFilesRoutes(apiV1 as unknown as Parameters<typeof registerFilesRoutes>[0], ix);
registerWorkspacesRoutes(
apiV1 as unknown as Parameters<typeof registerWorkspacesRoutes>[0],

View file

@ -0,0 +1,18 @@
import { createDecorator } from '@moonshot-ai/agent-core';
/**
* `IGuiStoreService` a server-backed key/value store mirroring the browser
* `localStorage` interface (`getItem` / `setItem` / `removeItem` / `clear` /
* `length`). Values are opaque strings; callers (the web UI) handle their own
* serialization. Persisted to `<homeDir>/gui.toml`.
*/
export interface IGuiStoreService {
readonly _serviceBrand: undefined;
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
clear(): Promise<void>;
length(): Promise<number>;
}
export const IGuiStoreService = createDecorator<IGuiStoreService>('guiStoreService');

View file

@ -0,0 +1,110 @@
import { randomBytes } from 'node:crypto';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { IEnvironmentService } from '@moonshot-ai/agent-core';
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
import { IGuiStoreService } from './guiStore';
/**
* Null-prototype record so keys present on `Object.prototype` (`toString`,
* `constructor`, `hasOwnProperty`, `__proto__`, ) never resolve to inherited
* members they are legal localStorage keys and must behave like any other key.
*/
function emptyStore(): Record<string, string> {
return Object.create(null) as Record<string, string>;
}
export class GuiStoreService implements IGuiStoreService {
readonly _serviceBrand: undefined;
private readonly filePath: string;
/** Serializes read-modify-write cycles so concurrent writers cannot clobber each other. */
private queue: Promise<void> = Promise.resolve();
constructor(@IEnvironmentService env: IEnvironmentService) {
this.filePath = join(env.homeDir, 'gui.toml');
}
async getItem(key: string): Promise<string | null> {
const all = await this.readAll();
if (!Object.prototype.hasOwnProperty.call(all, key)) return null;
return all[key] ?? null;
}
async setItem(key: string, value: string): Promise<void> {
await this.withLock(async () => {
const all = await this.readAll();
all[key] = value;
await this.writeAll(all);
});
}
async removeItem(key: string): Promise<void> {
await this.withLock(async () => {
const all = await this.readAll();
if (Object.prototype.hasOwnProperty.call(all, key)) {
delete all[key];
await this.writeAll(all);
}
});
}
async clear(): Promise<void> {
await this.withLock(() => this.writeAll(emptyStore()));
}
async length(): Promise<number> {
const all = await this.readAll();
return Object.keys(all).length;
}
private withLock(fn: () => Promise<void>): Promise<void> {
const run = this.queue.then(fn);
// Keep the chain alive regardless of outcome; the rejection is still
// surfaced to the caller of this specific operation via `run`.
this.queue = run.then(
() => undefined,
() => undefined,
);
return run;
}
private async readAll(): Promise<Record<string, string>> {
let text: string;
try {
text = await readFile(this.filePath, 'utf-8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyStore();
throw error;
}
if (text.trim().length === 0) return emptyStore();
try {
const parsed = parseToml(text) as Record<string, unknown>;
const out = emptyStore();
for (const [k, v] of Object.entries(parsed)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
// A corrupt or partially-written file must not take down the store;
// treat it as empty. The next write replaces it with valid TOML.
return emptyStore();
}
}
private async writeAll(obj: Record<string, string>): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
// Spread into a plain object so smol-toml never touches a null-prototype object.
const plain: Record<string, string> = { ...obj };
const text = Object.keys(plain).length === 0 ? '' : stringifyToml(plain);
// Atomic replace via temp file + rename (POSIX-atomic), so readers never
// observe a half-written file.
const tmp = `${this.filePath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
// 0600: the store can hold unsent drafts / input history; keep it private
// to the owning user on multi-user hosts.
await writeFile(tmp, text, { encoding: 'utf-8', mode: 0o600 });
await rename(tmp, this.filePath);
}
}

View file

@ -27,6 +27,8 @@ import {
ModelCatalogRefreshScheduler,
} from '#/services/modelCatalog/modelCatalogRefreshScheduler';
import { ISnapshotService, SnapshotService, loadSnapshotConfig } from '#/services/snapshot';
import { IGuiStoreService } from '#/services/guiStore/guiStore';
import { GuiStoreService } from '#/services/guiStore/guiStoreService';
export interface ServerServiceCollectionOptions {
readonly server: ServerStartOptions;
@ -67,6 +69,7 @@ export function createServerServiceCollection(
);
services.set(IRestGateway, new FastifyRestGateway(app));
services.set(Services.IEnvironmentService, envService);
services.set(IGuiStoreService, new SyncDescriptor(GuiStoreService, [], false));
services.set(
IWSGateway,

View file

@ -61,6 +61,14 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e
"GET",
"/api/v1/fs:home",
],
[
"GET",
"/api/v1/gui/store/getItem",
],
[
"GET",
"/api/v1/gui/store/length",
],
[
"GET",
"/api/v1/healthz",
@ -189,6 +197,18 @@ exports[`API surface snapshot > matches the documented v1 route table and meta e
"POST",
"/api/v1/files",
],
[
"POST",
"/api/v1/gui/store/clear",
],
[
"POST",
"/api/v1/gui/store/removeItem",
],
[
"POST",
"/api/v1/gui/store/setItem",
],
[
"POST",
"/api/v1/mcp/servers/{tail}",

View file

@ -0,0 +1,223 @@
import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { IRestGateway, startServer, type RunningServer } from '../src';
import { fixedTokenAuth } from './helpers/serverHarness';
let tmpDir: string;
let lockPath: string;
let bridgeHome: string;
let server: RunningServer | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'kimi-server-gui-store-test-'));
lockPath = join(tmpDir, 'lock');
bridgeHome = mkdtempSync(join(tmpdir(), 'kimi-server-gui-store-home-'));
});
afterEach(async () => {
try {
await server?.close();
} catch {
// ignore
}
server = undefined;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(bridgeHome, { recursive: true, force: true });
});
async function bootDaemon(): Promise<RunningServer> {
server = await startServer({
serviceOverrides: [fixedTokenAuth()],
host: '127.0.0.1',
port: 0,
lockPath,
logger: pino({ level: 'silent' }),
coreProcessOptions: { homeDir: bridgeHome },
});
return server;
}
function appOf(r: RunningServer): {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
} {
const app = r.services.invokeFunction((a) => {
const gw = a.get(IRestGateway);
return gw.app as unknown as {
inject: (req: unknown) => Promise<{ statusCode: number; json: () => unknown }>;
};
});
return {
inject(req: unknown) {
const q = req as { headers?: Record<string, string | string[] | undefined> };
return app.inject({
...q,
headers: { authorization: 'Bearer test-token', ...q.headers },
});
},
};
}
function envelopeOf<T>(body: unknown): {
code: number;
msg: string;
data: T | null;
request_id: string;
} {
return body as {
code: number;
msg: string;
data: T | null;
request_id: string;
};
}
function getItem(api: ReturnType<typeof appOf>, key: string) {
return api.inject({ method: 'GET', url: `/api/v1/gui/store/getItem?key=${key}` });
}
function setItem(api: ReturnType<typeof appOf>, key: string, value: string) {
return api.inject({
method: 'POST',
url: '/api/v1/gui/store/setItem',
payload: { key, value },
});
}
describe('gui store routes', () => {
it('getItem returns null when the file does not exist', async () => {
const r = await bootDaemon();
const res = await getItem(appOf(r), 'theme');
expect(res.statusCode).toBe(200);
const env = envelopeOf<{ value: string | null }>(res.json());
expect(env.code).toBe(0);
expect(env.data?.value).toBeNull();
});
it('setItem then getItem round-trips and persists to gui.toml', async () => {
const r = await bootDaemon();
const api = appOf(r);
const setRes = await setItem(api, 'theme', 'modern');
expect(envelopeOf<null>(setRes.json()).code).toBe(0);
const getEnv = envelopeOf<{ value: string | null }>((await getItem(api, 'theme')).json());
expect(getEnv.data?.value).toBe('modern');
const text = readFileSync(join(bridgeHome, 'gui.toml'), 'utf-8');
expect(text).toContain('theme = "modern"');
});
it('setItem overwrites an existing value', async () => {
const r = await bootDaemon();
const api = appOf(r);
await setItem(api, 'theme', 'modern');
await setItem(api, 'theme', 'terminal');
const env = envelopeOf<{ value: string | null }>((await getItem(api, 'theme')).json());
expect(env.data?.value).toBe('terminal');
});
it('removeItem deletes a key and leaves others intact', async () => {
const r = await bootDaemon();
const api = appOf(r);
await setItem(api, 'a', '1');
await setItem(api, 'b', '2');
const rmRes = await api.inject({
method: 'POST',
url: '/api/v1/gui/store/removeItem',
payload: { key: 'a' },
});
expect(envelopeOf<null>(rmRes.json()).code).toBe(0);
expect(envelopeOf<{ value: string | null }>((await getItem(api, 'a')).json()).data?.value).toBeNull();
expect(envelopeOf<{ value: string | null }>((await getItem(api, 'b')).json()).data?.value).toBe('2');
});
it('removeItem on a missing key is a no-op', async () => {
const r = await bootDaemon();
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/gui/store/removeItem',
payload: { key: 'nope' },
});
expect(envelopeOf<null>(res.json()).code).toBe(0);
});
it('length reports the count and clear empties the store', async () => {
const r = await bootDaemon();
const api = appOf(r);
await setItem(api, 'a', '1');
await setItem(api, 'b', '2');
const before = envelopeOf<{ length: number }>(
(await api.inject({ method: 'GET', url: '/api/v1/gui/store/length' })).json(),
);
expect(before.data?.length).toBe(2);
const clearRes = await api.inject({ method: 'POST', url: '/api/v1/gui/store/clear' });
expect(envelopeOf<null>(clearRes.json()).code).toBe(0);
const after = envelopeOf<{ length: number }>(
(await api.inject({ method: 'GET', url: '/api/v1/gui/store/length' })).json(),
);
expect(after.data?.length).toBe(0);
});
it('quotes dotted keys in the persisted TOML and reads them back', async () => {
const r = await bootDaemon();
const api = appOf(r);
await setItem(api, 'kimi-web.theme', 'modern');
const text = readFileSync(join(bridgeHome, 'gui.toml'), 'utf-8');
expect(text).toContain('"kimi-web.theme" = "modern"');
const env = envelopeOf<{ value: string | null }>((await getItem(api, 'kimi-web.theme')).json());
expect(env.data?.value).toBe('modern');
});
it('rejects an empty key', async () => {
const r = await bootDaemon();
const res = await appOf(r).inject({
method: 'POST',
url: '/api/v1/gui/store/setItem',
payload: { key: '', value: 'x' },
});
expect(envelopeOf(res.json()).code).toBe(40001);
});
it('treats Object.prototype keys as ordinary keys', async () => {
const r = await bootDaemon();
const api = appOf(r);
// On an empty store, prototype keys must not resolve to inherited members.
expect(
envelopeOf<{ value: string | null }>((await getItem(api, 'toString')).json()).data?.value,
).toBeNull();
expect(
envelopeOf<{ value: string | null }>((await getItem(api, 'constructor')).json()).data?.value,
).toBeNull();
// They can be set and read back like any other key, including __proto__.
await setItem(api, 'hasOwnProperty', 'x');
await setItem(api, '__proto__', 'y');
expect(
envelopeOf<{ value: string | null }>((await getItem(api, 'hasOwnProperty')).json()).data
?.value,
).toBe('x');
expect(
envelopeOf<{ value: string | null }>((await getItem(api, '__proto__')).json()).data?.value,
).toBe('y');
});
it('writes gui.toml with 0600 permissions', async () => {
const r = await bootDaemon();
await setItem(appOf(r), 'theme', 'modern');
const mode = statSync(join(bridgeHome, 'gui.toml')).mode & 0o777;
expect(mode).toBe(0o600);
});
});

3
pnpm-lock.yaml generated
View file

@ -580,6 +580,9 @@ importers:
pino-pretty:
specifier: ^13.0.0
version: 13.1.3
smol-toml:
specifier: ^1.6.1
version: 1.6.1
ulid:
specifier: ^3.0.1
version: 3.0.2