Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-slash-token.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 21 additions & 4 deletions apps/kimi-code/src/tui/commands/web.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -63,13 +65,28 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> {
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}`;
}
2 changes: 1 addition & 1 deletion apps/kimi-code/test/cli/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> }).onExit()).rejects.toBeInstanceOf(
Expand Down
126 changes: 124 additions & 2 deletions apps/kimi-code/test/tui/commands/web.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('#/cli/sub/server/daemon')>();
return { ...actual, ensureDaemon: mocks.ensureDaemon };
});

vi.mock('#/cli/sub/server/shared', async (importOriginal) => {
const actual = await importOriginal<typeof import('#/cli/sub/server/shared')>();
return { ...actual, tryResolveServerToken: mocks.tryResolveServerToken };
});

vi.mock('#/utils/open-url', async (importOriginal) => {
const actual = await importOriginal<typeof import('#/utils/open-url')>();
return { ...actual, openUrl: mocks.openUrl };
});

vi.mock('#/utils/paths', async (importOriginal) => {
const actual = await importOriginal<typeof import('#/utils/paths')>();
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<typeof vi.fn>;
showError: ReturnType<typeof vi.fn>;
mountEditorReplacement: ReturnType<typeof vi.fn>;
restoreEditor: ReturnType<typeof vi.fn>;
setExitOpenUrl: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
return { host, getMountedPanel: () => mountedPanel };
}

describe('web slash command', () => {
it('is registered as an always-available built-in', () => {
Expand All @@ -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(
Expand All @@ -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',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const superpowers = {
skillCount: 14,
mcpServerCount: 0,
enabledMcpServerCount: 0,
hookCount: 0,
hasErrors: false,
source: 'local-path' as const,
};
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading