mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-28 18:44:58 +00:00
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
223 lines
7 KiB
TypeScript
223 lines
7 KiB
TypeScript
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);
|
|
});
|
|
});
|