From 7e30add44592d64a1b1042ede360d788e9a610c1 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 28 Jul 2026 10:50:39 +0800 Subject: [PATCH 01/17] feat(kap-server): add global fs:mkdir endpoint (#2281) * feat(kap-server): add global fs:mkdir endpoint Add POST /api/v1/fs:mkdir to create a directory on the host filesystem by absolute path, backing the folder picker's "new folder" action. Implemented directly on node:fs/promises.mkdir in the transport layer for now, non-recursive by design, with wire errors mapped to the existing fs.* codes (40001/40409/40411/40919). * test(kap-server): update api surface snapshot for fs:mkdir * test(kap-server): stop export tests from holding server.close() open The export download tests reused pooled undici keep-alive connections, so afterEach's server.close() could wait out fastify's 72s default keepAliveTimeout and die on the 10s hook timeout (flaky on CI). Send connection: close on the streamed export requests, matching the fs:content tests. --- packages/kap-server/src/routes/workspaceFs.ts | 105 ++++++++++++++++++ .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/sessions.test.ts | 19 +++- packages/kap-server/test/workspaceFs.test.ts | 101 +++++++++++++++++ 4 files changed, 226 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/routes/workspaceFs.ts b/packages/kap-server/src/routes/workspaceFs.ts index 5e3863731a..f10725c54a 100644 --- a/packages/kap-server/src/routes/workspaceFs.ts +++ b/packages/kap-server/src/routes/workspaceFs.ts @@ -11,6 +11,12 @@ * - `HostFolderNotFoundError` → 40409 fs.path_not_found * - `HostFolderPermissionError` → 40411 fs.permission_denied * + * `fs::mkdir` is another server-v2 addition with no v1 counterpart: it creates + * a directory by absolute path (the folder picker's "new folder" backend). It + * is TEMPORARILY implemented directly on `node:fs/promises.mkdir` here in the + * transport layer; the engine deliberately has no "unconfined write" domain + * Service, same as the read side. + * * `fs::content` is a server-v2 addition with no v1 counterpart: it serves ANY * absolute path on the host as a raw byte stream, so the global bearer auth * is its only access gate. The response is plain file content (no envelope) @@ -34,6 +40,7 @@ * GET /fs::browse?path= list sub-directories (v1 mirror) * GET /fs::home $HOME + recent workspace roots (v1 mirror) * GET /fs::content?path= raw content of any host file (server-v2 addition) + * POST /fs::mkdir { path } create a directory by absolute path (server-v2 addition) * * **Wire path vs source path.** The source path strings carry a double colon * (`/fs::browse`, `/fs::home`) because that is the v1 declaration this mirror @@ -48,6 +55,7 @@ */ import { createReadStream, type ReadStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; import { @@ -96,6 +104,14 @@ interface WorkspaceFsRouteHost { reply: FsContentReply, ) => Promise | void, ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; body: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; } export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope): void { @@ -177,6 +193,33 @@ export function registerWorkspaceFsRoutes(app: WorkspaceFsRouteHost, core: Scope contentRoute.options, contentRoute.handler as unknown as Parameters[2], ); + + const mkdirRoute = defineRoute( + { + method: 'POST', + path: '/fs::mkdir', + body: fsMkdirBodySchema, + success: { data: fsMkdirResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: {}, + [ErrorCode.FS_PATH_NOT_FOUND]: {}, + [ErrorCode.FS_PERMISSION_DENIED]: {}, + [ErrorCode.FS_ALREADY_EXISTS]: {}, + }, + description: + 'Create a directory on the host filesystem by absolute path (folder-picker "new folder" backend). Non-recursive: the parent directory must already exist.', + tags: ['workspaces'], + operationId: 'fsMkdir', + }, + async (req, reply) => { + return handleFsMkdir(req, reply); + }, + ); + app.post( + mkdirRoute.path, + mkdirRoute.options, + mkdirRoute.handler as unknown as Parameters[2], + ); } // --------------------------------------------------------------------------- @@ -290,6 +333,68 @@ async function handleFsContent( return reply.send(stream) as unknown as void; } +// --------------------------------------------------------------------------- +// fs:mkdir — host-side directory creation, temporarily on node fs directly. +// --------------------------------------------------------------------------- + +const fsMkdirBodySchema = z.object({ + path: z.string().min(1), +}); + +const fsMkdirResponseSchema = z.object({ + path: z.string(), +}); + +interface FsMkdirRequest { + id: string; + body: { path: string }; +} + +async function handleFsMkdir( + req: FsMkdirRequest, + reply: { send(payload: unknown): unknown }, +): Promise { + const requestId = req.id; + const { path } = req.body; + if (!isAbsolute(path)) { + reply.send( + errEnvelope(ErrorCode.VALIDATION_FAILED, `path must be absolute: ${path}`, requestId), + ); + return; + } + + // Non-recursive on purpose: the folder picker creates one level at a time, + // and a missing parent surfacing as fs.path_not_found beats silently + // creating a deep tree the user mistyped. + try { + await mkdir(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + switch (code) { + case 'EEXIST': + reply.send( + errEnvelope(ErrorCode.FS_ALREADY_EXISTS, `path already exists: ${path}`, requestId), + ); + return; + case 'ENOENT': + case 'ENOTDIR': + reply.send( + errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `parent path not found: ${path}`, requestId), + ); + return; + case 'EACCES': + case 'EPERM': + reply.send( + errEnvelope(ErrorCode.FS_PERMISSION_DENIED, `permission denied: ${path}`, requestId), + ); + return; + } + throw err; + } + + reply.send(okEnvelope({ path }, requestId)); +} + /** Map a coded `os.fs.*` failure from `IHostFileSystem` onto the wire codes. */ function sendOsFsError( reply: { send(payload: unknown): unknown }, diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 4203a17505..844b2f304b 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -264,6 +264,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/files", ], + [ + "POST", + "/api/v1/fs:mkdir", + ], [ "POST", "/api/v1/gui/store/clear", diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 0073418f39..b6a2db27d9 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -152,9 +152,16 @@ describe('server-v2 /api/v1/sessions', () => { JSON.stringify({ event: 'prompt.submitted', time: 2 }), ].join('\n'); + // `connection: close` keeps the streamed download on a short-lived socket + // so undici never pools a keep-alive connection that would hold + // `server.close()` open in afterEach (fastify's default keepAliveTimeout + // is 72s, far beyond the hook timeout). const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + headers: authHeaders(server as RunningServer, { + 'content-type': 'application/json', + connection: 'close', + }), body: JSON.stringify({ web_log: webLog }), } as never); const archive = Buffer.from(await res.arrayBuffer()); @@ -201,7 +208,10 @@ describe('server-v2 /api/v1/sessions', () => { const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + headers: authHeaders(server as RunningServer, { + 'content-type': 'application/json', + connection: 'close', + }), body: '{}', } as never); const reader = res.body?.getReader(); @@ -241,7 +251,10 @@ describe('server-v2 /api/v1/sessions', () => { const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { method: 'POST', - headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + headers: authHeaders(server as RunningServer, { + 'content-type': 'application/json', + connection: 'close', + }), body: JSON.stringify({ desktop: true }), } as never); const archive = Buffer.from(await res.arrayBuffer()); diff --git a/packages/kap-server/test/workspaceFs.test.ts b/packages/kap-server/test/workspaceFs.test.ts index 7ef2bdafe0..a581ede98c 100644 --- a/packages/kap-server/test/workspaceFs.test.ts +++ b/packages/kap-server/test/workspaceFs.test.ts @@ -181,6 +181,107 @@ describe('server-v2 /api/v1 fs folder picker', () => { }); }); +describe('server-v2 /api/v1 fs:mkdir', () => { + let server: RunningServer | undefined; + let dir: string | undefined; + let instancesDir: string | undefined; + let base: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-')); + instancesDir = await mkdtemp(join(tmpdir(), 'kimi-server-v2-fsmkdir-instances-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: dir, + instancesDir, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (dir !== undefined) { + await rm(dir, { recursive: true, force: true }); + dir = undefined; + } + if (instancesDir !== undefined) { + await rm(instancesDir, { recursive: true, force: true }); + instancesDir = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify(body), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('creates a directory that fs:browse then lists', async () => { + const target = join(dir as string, 'fresh-folder'); + + const { status, body } = await postJson<{ path: string }>('/api/v1/fs:mkdir', { + path: target, + }); + expect(status).toBe(200); + expect(body.code).toBe(0); + expect(body.data.path).toBe(target); + + const browse = await fetch( + `${base}/api/v1/fs:browse?path=${encodeURIComponent(dir as string)}`, + { headers: authHeaders(server as RunningServer) } as never, + ); + const browseBody = (await browse.json()) as Envelope; + expect(browseBody.data.entries.map((e) => e.name)).toContain('fresh-folder'); + }); + + it('rejects a relative path (40001)', async () => { + const { body } = await postJson('/api/v1/fs:mkdir', { path: 'relative/folder' }); + expect(body.code).toBe(40001); + }); + + it('rejects an existing directory (40919)', async () => { + const target = join(dir as string, 'already-here'); + await mkdir(target); + + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40919); + }); + + it('rejects an existing file (40919)', async () => { + const target = join(dir as string, 'file.txt'); + await writeFile(target, 'hi'); + + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40919); + }); + + it('rejects a missing parent (40409)', async () => { + const target = join(dir as string, 'no-such-parent', 'child'); + const { body } = await postJson('/api/v1/fs:mkdir', { path: target }); + expect(body.code).toBe(40409); + }); + + it('does not serve the double-colon URL', async () => { + const res = await fetch(`${base}/api/v1/fs::mkdir`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ path: join(dir as string, 'x') }), + } as never); + expect(res.status).toBe(404); + }); +}); + describe('server-v2 /api/v1 fs:content', () => { let server: RunningServer | undefined; let dir: string | undefined; From 425cfdf53f0fd3b01527f5fba87acff68f49f368 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 28 Jul 2026 13:25:38 +0800 Subject: [PATCH 02/17] chore(web): upgrade markstream-vue to 1.0.7 (#2294) * chore(web): upgrade markstream-vue to 1.0.7 Fix garbled code-block line numbers reported on 0.29.2: the async code-block loading fallback rendered unstyled (proportional font, code overlapping the line-number gutter) and with an over-estimated reserved height that clipped leading lines. markstream-vue 1.0.6 adds self-contained fallback styles and 1.0.7 fixes the height estimate. * chore(nix): update pnpmDeps hash for markstream-vue 1.0.7 * chore(web): scope lockfile update to the markstream-vue graph Regenerate pnpm-lock.yaml with a plain install so only markstream-vue and its transitive deps move (markdown-it-ts, stream-markdown-parser, etc.); unrelated toolchains (e.g. lightningcss in kimi-inspect) stay pinned as before. Addresses review feedback. * chore(nix): update pnpmDeps hash after lockfile scoping * chore(changeset): shorten release note to one user-facing sentence --- .changeset/upgrade-markstream-vue-1-0-7.md | 5 ++ apps/kimi-web/package.json | 2 +- flake.nix | 2 +- pnpm-lock.yaml | 66 +++++++++------------- 4 files changed, 35 insertions(+), 40 deletions(-) create mode 100755 .changeset/upgrade-markstream-vue-1-0-7.md diff --git a/.changeset/upgrade-markstream-vue-1-0-7.md b/.changeset/upgrade-markstream-vue-1-0-7.md new file mode 100755 index 0000000000..50a5692db3 --- /dev/null +++ b/.changeset/upgrade-markstream-vue-1-0-7.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix garbled line numbers in code blocks. diff --git a/apps/kimi-web/package.json b/apps/kimi-web/package.json index 85dc3ac22a..4245ec8728 100644 --- a/apps/kimi-web/package.json +++ b/apps/kimi-web/package.json @@ -18,7 +18,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "katex": "^0.17.0", - "markstream-vue": "^1.0.4", + "markstream-vue": "^1.0.7", "mermaid": "^11.15.0", "shiki": "^4.3.0", "stream-markdown": "^0.0.16", diff --git a/flake.nix b/flake.nix index 7f56b35994..f6d38f6ba8 100644 --- a/flake.nix +++ b/flake.nix @@ -160,7 +160,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-+pzJfoWJwVXIUU8oc56LVpfNjSY6MABID5g11Cm92xw="; + hash = "sha256-k2McTzqvLoSzXQwwHJYdzA4prhJUlOa4JKbDektSHiA="; }; nativeBuildInputs = [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecf1623ff1..8aeee4d5d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,8 +218,8 @@ importers: specifier: ^0.17.0 version: 0.17.0 markstream-vue: - specifier: ^1.0.4 - version: 1.0.4(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) + specifier: ^1.0.7 + version: 1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)) mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -1519,9 +1519,6 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@chenglou/pretext@0.0.5': - resolution: {integrity: sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==} - '@chenglou/pretext@0.0.8': resolution: {integrity: sha512-yqm2GMxnPI7VHcHwe84P8ZF0JK/2d2DMKPqMN+s95jQhwDMYYXKVFVJUMEaVWckQStdsjdLav/0Vu+d9YbtGxA==} @@ -2127,15 +2124,9 @@ packages: '@fastify/swagger@9.7.0': resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/dom@1.8.0': resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} @@ -7141,6 +7132,9 @@ packages: linkify-it@5.0.1: resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + lint-staged@16.4.0: resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} engines: {node: '>=20.17'} @@ -7272,8 +7266,8 @@ packages: markdown-it-task-checkbox@1.0.6: resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==} - markdown-it-ts@1.0.2: - resolution: {integrity: sha512-zba9mN313K2HmKk+BOHqkO/nuZtj9M1TTnUlSbItGrCMpYzc8OHGCm+IaqxWCi2pGcgpiFC8ltxkasYWYpp/YQ==} + markdown-it-ts@1.0.5: + resolution: {integrity: sha512-vD3lIbUDHE0eorCuPdku4ikNFcIJC1ZzyAQA0d/2zjMg+8etvfC10uV8GOlzQBHQc7/NUh7H0VtW72gLjXANCw==} engines: {node: '>=18'} markdown-it@14.2.0: @@ -7311,15 +7305,16 @@ packages: markstream-core@1.0.3: resolution: {integrity: sha512-QXn+yERo1q+RD6YlGDW61zc/af65uhkQEH3K8YvEKkprHbgRJ/JIeBeEQjELCTyBnPoxqtKQ7I565rylY+PePg==} - markstream-vue@1.0.4: - resolution: {integrity: sha512-uWFjW6waY3E8gbfbXx9n8IDc7OFl6hJpdjAXVsAkk6UHtmE0d60JhjyFrsp6OOHtKB408M6gd9HOs3vOXN8Z9w==} + markstream-vue@1.0.7: + resolution: {integrity: sha512-88UVoAH34jh/BXGUVYtK8t+Tw9JETbHhRVF2DwBAIE8r8W6MO27xRADotHpz1Qn34Mas6XRW6NmqvzwwjtvciQ==} peerDependencies: '@antv/infographic': ^0.2.3 '@terrastruct/d2': '>=0.1.33' katex: '>=0.16.22' mermaid: '>=11' + stream-diffs: '>=0.0.2' stream-markdown: '>=0.0.16' - stream-monaco: '>=0.0.45' + stream-monaco: '>=0.0.48' vue: '>=3.0.0' vue-i18n: '>=9' peerDependenciesMeta: @@ -7331,6 +7326,8 @@ packages: optional: true mermaid: optional: true + stream-diffs: + optional: true stream-markdown: optional: true stream-monaco: @@ -8867,8 +8864,8 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stream-markdown-parser@1.0.7: - resolution: {integrity: sha512-IkWYtBv+9QPDzKKOoy1ZxuiwpcL0APfgUrBlUt9L4s0Sq5XnHY9rQK7tOs46ouHOX/OR0Nq6zTqfV0vbtLD4RA==} + stream-markdown-parser@1.1.4: + resolution: {integrity: sha512-fjkHUh7uIOH5Yp/kt26l2td+q/4sRusMFO4iB0d+GcWj40ZZuH+HxxiLxGeSD1oo8YdyapokzHOfchDIYFnNgg==} stream-markdown@0.0.16: resolution: {integrity: sha512-2WoOxlpc3N5RLc3zGuW+g/w76z6ketWBY0N1YzDYbXds1qw7zrUBv5PTQ3DOtpqgCjLuF3P2iCfjJTEqWGv0NQ==} @@ -10509,7 +10506,7 @@ snapshots: '@base-ui/utils@0.3.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) reselect: 5.2.0 @@ -10688,8 +10685,6 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@chenglou/pretext@0.0.5': {} - '@chenglou/pretext@0.0.8': {} '@chevrotain/types@11.1.2': {} @@ -11126,19 +11121,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.8.0': dependencies: '@floating-ui/core': 1.8.0 @@ -16414,6 +16400,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + lint-staged@16.4.0: dependencies: commander: 14.0.3 @@ -16546,12 +16536,12 @@ snapshots: markdown-it-task-checkbox@1.0.6: {} - markdown-it-ts@1.0.2: + markdown-it-ts@1.0.5: dependencies: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 entities: 4.5.0 - linkify-it: 5.0.1 + linkify-it: 5.0.2 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -16588,12 +16578,12 @@ snapshots: markstream-core@1.0.3: {} - markstream-vue@1.0.4(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): + markstream-vue@1.0.7(katex@0.17.0)(mermaid@11.15.0)(stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)))(vue-i18n@11.4.5(vue@3.5.35(typescript@6.0.2)))(vue@3.5.35(typescript@6.0.2)): dependencies: - '@chenglou/pretext': 0.0.5 - '@floating-ui/dom': 1.7.6 + '@chenglou/pretext': 0.0.8 + '@floating-ui/dom': 1.8.0 markstream-core: 1.0.3 - stream-markdown-parser: 1.0.7 + stream-markdown-parser: 1.1.4 vue: 3.5.35(typescript@6.0.2) optionalDependencies: katex: 0.17.0 @@ -18701,7 +18691,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-markdown-parser@1.0.7: + stream-markdown-parser@1.1.4: dependencies: markdown-it-container: 4.0.0 markdown-it-footnote: 4.0.0 @@ -18710,7 +18700,7 @@ snapshots: markdown-it-sub: 2.0.0 markdown-it-sup: 2.0.0 markdown-it-task-checkbox: 1.0.6 - markdown-it-ts: 1.0.2 + markdown-it-ts: 1.0.5 stream-markdown@0.0.16(react@19.2.5)(shiki@4.3.0)(vue@3.5.35(typescript@6.0.2)): dependencies: From e55608845884721fd4cdc5700e36d839348ba2ce Mon Sep 17 00:00:00 2001 From: Haozhe Date: Tue, 28 Jul 2026 14:29:08 +0800 Subject: [PATCH 03/17] feat(tree-sitter-bash): add a pure-TypeScript bash parser and an agent-core-v2 bashParser service (#2016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tree-sitter-bash): scaffold pure-TypeScript bash parser package Add packages/tree-sitter-bash with the SyntaxNode tree model (UTF-16 code-unit offsets, tree-sitter-bash named-node type names), a parse budget (deadline + node cap) with an aborted ParseResult variant, and a placeholder parse() to be replaced by the real lexer/parser. * feat(tree-sitter-bash): add lexer and core recursive-descent parser Cover the permission-analysis grammar subset: lists, pipelines, commands, words, quotes, expansions, command/process substitution, subshells, the full redirect operator set, heredocs and comments, with tree-sitter-bash named-node type names. Long scan loops check the parse deadline via budget.progress() so large literals cannot exhaust the node cap; parse depth is bounded on all recursion chains. * feat(tree-sitter-bash): support the full bash grammar Add compound commands (if/while/until/for/c-style-for/select/case/ function/compound/do_group), test commands with reference-exact extglob/regex right-hand-side rules, a Pratt expression engine for arithmetic expansion shared across arith/c-for/test modes, arrays and subscripts, declaration/unset commands, ansi-c/translated strings and brace expressions. Reserved words are recognized only in statement position. Case-aware balanced scanning keeps command substitutions intact around case items, expression leftovers are kept as ERROR nodes instead of dropped, and recursion depth is bounded per chain (substitution 150, parse 500, lexer scan 1024). * test(tree-sitter-bash): add differential fixtures, corpus, fuzz and perf suites Turn the ad-hoc wasm comparison work into permanent infrastructure: a differential helper pinning reference-equivalent and known-difference samples against the real tree-sitter-bash wasm (478 match / 79 known-diff fixtures plus the official v0.25.0 corpus), a three-way consistency check between fixtures, the known-difference registry and the README, deterministic seeded fuzz with tree-integrity assertions, and performance smoke tests. Converges 15 further divergence groups found by systematic probing and documents the rest; the full suite is 853 tests in ~4s. * feat(agent-core-v2): add App-scope bashParser service Wrap the pure @moonshot-ai/tree-sitter-bash package as IBashParserService (L1, no dependencies): parse(source, options) returns a wire-safe BashParseResult whose nodes drop the cyclic parent link so trees can cross the RPC boundary. Budget exhaustion surfaces as { ok: false, reason: 'aborted' }, malformed input as hasError — never a throw. * chore: add changeset for the bash parser service * chore: update pnpmDeps hash after adding tree-sitter-bash package * fix(agent-core-v2): register bashParser service with ScopeActivation The registration was written against the removed InstantiationType API (#/_base/di/extensions); switch to ScopeActivation.OnDemand from #/_base/di/scope so the package typechecks and the service registers. * feat(kimi-inspect): add Bash Parser view Add a fourth icon-rail tab that exercises the App-scope bash parser service over the debug RPC surface: a source textarea with a parse budget (timeoutMs / maxNodes), a dropdown of curated examples adapted from the tree-sitter-bash differential fixtures, and an expandable syntax tree with per-node type, UTF-16 range and leaf text, plus hasError / aborted / node-count badges. * fix(agent-core-v2): snapshot bash syntax trees iteratively A long left-associative chain (e.g. an arithmetic expression with a few thousand operands) parses into a tree thousands of levels deep while still within budget; the recursive DTO conversion then overflowed the JS call stack and made parse throw RangeError, breaking the never-throws contract. Convert the tree with an explicit stack, the same approach as the parser's own materialize. * feat(kimi-inspect): add a deep-arithmetic example to the Bash Parser view A thousand-operand left-associative chain fills the textarea with a thousand-level binary_expression tree — the shape that once overflowed the DTO conversion. Deeper chains still parse in-process but cannot cross the JSON RPC transport (V8 call-stack limit in serialization), so the example stays within the wire limit. * chore: update pnpmDeps hash for the rebased lockfile The rebase onto main merged pnpm-lock.yaml, invalidating the recorded fetchPnpmDeps hash; use the hash CI computed for the merged lockfile. --- .changeset/bash-parser-service.md | 5 + AGENTS.md | 1 + apps/kimi-inspect/src/App.tsx | 7 +- .../src/components/BashParserView.tsx | 279 ++ apps/kimi-inspect/src/components/NavRail.tsx | 12 +- flake.nix | 4 +- packages/agent-core-v2/package.json | 1 + .../scripts/check-domain-layers.mjs | 5 + .../src/app/bashParser/bashParser.ts | 42 + .../src/app/bashParser/bashParserService.ts | 79 + packages/agent-core-v2/src/index.ts | 2 + .../app/bashParser/bashParserService.test.ts | 93 + packages/tree-sitter-bash/README.md | 273 ++ packages/tree-sitter-bash/package.json | 47 + packages/tree-sitter-bash/src/budget.ts | 69 + packages/tree-sitter-bash/src/grammar.ts | 134 + packages/tree-sitter-bash/src/index.ts | 4 + packages/tree-sitter-bash/src/lexer.ts | 629 +++ packages/tree-sitter-bash/src/node.ts | 121 + packages/tree-sitter-bash/src/parse.ts | 46 + packages/tree-sitter-bash/src/parser.ts | 3787 +++++++++++++++++ .../test/differential.test.ts | 169 + .../tree-sitter-bash/test/fixtures/README.md | 42 + .../test/fixtures/corpus/README.md | 21 + .../test/fixtures/corpus/commands.txt | 725 ++++ .../test/fixtures/corpus/crlf.txt | 13 + .../test/fixtures/corpus/known-diffs.txt | 2009 +++++++++ .../test/fixtures/corpus/literals.txt | 1371 ++++++ .../test/fixtures/corpus/programs.txt | 108 + .../test/fixtures/corpus/statements.txt | 1622 +++++++ .../test/fixtures/differential/arithmetic.txt | 236 + .../test/fixtures/differential/case.txt | 436 ++ .../test/fixtures/differential/expansions.txt | 599 +++ .../test/fixtures/differential/heredoc.txt | 597 +++ .../test/fixtures/differential/recovery.txt | 112 + .../test/fixtures/differential/redirects.txt | 120 + .../test/fixtures/differential/statements.txt | 391 ++ .../fixtures/differential/test-command.txt | 650 +++ packages/tree-sitter-bash/test/fuzz.test.ts | 165 + .../test/helpers/differential.ts | 271 ++ .../test/helpers/known-differences.ts | 179 + packages/tree-sitter-bash/test/node.test.ts | 132 + packages/tree-sitter-bash/test/parse.test.ts | 519 +++ .../test/parser-compound.test.ts | 1232 ++++++ .../tree-sitter-bash/test/performance.test.ts | 95 + packages/tree-sitter-bash/tsconfig.json | 5 + packages/tree-sitter-bash/tsdown.config.ts | 13 + packages/tree-sitter-bash/vitest.config.ts | 8 + pnpm-lock.yaml | 47 + 49 files changed, 17524 insertions(+), 3 deletions(-) create mode 100644 .changeset/bash-parser-service.md create mode 100644 apps/kimi-inspect/src/components/BashParserView.tsx create mode 100644 packages/agent-core-v2/src/app/bashParser/bashParser.ts create mode 100644 packages/agent-core-v2/src/app/bashParser/bashParserService.ts create mode 100644 packages/agent-core-v2/test/app/bashParser/bashParserService.test.ts create mode 100644 packages/tree-sitter-bash/README.md create mode 100644 packages/tree-sitter-bash/package.json create mode 100644 packages/tree-sitter-bash/src/budget.ts create mode 100644 packages/tree-sitter-bash/src/grammar.ts create mode 100644 packages/tree-sitter-bash/src/index.ts create mode 100644 packages/tree-sitter-bash/src/lexer.ts create mode 100644 packages/tree-sitter-bash/src/node.ts create mode 100644 packages/tree-sitter-bash/src/parse.ts create mode 100644 packages/tree-sitter-bash/src/parser.ts create mode 100644 packages/tree-sitter-bash/test/differential.test.ts create mode 100644 packages/tree-sitter-bash/test/fixtures/README.md create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/README.md create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/commands.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/crlf.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/known-diffs.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/literals.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/programs.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/corpus/statements.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/arithmetic.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/case.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/expansions.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/heredoc.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/recovery.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/redirects.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/statements.txt create mode 100644 packages/tree-sitter-bash/test/fixtures/differential/test-command.txt create mode 100644 packages/tree-sitter-bash/test/fuzz.test.ts create mode 100644 packages/tree-sitter-bash/test/helpers/differential.ts create mode 100644 packages/tree-sitter-bash/test/helpers/known-differences.ts create mode 100644 packages/tree-sitter-bash/test/node.test.ts create mode 100644 packages/tree-sitter-bash/test/parse.test.ts create mode 100644 packages/tree-sitter-bash/test/parser-compound.test.ts create mode 100644 packages/tree-sitter-bash/test/performance.test.ts create mode 100644 packages/tree-sitter-bash/tsconfig.json create mode 100644 packages/tree-sitter-bash/tsdown.config.ts create mode 100644 packages/tree-sitter-bash/vitest.config.ts diff --git a/.changeset/bash-parser-service.md b/.changeset/bash-parser-service.md new file mode 100644 index 0000000000..05e5f34208 --- /dev/null +++ b/.changeset/bash-parser-service.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add an internal bash parsing capability that turns shell command strings into syntax trees, in preparation for per-command permission analysis. No user-facing behavior change yet. diff --git a/AGENTS.md b/AGENTS.md index 31b25124b4..0ba8ee0b65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. +- `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). ## Environment Requirements diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 07ed0703b0..b7c0b67de5 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -8,7 +8,9 @@ * / State tabs), the chat column, and the right dock (`RightPanel`) merging * the transcript audit and the agent inspector under Audit / Agent tabs; * the `models` view is the full-width model catalog; the `services` view is - * the full-width app-scope Service reflection (`AppServicesView`). + * the full-width app-scope Service reflection (`AppServicesView`); the + * `bash` view is the full-width `IBashParserService` playground + * (`BashParserView`). */ import { useEffect, useState } from 'react'; @@ -17,6 +19,7 @@ import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/session import type { AuditTrail } from './audit/trail'; import { AppServicesView } from './components/AppServicesView'; +import { BashParserView } from './components/BashParserView'; import { ChatView } from './components/ChatView'; import { ModelCatalogView } from './components/ModelCatalogView'; import { NavRail, type AppView } from './components/NavRail'; @@ -82,6 +85,8 @@ export function App() { {view === 'services' ? ( + ) : view === 'bash' ? ( + ) : view === 'models' ? ( { diff --git a/apps/kimi-inspect/src/components/BashParserView.tsx b/apps/kimi-inspect/src/components/BashParserView.tsx new file mode 100644 index 0000000000..1532705722 --- /dev/null +++ b/apps/kimi-inspect/src/components/BashParserView.tsx @@ -0,0 +1,279 @@ +/** + * Bash Parser view — a playground for the App-scope `IBashParserService` + * (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`). + * + * left: the bash source textarea plus the parse budget (timeoutMs / + * maxNodes, empty = package default); the `examples…` dropdown + * fills the textarea with curated snippets from the parser's own + * differential fixtures; + * right: the parse result — status badges (hasError / aborted / node + * count) and the syntax tree, one row per node with its type, + * UTF-16 range and (for leaves) the source text. Anonymous tokens + * are dimmed; rows expand/collapse. + * + * Parsing is debounced off the textarea and rides the same `/api/v1/debug` + * channel as every other panel (`klient.core(IBashParserService).parse`) — + * the budgeted parse never throws, `{ ok: false }` means budget exhaustion. + */ + +import { useEffect, useState } from 'react'; + +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser'; + +import { useConnection } from '../connection'; +import { Badge, errorMessage } from '../ui'; + +const DEFAULT_SOURCE = `if [ -f config.sh ]; then + source config.sh && echo "loaded" | tee -a setup.log +else + echo "missing" >&2; exit 1 +fi +`; + +const PARSE_DEBOUNCE_MS = 300; + +/** + * Quick-fill examples, adapted from the parser's own differential fixtures + * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one + * exercises a distinct area of the grammar. The last three probe the + * non-happy paths: deep nesting (a left-associative arithmetic chain, the + * case that once overflowed the DTO conversion) and the error-recovery + * paths that set `hasError`. + */ +const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [ + { + name: 'deep arithmetic (1000 operands)', + // A thousand left-nested binary_expression levels. Deeper chains parse + // fine in-process, but past ~2500 levels the JSON RPC transport itself + // cannot serialize the tree (V8 call-stack limit in JSON.stringify). + source: `echo $((${'1+'.repeat(1000)}1))`, + }, + { + name: 'pipeline & redirects', + source: `git log --oneline | head -20 | tee /tmp/log.txt +find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO +cmd <<< "$input" >out.txt 2>&1 +`, + }, + { + name: 'case statement', + source: `case $x in + a) echo A ;; + b|c) echo BC ;& + foo*|bar) echo match ;; + [a-z]) echo lower ;; + *) echo other ;; +esac +`, + }, + { + name: 'heredoc', + source: `foo() { cat < sum + countNodes(child), 0); +} + +export function BashParserView() { + const { klient } = useConnection(); + const [source, setSource] = useState(DEFAULT_SOURCE); + const [timeoutMs, setTimeoutMs] = useState(''); + const [maxNodes, setMaxNodes] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handle = setTimeout(() => { + klient + .core(IBashParserService) + .parse(source, { + timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs), + maxNodes: maxNodes === '' ? undefined : Number(maxNodes), + }) + .then(setResult, (e: unknown) => { + setResult(null); + setError(errorMessage(e)); + }); + }, PARSE_DEBOUNCE_MS); + return () => { + clearTimeout(handle); + }; + }, [klient, source, timeoutMs, maxNodes]); + + const nodeCount = result !== null && result.ok ? countNodes(result.root) : null; + + return ( +
+
+
+ bash source + +
+ + +
+