mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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
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:
parent
aa6b0d065e
commit
ceb27f5e44
12 changed files with 557 additions and 0 deletions
7
.changeset/add-gui-store-api.md
Normal file
7
.changeset/add-gui-store-api.md
Normal 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.
|
||||
|
|
@ -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
packages/protocol/src/rest/guiStore.ts
Normal file
22
packages/protocol/src/rest/guiStore.ts
Normal 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>;
|
||||
|
|
@ -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:",
|
||||
|
|
|
|||
146
packages/server/src/routes/guiStore.ts
Normal file
146
packages/server/src/routes/guiStore.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
18
packages/server/src/services/guiStore/guiStore.ts
Normal file
18
packages/server/src/services/guiStore/guiStore.ts
Normal 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');
|
||||
110
packages/server/src/services/guiStore/guiStoreService.ts
Normal file
110
packages/server/src/services/guiStore/guiStoreService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
223
packages/server/test/guiStore.e2e.test.ts
Normal file
223
packages/server/test/guiStore.e2e.test.ts
Normal 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
3
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue