diff --git a/.changeset/add-gui-store-api.md b/.changeset/add-gui-store-api.md new file mode 100644 index 000000000..0bca72941 --- /dev/null +++ b/.changeset/add-gui-store-api.md @@ -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. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index accd02078..16489267e 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -40,3 +40,4 @@ export * from './rest/modelCatalog'; export * from './rest/config'; export * from './rest/terminal'; export * from './rest/connection'; +export * from './rest/guiStore'; diff --git a/packages/protocol/src/rest/guiStore.ts b/packages/protocol/src/rest/guiStore.ts new file mode 100644 index 000000000..b7daf1910 --- /dev/null +++ b/packages/protocol/src/rest/guiStore.ts @@ -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; + +export const guiStoreLengthResponseSchema = z.object({ + length: z.number(), +}); +export type GuiStoreLengthResponse = z.infer; diff --git a/packages/server/package.json b/packages/server/package.json index dd65cc98a..7f0aa2088 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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:", diff --git a/packages/server/src/routes/guiStore.ts b/packages/server/src/routes/guiStore.ts new file mode 100644 index 000000000..1a93d9e21 --- /dev/null +++ b/packages/server/src/routes/guiStore.ts @@ -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 }, + handler: ( + req: { id: string; query?: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | void, + ): unknown; + post( + path: string, + options: { schema?: Record }, + handler: ( + req: { id: string; body?: unknown }, + reply: { send(payload: unknown): void }, + ) => Promise | 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[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[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[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[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[2], + ); +} diff --git a/packages/server/src/routes/registerApiV1Routes.ts b/packages/server/src/routes/registerApiV1Routes.ts index 38297e5f0..5c3a53c94 100644 --- a/packages/server/src/routes/registerApiV1Routes.ts +++ b/packages/server/src/routes/registerApiV1Routes.ts @@ -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[0], ix); + registerGuiStoreRoutes(apiV1 as unknown as Parameters[0], ix); registerFilesRoutes(apiV1 as unknown as Parameters[0], ix); registerWorkspacesRoutes( apiV1 as unknown as Parameters[0], diff --git a/packages/server/src/services/guiStore/guiStore.ts b/packages/server/src/services/guiStore/guiStore.ts new file mode 100644 index 000000000..c6d9e936e --- /dev/null +++ b/packages/server/src/services/guiStore/guiStore.ts @@ -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 `/gui.toml`. + */ +export interface IGuiStoreService { + readonly _serviceBrand: undefined; + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + clear(): Promise; + length(): Promise; +} + +export const IGuiStoreService = createDecorator('guiStoreService'); diff --git a/packages/server/src/services/guiStore/guiStoreService.ts b/packages/server/src/services/guiStore/guiStoreService.ts new file mode 100644 index 000000000..3a3732eac --- /dev/null +++ b/packages/server/src/services/guiStore/guiStoreService.ts @@ -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 { + return Object.create(null) as Record; +} + +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 = Promise.resolve(); + + constructor(@IEnvironmentService env: IEnvironmentService) { + this.filePath = join(env.homeDir, 'gui.toml'); + } + + async getItem(key: string): Promise { + 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 { + await this.withLock(async () => { + const all = await this.readAll(); + all[key] = value; + await this.writeAll(all); + }); + } + + async removeItem(key: string): Promise { + 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 { + await this.withLock(() => this.writeAll(emptyStore())); + } + + async length(): Promise { + const all = await this.readAll(); + return Object.keys(all).length; + } + + private withLock(fn: () => Promise): Promise { + 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> { + 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; + 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): Promise { + 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 = { ...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); + } +} diff --git a/packages/server/src/services/serviceCollection.ts b/packages/server/src/services/serviceCollection.ts index 0ffdc8ed9..fb5486b41 100644 --- a/packages/server/src/services/serviceCollection.ts +++ b/packages/server/src/services/serviceCollection.ts @@ -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, diff --git a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap index 2ad3980b2..df69bdedc 100644 --- a/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap +++ b/packages/server/test/__snapshots__/api-surface.snapshot.test.ts.snap @@ -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}", diff --git a/packages/server/test/guiStore.e2e.test.ts b/packages/server/test/guiStore.e2e.test.ts new file mode 100644 index 000000000..dc4a847e8 --- /dev/null +++ b/packages/server/test/guiStore.e2e.test.ts @@ -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 { + 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 }; + return app.inject({ + ...q, + headers: { authorization: 'Bearer test-token', ...q.headers }, + }); + }, + }; +} + +function envelopeOf(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, key: string) { + return api.inject({ method: 'GET', url: `/api/v1/gui/store/getItem?key=${key}` }); +} + +function setItem(api: ReturnType, 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(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(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(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(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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62e201ac4..245b39318 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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