mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-26 00:54:36 +00:00
feat(kimi-code): add remote control web tunnel (#3034)
* feat(kimi-code): add remote control web tunnel Add CLI and TUI entry points for exposing the local web UI remotely. Bridge HTTP and WebSocket traffic with local authentication and reconnect handling. * fix(kimi-code): prevent remote control websocket crash * fix(kimi-code): align websocket dependency versions * fix(kimi-code): harden remote control connection setup Reconnect when management closes during the HTTP tunnel handshake. Reject non-loopback Remote Control binds whose CSP blocks path bootstrap. * fix(kimi-code): fix remote control rewriting, caching, and WS frame loss * feat(kimi-code): add remote control QR output * build: update pnpm dependencies hash * refactor(kimi-code): remove the --allow-remote-terminals flag * feat(kimi-code): add remote control lock, rc command, and QR fixes * fix(kap-server): broadcast user prompts to all session clients on submit - agent-core-v2: emit prompt.submitted (status running|queued) at enqueue and prompt.started when the turn launches - kap-server: project prompt.submitted/prompt.started into transcript prompt entities and the live transcript REST response - update flake.nix pnpmDeps hash for the PR lockfile * fix(node-sdk): drop v2-only prompt.started from SDK event stream - event-mapper: add prompt.started to the dropped v2-only prompt lifecycle types (parity with submitted/completed/aborted/steered) - cli test: assert only visible sub-commands and stub the experimental flag env for determinism * ci(pkg-pr-new): post custom install comment for npm 12 compatibility * feat(kimi-code): render remote control QR as inline image on capable terminals * feat(kimi-code): improve remote control terminal output - add onboarding, security, device management, and help guidance - show compact clickable links and QR image fallback details - report relay and remote device connection lifecycle * test(agent-core-v2): update tool event snapshot * revert(ci): keep preview workflow unchanged in rc pr --------- Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
parent
e6a302b310
commit
f0a609487f
41 changed files with 3001 additions and 92 deletions
5
.changeset/add-remote-control.md
Normal file
5
.changeset/add-remote-control.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/broadcast-user-prompts.md
Normal file
5
.changeset/broadcast-user-prompts.md
Normal file
|
|
@ -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.
|
||||
5
.changeset/drop-allow-remote-terminals.md
Normal file
5
.changeset/drop-allow-remote-terminals.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
170
apps/kimi-code/src/cli/sub/web/remote-control-lock.ts
Normal file
170
apps/kimi-code/src/cli/sub/web/remote-control-lock.ts
Normal file
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
const MAX_ACQUIRE_ATTEMPTS = 3;
|
||||
|
||||
export async function acquireRemoteControlLock(
|
||||
homeDir: string,
|
||||
details: { localOrigin: string; deviceId: string; url: string },
|
||||
): Promise<RemoteControlLock> {
|
||||
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<RemoteControlLockInfo | undefined> {
|
||||
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<void> {
|
||||
const info = await readRemoteControlLock(lockPath);
|
||||
if (info === undefined || info.nonce !== nonce) return;
|
||||
await removeFile(lockPath);
|
||||
}
|
||||
|
||||
async function readRemoteControlLock(lockPath: string): Promise<RemoteControlLockInfo | undefined> {
|
||||
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<RemoteControlLockDisk>;
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
1007
apps/kimi-code/src/cli/sub/web/remote-control.ts
Normal file
1007
apps/kimi-code/src/cli/sub/web/remote-control.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<void>;
|
||||
onShutdown?: (reason: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface WebCommandDeps {
|
||||
|
|
@ -75,6 +88,7 @@ export interface WebCommandDeps {
|
|||
options: ParsedServerOptions,
|
||||
hooks?: StartForegroundHooks,
|
||||
) => Promise<never>;
|
||||
startRemoteControl?: (options: RemoteControlOptions) => Promise<RemoteControlHandle>;
|
||||
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 <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 <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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
62
apps/kimi-code/src/utils/remote-control-qr.ts
Normal file
62
apps/kimi-code/src/utils/remote-control-qr.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
705
apps/kimi-code/test/cli/web/remote-control.test.ts
Normal file
705
apps/kimi-code/test/cli/web/remote-control.test.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
104
apps/kimi-code/test/utils/remote-control-qr.test.ts
Normal file
104
apps/kimi-code/test/utils/remote-control-qr.test.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() })); }
|
||||
}
|
||||
|
||||
|
|
|
|||
16
packages/agent-core-v2/src/app/remoteControl/flag.ts
Normal file
16
packages/agent-core-v2/src/app/remoteControl/flag.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>" }
|
||||
|
|
|
|||
|
|
@ -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') });
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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" } } ] }
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const DROPPED_DOMAIN_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|||
'prompt.submitted',
|
||||
'prompt.completed',
|
||||
'prompt.aborted',
|
||||
'prompt.started',
|
||||
'prompt.steered',
|
||||
]);
|
||||
|
||||
|
|
|
|||
135
pnpm-lock.yaml
generated
135
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue