mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(tui): carry server token in /web and print it on exit (#1133)
* 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. * 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).
This commit is contained in:
parent
e5eaeb4634
commit
f1c8175f9c
5 changed files with 157 additions and 7 deletions
5
.changeset/web-slash-token.md
Normal file
5
.changeset/web-slash-token.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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<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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue