From 48e942e88f064e956c7b08bc04afe85d00fe7806 Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 26 Jun 2026 17:31:48 +0800 Subject: [PATCH 1/2] fix(tui): show the server token when handing off via /web The /web slash command opened the session deep link without the bearer token, so the web UI was not authenticated and the token was never shown, unlike the kimi web subcommand. Resolve the persistent server token, append it as the #token= fragment so the browser signs in on load, and show it in green below the status line so it can be copied before the terminal exits. --- .changeset/web-slash-token.md | 5 + apps/kimi-code/src/tui/commands/web.ts | 25 +++- apps/kimi-code/test/cli/run-shell.test.ts | 2 +- apps/kimi-code/test/tui/commands/web.test.ts | 126 ++++++++++++++++++- 4 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 .changeset/web-slash-token.md diff --git a/.changeset/web-slash-token.md b/.changeset/web-slash-token.md new file mode 100644 index 0000000000..55486fe2a7 --- /dev/null +++ b/.changeset/web-slash-token.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits. diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 215b0fc6cf..366860016e 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,5 +1,7 @@ import { ensureDaemon } from '#/cli/sub/server/daemon'; +import { tryResolveServerToken } from '#/cli/sub/server/shared'; import { openUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; @@ -63,13 +65,28 @@ export async function handleWebCommand(host: SlashCommandHost): Promise { return; } - const url = webSessionUrl(origin, sessionId); + // Resolve the persistent token so the opened browser auto-authenticates via + // the `#token=` fragment — matching the `kimi web` subcommand. Show the URL + // and token in green under the status line so they can be copied before the + // terminal exits. Best-effort: an older/never-started server has no token + // file, so we fall back to the plain URL and skip the token line. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + host.showStatus(`open ${url}`, 'success'); + if (token !== undefined) { + host.showStatus(`Token: ${token}`, 'success'); + } openUrl(url); host.setExitOpenUrl(url); await host.stop(); } -/** Build the deep-link URL the web UI recognises for a session. */ -export function webSessionUrl(origin: string, sessionId: string): string { - return `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; +/** + * Build the deep-link URL the web UI recognises for a session. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `kimi web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; } diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index cf273a89d5..c8b6ca159b 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -594,7 +594,7 @@ describe('runShell', () => { '1.2.3-test', ); const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; - const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1'; + const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1'; (tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl; await expect((tui as { onExit: () => Promise }).onExit()).rejects.toBeInstanceOf( diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 5692d1d7af..cbd6e5eec3 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,7 +1,63 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; -import { webSessionUrl } from '#/tui/commands/web'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; + +const mocks = vi.hoisted(() => ({ + ensureDaemon: vi.fn(), + tryResolveServerToken: vi.fn(), + getDataDir: vi.fn(() => '/tmp/kimi-home'), + openUrl: vi.fn(), +})); + +vi.mock('#/cli/sub/server/daemon', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ensureDaemon: mocks.ensureDaemon }; +}); + +vi.mock('#/cli/sub/server/shared', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken }; +}); + +vi.mock('#/utils/open-url', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, openUrl: mocks.openUrl }; +}); + +vi.mock('#/utils/paths', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getDataDir: mocks.getDataDir }; +}); + +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; + +function makeHost() { + let mountedPanel: MountedPanel | null = null; + const host = { + session: { id: 'ses-1' }, + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(), + setExitOpenUrl: vi.fn(), + stop: vi.fn(async () => {}), + } as unknown as SlashCommandHost & { + showStatus: ReturnType; + showError: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + setExitOpenUrl: ReturnType; + stop: ReturnType; + }; + return { host, getMountedPanel: () => mountedPanel }; +} describe('web slash command', () => { it('is registered as an always-available built-in', () => { @@ -11,6 +67,60 @@ describe('web slash command', () => { }); }); +describe('handleWebCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDataDir.mockReturnValue('/tmp/kimi-home'); + mocks.ensureDaemon.mockResolvedValue({ + origin: 'http://127.0.0.1:58627', + reused: false, + host: '127.0.0.1', + port: 58627, + }); + }); + + it('shows the token in green and opens the deep link carrying the token fragment', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + 'success', + ); + expect(host.showStatus).toHaveBeenCalledWith('Token: tok-1', 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.setExitOpenUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + expect(host.stop).toHaveBeenCalledOnce(); + }); + + it('skips the token line and fragment when no token is available', async () => { + mocks.tryResolveServerToken.mockReturnValue(undefined); + const { host, getMountedPanel } = makeHost(); + + const pending = handleWebCommand(host); + getMountedPanel()?.handleInput('\r'); + await pending; + + expect(host.showStatus).toHaveBeenCalledWith('Starting Kimi server and opening web UI…'); + expect(host.showStatus).toHaveBeenCalledWith( + 'open http://127.0.0.1:58627/sessions/ses-1', + 'success', + ); + expect(host.showStatus).not.toHaveBeenCalledWith(expect.stringContaining('Token:'), 'success'); + expect(mocks.openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + expect(host.setExitOpenUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/sessions/ses-1'); + }); +}); + describe('webSessionUrl', () => { it('deep-links to the session under the origin', () => { expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( @@ -29,4 +139,16 @@ describe('webSessionUrl', () => { 'http://127.0.0.1:58627/sessions/a%2Fb%20c', ); }); + + it('carries the bearer token in the fragment so the browser authenticates on load', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', 'tok-1')).toBe( + 'http://127.0.0.1:58627/sessions/abc123#token=tok-1', + ); + }); + + it('omits the fragment when no token is available', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', undefined)).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); }); From 5dbc32a5b9bf8b61dae936c6844219e43500161d Mon Sep 17 00:00:00 2001 From: qer Date: Fri, 26 Jun 2026 17:57:10 +0800 Subject: [PATCH 2/2] test(plugins-selector): add required hookCount to fixtures PluginSummary/PluginInfo gained a required hookCount field, so the app's typecheck failed on fixtures that did not provide it. Add hookCount: 0 to the test summaries (none of these fixtures declare hooks). --- .../test/tui/components/dialogs/plugins-selector.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 83475e345c..bb213b698a 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -54,6 +54,7 @@ const superpowers = { skillCount: 14, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, hasErrors: false, source: 'local-path' as const, }; @@ -98,6 +99,7 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', @@ -110,6 +112,7 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip', @@ -122,6 +125,7 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, hasErrors: false, source: 'zip-url', originalSource: 'https://code.kimi.com/demo.zip', @@ -134,6 +138,7 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, hasErrors: false, source: 'local-path', originalSource: 'https://code.kimi.com/kimi-code/plugins/official/local', @@ -416,6 +421,7 @@ describe('plugins selector dialogs', () => { skillCount: 1, mcpServerCount: 1, enabledMcpServerCount: 1, + hookCount: 0, hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z',