Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/add-gui-store-api.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ export * from './rest/modelCatalog';
export * from './rest/config';
export * from './rest/terminal';
export * from './rest/connection';
export * from './rest/guiStore';
22 changes: 22 additions & 0 deletions packages/protocol/src/rest/guiStore.ts
Original file line number Diff line number Diff line change
@@ -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>;
2 changes: 2 additions & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:",
Expand Down
146 changes: 146 additions & 0 deletions packages/server/src/routes/guiStore.ts
Original file line number Diff line number Diff line change
@@ -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],
);
}
2 changes: 2 additions & 0 deletions packages/server/src/routes/registerApiV1Routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/services/guiStore/guiStore.ts
Original file line number Diff line number Diff line change
@@ -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');
110 changes: 110 additions & 0 deletions packages/server/src/services/guiStore/guiStoreService.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use own-key storage for GUI store keys

When a caller uses a legal localStorage key that exists on Object.prototype (toString, constructor, hasOwnProperty) on an empty store, this returns the inherited function instead of null; for __proto__, setItem also goes through the prototype setter and does not persist the entry. The route schema accepts any non-empty string key, so these valid keys produce malformed responses or cannot be stored; use a null-prototype object/Map and an own-property check for reads.

Useful? React with 👍 / 👎.

}

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);
}
}
3 changes: 3 additions & 0 deletions packages/server/src/services/serviceCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down
Loading
Loading