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', + ); + }); }); 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',