diff --git a/.changeset/add-remote-control.md b/.changeset/add-remote-control.md new file mode 100644 index 000000000..321ecc0ce --- /dev/null +++ b/.changeset/add-remote-control.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add Remote Control as an experimental feature for accessing a local web session remotely. Enable it with `KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL=1`, then run `kimi rc`, `kimi web --remote-control`, or `/remote-control` to start it. diff --git a/.changeset/broadcast-user-prompts.md b/.changeset/broadcast-user-prompts.md new file mode 100644 index 000000000..af1f4bcbf --- /dev/null +++ b/.changeset/broadcast-user-prompts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix messages sent from one web client not appearing on other clients connected to the same session. diff --git a/.changeset/drop-allow-remote-terminals.md b/.changeset/drop-allow-remote-terminals.md new file mode 100644 index 000000000..a4a35a6ad --- /dev/null +++ b/.changeset/drop-allow-remote-terminals.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Remove the `--allow-remote-terminals` flag from `kimi web`; PTY terminal routes now stay available on loopback binds only. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index e072ad469..681eaed3c 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -94,7 +94,9 @@ "@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-web": "workspace:*", + "@types/qrcode": "^1.5.6", "@types/semver": "^7.7.0", + "@types/ws": "^8.18.0", "@types/yazl": "^2.4.6", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", @@ -110,5 +112,9 @@ }, "engines": { "node": ">=22.19.0" + }, + "dependencies": { + "qrcode": "^1.5.4", + "ws": "^8.18.0" } } diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts index 8cf840671..c7bb5d836 100644 --- a/apps/kimi-code/src/cli/sub/web/index.ts +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -13,6 +13,7 @@ import type { Command } from 'commander'; import { registerDeprecatedServerCommand } from './deprecated-server'; +import { isRemoteControlEnabled } from './remote-control'; import { registerRotateTokenCommand } from './rotate-token'; import { buildWebCommand } from './run'; @@ -24,4 +25,11 @@ export function registerWebCommand(program: Command): void { ); registerRotateTokenCommand(web); registerDeprecatedServerCommand(program); + buildWebCommand( + program + .command('rc', { hidden: !isRemoteControlEnabled() }) + .alias('remote') + .description('Run the local Kimi server and open the web UI through Remote Control (experimental).'), + { forceRemoteControl: true }, + ); } diff --git a/apps/kimi-code/src/cli/sub/web/remote-control-lock.ts b/apps/kimi-code/src/cli/sub/web/remote-control-lock.ts new file mode 100644 index 000000000..bc5d02960 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/remote-control-lock.ts @@ -0,0 +1,170 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export interface RemoteControlLockInfo { + readonly pid: number; + readonly nonce: string; + readonly localOrigin: string; + readonly deviceId: string; + readonly url: string; + readonly startedAt: number; +} + +interface RemoteControlLockDisk { + readonly pid: number; + readonly nonce: string; + readonly local_origin: string; + readonly device_id: string; + readonly url: string; + readonly started_at: number; +} + +export class RemoteControlAlreadyRunningError extends Error { + readonly holder: RemoteControlLockInfo; + + constructor(holder: RemoteControlLockInfo) { + super(formatRemoteControlAlreadyRunning(holder)); + this.name = 'RemoteControlAlreadyRunningError'; + this.holder = holder; + } +} + +export function formatRemoteControlAlreadyRunning(holder: RemoteControlLockInfo): string { + return [ + `Remote Control is already running on this machine (pid ${holder.pid}, ${holder.localOrigin}, since ${new Date(holder.startedAt).toLocaleString()}).`, + `Use the existing link: ${holder.url}`, + 'To start a new one here, stop the other `kimi web --remote-control` process first.', + ].join('\n'); +} + +export function remoteControlLockPath(homeDir: string): string { + return join(homeDir, 'server', 'rc.json'); +} + +export interface RemoteControlLock { + release(): Promise; +} + +const MAX_ACQUIRE_ATTEMPTS = 3; + +export async function acquireRemoteControlLock( + homeDir: string, + details: { localOrigin: string; deviceId: string; url: string }, +): Promise { + const lockPath = remoteControlLockPath(homeDir); + await mkdir(dirname(lockPath), { recursive: true }); + const info: RemoteControlLockInfo = { + pid: process.pid, + nonce: randomBytes(8).toString('hex'), + localOrigin: details.localOrigin, + deviceId: details.deviceId, + url: details.url, + startedAt: Date.now(), + }; + for (let attempt = 0; ; attempt += 1) { + try { + const handle = await open(lockPath, 'wx'); + try { + await handle.writeFile(encodeLock(info)); + } finally { + await handle.close(); + } + return { release: () => releaseRemoteControlLock(lockPath, info.nonce) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || attempt >= MAX_ACQUIRE_ATTEMPTS) { + throw error; + } + const holder = await readRemoteControlLock(lockPath); + if (holder !== undefined && pidAlive(holder.pid)) { + throw new RemoteControlAlreadyRunningError(holder); + } + await removeFile(lockPath); + } + } +} + +export async function inspectRemoteControlLock( + homeDir: string, +): Promise { + const lockPath = remoteControlLockPath(homeDir); + const info = await readRemoteControlLock(lockPath); + if (info === undefined) return undefined; + if (!pidAlive(info.pid)) { + await removeFile(lockPath); + return undefined; + } + return info; +} + +async function releaseRemoteControlLock(lockPath: string, nonce: string): Promise { + const info = await readRemoteControlLock(lockPath); + if (info === undefined || info.nonce !== nonce) return; + await removeFile(lockPath); +} + +async function readRemoteControlLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + return decodeLock(raw); +} + +function encodeLock(info: RemoteControlLockInfo): string { + const disk: RemoteControlLockDisk = { + pid: info.pid, + nonce: info.nonce, + local_origin: info.localOrigin, + device_id: info.deviceId, + url: info.url, + started_at: info.startedAt, + }; + return JSON.stringify(disk); +} + +function decodeLock(raw: string): RemoteControlLockInfo | undefined { + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.pid === 'number' && + typeof parsed.nonce === 'string' && + typeof parsed.local_origin === 'string' && + typeof parsed.device_id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.started_at === 'number' + ) { + return { + pid: parsed.pid, + nonce: parsed.nonce, + localOrigin: parsed.local_origin, + deviceId: parsed.device_id, + url: parsed.url, + startedAt: parsed.started_at, + }; + } + return undefined; + } catch { + return undefined; + } +} + +async function removeFile(lockPath: string): Promise { + try { + await unlink(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } +} + +function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false; + return true; + } +} diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts new file mode 100644 index 000000000..d0cf1fe1c --- /dev/null +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -0,0 +1,1007 @@ +import { hostname, platform } from 'node:os'; +import { join } from 'node:path'; +import { request as httpRequest, validateHeaderName, validateHeaderValue } from 'node:http'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { + createKimiDeviceId, + FileTokenStorage, + KIMI_CODE_PROVIDER_NAME, + resolveKimiTokenStorageName, +} from '@moonshot-ai/kimi-code-oauth'; +import { WebSocket, type RawData } from 'ws'; +import chalk from 'chalk'; + +import { getVersion } from '../../version'; +import { darkColors } from '../../../tui/theme/colors'; +import { toTerminalHyperlink } from '../../../utils/terminal-hyperlink'; +import { acquireRemoteControlLock } from './remote-control-lock'; + +export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.kimi.com'; + +export const REMOTE_CONTROL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL'; + +const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export function isRemoteControlEnabled( + env: Readonly> = process.env, +): boolean { + const truthy = (key: string): boolean => + TRUTHY_ENV_VALUES.has((env[key] ?? '').trim().toLowerCase()); + return truthy('KIMI_CODE_EXPERIMENTAL_FLAG') || truthy(REMOTE_CONTROL_FLAG_ENV); +} + +const MAX_HTTP_HEADER_BYTES = 64 * 1024; +const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024; +const HTTP_REQUEST_TIMEOUT_MS = 30_000; +const REGISTER_TIMEOUT_MS = 10_000; +const MAX_RECONNECT_DELAY_MS = 30_000; +const BLOCKED_REQUEST_HEADERS = new Set([ + 'authorization', + 'cookie', + 'host', + 'origin', + 'proxy-authorization', + 'proxy-authenticate', + 'accept-encoding', + 'connection', + 'keep-alive', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const BLOCKED_RESPONSE_HEADERS = new Set([ + 'connection', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +interface RelayMessage { + readonly type: string; + readonly payload?: Record; +} + +interface PendingHttpRequest { + readonly chunks: Buffer[]; + size: number; +} + +export interface ParsedRawHttpRequest { + readonly method: string; + readonly path: string; + readonly headers: readonly [string, string][]; + readonly body: Buffer; +} + +export type RemoteControlStatus = + | 'relay_connected' + | 'relay_disconnected' + | 'device_connected' + | 'device_disconnected'; + +export interface RemoteControlOptions { + readonly homeDir: string; + readonly localOrigin: string; + readonly localServerToken: string; + readonly relayOrigin?: string; + readonly stderr?: Pick; + readonly onStatus?: (status: RemoteControlStatus) => void; +} + +export interface RemoteControlHandle { + readonly deviceId: string; + readonly deviceName: string; + readonly url: string; + close(): Promise; +} + +interface ActiveStream { + readonly local: WebSocket; + readonly tunnel: WebSocket; +} + +class RegistrationError extends Error {} + +export interface RemoteControlOutputOptions { + readonly url: string; + readonly localOrigin: string; + readonly deviceName: string; + readonly qrCode: string; + readonly pngPath: string; +} + +export function formatRemoteControlOutput(options: RemoteControlOutputOptions): string { + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const status = (text: string): string => chalk.hex(darkColors.success)(text); + const link = (url: string): string => + toTerminalHyperlink(accent(shortRemoteControlUrl(url)), url); + const docs = toTerminalHyperlink('docs', 'https://kimi.com/code/docs/remote-control'); + const feedback = toTerminalHyperlink('feedback', 'https://kimi.com/code/feedback'); + return [ + '', + ` ${title('Kimi Remote Control ready')} ${muted(`${getVersion()} (experimental)`)}`, + ` ${muted('Use Kimi Code on this machine from your phone or another computer.')}`, + '', + ` ${label('1.')} Scan the QR code, or open ${link(options.url)}`, + ` ${label('2.')} Log in with your Kimi account`, + ` ${label('3.')} Start chatting — sessions run on this machine`, + '', + ` ${status('✓')} ${muted(`Connected to ${new URL(options.url).host}, waiting for remote devices…`)}`, + ` ${label('This device: ')}${muted(options.deviceName)} ${label('· Manage devices (max 5): ')}${link(new URL('/devices', new URL(options.url).origin).toString())}`, + ` ${status('⚠')} ${muted('This link grants control of this machine. Do not share it.')}`, + '', + options.qrCode.trimEnd(), + ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`, + ` ${label('Local UI: ')}${muted(options.localOrigin)} ${muted('(LAN: --host)')}`, + '', + ` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`, + ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`, + '', + ].join('\\n'); +} + +export function formatRemoteControlStatus(status: RemoteControlStatus): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const value = (text: string): string => chalk.hex(darkColors.success)(text); + switch (status) { + case 'relay_connected': + return ` ${value('✓')} ${label('Connected to relay, waiting for remote devices…')}\\n`; + case 'relay_disconnected': + return ` ${value('!')} ${label('Relay disconnected; reconnecting…')}\\n`; + case 'device_connected': + return ` ${value('✓')} ${label('Remote device connected (1 active session)')}\\n`; + case 'device_disconnected': + return ` ${value('→')} ${label('Remote device disconnected')}\\n`; + } +} + +function shortRemoteControlUrl(url: string): string { + const parsed = new URL(url); + const parts = parsed.pathname.split('/'); + const deviceIndex = parts.indexOf('devices'); + const deviceId = deviceIndex >= 0 ? parts[deviceIndex + 1] : undefined; + if (deviceId !== undefined && deviceId.length > 12) { + parts[deviceIndex + 1] = `${deviceId.slice(0, 6)}…${deviceId.slice(-4)}`; + } + return `${parsed.host}${parts.join('/')}`; +} + +export function buildRemoteControlUrl( + deviceId: string, + sessionId?: string, + relayOrigin = REMOTE_CONTROL_RELAY_ORIGIN, +): string { + const url = new URL(relayOrigin); + const relayPath = url.pathname.replace(/\/+$/, ''); + const devicePath = `${relayPath}/devices/${encodeURIComponent(deviceId)}`; + url.pathname = + sessionId === undefined + ? `${devicePath}/` + : `${devicePath}/sessions/${encodeURIComponent(sessionId)}`; + url.search = new URLSearchParams({ rc: '1', from: 'kimi_code_cli' }).toString(); + url.hash = ''; + return url.toString(); +} + +export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest { + const separator = raw.indexOf('\r\n\r\n'); + if (separator < 0 || separator > MAX_HTTP_HEADER_BYTES) { + throw new SyntaxError('invalid HTTP request headers'); + } + const head = raw.subarray(0, separator).toString('latin1'); + const lines = head.split('\r\n'); + const requestLine = lines.shift(); + const match = requestLine?.match( + /^([!#$%&'*+.^_`|~0-9A-Za-z-]+) (\/[^\u0000-\u0020]*) HTTP\/1\.[01]$/, + ); + if (match === null || match === undefined || match[2]!.startsWith('//')) { + throw new SyntaxError('invalid HTTP request line'); + } + const headers: [string, string][] = []; + for (const line of lines) { + const colon = line.indexOf(':'); + if (colon <= 0) throw new SyntaxError('invalid HTTP request header'); + const name = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + try { + validateHeaderName(name); + validateHeaderValue(name, value); + } catch { + throw new SyntaxError('invalid HTTP request header'); + } + headers.push([name, value]); + } + return { + method: match[1]!, + path: match[2]!, + headers, + body: raw.subarray(separator + 4), + }; +} + +export function filterForwardRequestHeaders( + headers: readonly [string, string][], + serverToken: string, +): string[] { + const connectionHeaders = new Set(); + for (const [name, value] of headers) { + if (name.toLowerCase() === 'connection') { + for (const token of value.split(',')) connectionHeaders.add(token.trim().toLowerCase()); + } + } + const result: string[] = []; + for (const [name, value] of headers) { + const lower = name.toLowerCase(); + if (BLOCKED_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower)) continue; + result.push(name, value); + } + result.push('Authorization', `Bearer ${serverToken}`); + return result; +} + +export function rewriteRemoteControlResponse( + contentType: string, + body: Buffer, + publicPrefix: string, +): Buffer { + const normalizedPrefix = publicPrefix.replace(/\/+$/, ''); + if (contentType.toLowerCase().includes('text/html')) { + const prefixLiteral = JSON.stringify(normalizedPrefix); + const injected = ``; + let text = body.toString('utf8'); + const headMatch = /]*)?>/i.exec(text); + text = + headMatch === null + ? injected + text + : text.slice(0, headMatch.index + headMatch[0].length) + + injected + + text.slice(headMatch.index + headMatch[0].length); + text = text.replaceAll(/\bsrc="\//g, `src="${normalizedPrefix}/`); + text = text.replaceAll(/\bhref="\//g, `href="${normalizedPrefix}/`); + return Buffer.from(text); + } + const lower = contentType.toLowerCase(); + if (lower.includes('javascript') || lower.includes('text/css')) { + let text = body.toString('utf8'); + text = text.replaceAll('"/assets/', `"${normalizedPrefix}/assets/`); + text = text.replaceAll("'/assets/", `'${normalizedPrefix}/assets/`); + text = text.replaceAll('(/assets/', `(${normalizedPrefix}/assets/`); + text = text.replaceAll('"/sessions/"', `"${normalizedPrefix}/sessions/"`); + text = text.replaceAll('return"/"+', `return"${normalizedPrefix}/"+`); + return Buffer.from(text); + } + return body; +} + +export async function startRemoteControl( + options: RemoteControlOptions, +): Promise { + if (options.localServerToken.length === 0) { + throw new Error('Remote Control requires local server authentication.'); + } + const storage = new FileTokenStorage(join(options.homeDir, 'credentials')); + const token = await storage.load( + resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }), + ); + if (token?.refreshToken === undefined || token.refreshToken.length === 0) { + throw new Error('Remote Control requires a Kimi login. Run `kimi login` first.'); + } + const relayOrigin = options.relayOrigin ?? REMOTE_CONTROL_RELAY_ORIGIN; + const deviceId = createKimiDeviceId(options.homeDir); + const deviceName = hostname(); + const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin); + const lock = await acquireRemoteControlLock(options.homeDir, { + localOrigin: options.localOrigin.replace(/\/+$/, ''), + deviceId, + url, + }); + const client = new RemoteControlClient({ + ...options, + relayOrigin, + deviceId, + refreshToken: token.refreshToken, + }); + try { + await client.start(); + } catch (error) { + await lock.release(); + throw error; + } + return { + deviceId, + deviceName, + url, + close: async () => { + await client.close(); + await lock.release(); + }, + }; +} + +class RemoteControlClient { + private readonly localOrigin: string; + private readonly localServerToken: string; + private readonly relayOrigin: string; + private readonly deviceId: string; + private readonly refreshToken: string; + private readonly stderr: Pick; + private readonly onStatus: (status: RemoteControlStatus) => void; + private readonly streams = new Map(); + private readonly pendingHttpRequests = new Map(); + private management: WebSocket | undefined; + private reconnectAbort: AbortController | undefined; + private http: WebSocket | undefined; + private pendingHttpBytes = 0; + private reconnectAttempt = 0; + private reconnectImmediately = false; + private stopped = false; + private connected = false; + private relayOnline = false; + private runPromise: Promise | undefined; + private initialResolve: (() => void) | undefined; + private initialReject: ((error: unknown) => void) | undefined; + + constructor( + options: RemoteControlOptions & { + readonly relayOrigin: string; + readonly deviceId: string; + readonly refreshToken: string; + }, + ) { + this.localOrigin = options.localOrigin.replace(/\/+$/, ''); + this.localServerToken = options.localServerToken; + this.relayOrigin = options.relayOrigin; + this.deviceId = options.deviceId; + this.refreshToken = options.refreshToken; + this.stderr = options.stderr ?? process.stderr; + this.onStatus = options.onStatus ?? (() => {}); + } + + async start(): Promise { + const initial = new Promise((resolve, reject) => { + this.initialResolve = resolve; + this.initialReject = reject; + }); + this.runPromise = this.run(); + await initial; + } + + async close(): Promise { + if (this.stopped) { + await this.runPromise; + return; + } + this.stopped = true; + if (!this.connected) this.rejectInitial(new Error('Remote Control closed before ready.')); + if (this.management?.readyState === WebSocket.OPEN) { + this.management.send( + JSON.stringify({ type: 'disconnect', payload: { reason: 'local_server_stopped' } }), + ); + } + this.closeCycle(); + this.reconnectAbort?.abort(); + await this.runPromise; + } + + private async run(): Promise { + while (!this.stopped) { + try { + await this.serveCycle(); + } catch (error) { + if (error instanceof RegistrationError) { + if (!this.connected) this.rejectInitial(error); + else this.stderr.write(`${error.message}\n`); + this.stopped = true; + return; + } + if (!this.stopped && !this.reconnectImmediately) { + this.stderr.write(`Remote Control disconnected: ${errorMessage(error)}\n`); + } + } finally { + this.closeCycle(); + } + if (this.stopped) { + if (!this.connected) this.rejectInitial(new Error('Remote Control stopped before ready.')); + return; + } + if (this.reconnectImmediately) { + this.reconnectImmediately = false; + continue; + } + this.reconnectAttempt += 1; + const delay = Math.min( + MAX_RECONNECT_DELAY_MS, + 1000 * 2 ** Math.min(this.reconnectAttempt - 1, 5), + ); + await this.waitForReconnect(delay); + } + } + + private async serveCycle(): Promise { + const management = await this.connectRelay('/v1/remote/create'); + this.management = management; + management.send( + JSON.stringify({ + type: 'register', + payload: { + device_id: this.deviceId, + alias: hostname(), + platform: platform(), + client_version: `kimi-code/${getVersion()}`, + local_base_url: this.localOrigin, + }, + }), + ); + const registration = await waitForRelayMessage(management, REGISTER_TIMEOUT_MS); + if (registration.type === 'register_nak') { + const code = stringField(registration.payload, 'error_code') ?? 'REGISTRATION_REJECTED'; + const message = stringField(registration.payload, 'error_message') ?? 'registration rejected'; + throw new RegistrationError(`Remote Control registration failed (${code}): ${message}`); + } + if (registration.type !== 'register_ack') { + throw new Error(`Remote Control expected register_ack, received ${registration.type}`); + } + + const managementEnd = waitForSocketEnd(management); + const http = await this.connectRelay( + `/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`, + ); + this.http = http; + if (management.readyState !== WebSocket.OPEN) { + throw new Error('management connection closed'); + } + management.on('message', (data) => this.handleManagementMessage(data)); + http.on('message', (data) => this.handleHttpMessage(data)); + this.reconnectAttempt = 0; + this.relayOnline = true; + this.onStatus('relay_connected'); + + if (!this.connected) { + this.connected = true; + this.initialResolve?.(); + this.initialResolve = undefined; + this.initialReject = undefined; + } + + await Promise.race([managementEnd, waitForSocketEnd(http)]); + if (!this.stopped) throw new Error('relay connection closed'); + } + + private connectRelay(path: string): Promise { + return connectWebSocket(relayWebSocketUrl(this.relayOrigin, path), this.refreshToken); + } + + private rejectInitial(error: Error): void { + this.initialReject?.(error); + this.initialReject = undefined; + this.initialResolve = undefined; + } + + private handleManagementMessage(data: RawData): void { + let message: RelayMessage; + try { + message = parseRelayMessage(data); + } catch (error) { + this.stderr.write(`Remote Control message error: ${errorMessage(error)}\n`); + return; + } + if (message.type === 'open_ws') { + void this.openStream(message.payload ?? {}); + return; + } + if (message.type === 'close_ws') { + const streamId = stringField(message.payload, 'stream_id'); + if (streamId !== undefined) this.closeStream(streamId); + return; + } + if (message.type === 'disconnect') { + const reason = stringField(message.payload, 'reason'); + if (reason === 'user_requested') this.stopped = true; + if (reason === 'server_shutting_down') this.reconnectImmediately = true; + this.closeCycle(); + } + } + + private handleHttpMessage(data: RawData): void { + const text = rawDataText(data).trim(); + if (text.length === 0) return; + let requestId: string | undefined; + try { + const parsed = JSON.parse(text) as Record; + if (parsed['type'] !== 'request') return; + requestId = typeof parsed['request_id'] === 'string' ? parsed['request_id'] : undefined; + if ( + requestId === undefined || + typeof parsed['body_base64'] !== 'string' || + typeof parsed['is_last'] !== 'boolean' + ) { + throw new SyntaxError('invalid HTTP tunnel request message'); + } + const chunk = decodeBase64(parsed['body_base64']); + const pending = this.pendingHttpRequests.get(requestId) ?? { chunks: [], size: 0 }; + if (this.pendingHttpBytes + chunk.length > MAX_HTTP_REQUEST_BYTES) { + throw new SyntaxError('HTTP tunnel request exceeds 10 MiB'); + } + pending.chunks.push(chunk); + pending.size += chunk.length; + this.pendingHttpBytes += chunk.length; + this.pendingHttpRequests.set(requestId, pending); + if (!parsed['is_last']) return; + const rawRequest = Buffer.concat(pending.chunks, pending.size); + this.clearPendingHttpRequest(requestId); + void this.forwardHttpRequest(requestId, rawRequest); + } catch (error) { + if (requestId !== undefined) { + this.clearPendingHttpRequest(requestId); + this.sendHttpResponse(requestId, buildErrorResponse(400)); + } + this.stderr.write(`Remote Control HTTP message error: ${errorMessage(error)}\n`); + } + } + + private async forwardHttpRequest(requestId: string, rawRequest: Buffer): Promise { + try { + const parsed = parseRawHttpRequest(rawRequest); + const response = await requestLocalHttp( + this.localOrigin, + parsed, + this.localServerToken, + this.publicPrefix(), + ); + this.sendHttpResponse(requestId, response); + } catch (error) { + const status = error instanceof SyntaxError ? 400 : 502; + this.sendHttpResponse(requestId, buildErrorResponse(status)); + this.stderr.write(`Remote Control HTTP forwarding failed: ${errorMessage(error)}\n`); + } + } + + private sendHttpResponse(requestId: string, response: Buffer): void { + if (this.http?.readyState !== WebSocket.OPEN) return; + this.http.send( + JSON.stringify({ + request_id: requestId, + type: 'response', + is_last: true, + body_base64: response.toString('base64'), + }), + ); + } + + private async openStream(payload: Record): Promise { + const streamId = stringField(payload, 'stream_id'); + const path = stringField(payload, 'path'); + if (streamId === undefined || path === undefined || !path.startsWith('/') || path.startsWith('//')) { + if (streamId !== undefined) { + this.sendOpenStreamResult(streamId, false, 'LOCAL_WS_FAILED', 'invalid local WebSocket path'); + } + return; + } + + let local: WebSocket | undefined; + let tunnel: WebSocket | undefined; + const earlyLocalFrames: [RawData, boolean][] = []; + try { + local = await connectWebSocket( + localWebSocketUrl(this.localOrigin, path), + this.localServerToken, + relayHeaders(payload['headers']), + earlyLocalFrames, + ); + tunnel = await this.connectRelay(`/v1/remote/stream/${encodeURIComponent(streamId)}`); + if (this.stopped || this.management?.readyState !== WebSocket.OPEN) { + throw new Error('management connection closed'); + } + this.streams.set(streamId, { local, tunnel }); + this.onStatus('device_connected'); + bridgeSockets( + local, + tunnel, + () => { + if (this.streams.get(streamId)?.local === local) { + this.streams.delete(streamId); + this.onStatus('device_disconnected'); + } + }, + earlyLocalFrames, + ); + this.sendOpenStreamResult(streamId, true); + } catch (error) { + local?.close(); + tunnel?.close(); + this.sendOpenStreamResult( + streamId, + false, + local === undefined ? 'LOCAL_WS_FAILED' : 'TUNNEL_STREAM_FAILED', + errorMessage(error), + ); + } + } + + private sendOpenStreamResult( + streamId: string, + success: boolean, + errorCode?: string, + error?: string, + ): void { + if (this.management?.readyState !== WebSocket.OPEN) return; + this.management.send( + JSON.stringify({ + type: 'open_ws_result', + payload: { + stream_id: streamId, + success, + error_code: errorCode, + error_message: error, + }, + }), + ); + } + + private closeStream(streamId: string): void { + const stream = this.streams.get(streamId); + if (stream === undefined) return; + this.streams.delete(streamId); + this.onStatus('device_disconnected'); + stream.local.close(); + stream.tunnel.close(); + } + + private clearPendingHttpRequest(requestId: string): void { + const pending = this.pendingHttpRequests.get(requestId); + if (pending === undefined) return; + this.pendingHttpRequests.delete(requestId); + this.pendingHttpBytes -= pending.size; + } + + private closeCycle(): void { + for (const streamId of this.streams.keys()) this.closeStream(streamId); + this.pendingHttpRequests.clear(); + this.pendingHttpBytes = 0; + this.management?.close(); + this.http?.close(); + if (this.relayOnline) { + this.relayOnline = false; + this.onStatus('relay_disconnected'); + } + this.management = undefined; + this.http = undefined; + } + + private publicPrefix(): string { + const relayPath = new URL(this.relayOrigin).pathname.replace(/\/+$/, ''); + return `${relayPath}/devices/${encodeURIComponent(this.deviceId)}`; + } + + private async waitForReconnect(ms: number): Promise { + if (this.stopped) return; + const controller = new AbortController(); + this.reconnectAbort = controller; + try { + await sleep(ms, undefined, { signal: controller.signal }); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'AbortError') throw error; + } finally { + if (this.reconnectAbort === controller) this.reconnectAbort = undefined; + } + } +} + +async function connectWebSocket( + url: string, + token: string, + headers: Record = {}, + earlyFrames?: [RawData, boolean][], +): Promise { + const protocol = `kimi-code.bearer.${token}`; + if (isWebSocketProtocolToken(protocol)) { + try { + return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames); + } catch {} + } + return connectWebSocketAttempt( + url, + undefined, + { + ...headers, + Authorization: `Bearer ${token}`, + }, + earlyFrames, + ); +} + +function connectWebSocketAttempt( + url: string, + protocols: string[] | undefined, + headers: Record, + earlyFrames?: [RawData, boolean][], +): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url, protocols, { + headers, + handshakeTimeout: REGISTER_TIMEOUT_MS, + }); + if (earlyFrames !== undefined) { + socket.on('message', (data, isBinary) => { + earlyFrames.push([data, isBinary]); + }); + } + let settled = false; + const cleanup = (): void => { + socket.off('open', onOpen); + socket.off('error', onError); + socket.off('close', onClose); + }; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + cleanup(); + if (error === undefined) resolve(socket); + else reject(error); + }; + const onOpen = (): void => finish(); + const onError = (error: Error): void => finish(error); + const onClose = (code: number, reason: Buffer): void => { + finish(new Error(`WebSocket closed during handshake (${code} ${reason.toString()})`)); + }; + socket.once('open', onOpen); + socket.once('error', onError); + socket.once('close', onClose); + }); +} + +function isWebSocketProtocolToken(value: string): boolean { + return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value); +} + +function waitForRelayMessage(socket: WebSocket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(new Error('Remote Control registration timed out')), timeoutMs); + const onMessage = (data: RawData): void => { + try { + finish(undefined, parseRelayMessage(data)); + } catch (error) { + finish(error); + } + }; + const onClose = (code: number, reason: Buffer): void => { + finish(new Error(`Remote Control registration closed (${code} ${reason.toString()})`)); + }; + const onError = (error: Error): void => finish(error); + const finish = (error?: unknown, message?: RelayMessage): void => { + clearTimeout(timer); + socket.off('message', onMessage); + socket.off('close', onClose); + socket.off('error', onError); + if (error !== undefined) reject(error); + else resolve(message!); + }; + socket.once('message', onMessage); + socket.once('close', onClose); + socket.once('error', onError); + }); +} + +function waitForSocketEnd(socket: WebSocket): Promise { + return new Promise((resolve) => { + socket.once('close', () => resolve()); + socket.once('error', () => resolve()); + }); +} + +function parseRelayMessage(data: RawData): RelayMessage { + const parsed = JSON.parse(rawDataText(data)) as Record; + if (typeof parsed['type'] !== 'string') throw new Error('relay message has no type'); + const payload = isRecord(parsed['payload']) ? parsed['payload'] : undefined; + return { type: parsed['type'], payload }; +} + +function requestLocalHttp( + localOrigin: string, + parsed: ParsedRawHttpRequest, + serverToken: string, + publicPrefix: string, +): Promise { + const origin = new URL(localOrigin); + return new Promise((resolve, reject) => { + const request = httpRequest( + { + protocol: origin.protocol, + hostname: origin.hostname, + port: origin.port, + method: parsed.method, + path: parsed.path, + headers: [ + ...filterForwardRequestHeaders(parsed.headers, serverToken), + 'Host', + origin.host, + ], + timeout: HTTP_REQUEST_TIMEOUT_MS, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); + response.once('error', reject); + response.once('end', () => { + const contentType = response.headers['content-type'] ?? ''; + const receivedBody = Buffer.concat(chunks); + const body = + response.headers['content-encoding'] === undefined + ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) + : receivedBody; + const rewritten = body !== receivedBody; + const headers = filterResponseHeaders(response.rawHeaders, rewritten); + if (rewritten) headers.push('Cache-Control', 'no-cache'); + headers.push('Content-Length', String(body.length)); + const statusCode = response.statusCode ?? 502; + const statusMessage = response.statusMessage ?? 'Bad Gateway'; + resolve( + Buffer.concat([ + Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`), + body, + ]), + ); + }); + }, + ); + request.once('timeout', () => request.destroy(new Error('local HTTP request timed out'))); + request.once('error', reject); + request.end(parsed.body); + }); +} + +function filterResponseHeaders(rawHeaders: readonly string[], blockCacheControl = false): string[] { + const connectionHeaders = new Set(); + for (let index = 0; index < rawHeaders.length; index += 2) { + if (rawHeaders[index]!.toLowerCase() === 'connection') { + for (const token of rawHeaders[index + 1]!.split(',')) { + connectionHeaders.add(token.trim().toLowerCase()); + } + } + } + const result: string[] = []; + for (let index = 0; index < rawHeaders.length; index += 2) { + const name = rawHeaders[index]!; + const lower = name.toLowerCase(); + if (BLOCKED_RESPONSE_HEADERS.has(lower) || connectionHeaders.has(lower)) { + continue; + } + if (blockCacheControl && lower === 'cache-control') continue; + result.push(name, rawHeaders[index + 1]!); + } + return result; +} + +function relayHeaders(value: unknown): Record { + if (!isRecord(value)) return {}; + const entries: [string, string][] = []; + for (const [name, raw] of Object.entries(value)) { + if (typeof raw !== 'string') continue; + const lower = name.toLowerCase(); + if (BLOCKED_REQUEST_HEADERS.has(lower)) continue; + try { + validateHeaderName(name); + validateHeaderValue(name, raw); + entries.push([name, raw]); + } catch {} + } + return Object.fromEntries(entries); +} + +function bridgeSockets( + left: WebSocket, + right: WebSocket, + onClose: () => void, + earlyLeftFrames?: [RawData, boolean][], +): void { + let closed = false; + const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => { + if (closed) return; + closed = true; + onClose(); + const safeCode = isValidCloseCode(code) ? code : 1000; + if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason); + if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason); + }; + if (earlyLeftFrames !== undefined) { + left.removeAllListeners('message'); + for (const [data, isBinary] of earlyLeftFrames) { + if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); + } + } + left.on('message', (data, isBinary) => { + if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); + }); + right.on('message', (data, isBinary) => { + if (left.readyState === WebSocket.OPEN) left.send(data, { binary: isBinary }); + }); + left.once('close', closeBoth); + right.once('close', closeBoth); + left.once('error', () => closeBoth(1011)); + right.once('error', () => closeBoth(1011)); +} + +function isValidCloseCode(code: number): boolean { + return ( + code === 1000 || + code === 1001 || + code === 1002 || + code === 1003 || + (code >= 1007 && code <= 1014) || + (code >= 3000 && code <= 4999) + ); +} + +function relayWebSocketUrl(origin: string, path: string): string { + const url = new URL(origin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + const relayPath = url.pathname.replace(/\/+$/, ''); + const [pathname, query] = path.split('?', 2); + url.pathname = `${relayPath}${pathname}`; + url.search = query === undefined ? '' : query; + url.hash = ''; + return url.toString(); +} + +function localWebSocketUrl(origin: string, path: string): string { + const url = new URL(origin); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + url.pathname = path.split('?', 1)[0]!; + const query = path.includes('?') ? path.slice(path.indexOf('?') + 1) : ''; + url.search = query; + url.hash = ''; + return url.toString(); +} + +function headerLines(headers: readonly string[]): string { + let result = ''; + for (let index = 0; index < headers.length; index += 2) { + result += `${headers[index]}: ${headers[index + 1]}\r\n`; + } + return result.replace(/\r\n$/, ''); +} + +function buildErrorResponse(status: number): Buffer { + const reason = status === 400 ? 'Bad Request' : 'Bad Gateway'; + return Buffer.from(`HTTP/1.1 ${status} ${reason}\r\nContent-Length: 0\r\n\r\n`); +} + +function stringField( + value: Record | undefined, + key: string, +): string | undefined { + const field = value?.[key]; + return typeof field === 'string' ? field : undefined; +} + +function decodeBase64(value: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + throw new SyntaxError('invalid HTTP tunnel request base64'); + } + return Buffer.from(value, 'base64'); +} + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 279293744..261623444 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -14,13 +14,14 @@ import { join } from 'node:path'; import { createServerLogger, startServer, type ServerLogger } from '@moonshot-ai/kap-server'; import { shutdownTelemetry, track } from '@moonshot-ai/kimi-telemetry'; import chalk from 'chalk'; -import { type Command } from 'commander'; +import { type Command, Option } from 'commander'; import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app'; import { getNativeWebAssetsDir } from '#/native/web-assets'; import { darkColors } from '#/tui/theme/colors'; import { openUrl as defaultOpenUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; import { initializeServerTelemetry } from '../../telemetry'; import { @@ -35,6 +36,16 @@ import { splitTokenFragment, } from './access-urls'; import { type NetworkAddress } from './networks'; +import { + formatRemoteControlOutput, + formatRemoteControlStatus, + isRemoteControlEnabled, + REMOTE_CONTROL_FLAG_ENV, + startRemoteControl, + type RemoteControlHandle, + type RemoteControlOptions, + type RemoteControlStatus, +} from './remote-control'; import { DEFAULT_FOREGROUND_LOG_LEVEL, DEFAULT_LAN_HOST, @@ -62,11 +73,13 @@ interface RoutedServer { export interface WebCliOptions extends ServerCliOptions { open?: boolean; + remoteControl?: boolean; } export interface StartForegroundHooks { /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void; + onReady?: (origin: string) => void | Promise; + onShutdown?: (reason: string) => void | Promise; } export interface WebCommandDeps { @@ -75,6 +88,7 @@ export interface WebCommandDeps { options: ParsedServerOptions, hooks?: StartForegroundHooks, ) => Promise; + startRemoteControl?: (options: RemoteControlOptions) => Promise; openUrl(url: string): void; /** * Best-effort read of the server's persistent bearer token. When it returns @@ -105,8 +119,12 @@ export function buildWebUrl(origin: string, token: string): string { } /** Build the `web` command, mounting the runner action on `cmd` itself. */ -export function buildWebCommand(cmd: Command): Command { - return cmd +export function buildWebCommand( + cmd: Command, + opts: { forceRemoteControl?: boolean } = {}, +): Command { + const forceRemoteControl = opts.forceRemoteControl === true; + const withServerOptions = cmd .option( '--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, @@ -130,11 +148,6 @@ export function buildWebCommand(cmd: Command): Command { 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', false, ) - .option( - '--allow-remote-terminals', - 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', - false, - ) .option( '--dangerous-bypass-auth', 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', @@ -152,11 +165,24 @@ export function buildWebCommand(cmd: Command): Command { .option( '--web-title ', 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Kimi Code").', - ) + ); + if (!forceRemoteControl) { + withServerOptions.addOption( + new Option( + '--rc, --remote-control', + 'Expose the web UI through Kimi Remote Control (experimental).', + ) + .default(false) + .hideHelp(!isRemoteControlEnabled()), + ); + } + return withServerOptions .option('--no-open', 'Do not open the web UI in the default browser.', true) .action(async (opts: WebCliOptions) => { try { - await handleWebCommand(opts); + await handleWebCommand( + forceRemoteControl ? { ...opts, remoteControl: true } : opts, + ); } catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); @@ -169,9 +195,21 @@ export async function handleWebCommand( deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, ): Promise<void> { const parsed = parseServerOptions(opts); + if (opts.remoteControl === true && !isRemoteControlEnabled()) { + throw new Error( + `--remote-control is experimental: set ${REMOTE_CONTROL_FLAG_ENV}=1 (or KIMI_CODE_EXPERIMENTAL_FLAG=1) to enable it.`, + ); + } + if (opts.remoteControl === true && parsed.dangerousBypassAuth) { + throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.'); + } + if (opts.remoteControl === true && !isLoopbackHost(parsed.host)) { + throw new Error('--remote-control requires a loopback host.'); + } const run = deps.startServerForeground ?? startServerForeground; + let remoteControl: RemoteControlHandle | undefined; await run(parsed, { - onReady: (origin) => { + onReady: async (origin) => { // Resolve the persistent token only once the server is up: a fresh // server writes `server.token` on first boot, so reading it beforehand // would miss first-time starts and the browser would hit the auth gate. @@ -180,6 +218,38 @@ export async function handleWebCommand( // token line when unavailable. When auth is bypassed, the token is // meaningless and is intentionally NOT shown or carried in the URL. const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + if (opts.remoteControl === true) { + if (token === undefined) throw new Error('Unable to read the local server token.'); + const dataDir = getDataDir(); + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) deps.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await (deps.startRemoteControl ?? startRemoteControl)({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + stderr: deps.stderr, + onStatus, + }); + const qrCode = await generateRemoteControlQr(remoteControl.url, dataDir); + deps.stdout.write( + formatRemoteControlOutput({ + url: remoteControl.url, + localOrigin: origin, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) deps.stdout.write(line); + if (opts.open === true) deps.openUrl(remoteControl.url); + return; + } deps.stdout.write( parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL ? formatReadyBanner(origin, parsed.host, { @@ -193,6 +263,9 @@ export async function handleWebCommand( deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); } }, + onShutdown: async () => { + await remoteControl?.close(); + }, }); } @@ -230,7 +303,7 @@ export async function startServerForeground( options: ParsedServerOptions, hooks: StartForegroundHooks = {}, ): Promise<never> { - return runServerInProcess(options, hooks.onReady); + return runServerInProcess(options, hooks); } /** @@ -239,7 +312,7 @@ export async function startServerForeground( */ async function runServerInProcess( options: ParsedServerOptions, - onReady?: (origin: string) => void, + hooks: StartForegroundHooks, ): Promise<never> { const version = getVersion(); // Registers the telemetry provider for `track` / `shutdownTelemetry`; the @@ -253,6 +326,14 @@ async function runServerInProcess( if (stopping) return; stopping = true; running?.logger.info({ reason }, 'server shutting down'); + try { + await hooks.onShutdown?.(reason); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'foreground shutdown hook error', + ); + } try { await running?.close(); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); @@ -296,7 +377,6 @@ async function runServerInProcess( debugEndpoints: options.debugEndpoints, insecureNoTls: options.insecureNoTls, allowRemoteShutdown: options.allowRemoteShutdown, - allowRemoteTerminals: options.allowRemoteTerminals, allowedHosts: options.allowedHosts, disableAuth: options.dangerousBypassAuth, webTitle: options.webTitle, @@ -324,7 +404,17 @@ async function runServerInProcess( running.logger.info({ address: running.address }, 'server ready'); - onReady?.(running.address); + try { + await hooks.onReady?.(running.address); + } catch (error) { + try { + await hooks.onShutdown?.('startup_failed'); + } finally { + await running.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } + throw error; + } return new Promise<never>(() => { // Keeps the event loop alive; the process ends via shutdown()/process.exit. diff --git a/apps/kimi-code/src/cli/sub/web/shared.ts b/apps/kimi-code/src/cli/sub/web/shared.ts index a416d7e88..91fede6c0 100644 --- a/apps/kimi-code/src/cli/sub/web/shared.ts +++ b/apps/kimi-code/src/cli/sub/web/shared.ts @@ -40,8 +40,6 @@ export interface ParsedServerOptions { insecureNoTls: boolean; /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ allowRemoteShutdown: boolean; - /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ - allowRemoteTerminals: boolean; /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ dangerousBypassAuth: boolean; /** Extra `Host` header values to allow through the DNS-rebinding check. */ @@ -59,8 +57,6 @@ export interface ServerCliOptions { insecureNoTls?: boolean; /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ allowRemoteShutdown?: boolean; - /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ - allowRemoteTerminals?: boolean; /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ dangerousBypassAuth?: boolean; /** Extra `Host` header values to allow (`--allowed-host`). */ @@ -77,7 +73,6 @@ export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions debugEndpoints: opts.debugEndpoints === true, insecureNoTls: opts.insecureNoTls !== false, allowRemoteShutdown: opts.allowRemoteShutdown === true, - allowRemoteTerminals: opts.allowRemoteTerminals === true, dangerousBypassAuth: opts.dangerousBypassAuth === true, allowedHosts: parseAllowedHostArgs(opts.allowedHost), webTitle: opts.webTitle, diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 621282c9e..05a692d39 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -71,7 +71,7 @@ import { import { handleSwarmCommand } from './swarm'; import { handleTowerCommand } from './tower'; import { handleUndoCommand } from './undo'; -import { handleWebCommand } from './web'; +import { handleRemoteControlCommand, handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working @@ -110,7 +110,7 @@ export { handleTitleCommand, } from './session'; export { handleUndoCommand } from './undo'; -export { handleWebCommand } from './web'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Host interface @@ -613,6 +613,9 @@ async function handleBuiltInSlashCommand( case 'web': await handleWebCommand(host); return; + case 'remote-control': + await handleRemoteControlCommand(host); + return; default: host.showError(`Unknown slash command: /${String(name)}`); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 91d8836b3..6bbffc67b 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -31,7 +31,7 @@ export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from export { goalArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; -export { handleWebCommand } from './web'; +export { handleRemoteControlCommand, handleWebCommand } from './web'; export { promptApiKey, promptCatalogProviderSelection, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 61ecc69ea..67e7cd74d 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -431,6 +431,14 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 40, availability: 'always', }, + { + name: 'remote-control', + aliases: ['rc'], + description: 'Open the current session through Kimi Remote Control (experimental)', + priority: 40, + availability: 'always', + experimentalFlag: 'remote-control', + }, { name: 'exit', aliases: ['quit', 'q'], diff --git a/apps/kimi-code/src/tui/commands/web.ts b/apps/kimi-code/src/tui/commands/web.ts index 4a9035a32..e759466aa 100644 --- a/apps/kimi-code/src/tui/commands/web.ts +++ b/apps/kimi-code/src/tui/commands/web.ts @@ -1,10 +1,22 @@ import chalk from 'chalk'; import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { + buildRemoteControlUrl, + formatRemoteControlOutput, + formatRemoteControlStatus, + startRemoteControl, + type RemoteControlStatus, +} from '#/cli/sub/web/remote-control'; +import { + formatRemoteControlAlreadyRunning, + inspectRemoteControlLock, +} from '#/cli/sub/web/remote-control-lock'; import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; import { getDataDir } from '#/utils/paths'; +import { generateRemoteControlQr } from '#/utils/remote-control-qr'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { darkColors } from '../theme/colors'; @@ -30,6 +42,65 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> { await host.stop(); } +export async function handleRemoteControlCommand(host: SlashCommandHost): Promise<void> { + await host.waitForLazyCreation(); + const session = host.session; + + const holder = await inspectRemoteControlLock(getDataDir()); + if (holder !== undefined) { + host.showError(formatRemoteControlAlreadyRunning(holder)); + return; + } + + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + let remoteControl: Awaited<ReturnType<typeof startRemoteControl>> | undefined; + try { + await startServerForeground(options, { + onReady: async (origin) => { + const dataDir = getDataDir(); + const token = tryResolveServerToken(dataDir); + if (token === undefined) throw new Error('Unable to read the local server token.'); + let outputReady = false; + const pendingStatuses: string[] = []; + const onStatus = (status: RemoteControlStatus): void => { + const line = formatRemoteControlStatus(status); + if (outputReady) process.stdout.write(line); + else pendingStatuses.push(line); + }; + remoteControl = await startRemoteControl({ + homeDir: dataDir, + localOrigin: origin, + localServerToken: token, + onStatus, + }); + const url = buildRemoteControlUrl(remoteControl.deviceId, session?.id); + const qrCode = await generateRemoteControlQr(url, dataDir); + process.stdout.write( + formatRemoteControlOutput({ + url, + localOrigin: origin, + deviceName: remoteControl.deviceName, + qrCode: qrCode.terminal, + pngPath: qrCode.pngPath, + }), + ); + outputReady = true; + for (const line of pendingStatuses) process.stdout.write(line); + openUrl(url); + }, + onShutdown: async () => { + await remoteControl?.close(); + }, + }); + } catch (error) { + process.stderr.write(`Failed to start Remote Control: ${formatErrorMessage(error)}\n`); + process.exit(1); + } + }); + await host.stop(); +} + /** * Register the exit takeover that turns this process into the new server once * the TUI has shut down (where `process.exit` would normally happen): the @@ -63,12 +134,12 @@ function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): voi /** Styled `Session:` line for the foreground handoff; the token fragment is * dimmed like in the ready banner so the host/path stands out. */ -function sessionLine(url: string): string { +function sessionLine(url: string, labelText = 'Session: '): string { const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); const accent = (text: string): string => chalk.hex(darkColors.accent)(text); const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); const [base, frag] = splitTokenFragment(url); - return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`; + return `${label(labelText)}${accent(base)}${frag === '' ? '' : dim(frag)}`; } /** diff --git a/apps/kimi-code/src/utils/remote-control-qr.ts b/apps/kimi-code/src/utils/remote-control-qr.ts new file mode 100644 index 000000000..b7e3fab6e --- /dev/null +++ b/apps/kimi-code/src/utils/remote-control-qr.ts @@ -0,0 +1,62 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + getCapabilities, + getCellDimensions, + getPngDimensions, + renderImage, +} from '@moonshot-ai/pi-tui'; +import * as QRCode from 'qrcode'; + +const TERMINAL_QR_MARGIN = 2; +const TERMINAL_QR_DARK = '0;0;0'; +const TERMINAL_QR_LIGHT = '255;255;255'; +const ANSI_RESET = '\u001B[0m'; + +const QR_PNG_MARGIN = 4; +const QR_IMAGE_MIN_PX_PER_MODULE = 4; + +export async function generateRemoteControlQr( + url: string, + dataDir: string, +): Promise<{ terminal: string; pngPath: string }> { + await mkdir(dataDir, { recursive: true }); + const pngPath = resolve(dataDir, 'rc-qrcode.png'); + const png = await QRCode.toBuffer(url, { type: 'png', margin: QR_PNG_MARGIN }); + await writeFile(pngPath, png); + const terminal = renderInlineImageQr(url, png) ?? renderTerminalQr(url); + return { terminal, pngPath }; +} + +function renderInlineImageQr(url: string, png: Buffer): string | null { + if (getCapabilities().images === null) return null; + const base64 = png.toString('base64'); + const dimensions = getPngDimensions(base64); + if (dimensions === null) return null; + const moduleCount = + QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size + QR_PNG_MARGIN * 2; + const maxWidthCells = Math.ceil( + (moduleCount * QR_IMAGE_MIN_PX_PER_MODULE) / getCellDimensions().widthPx, + ); + const rendered = renderImage(base64, dimensions, { maxWidthCells }); + return rendered === null ? null : `${rendered.sequence}\n`; +} + +export function renderTerminalQr(url: string): string { + const qr = QRCode.create(url, { errorCorrectionLevel: 'M' }); + const size: number = qr.modules.size; + const data: Uint8Array = qr.modules.data; + const isDark = (x: number, y: number): boolean => + x >= 0 && y >= 0 && x < size && y < size && data[y * size + x] === 1; + let output = ''; + for (let y = -TERMINAL_QR_MARGIN; y < size + TERMINAL_QR_MARGIN; y += 2) { + for (let x = -TERMINAL_QR_MARGIN; x < size + TERMINAL_QR_MARGIN; x++) { + const top = isDark(x, y) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + const bottom = isDark(x, y + 1) ? TERMINAL_QR_DARK : TERMINAL_QR_LIGHT; + output += `\u001B[38;2;${top}m\u001B[48;2;${bottom}m▀`; + } + output += `${ANSI_RESET}\n`; + } + return output + ANSI_RESET; +} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 95936fe5c..9ff60d5d5 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -5,7 +5,7 @@ * Run: pnpm -C apps/kimi-code exec vitest run test/cli/options.test.ts */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; import { createProgram } from '#/cli/commands'; import type { CLIOptions } from '#/cli/options'; @@ -572,13 +572,16 @@ describe('CLI options parsing', () => { }); it('registers the visible sub-commands', () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + onTestFinished(() => { vi.unstubAllEnvs(); }); const program = createProgram( '0.0.0', () => {}, () => {}, ); const commandNames: string[] = program.commands - .filter((command) => !command.name().startsWith('__')) + .filter((command) => !command.name().startsWith('__') && !(command as unknown as { _hidden?: boolean })._hidden) .map((command) => command.name()); expect(commandNames).toEqual([ 'export', diff --git a/apps/kimi-code/test/cli/web/remote-control.test.ts b/apps/kimi-code/test/cli/web/remote-control.test.ts new file mode 100644 index 000000000..1b7f1d55e --- /dev/null +++ b/apps/kimi-code/test/cli/web/remote-control.test.ts @@ -0,0 +1,705 @@ +import { createServer, type IncomingMessage } from 'node:http'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + FileTokenStorage, + KIMI_CODE_PROVIDER_NAME, + resolveKimiTokenStorageName, + type TokenInfo, +} from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, describe, expect, it } from 'vitest'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; + +import { + buildRemoteControlUrl, + filterForwardRequestHeaders, + formatRemoteControlOutput, + formatRemoteControlStatus, + isRemoteControlEnabled, + parseRawHttpRequest, + rewriteRemoteControlResponse, + startRemoteControl, + type RemoteControlHandle, +} from '#/cli/sub/web/remote-control'; +import { remoteControlLockPath } from '#/cli/sub/web/remote-control-lock'; + +const TOKEN: TokenInfo = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: 0, + scope: '', + tokenType: 'Bearer', + expiresIn: 0, +}; + +const cleanups: Array<() => Promise<void> | void> = []; + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()!(); +}); + +describe('Remote Control experimental flag', () => { + it('is off unless the per-feature env or the master switch is truthy', () => { + expect(isRemoteControlEnabled({})).toBe(false); + expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: '0' })).toBe(false); + expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: '1' })).toBe(true); + expect(isRemoteControlEnabled({ KIMI_CODE_EXPERIMENTAL_FLAG: 'true' })).toBe(true); + expect( + isRemoteControlEnabled({ + KIMI_CODE_EXPERIMENTAL_FLAG: '0', + KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL: 'yes', + }), + ).toBe(true); + }); +}); + +describe('Remote Control URLs', () => { + it('builds the public device entry without a local token', () => { + const url = buildRemoteControlUrl('device/one'); + expect(url).toBe( + 'https://code-rc.kimi.com/devices/device%2Fone/?rc=1&from=kimi_code_cli', + ); + expect(url).not.toContain('token'); + }); + + it('builds an encoded session deep link before the query', () => { + expect(buildRemoteControlUrl('device-1', 'session/a b')).toBe( + 'https://code-rc.kimi.com/devices/device-1/sessions/session%2Fa%20b?rc=1&from=kimi_code_cli', + ); + }); +}); + +describe('Remote Control output', () => { + it('keeps the full URL clickable while showing a short link and the setup contract', () => { + const url = 'https://example.test/devices/example-device/?rc=1&from=kimi_code_cli'; + const output = formatRemoteControlOutput({ + url, + localOrigin: 'http://127.0.0.1:1234', + deviceName: 'example-device', + qrCode: 'QR\n', + pngPath: '/tmp/example-qr.png', + }); + expect(output).toContain('Use Kimi Code on this machine'); + expect(output).toContain('1.'); + expect(output).toContain('2.'); + expect(output).toContain('3.'); + expect(output).toContain('example.test/devices/exampl…vice/'); + expect(output).toContain(`\u001B]8;;${url}`); + expect(output).toContain('Connected to example.test'); + expect(output).toContain('This device:'); + expect(output).toContain('max 5'); + expect(output).toContain('PNG:'); + expect(output).toContain('grants control of this machine'); + expect(output).toContain('docs'); + expect(output).toContain('feedback'); + expect(output).toContain('Logs: off'); + expect(output).not.toContain('stream-1'); + }); + + it('formats relay and device lifecycle states', () => { + expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected'); + expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected'); + }); +}); + +describe('Remote Control HTTP forwarding', () => { + it('parses raw requests and replaces relay credentials with local bearer auth', () => { + const parsed = parseRawHttpRequest( + Buffer.from( + 'POST /api/v1/messages?q=1 HTTP/1.1\r\nHost: relay.example\r\nAuthorization: Bearer relay\r\nCookie: sid=1\r\nOrigin: https://relay.example\r\nConnection: keep-alive, X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\nContent-Length: 4\r\n\r\ndata', + ), + ); + expect(parsed).toMatchObject({ method: 'POST', path: '/api/v1/messages?q=1' }); + expect(parsed.body.toString()).toBe('data'); + expect(filterForwardRequestHeaders(parsed.headers, 'local-token')).toEqual([ + 'X-Keep', + 'yes', + 'Content-Length', + '4', + 'Authorization', + 'Bearer local-token', + ]); + }); + + it('rejects absolute-form and malformed request targets', () => { + expect(() => + parseRawHttpRequest(Buffer.from('GET https://example.test/ HTTP/1.1\r\n\r\n')), + ).toThrow(/request line/); + expect(() => parseRawHttpRequest(Buffer.from('GET //example.test/ HTTP/1.1\r\n\r\n'))).toThrow( + /request line/, + ); + }); + + it('rewrites HTML, JavaScript, and CSS under the device prefix', () => { + const prefix = '/coding-relay/devices/device-1'; + const html = rewriteRemoteControlResponse( + 'text/html; charset=utf-8', + Buffer.from('<html><head></head><body><script src="/boot.js"></script><a href="/x">x</a></body></html>'), + prefix, + ).toString(); + expect(html).toContain(`src="${prefix}/boot.js"`); + expect(html).toContain(`href="${prefix}/x"`); + expect(html).toContain("sessionStorage.setItem('kimi-desktop-server-origin',location.origin+p)"); + expect(html).toContain('history.pushState=w(history.pushState)'); + + const js = rewriteRemoteControlResponse( + 'text/javascript', + Buffer.from( + 'const a="/assets/a.js";const s="/sessions/";const p=function(e){return"/"+e};', + ), + prefix, + ).toString(); + expect(js).toBe( + `const a="${prefix}/assets/a.js";const s="${prefix}/sessions/";const p=function(e){return"${prefix}/"+e};`, + ); + + const css = rewriteRemoteControlResponse( + 'text/css', + Buffer.from('.x{background:url(/assets/x.png)}'), + prefix, + ).toString(); + expect(css).toBe(`.x{background:url(${prefix}/assets/x.png)}`); + }); +}); + +describe('Remote Control tunnel', () => { + it('surfaces register_nak details', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-nak-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }), + TOKEN, + ); + const managementServer = new WebSocketServer({ noServer: true }); + const relayServer = createServer(); + managementServer.on('connection', (ws) => { + ws.once('message', () => { + ws.send( + JSON.stringify({ + type: 'register_nak', + payload: { + error_code: 'DEVICE_LIMIT_EXCEEDED', + error_message: 'membership allows 3 devices', + }, + }), + ); + }); + }); + relayServer.on('upgrade', (request, socket, head) => { + managementServer.handleUpgrade(request, socket, head, (ws) => + managementServer.emit('connection', ws, request), + ); + }); + const relayPort = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, + stderr: { write: () => true }, + }), + ).rejects.toThrow(/DEVICE_LIMIT_EXCEEDED.*membership allows 3 devices/); + }); + + it('uses only Authorization when the refresh token is not a valid subprotocol token', async () => { + const homeDir = await createRemoteControlHome('invalid/token='); + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests).toHaveLength(2); + expect(relay.requests.every((request) => request.protocol === undefined)).toBe(true); + expect(relay.requests.every((request) => request.authorization === 'Bearer invalid/token=')).toBe( + true, + ); + }); + + it('retries with only Authorization when the server does not echo the subprotocol', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay({ echoProtocol: false }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.some((request) => request.protocol?.startsWith('kimi-code.bearer.'))).toBe( + true, + ); + expect( + relay.requests.some( + (request) => + request.protocol === undefined && + request.authorization === `Bearer ${TOKEN.refreshToken}`, + ), + ).toBe(true); + }); + + it('keeps the initial start pending through transient failures and recovers', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay({ rejectUpgrades: 2 }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.length).toBeGreaterThanOrEqual(4); + expect(handle.url).toContain('?rc=1&from=kimi_code_cli'); + }, 6000); + + it('reconnects when management closes during the HTTP tunnel handshake', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay({ closeManagementDuringFirstHttpHandshake: true }); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests.length).toBeGreaterThanOrEqual(4); + }, 6000); + + it('registers, forwards HTTP and WS with local auth, then reconnects the pair', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }), + TOKEN, + ); + + let localHttpRequest: IncomingMessage | undefined; + let localWsRequest: IncomingMessage | undefined; + const localWsServer = new WebSocketServer({ noServer: true }); + const localServer = createServer((request, response) => { + localHttpRequest = request; + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=31536000, immutable', + Connection: 'X-Remove', + 'X-Remove': 'gone', + }); + response.end('<html><head></head><script src="/boot.js"></script></html>'); + }); + localServer.on('upgrade', (request, socket, head) => { + localWsRequest = request; + localWsServer.handleUpgrade(request, socket, head, (ws) => localWsServer.emit('connection', ws, request)); + }); + const localPort = await listen(localServer); + cleanups.push(() => closeServer(localServer)); + + const managementServer = new WebSocketServer({ noServer: true }); + const httpTunnelServer = new WebSocketServer({ noServer: true }); + const streamServer = new WebSocketServer({ noServer: true }); + const relayServer = createServer(); + const managementConnections: WebSocket[] = []; + const httpConnections: WebSocket[] = []; + const streamConnections: WebSocket[] = []; + const registrations: unknown[] = []; + const managementMessages: unknown[] = []; + const streamMessages: string[] = []; + let localWs: WebSocket | undefined; + + managementServer.on('connection', (ws) => { + managementConnections.push(ws); + ws.on('message', (data) => { + const message = JSON.parse(rawDataText(data)) as { type: string }; + managementMessages.push(message); + if (message.type === 'register') { + registrations.push(message); + ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } })); + } + }); + }); + httpTunnelServer.on('connection', (ws) => httpConnections.push(ws)); + streamServer.on('connection', (ws) => { + streamConnections.push(ws); + ws.on('message', (data) => streamMessages.push(rawDataText(data))); + }); + localWsServer.on('connection', (ws) => { + localWs = ws; + ws.send('server-hello-frame'); + }); + relayServer.on('upgrade', (request, socket, head) => { + const pathname = new URL(request.url!, 'http://relay.test').pathname; + const target = pathname.endsWith('/v1/remote/create') + ? managementServer + : pathname.endsWith('/v1/remote/http') + ? httpTunnelServer + : streamServer; + target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request)); + }); + const relayPort = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + handle = await startRemoteControl({ + homeDir, + localOrigin: `http://127.0.0.1:${localPort}`, + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(registrations).toHaveLength(1); + expect(handle.url).toContain('/coding-relay/devices/'); + expect(handle.url).toContain('?rc=1&from=kimi_code_cli'); + + const rawRequest = Buffer.from( + 'GET / HTTP/1.1\r\nHost: relay.test\r\nAuthorization: Bearer relay-token\r\nCookie: sid=1\r\nOrigin: https://relay.test\r\nConnection: X-Hop\r\nX-Hop: remove\r\nX-Keep: yes\r\n\r\n', + ); + const splitAt = Math.floor(rawRequest.length / 2); + httpConnections[0]!.send( + JSON.stringify({ + request_id: 'request-1', + type: 'request', + is_last: false, + body_base64: rawRequest.subarray(0, splitAt).toString('base64'), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(localHttpRequest).toBeUndefined(); + const responsePromise = nextJsonMessage(httpConnections[0]!); + httpConnections[0]!.send( + JSON.stringify({ + request_id: 'request-1', + type: 'request', + is_last: true, + body_base64: rawRequest.subarray(splitAt).toString('base64'), + }), + ); + const responseMessage = await responsePromise; + const response = Buffer.from(responseMessage['body_base64'] as string, 'base64').toString(); + expect(response).toContain('HTTP/1.1 200 OK'); + expect(localHttpRequest?.headers.authorization).toBe('Bearer local-server-token'); + expect(localHttpRequest?.headers.cookie).toBeUndefined(); + expect(localHttpRequest?.headers.origin).toBeUndefined(); + expect(localHttpRequest?.headers['x-hop']).toBeUndefined(); + expect(localHttpRequest?.headers['x-keep']).toBe('yes'); + expect(response).not.toContain('X-Remove'); + expect(response).not.toContain('immutable'); + expect(response).toContain('Cache-Control: no-cache'); + expect(response).toContain(`/coding-relay/devices/${handle.deviceId}/boot.js`); + + managementConnections[0]!.send( + JSON.stringify({ + type: 'open_ws', + payload: { + stream_id: 'stream-1', + path: '/api/v1/ws', + headers: { Cookie: 'relay-cookie', Origin: 'https://relay.test', 'X-Keep': 'yes' }, + }, + }), + ); + await waitFor(() => streamConnections.length === 1 && localWs !== undefined); + expect(localWsRequest?.headers['sec-websocket-protocol']).toBe( + 'kimi-code.bearer.local-server-token', + ); + expect(localWsRequest?.headers.authorization).toBeUndefined(); + expect(localWsRequest?.headers.cookie).toBeUndefined(); + expect(localWsRequest?.headers.origin).toBeUndefined(); + expect(localWsRequest?.headers['x-keep']).toBe('yes'); + await waitFor(() => + managementMessages.some( + (value) => + (value as { type?: string }).type === 'open_ws_result' && + (value as { payload?: { success?: boolean } }).payload?.success === true, + ), + ); + + await waitFor(() => streamMessages.includes('server-hello-frame')); + const localMessage = nextTextMessage(localWs!); + streamConnections[0]!.send('from-relay'); + await expect(localMessage).resolves.toBe('from-relay'); + const relayMessage = nextTextMessage(streamConnections[0]!); + localWs!.send('from-local'); + await expect(relayMessage).resolves.toBe('from-local'); + + streamConnections[0]!.terminate(); + await waitFor(() => localWs?.readyState === 3); + + httpConnections[0]!.terminate(); + await waitFor(() => registrations.length === 2 && httpConnections.length === 2, 4000); + + await handle.close(); + await waitFor(() => + managementMessages.some( + (value) => + (value as { type?: string; payload?: { reason?: string } }).type === 'disconnect' && + (value as { payload?: { reason?: string } }).payload?.reason === 'local_server_stopped', + ), + ); + }); +}); + +describe('Remote Control single-instance lock', () => { + async function deadPid(): Promise<number> { + const child = spawn(process.execPath, ['-e', ''], { stdio: 'ignore' }); + await new Promise<void>((resolve) => child.on('exit', () => resolve())); + return child.pid!; + } + + it('refuses a second instance on the same home and reports the running link', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay(); + let first: RemoteControlHandle | undefined; + cleanups.push(async () => first?.close()); + first = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58628', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }), + ).rejects.toThrow(/already running[\s\S]*127\.0\.0\.1:58627[\s\S]*\/devices\//); + expect(relay.requests).toHaveLength(2); + }); + + it('reaps a stale lock left by a dead process', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + await mkdir(join(homeDir, 'server'), { recursive: true }); + await writeFile( + remoteControlLockPath(homeDir), + JSON.stringify({ + pid: await deadPid(), + nonce: 'stale', + local_origin: 'http://127.0.0.1:1', + device_id: 'dead-device', + url: 'https://code-rc.kimi.com/devices/dead-device/', + started_at: 0, + }), + ); + const relay = await startAuthRelay(); + let handle: RemoteControlHandle | undefined; + cleanups.push(async () => handle?.close()); + + handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + + const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as { + pid: number; + }; + expect(lock.pid).toBe(process.pid); + }); + + it('releases the lock on close so a new instance can start', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay(); + const options = { + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }; + const first = await startRemoteControl(options); + await first.close(); + + let second: RemoteControlHandle | undefined; + cleanups.push(async () => second?.close()); + second = await startRemoteControl(options); + expect(second.url).toContain('/devices/'); + }); + + it('does not remove a successor lock when closing', async () => { + const homeDir = await createRemoteControlHome(TOKEN.refreshToken); + const relay = await startAuthRelay(); + const handle = await startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }); + cleanups.push(async () => handle?.close()); + await writeFile( + remoteControlLockPath(homeDir), + JSON.stringify({ + pid: process.pid, + nonce: 'successor', + local_origin: 'http://127.0.0.1:58628', + device_id: 'device-2', + url: 'https://code-rc.kimi.com/devices/device-2/', + started_at: Date.now(), + }), + ); + + await handle.close(); + + const lock = JSON.parse(await readFile(remoteControlLockPath(homeDir), 'utf8')) as { + nonce: string; + }; + expect(lock.nonce).toBe('successor'); + }); +}); + +async function createRemoteControlHome(refreshToken: string): Promise<string> { + const homeDir = mkdtempSync(join(tmpdir(), 'kimi-rc-auth-')); + cleanups.push(() => rmSync(homeDir, { recursive: true, force: true })); + await new FileTokenStorage(join(homeDir, 'credentials')).save( + resolveKimiTokenStorageName({ providerName: KIMI_CODE_PROVIDER_NAME }), + { + ...TOKEN, + refreshToken, + }, + ); + return homeDir; +} + +async function startAuthRelay( + options: { + echoProtocol?: boolean; + rejectUpgrades?: number; + closeManagementDuringFirstHttpHandshake?: boolean; + } = {}, +): Promise<{ + port: number; + requests: Array<{ authorization?: string; protocol?: string }>; +}> { + const handleProtocols = options.echoProtocol === false ? (): false => false : undefined; + const managementServer = new WebSocketServer({ noServer: true, handleProtocols }); + const httpTunnelServer = new WebSocketServer({ noServer: true, handleProtocols }); + const relayServer = createServer(); + const requests: Array<{ authorization?: string; protocol?: string }> = []; + let remainingRejections = options.rejectUpgrades ?? 0; + let closeManagement = options.closeManagementDuringFirstHttpHandshake === true; + let delayHttpUpgrade = closeManagement; + + managementServer.on('connection', (ws) => { + ws.on('error', () => {}); + ws.on('message', (data) => { + const message = JSON.parse(rawDataText(data)) as { type?: string }; + if (message.type === 'register') { + ws.send(JSON.stringify({ type: 'register_ack', payload: { success: true } })); + if (closeManagement) { + closeManagement = false; + setTimeout(() => ws.close(), 10); + } + } + }); + }); + httpTunnelServer.on('connection', (ws) => ws.on('error', () => {})); + relayServer.on('upgrade', (request, socket, head) => { + const authorization = request.headers.authorization; + const protocol = request.headers['sec-websocket-protocol']; + requests.push({ + authorization: Array.isArray(authorization) ? authorization[0] : authorization, + protocol: Array.isArray(protocol) ? protocol[0] : protocol, + }); + if (remainingRejections > 0) { + remainingRejections -= 1; + socket.end( + 'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n', + ); + return; + } + const pathname = new URL(request.url!, 'http://relay.test').pathname; + const target = pathname.endsWith('/v1/remote/create') + ? managementServer + : httpTunnelServer; + const upgrade = (): void => { + target.handleUpgrade(request, socket, head, (ws) => target.emit('connection', ws, request)); + }; + if (target === httpTunnelServer && delayHttpUpgrade) { + delayHttpUpgrade = false; + setTimeout(upgrade, 50); + return; + } + upgrade(); + }); + const port = await listen(relayServer); + cleanups.push(() => closeServer(relayServer)); + return { port, requests }; +} + +function listen(server: ReturnType<typeof createServer>): Promise<number> { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (address === null || typeof address === 'string') reject(new Error('missing address')); + else resolve(address.port); + }); + }); +} + +function closeServer(server: ReturnType<typeof createServer>): Promise<void> { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve(); + else reject(error); + }); + }); +} + +function rawDataText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data as ArrayBuffer).toString('utf8'); +} + +function nextJsonMessage(socket: WebSocket): Promise<Record<string, unknown>> { + return new Promise((resolve) => { + socket.once('message', (data) => resolve(JSON.parse(rawDataText(data)) as Record<string, unknown>)); + }); +} + +function nextTextMessage(socket: WebSocket): Promise<string> { + return new Promise((resolve) => { + socket.once('message', (data) => resolve(rawDataText(data))); + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('condition timed out'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index ab3ea9fab..4aa97f9b3 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -15,6 +15,8 @@ import chalk, { Chalk } from 'chalk'; import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetCapabilitiesCache, setCapabilities } from '@moonshot-ai/pi-tui'; + import { registerWebCommand } from '#/cli/sub/web'; import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill'; import type { WebCommandDeps } from '#/cli/sub/web/run'; @@ -50,7 +52,7 @@ function makeRunner(origin = 'http://127.0.0.1:58627'): { const calls: { options: ParsedServerOptions | undefined } = { options: undefined }; const runner: ForegroundRunner = async (options, hooks) => { calls.options = options; - hooks?.onReady?.(origin); + await hooks?.onReady?.(origin); return undefined as never; }; return { runner, calls }; @@ -99,11 +101,12 @@ describe('kimi web', () => { expect(longs).toContain('--allowed-host'); expect(longs).toContain('--insecure-no-tls'); expect(longs).toContain('--allow-remote-shutdown'); - expect(longs).toContain('--allow-remote-terminals'); expect(longs).toContain('--dangerous-bypass-auth'); expect(longs).toContain('--log-level'); expect(longs).toContain('--debug-endpoints'); expect(longs).toContain('--web-title'); + const remoteControl = web!.options.find((option) => option.long === '--remote-control'); + expect(remoteControl?.short).toBe('--rc'); // web opens the browser by default → the option is the negative --no-open. expect(longs).toContain('--no-open'); // The background/daemon era flags are gone: the server always runs in the @@ -112,6 +115,7 @@ describe('kimi web', () => { expect(longs).not.toContain('--keep-alive'); expect(longs).not.toContain('--daemon'); expect(longs).not.toContain('--idle-grace-ms'); + expect(longs).not.toContain('--allow-remote-terminals'); }); it('routes `kimi server` and any legacy subcommand to a deprecation notice', async () => { @@ -339,6 +343,11 @@ describe('ready banner reflects the bind class', () => { }); describe('`kimi web` opens the browser', () => { + afterEach(() => { + vi.unstubAllEnvs(); + resetCapabilitiesCache(); + }); + it('opens the Web UI URL with the #token= fragment by default', async () => { const { handleWebCommand } = await import('#/cli/sub/web/run'); const { runner } = makeRunner(); @@ -392,6 +401,171 @@ describe('`kimi web` opens the browser', () => { expect(openUrl).not.toHaveBeenCalled(); }); + + it('maps --remote-control and --rc to the same option', () => { + for (const flag of ['--remote-control', '--rc']) { + const program = makeProgram(); + const web = program.commands.find((command) => command.name() === 'web')!; + web.parseOptions([flag]); + expect(web.opts()).toMatchObject({ remoteControl: true }); + } + }); + + it('rejects Remote Control on a non-loopback host', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { remoteControl: true, host: '0.0.0.0', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow('--remote-control requires a loopback host.'); + }); + + it('opens and saves only the public Remote Control URL without the local server token', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-qrcode-')); + const dataDir = join(tempRoot, 'custom-home'); + vi.stubEnv('KIMI_CODE_HOME', dataDir); + const publicUrl = + 'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli'; + const pngPath = join(dataDir, 'rc-qrcode.png'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { generateRemoteControlQr, renderTerminalQr } = await import('#/utils/remote-control-qr'); + const QRCode = await import('qrcode'); + const { isAbsolute } = await import('node:path'); + await generateRemoteControlQr('https://example.test/previous', dataDir); + const previousPng = readFileSync(pngPath); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const openUrl = vi.fn(); + const startRemoteControl = vi.fn(async () => ({ + deviceId: 'device-1', + deviceName: 'example-device', + url: publicUrl, + close: vi.fn(async () => {}), + })); + + try { + await handleWebCommand( + { remoteControl: true, open: true }, + { + startServerForeground: runner, + startRemoteControl, + resolveToken: () => 'local-server-token', + openUrl, + stdout, + stderr, + }, + ); + + expect(startRemoteControl).toHaveBeenCalledWith( + expect.objectContaining({ + homeDir: dataDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + }), + ); + expect(openUrl).toHaveBeenCalledWith(publicUrl); + const written = readStdout(); + expect(written).toContain('Kimi Remote Control ready'); + expect(written).toContain(renderTerminalQr(publicUrl)); + expect(written).not.toContain(renderTerminalQr('http://127.0.0.1:58627')); + expect(isAbsolute(pngPath)).toBe(true); + expect(written).toContain(`QR code PNG: ${pngPath}`); + const png = readFileSync(pngPath); + expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(png).not.toEqual(previousPng); + expect(png).toEqual(await QRCode.toBuffer(publicUrl)); + expect(written).not.toContain('local-server-token'); + expect(written).not.toContain('#token='); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects --remote-control while the experimental flag is off', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { remoteControl: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow('--remote-control is experimental:'); + }); + + it('hides --remote-control from help unless the experimental flag is on', () => { + const remoteControlOption = () => + makeProgram() + .commands.find((command) => command.name() === 'web')! + .options.find((option) => option.long === '--remote-control'); + + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + expect(remoteControlOption()?.hidden).toBe(true); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + expect(remoteControlOption()?.hidden).toBe(false); + }); +}); + +describe('kimi rc', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('registers `rc` with the `remote` alias and the web server options, without a --remote-control flag', () => { + const program = makeProgram(); + const rc = program.commands.find((c) => c.name() === 'rc'); + expect(rc).toBeDefined(); + expect(rc!.alias()).toBe('remote'); + const longs = rc!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--port'); + expect(longs).toContain('--host'); + expect(longs).toContain('--no-open'); + expect(longs).not.toContain('--remote-control'); + }); + + it('hides `rc` from help unless the experimental flag is on', () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + expect(makeProgram().helpInformation()).not.toContain('rc|remote'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + expect(makeProgram().helpInformation()).toContain('rc|remote'); + }); + + it('forces Remote Control for both `rc` and `remote`', async () => { + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0'); + vi.stubEnv('KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); + for (const name of ['rc', 'remote']) { + const program = makeProgram(); + let stderr = ''; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + try { + await program.parseAsync(['node', 'kimi', name]); + } finally { + errSpy.mockRestore(); + exitSpy.mockRestore(); + } + // The flag-off experimental error proves remoteControl was forced before + // the runner could start. + expect(stderr).toContain('--remote-control is experimental:'); + } + }); }); describe('`kimi web` option threading', () => { @@ -409,7 +583,6 @@ describe('`kimi web` option threading', () => { dangerousBypassAuth: true, debugEndpoints: true, allowRemoteShutdown: true, - allowRemoteTerminals: true, open: false, }, { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, @@ -422,7 +595,6 @@ describe('`kimi web` option threading', () => { debugEndpoints: true, insecureNoTls: true, allowRemoteShutdown: true, - allowRemoteTerminals: true, dangerousBypassAuth: true, allowedHosts: ['.example.com'], }); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 46feb846f..1113a5998 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -233,4 +233,12 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(command!, 'teardown')).toBe('always'); expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always'); }); + + it('gates remote-control behind the remote-control experiment, always available', () => { + const command = findBuiltInSlashCommand('remote-control'); + expect(command).toBeDefined(); + expect((command as KimiSlashCommand).experimentalFlag).toBe('remote-control'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); + }); diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index fb4c2cbd3..d11ce73f9 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -64,6 +64,13 @@ describe('resolveSlashCommandInput', () => { }); }); + it('gates /remote-control behind the remote-control experimental flag', () => { + expect(resolve('/rc')).toEqual({ kind: 'message', input: '/rc' }); + setExperimentalFeatures([{ id: 'remote-control', enabled: true }]); + expect(resolve('/rc')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); + expect(resolve('/remote-control')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); + }); + it('blocks idle-only built-ins while streaming', () => { expect(resolve('/new', { isStreaming: true })).toEqual({ kind: 'blocked', diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index 92a01a483..53f721e12 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -1,16 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setCapabilities } from '@moonshot-ai/pi-tui'; + import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; +import { + handleRemoteControlCommand, + handleWebCommand, + webSessionUrl, +} from '#/tui/commands/web'; +import { renderTerminalQr } from '#/utils/remote-control-qr'; const mocks = vi.hoisted(() => ({ startServerForeground: vi.fn(), + startRemoteControl: vi.fn(), tryResolveServerToken: vi.fn(), getDataDir: vi.fn(() => '/tmp/kimi-home'), openUrl: vi.fn(), })); +vi.mock('#/cli/sub/web/remote-control', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/web/remote-control')>(); + return { ...actual, startRemoteControl: mocks.startRemoteControl }; +}); + vi.mock('#/cli/sub/web/run', async (importOriginal) => { const actual = await importOriginal<typeof import('#/cli/sub/web/run')>(); return { ...actual, startServerForeground: mocks.startServerForeground }; @@ -44,6 +57,7 @@ function makeHost() { setExitOpenUrl: vi.fn(), setExitForegroundTask: vi.fn(), stop: vi.fn(async () => {}), + waitForLazyCreation: vi.fn(async () => {}), } as unknown as SlashCommandHost & { showStatus: ReturnType<typeof vi.fn>; showError: ReturnType<typeof vi.fn>; @@ -52,6 +66,7 @@ function makeHost() { setExitOpenUrl: ReturnType<typeof vi.fn>; setExitForegroundTask: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; + waitForLazyCreation: ReturnType<typeof vi.fn>; }; return host; } @@ -62,6 +77,13 @@ describe('web slash command', () => { expect(command).toBeDefined(); expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); }); + + it('registers /remote-control and /rc as the same always-available built-in', () => { + const command = findBuiltInSlashCommand('remote-control'); + expect(command).toBeDefined(); + expect(findBuiltInSlashCommand('rc')).toBe(command); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); describe('handleWebCommand', () => { @@ -120,6 +142,175 @@ describe('handleWebCommand', () => { }); }); +describe('handleRemoteControlCommand', () => { + it('stays in the TUI with a readable error when another instance holds Remote Control', async () => { + vi.clearAllMocks(); + const { mkdtempSync, mkdirSync, rmSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-lock-')); + const dataDir = join(tempRoot, 'home'); + mkdirSync(join(dataDir, 'server'), { recursive: true }); + writeFileSync( + join(dataDir, 'server', 'rc.json'), + JSON.stringify({ + pid: process.pid, + nonce: 'holder', + local_origin: 'http://127.0.0.1:58627', + device_id: 'device-1', + url: 'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli', + started_at: Date.now(), + }), + ); + mocks.getDataDir.mockReturnValue(dataDir); + const host = makeHost(); + + try { + await handleRemoteControlCommand(host); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('already running')); + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('/devices/device-1/'), + ); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + expect(host.stop).not.toHaveBeenCalled(); + expect(mocks.startServerForeground).not.toHaveBeenCalled(); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('starts the tunnel and saves a token-free session QR code', async () => { + vi.clearAllMocks(); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const { mkdtempSync, readFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { isAbsolute, join } = await import('node:path'); + const QRCode = await import('qrcode'); + const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-qrcode-')); + const dataDir = join(tempRoot, 'custom-home'); + const entryUrl = + 'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli'; + const sessionUrl = + 'https://code-rc.kimi.com/devices/device-1/sessions/ses-1?rc=1&from=kimi_code_cli'; + const pngPath = join(dataDir, 'rc-qrcode.png'); + mocks.getDataDir.mockReturnValue(dataDir); + mocks.tryResolveServerToken.mockReturnValue('local-server-token'); + const close = vi.fn(async () => {}); + mocks.startRemoteControl.mockResolvedValue({ + deviceId: 'device-1', + deviceName: 'example-device', + url: entryUrl, + close, + }); + mocks.startServerForeground.mockImplementation( + async ( + _options: unknown, + hooks: { + onReady?: (origin: string) => void | Promise<void>; + onShutdown?: (reason: string) => void | Promise<void>; + }, + ) => { + await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onShutdown?.('SIGINT'); + }, + ); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const host = makeHost(); + + try { + await handleRemoteControlCommand(host); + const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>; + await task(); + + expect(mocks.startRemoteControl).toHaveBeenCalledWith( + expect.objectContaining({ + homeDir: dataDir, + localOrigin: 'http://127.0.0.1:58627', + localServerToken: 'local-server-token', + }), + ); + expect(mocks.openUrl).toHaveBeenCalledWith(sessionUrl); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain('Kimi Remote Control ready'); + expect(written).toContain(renderTerminalQr(sessionUrl)); + expect(written).not.toContain(renderTerminalQr(entryUrl)); + expect(isAbsolute(pngPath)).toBe(true); + expect(written).toContain(`QR code PNG: ${pngPath}`); + const png = readFileSync(pngPath); + expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(png).toEqual(await QRCode.toBuffer(sessionUrl)); + expect(written).not.toContain('local-server-token'); + expect(written).not.toContain('#token='); + expect(close).toHaveBeenCalledOnce(); + } finally { + writeSpy.mockRestore(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('opens the device entry URL without a session instead of creating one', async () => { + vi.clearAllMocks(); + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const { mkdtempSync, readFileSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const QRCode = await import('qrcode'); + const tempRoot = mkdtempSync(join(tmpdir(), 'kimi-rc-entry-')); + const dataDir = join(tempRoot, 'custom-home'); + const entryUrl = + 'https://code-rc.kimi.com/devices/device-1/?rc=1&from=kimi_code_cli'; + mocks.getDataDir.mockReturnValue(dataDir); + mocks.tryResolveServerToken.mockReturnValue('local-server-token'); + const close = vi.fn(async () => {}); + mocks.startRemoteControl.mockResolvedValue({ + deviceId: 'device-1', + deviceName: 'example-device', + url: entryUrl, + close, + }); + mocks.startServerForeground.mockImplementation( + async ( + _options: unknown, + hooks: { + onReady?: (origin: string) => void | Promise<void>; + onShutdown?: (reason: string) => void | Promise<void>; + }, + ) => { + await hooks.onReady?.('http://127.0.0.1:58627'); + await hooks.onShutdown?.('SIGINT'); + }, + ); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const host = makeHost(); + host.session = undefined; + + try { + await handleRemoteControlCommand(host); + + expect(host.waitForLazyCreation).toHaveBeenCalledOnce(); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); + expect(host.stop).toHaveBeenCalledOnce(); + + const task = host.setExitForegroundTask.mock.calls[0]![0] as () => Promise<void>; + await task(); + + expect(mocks.openUrl).toHaveBeenCalledWith(entryUrl); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain(renderTerminalQr(entryUrl)); + expect(written).not.toContain('/sessions/'); + expect(readFileSync(join(dataDir, 'rc-qrcode.png'))).toEqual( + await QRCode.toBuffer(entryUrl), + ); + expect(close).toHaveBeenCalledOnce(); + } finally { + writeSpy.mockRestore(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + describe('webSessionUrl', () => { it('deep-links to the session under the origin', () => { expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( diff --git a/apps/kimi-code/test/utils/remote-control-qr.test.ts b/apps/kimi-code/test/utils/remote-control-qr.test.ts new file mode 100644 index 000000000..e69cb8347 --- /dev/null +++ b/apps/kimi-code/test/utils/remote-control-qr.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { resetCapabilitiesCache, setCapabilities } from '@moonshot-ai/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as QRCode from 'qrcode'; + +import { generateRemoteControlQr, renderTerminalQr } from '#/utils/remote-control-qr'; + +const RESET = '\u001B[0m'; +const WHITE_CELL = '\u001B[38;2;255;255;255m\u001B[48;2;255;255;255m▀'; + +describe('renderTerminalQr', () => { + it('renders truecolor black-on-white half blocks with a white quiet zone', () => { + const url = 'https://example.test/rc/entry'; + const output = renderTerminalQr(url); + const size = QRCode.create(url, { errorCorrectionLevel: 'M' }).modules.size; + const width = size + 4; + + expect(output).toContain('\u001B[38;2;0;0;0m'); + expect(output).not.toContain('\u001B[40m'); + expect(output).not.toContain('\u001B[47m'); + expect(output).not.toContain('\u001B[30m'); + expect(output).not.toContain('\u001B[37m'); + expect(output.endsWith(RESET)).toBe(true); + + const lines = output.split('\n'); + expect(lines.at(-1)).toBe(RESET); + const rows = lines.slice(0, -1); + expect(rows.length).toBe(Math.ceil((size + 4) / 2)); + for (const row of rows) { + expect(row.startsWith(WHITE_CELL.repeat(2))).toBe(true); + expect(row.endsWith(`${WHITE_CELL.repeat(2)}${RESET}`)).toBe(true); + expect(row.split('▀').length - 1).toBe(width); + } + expect(rows[0]).toBe(`${WHITE_CELL.repeat(width)}${RESET}`); + expect(rows.at(-1)).toBe(`${WHITE_CELL.repeat(width)}${RESET}`); + }); + + it('renders different output for different URLs', () => { + expect(renderTerminalQr('https://example.test/a')).not.toBe( + renderTerminalQr('https://example.test/b'), + ); + }); +}); + +describe('generateRemoteControlQr terminal rendering', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + + async function generateInTempDir(url: string) { + const dir = mkdtempSync(join(tmpdir(), 'kimi-rc-qr-')); + try { + const result = await generateRemoteControlQr(url, dir); + return { ...result, dir }; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } + } + + it('falls back to half-block rendering when the terminal has no image protocol', async () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + expect(terminal).toBe(renderTerminalQr(url)); + expect(readFileSync(pngPath)).toEqual(await QRCode.toBuffer(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('renders the PNG as a kitty image when the kitty protocol is available', async () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + const png = readFileSync(pngPath); + expect(terminal).toContain('\u001B_G'); + expect(terminal).toContain(png.toString('base64')); + expect(terminal).not.toBe(renderTerminalQr(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('renders the PNG as an iterm2 inline image when the iterm2 protocol is available', async () => { + setCapabilities({ images: 'iterm2', trueColor: true, hyperlinks: true }); + const url = 'https://example.test/rc/entry'; + const { terminal, pngPath, dir } = await generateInTempDir(url); + try { + const png = readFileSync(pngPath); + expect(terminal).toContain('\u001B]1337;File='); + expect(terminal).toContain(png.toString('base64')); + expect(terminal).not.toBe(renderTerminalQr(url)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/flake.nix b/flake.nix index f1b964bb0..df575b0ac 100644 --- a/flake.nix +++ b/flake.nix @@ -162,7 +162,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-BZFvB+JuwKiUmg8+OIQHNv2hr0Pm4pkr3s63cr1cVio="; + hash = "sha256-NDcCQ5vxsGaSdJ3U0bvq2RkXKwrYTI7/8zZn/x1fvJ8="; }; nativeBuildInputs = [ diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index 76d43dfe4..bb6787ad7 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -88,7 +88,9 @@ - V3 resume 期 signal 靠 `emitLive` 隐式压制(skill/swarm)——"这个 signal 发不发 得出去"取决于调用时相位,调用点看不出来。 - V4 `IEventService` payload 无类型、事件名裸字符串、同一事件两处发布者。 -- V5 `prompt.submitted` 协议里存在但无人发;`AsyncEmitter/handleVetos` 是死代码。 +- V5 `prompt.submitted` 曾长期只存在于协议而无人发,现已由 `AgentPromptService` + 在提交时发出(排队/运行以 `status` 区分,启动时再发 `prompt.started`); + `AsyncEmitter/handleVetos` 是死代码。 **回环与相位** - L1 订阅者回写链真实存在且无统一约束:turn.onEnded→goal 续跑→再 launch turn; diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index eb7e6a075..7bfb1308b 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -106,6 +106,32 @@ export class PromptQueued extends AgentEvent2<PromptQueuedPayload> { } export interface PromptQueued extends PromptQueuedPayload {} +export interface PromptSubmittedPayload { + readonly agentId: string; + readonly promptId: string; + readonly userMessageId: string; + readonly status: 'running' | 'queued'; + readonly content: ContentPart[]; + readonly createdAt: string; +} + +export class PromptSubmitted extends AgentEvent2<PromptSubmittedPayload> { + static override readonly type = 'prompt.submitted'; + static override readonly observable = true; +} +export interface PromptSubmitted extends PromptSubmittedPayload {} + +export interface PromptStartedPayload { + readonly agentId: string; + readonly promptId: string; +} + +export class PromptStarted extends AgentEvent2<PromptStartedPayload> { + static override readonly type = 'prompt.started'; + static override readonly observable = true; +} +export interface PromptStarted extends PromptStartedPayload {} + interface Deferred<T> { readonly promise: Promise<T>; resolve(value: T): void; reject(reason: unknown): void } interface Record extends PromptSnapshot { state: PromptState; @@ -236,16 +262,15 @@ export class AgentPromptService implements IAgentPromptService { completion: completionDeferred.promise, }; this.pending.push(record); - if (this.active === undefined && !this.launching) { - if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { - this.publishQueued(record); - return record.handle; - } - void this.startNext(); - await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); - } else { + const idle = this.active === undefined && !this.launching; + const queued = !idle || (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running'); + this.publishSubmitted(record, queued ? 'queued' : 'running'); + if (queued) { this.publishQueued(record); + return record.handle; } + void this.startNext(); + await Promise.race([record.launchedDeferred.promise, record.completionDeferred.promise]); return record.handle; } @@ -421,6 +446,7 @@ export class AgentPromptService implements IAgentPromptService { const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminder())).assigned).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); + this.publishStarted(item); void turn.result.then((result) => this.settle(item, result)); } catch { item.state = 'failed'; @@ -491,6 +517,14 @@ export class AgentPromptService implements IAgentPromptService { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; void this.dispatcher.dispatch(new PromptQueued({ agentId: this.scopeContext.agentId, promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length })); } + private publishSubmitted(record: Record, status: 'running' | 'queued'): void { + if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; + void this.dispatcher.dispatch(new PromptSubmitted({ agentId: this.scopeContext.agentId, promptId: record.id, userMessageId: record.userMessageId, status, content: stripBundledSkillBlocks(record.message), createdAt: record.createdAt })); + } + private publishStarted(record: Record): void { + if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; + void this.dispatcher.dispatch(new PromptStarted({ agentId: this.scopeContext.agentId, promptId: record.id })); + } private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ agentId: this.scopeContext.agentId, promptId, abortedAt: new Date().toISOString() })); } } diff --git a/packages/agent-core-v2/src/app/remoteControl/flag.ts b/packages/agent-core-v2/src/app/remoteControl/flag.ts new file mode 100644 index 000000000..5d999c208 --- /dev/null +++ b/packages/agent-core-v2/src/app/remoteControl/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const REMOTE_CONTROL_FLAG_ID = 'remote-control'; +export const REMOTE_CONTROL_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL'; + +export const remoteControlFlag: FlagDefinitionInput = { + id: REMOTE_CONTROL_FLAG_ID, + title: 'Remote Control', + description: + 'Expose the local web UI through Kimi Remote Control (`kimi web --remote-control`, `/remote-control`).', + env: REMOTE_CONTROL_FLAG_ENV, + default: false, + surface: 'both', +}; + +registerFlagDefinition(remoteControlFlag); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 5fd0cb5b7..48a14a09a 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -179,6 +179,7 @@ export * from '#/kosong/provider/providerService'; export * from '#/kosong/provider/providerDefinition'; export * from '#/kosong/provider/protocolAdapterRegistry'; import '#/features/skill/catalog/configSection'; +import '#/app/remoteControl/flag'; import '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/agentIdentity'; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 5fd1356b5..17dab29f8 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -69,10 +69,12 @@ describe('Agent loop', () => { [wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "<time>" } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" } @@ -127,10 +129,12 @@ describe('Agent loop', () => { expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Hello" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Hello" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Hello" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Hello" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Hello" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" } @@ -350,10 +354,12 @@ describe('Agent loop', () => { [wire] tools.set_active_tools { "agentId": "main", "names": [ "Lookup" ], "time": "<time>" } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" } diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 66cca1bac..1a14c875f 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -12,7 +12,7 @@ import type { ContentPart } from '#/kosong/contract/message'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; -import { AgentPromptService, PromptQueued, PromptSteered } from '#/agent/prompt/promptService'; +import { AgentPromptService, PromptQueued, PromptStarted, PromptSteered, PromptSubmitted } from '#/agent/prompt/promptService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -157,6 +157,32 @@ describe('AgentPromptService', () => { expect(queued).toEqual([{ promptId: 'waiting', queueLength: 1 }]); }); + it('publishes prompt.submitted for every user prompt and prompt.started on launch', async () => { + const { prompt, eventBus } = harness(); + const submitted: Array<{ promptId: string; userMessageId: string; status: string; content: ContentPart[] }> = []; + const started: string[] = []; + eventBus.subscribe(PromptSubmitted, (e) => { + submitted.push({ promptId: e.promptId, userMessageId: e.userMessageId, status: e.status, content: e.content }); + }); + eventBus.subscribe(PromptStarted, (e) => { + started.push(e.promptId); + }); + + const active = await prompt.enqueue({ id: 'active', message: message('active') }); + expect(submitted).toEqual([ + { promptId: 'active', userMessageId: 'active', status: 'running', content: [{ type: 'text', text: 'active' }] }, + ]); + await active.launched; + expect(started).toEqual(['active']); + + await prompt.enqueue({ id: 'waiting', message: message('waiting') }); + expect(submitted).toEqual([ + { promptId: 'active', userMessageId: 'active', status: 'running', content: [{ type: 'text', text: 'active' }] }, + { promptId: 'waiting', userMessageId: 'waiting', status: 'queued', content: [{ type: 'text', text: 'waiting' }] }, + ]); + expect(started).toEqual(['active']); + }); + it('atomically rejects steer when any id is not pending', async () => { const { prompt } = harness(); await prompt.enqueue({ message: message('active') }); diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index a8c785714..aa9e5f5f4 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -321,10 +321,12 @@ describe('Agent config', () => { expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(` [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up before config changes" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up before config changes" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up before config changes" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up before config changes" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up before config changes" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 0, "step": 1, "stepId": "<uuid-1>" } @@ -415,10 +417,12 @@ describe('Agent config', () => { [emit] prompt.completed { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "finishedAt": "<time>", "reason": "completed" } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Start a fresh turn" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Start a fresh turn" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Start a fresh turn" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" }, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 1, "step": 1, "stepId": "<uuid-6>" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } diff --git a/packages/agent-core-v2/test/features/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts index ec55a2298..105be8c4d 100644 --- a/packages/agent-core-v2/test/features/plan/plan.test.ts +++ b/packages/agent-core-v2/test/features/plan/plan.test.ts @@ -715,10 +715,12 @@ describe('Plan service', () => { [emit] agent.status.updated { "time": "<time>", "agentId": "main", "planMode": true } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Inspect without mutating files" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Inspect without mutating files" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Inspect without mutating files" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Inspect without mutating files" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] } @@ -796,10 +798,12 @@ describe('Plan service', () => { [emit] agent.status.updated { "time": "<time>", "agentId": "main", "planMode": true } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Remove forbidden.txt" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Remove forbidden.txt" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Remove forbidden.txt" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] plugin.session_start { "agentId": "main", "content": null, "time": "<time>" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<plan-mode-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "plan_mode" } } ] } diff --git a/packages/agent-core-v2/test/harness/snapshots.ts b/packages/agent-core-v2/test/harness/snapshots.ts index 6b8e07de7..ef2a4cff1 100644 --- a/packages/agent-core-v2/test/harness/snapshots.ts +++ b/packages/agent-core-v2/test/harness/snapshots.ts @@ -298,7 +298,7 @@ function normalizeObjectField(key: string, value: unknown, labels: SnapshotLabel ) { return '<time>'; } - if ((key === 'finishedAt' || key === 'abortedAt' || key === 'steeredAt') && typeof value === 'string') return '<time>'; + if ((key === 'finishedAt' || key === 'abortedAt' || key === 'steeredAt' || key === 'createdAt') && typeof value === 'string') return '<time>'; if (key === 'protocol_version' && value === WIRE_PROTOCOL_VERSION) { return '<protocol-version>'; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 56bc76c5d..432412572 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -3872,10 +3872,12 @@ describe('Agent tools', () => { [wire] tools.register_user_tool { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false }, "agentId": "main", "time": "<time>" } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "content": [ { "type": "text", "text": "Look up moon" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "userMessageId": "<msg-1>", "status": "running", "content": [ { "type": "text", "text": "Look up moon" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Look up moon" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 0, "origin": { "kind": "user" }, "prompt": "Look up moon" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-1>" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 1, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } } ] } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Look up moon" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-1>" }, "time": "<time>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } }, "time": "<time>" } @@ -3951,10 +3953,12 @@ describe('Agent tools', () => { [emit] prompt.completed { "time": "<time>", "agentId": "main", "promptId": "<msg-1>", "finishedAt": "<time>", "reason": "completed" } [wire] prompt.accepted { "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "time": "<time>" } [emit] prompt.accepted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ] } + [emit] prompt.submitted { "time": "<time>", "agentId": "main", "promptId": "<msg-2>", "userMessageId": "<msg-2>", "status": "running", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "createdAt": "<time>" } [wire] turn.prompt { "agentId": "main", "input": [ { "type": "text", "text": "Can you still use Lookup?" } ], "origin": { "kind": "user" }, "time": "<time>" } [emit] turn.started { "time": "<time>", "agentId": "main", "turnId": 1, "origin": { "kind": "user" }, "prompt": "Can you still use Lookup?" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } [emit] context.spliced { "time": "<time>", "agentId": "main", "start": 5, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" } ] } + [emit] prompt.started { "time": "<time>", "agentId": "main", "promptId": "<msg-2>" } [wire] context.append_message { "agentId": "main", "message": { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "toolCalls": [], "origin": { "kind": "user" }, "id": "<msg-2>" }, "time": "<time>" } [emit] turn.step.started { "time": "<time>", "agentId": "main", "turnId": 1, "step": 1, "stepId": "<uuid-6>" } [emit] agent.activity.updated { "time": "<time>", "lifecycle": "ready", "turn": { "turnId": 1, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "<time>" }, "background": [], "agentId": "main" } diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 6798a39ce..1fb40bf5b 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -24,7 +24,9 @@ import type { WarningIssued } from '@moonshot-ai/agent-core-v2/agent/profile/pro import type { PromptAborted, PromptCompleted, + PromptStarted, PromptSteered, + PromptSubmitted, } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; import type { PromptAccepted } from '@moonshot-ai/agent-core-v2/agent/prompt/promptOps'; import type { PromptQueued } from '@moonshot-ai/agent-core-v2/agent/prompt/promptService'; @@ -91,6 +93,8 @@ type PlanRevisionEvent = { readonly type: 'plan.revision' } & PlanRevision; type AgentActivityUpdatedEvent = { readonly type: 'agent.activity.updated' } & AgentActivityUpdated; type PromptAcceptedEvent = { readonly type: 'prompt.accepted' } & PromptAccepted; type PromptQueuedEvent = { readonly type: 'prompt.queued' } & PromptQueued; +type PromptSubmittedEvent = { readonly type: 'prompt.submitted' } & PromptSubmitted; +type PromptStartedEvent = { readonly type: 'prompt.started' } & PromptStarted; type PromptCompletedEvent = { readonly type: 'prompt.completed' } & PromptCompleted; type PromptAbortedEvent = { readonly type: 'prompt.aborted' } & PromptAborted; type PromptSteeredEvent = { readonly type: 'prompt.steered' } & PromptSteered; @@ -125,6 +129,8 @@ export type ProjectorBusEvent = | AgentActivityUpdatedEvent | PromptAcceptedEvent | PromptQueuedEvent + | PromptSubmittedEvent + | PromptStartedEvent | PromptCompletedEvent | PromptAbortedEvent | PromptSteeredEvent @@ -140,15 +146,6 @@ export type ProjectorBusEvent = | ({ readonly type: 'error' } & AgentErrorEvent) | ({ readonly type: 'warning' } & WarningIssued); -export interface ProjectorPromptSubmittedEvent { - readonly type: 'prompt.submitted'; - readonly promptId: string; - readonly userMessageId: string; - readonly status: 'running' | 'queued' | 'blocked'; - readonly content?: unknown; - readonly createdAt: string; -} - export type ProjectorFrameLookup = ( turnId: string, stepId: string, @@ -226,7 +223,7 @@ export class AgentTranscriptProjector { private readonly lookups?: ProjectorLookups, ) {} - map(event: ProjectorBusEvent | ProjectorPromptSubmittedEvent): TranscriptOperation[] { + map(event: ProjectorBusEvent): TranscriptOperation[] { switch (event.type) { case 'plan.revision': return this.onPlanRevision(event); @@ -284,6 +281,8 @@ export class AgentTranscriptProjector { return this.onPromptQueued(event); case 'prompt.submitted': return this.onPromptSubmitted(event); + case 'prompt.started': + return this.onPromptStarted(event); case 'prompt.completed': return this.onPromptCompleted(event); case 'prompt.aborted': @@ -1233,13 +1232,28 @@ export class AgentTranscriptProjector { return [{ op: 'prompt.upsert', prompt }]; } - private onPromptSubmitted(event: ProjectorPromptSubmittedEvent): TranscriptOperation[] { - const prompt = this.upsertPrompt(event.promptId, () => ({ + private onPromptSubmitted(event: PromptSubmittedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ promptId: event.promptId, - status: event.status, + status: prev !== undefined && isTerminalPromptStatus(prev.status) ? prev.status : event.status, userMessageId: event.userMessageId, - content: event.content, - createdAt: event.createdAt, + content: projectPromptContentParts(event.content), + createdAt: prev?.createdAt ?? event.createdAt, + finishedAt: prev?.finishedAt, + steeredAt: prev?.steeredAt, + })); + return [{ op: 'prompt.upsert', prompt }]; + } + + private onPromptStarted(event: PromptStartedEvent): TranscriptOperation[] { + const prompt = this.upsertPrompt(event.promptId, (prev) => ({ + promptId: event.promptId, + status: 'running', + userMessageId: prev?.userMessageId, + content: prev?.content, + createdAt: prev?.createdAt ?? new Date().toISOString(), + finishedAt: prev?.finishedAt, + steeredAt: prev?.steeredAt, })); return [{ op: 'prompt.upsert', prompt }]; } @@ -1345,6 +1359,10 @@ function nowIso(): string { return new Date().toISOString(); } +function isTerminalPromptStatus(status: TranscriptPrompt['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'aborted' || status === 'blocked'; +} + function epochMsToIso(value: number): string { return new Date(value).toISOString(); } diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index fd5baba61..ec5a27412 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -112,7 +112,6 @@ export interface ServerStartOptions { readonly disableHostCheck?: boolean; readonly insecureNoTls?: boolean; readonly allowRemoteShutdown?: boolean; - readonly allowRemoteTerminals?: boolean; readonly authTokenService?: IAuthTokenService; readonly disableAuth?: boolean; readonly webTitle?: string; @@ -161,7 +160,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ ); } const enableShutdown = exposureClass === 'loopback' || opts.allowRemoteShutdown === true; - const enableTerminals = exposureClass === 'loopback' || opts.allowRemoteTerminals === true; + const enableTerminals = exposureClass === 'loopback'; const debugEndpoints = exposureClass === 'loopback' && opts.debugEndpoints === true; const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' }); const onUnhandledRejection = (reason: unknown): void => { diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index ddac05747..9c8b21d81 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -884,7 +884,7 @@ export class SessionEventBroadcaster { promptAttachments?: unknown; }; wireEvent = Object.assign({}, wireFields, { agentId, sessionId }) as unknown as Event; - } else if (event.type === 'prompt.steered' || event.type === 'prompt.queued') { + } else if (event.type === 'prompt.steered' || event.type === 'prompt.queued' || event.type === 'prompt.submitted') { const content = (event as unknown as { content: Parameters<typeof projectPromptContentParts>[0] }).content; wireEvent = Object.assign({}, event, { content: projectPromptContentParts(content), @@ -1149,6 +1149,7 @@ const TRANSCRIPT_PROJECTED_EVENT_TYPES: ReadonlySet<string> = new Set([ 'agent.status.updated', 'hook.result', 'prompt.submitted', + 'prompt.started', 'prompt.completed', 'prompt.aborted', 'prompt.steered', diff --git a/packages/kap-server/test/securityExposure.test.ts b/packages/kap-server/test/securityExposure.test.ts index 927bfa478..5e7680838 100644 --- a/packages/kap-server/test/securityExposure.test.ts +++ b/packages/kap-server/test/securityExposure.test.ts @@ -114,24 +114,4 @@ describe('server-v2 exposure hardening hooks', () => { }); expect(terminals.statusCode).toBe(404); }); - - it('can explicitly re-enable terminal routes on non-loopback', async () => { - server = await startServer({ - hostIdentity: TEST_HOST_IDENTITY, - host: '0.0.0.0', - port: 0, - homeDir: home, - logLevel: 'silent', - insecureNoTls: true, - allowRemoteTerminals: true, - }); - const token = server.authTokenService.getToken(); - const res = await server.app.inject({ - method: 'GET', - url: '/api/v1/sessions/missing/terminals', - headers: { authorization: `Bearer ${token}` }, - }); - const body = res.json() as Record<string, unknown>; - expect(body['code']).toBe(40401); - }); }); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index f38ab8eb2..53de53057 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -1702,6 +1702,14 @@ describe('AgentTranscriptProjector', () => { expect(tx.getPrompt('p1')).toMatchObject({ status: 'running', userMessageId: 'm1' }); expect(tx.getPrompt('p2')).toMatchObject({ status: 'queued' }); + feed(ev({ type: 'prompt.started', promptId: 'p2' })); + expect(tx.getPrompt('p2')).toMatchObject({ + status: 'running', + userMessageId: 'm2', + content: [{ type: 'text', text: 'second' }], + createdAt: '2026-01-01T00:00:01.000Z', + }); + feed( ev({ type: 'prompt.steered', diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 4283f1675..6b308e2ab 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -969,7 +969,7 @@ describe('SessionEventBroadcaster', () => { ); }); - it.each(['prompt.steered', 'prompt.queued'])( + it.each(['prompt.steered', 'prompt.queued', 'prompt.submitted'])( 'projects %s content without leaking daemon refs (live + tail replay)', async (type) => { const lc = new FakeLifecycle(); @@ -981,7 +981,9 @@ describe('SessionEventBroadcaster', () => { const ids = type === 'prompt.steered' ? { activePromptId: 'p1', promptIds: ['p2'], steeredAt: '2026-01-01T00:00:02.000Z' } - : { promptId: 'p2', queueLength: 1 }; + : type === 'prompt.submitted' + ? { promptId: 'p2', userMessageId: 'p2', status: 'queued', createdAt: '2026-01-01T00:00:01.000Z' } + : { promptId: 'p2', queueLength: 1 }; main.bus.emit( agentEvent(type, { ...ids, diff --git a/packages/kap-server/test/transcript.test.ts b/packages/kap-server/test/transcript.test.ts index cfc631b8f..8899d1879 100644 --- a/packages/kap-server/test/transcript.test.ts +++ b/packages/kap-server/test/transcript.test.ts @@ -62,6 +62,14 @@ interface TranscriptContract { state: string; [key: string]: unknown; }[]; + prompts: { + promptId: string; + status: string; + userMessageId?: string; + content?: unknown; + createdAt?: string; + [key: string]: unknown; + }[]; meta: Record<string, unknown>; agents: { agentId: string; type?: string }[]; pending_interactions: string[]; @@ -322,6 +330,55 @@ describe('server-v2 /api/v1/sessions/{sid}/transcript', () => { ); }); + it('exposes the prompt queue entities in the live transcript response', async () => { + const id = await createSession(); + await ensureMainAgent(id); + await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`); + + const bus = mainAgentBus(id); + bus.publish( + serverEvent({ + type: 'prompt.submitted', + promptId: 'p1', + userMessageId: 'p1', + status: 'running', + content: [{ type: 'text', text: 'first' }], + createdAt: '2026-01-01T00:00:00.000Z', + }), + ); + bus.publish( + serverEvent({ + type: 'prompt.submitted', + promptId: 'p2', + userMessageId: 'p2', + status: 'queued', + content: [{ type: 'text', text: 'second' }], + createdAt: '2026-01-01T00:00:01.000Z', + }), + ); + + let { body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`); + expect(body.data.prompts).toContainEqual( + expect.objectContaining({ + promptId: 'p1', + status: 'running', + userMessageId: 'p1', + content: [{ type: 'text', text: 'first' }], + }), + ); + expect(body.data.prompts).toContainEqual(expect.objectContaining({ promptId: 'p2', status: 'queued' })); + + bus.publish(serverEvent({ type: 'prompt.started', promptId: 'p2' })); + ({ body } = await getJson<TranscriptContract>(`/api/v1/sessions/${id}/transcript?agent_id=main`)); + expect(body.data.prompts).toContainEqual( + expect.objectContaining({ + promptId: 'p2', + status: 'running', + content: [{ type: 'text', text: 'second' }], + }), + ); + }); + it('paginates live turns with page_size and before_turn', async () => { const id = await createSession(); await ensureMainAgent(id); diff --git a/packages/node-sdk/src/v2/event-mapper.ts b/packages/node-sdk/src/v2/event-mapper.ts index 206bc9132..4c809a228 100644 --- a/packages/node-sdk/src/v2/event-mapper.ts +++ b/packages/node-sdk/src/v2/event-mapper.ts @@ -39,6 +39,7 @@ const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet<string> = new Set([ 'prompt.submitted', 'prompt.completed', 'prompt.aborted', + 'prompt.started', 'prompt.steered', ]); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 168f97b73..3a604a73a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,13 @@ importers: version: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) apps/kimi-code: + dependencies: + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + ws: + specifier: ^8.18.0 + version: 8.20.0 devDependencies: '@moonshot-ai/acp-adapter': specifier: workspace:^ @@ -110,9 +117,15 @@ importers: '@moonshot-ai/vis-web': specifier: workspace:* version: link:../vis/web + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/semver': specifier: ^7.7.0 version: 7.7.1 + '@types/ws': + specifier: ^8.18.0 + version: 8.18.1 '@types/yazl': specifier: ^2.4.6 version: 2.4.6 @@ -447,11 +460,11 @@ importers: version: 1.13.1 vitepress-plugin-mermaid: specifier: ^2.0.17 - version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2)) + version: 2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2)) devDependencies: vitepress: specifier: ^1.5.0 - version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2) + version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2) packages/acp-adapter: dependencies: @@ -4460,6 +4473,9 @@ packages: '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5064,6 +5080,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -5164,6 +5184,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -5541,6 +5564,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decamelize@4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} @@ -5639,6 +5666,9 @@ packages: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -7856,6 +7886,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -7967,6 +8001,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -8188,6 +8227,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} @@ -8391,6 +8433,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -9499,6 +9544,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -9521,6 +9569,10 @@ packages: workerpool@9.3.4: resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -9577,6 +9629,9 @@ packages: xstate@5.32.5: resolution: {integrity: sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -9600,6 +9655,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -9612,6 +9671,10 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -13184,6 +13247,10 @@ snapshots: dependencies: '@types/retry': 0.12.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.17 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -13554,7 +13621,7 @@ snapshots: transitivePeerDependencies: - typescript - '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.2)': + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.2)': dependencies: '@vueuse/core': 12.8.2(typescript@6.0.2) '@vueuse/shared': 12.8.2(typescript@6.0.2) @@ -13562,6 +13629,7 @@ snapshots: optionalDependencies: focus-trap: 7.8.0 fuse.js: 7.5.0 + qrcode: 1.5.4 transitivePeerDependencies: - typescript @@ -13891,6 +13959,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001788: {} @@ -14000,6 +14070,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -14394,6 +14470,8 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decamelize@1.2.0: {} + decamelize@4.0.0: {} decimal.js@10.6.0: @@ -14468,6 +14546,8 @@ snapshots: diff@9.0.0: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -17146,6 +17226,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + pngjs@6.0.0: {} pngjs@7.0.0: {} @@ -17273,6 +17355,12 @@ snapshots: punycode@2.3.1: optional: true + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.1: dependencies: side-channel: 1.1.0 @@ -17647,6 +17735,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + reselect@5.2.0: {} resize-observer-polyfill@1.5.1: {} @@ -17935,6 +18025,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@2.7.2: {} set-cookie-parser@3.1.2: {} @@ -18880,14 +18972,14 @@ snapshots: transitivePeerDependencies: - supports-color - vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2)): + vitepress-plugin-mermaid@2.0.17(mermaid@11.15.0)(vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2)): dependencies: mermaid: 11.15.0 - vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2) + vitepress: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2) optionalDependencies: '@mermaid-js/mermaid-mindmap': 9.3.0 - vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2): + vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(fuse.js@7.5.0)(lightningcss@1.32.0)(postcss@8.5.15)(qrcode@1.5.4)(search-insights@2.17.3)(typescript@6.0.2): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) @@ -18900,7 +18992,7 @@ snapshots: '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.35 '@vueuse/core': 12.8.2(typescript@6.0.2) - '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(typescript@6.0.2) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(fuse.js@7.5.0)(qrcode@1.5.4)(typescript@6.0.2) focus-trap: 7.8.0 mark.js: 8.11.1 minisearch: 7.2.0 @@ -19080,6 +19172,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -19105,6 +19199,12 @@ snapshots: workerpool@9.3.4: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -19153,6 +19253,8 @@ snapshots: xstate@5.32.5: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -19165,6 +19267,11 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -19176,6 +19283,20 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@16.2.0: dependencies: cliui: 7.0.4