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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/disable-remote-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@moonshot-ai/kimi-code': minor
---

Remote Control is disabled. `kimi rc`, `kimi web --rc` and the `/rc` slash
command are gone, and `POST /api/v1/remote-control` refuses to enable the
tunnel. The local web UI is unaffected: run `kimi web`.

The tunnel forwarded public-internet traffic into the local server, which has
the terminal, file and approval APIs enabled, and authenticated to the relay
with the long-lived Kimi refresh token. There is no environment variable to
turn it back on.
22 changes: 22 additions & 0 deletions HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,25 @@ rationale. Line numbers are from the commit that introduced the note and will dr

> Auto mode no longer blanket-approves Bash; these hook-flow tests are
> about hook ordering, so opt in explicitly rather than gate on approval.

### `packages/remote-control/src/remote-control.ts`

**`export async function startRemoteControl(`**

> Remote Control is disabled in this fork.
>
> The tunnel registers the machine with a Moonshot-operated relay and forwards public-internet traffic into the local kap-server. That server has the terminal API enabled, because it is only reachable on a loopback bind and the tunnel requires one, so a caller the relay lets through can run shell commands, read and write files, and answer the agent's own approval prompts.
>
> The client performs no local authorization. It strips the caller's `Authorization`, `Cookie`, `Host` and `Origin`, then injects the local server token and Host, so kap-server's bearer, Host and Origin checks pass by construction rather than by decision. The entire trust boundary is the relay operator's account check.
>
> Relay authentication uses the long-lived Kimi *refresh* token, not an access token, carried as a WebSocket subprotocol value where proxies and CDNs log it far more readily than an `Authorization` header, and re-sent on every reconnect.
>
> `startRemoteControl` is the single chokepoint: the CLI (`kimi rc`), the TUI (`/rc`) and the server route (`POST /api/v1/remote-control`) all start a tunnel through it, so refusing there also covers any caller a later upstream merge introduces. Upstream's implementation is kept as `startRemoteControlTunnel`, unreachable, so upstream's own tunnel tests keep running and upstream changes still merge cleanly.
>
> There is deliberately no environment escape hatch. An env variable would re-enable a public tunnel from exactly the contexts where the environment is least trustworthy.

### `packages/kap-server/src/start.ts`

**`staticEnableError: REMOTE_CONTROL_DISABLED_MESSAGE,`**

> Upstream sets this only for a non-loopback bind or `--dangerous-bypass-auth`, which leaves `POST /api/v1/remote-control {"enabled":true}` working for a plain `kimi web`: anything holding the local server token could start a public tunnel with no terminal interaction and no second confirmation. Making it unconditional turns that route into a clean refusal.
11 changes: 4 additions & 7 deletions apps/kimi-code/src/cli/sub/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
* management subcommand is `web rotate-token` (rotate the home-wide bearer
* token). Servers left behind by pre-0.28.0 builds are cleaned up with
* `kimi server kill`.
*
* Upstream also registers `kimi rc` / `kimi remote`, which serves the same UI
* through the Kimi Remote Control relay. This fork does not: Remote Control is
* disabled, so the subcommand is not mounted.
*/

import type { Command } from 'commander';
Expand All @@ -24,11 +28,4 @@ export function registerWebCommand(program: Command): void {
);
registerRotateTokenCommand(web);
registerDeprecatedServerCommand(program);
buildWebCommand(
program
.command('rc')
.alias('remote')
.description('Run the local Kimi server and open the web UI through Remote Control.'),
{ forceRemoteControl: true },
);
}
2 changes: 2 additions & 0 deletions apps/kimi-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export {
REMOTE_CONTROL_RELAY_URL_ENV,
resolveRemoteControlRelayOrigin,
rewriteRemoteControlResponse,
RemoteControlDisabledError,
REMOTE_CONTROL_DISABLED_MESSAGE,
startRemoteControl,
} from '@moonshot-ai/remote-control';
export type {
Expand Down
30 changes: 7 additions & 23 deletions apps/kimi-code/src/cli/sub/web/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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, Option } from 'commander';
import type { Command } from 'commander';

import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app';
import { getNativeWebAssetsDir } from '#/native/web-assets';
Expand All @@ -40,6 +40,7 @@ import { type NetworkAddress } from './networks';
import {
formatRemoteControlOutput,
formatRemoteControlStatus,
RemoteControlDisabledError,
startRemoteControl,
type RemoteControlHandle,
type RemoteControlOptions,
Expand Down Expand Up @@ -118,11 +119,7 @@ export function buildWebUrl(origin: string, token: string): string {
}

/** Build the `web` command, mounting the runner action on `cmd` itself. */
export function buildWebCommand(
cmd: Command,
opts: { forceRemoteControl?: boolean } = {},
): Command {
const forceRemoteControl = opts.forceRemoteControl === true;
export function buildWebCommand(cmd: Command): Command {
const withServerOptions = cmd
.option(
'--port <port>',
Expand Down Expand Up @@ -165,21 +162,11 @@ export function buildWebCommand(
'--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.',
).default(false),
);
}
return withServerOptions
.option('--no-open', 'Do not open the web UI in the default browser.', true)
.action(async (opts: WebCliOptions) => {
try {
await handleWebCommand(
forceRemoteControl ? { ...opts, remoteControl: true } : opts,
);
await handleWebCommand(opts);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
Expand All @@ -191,13 +178,10 @@ export async function handleWebCommand(
opts: WebCliOptions,
deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS,
): Promise<void> {
const parsed = parseServerOptions(opts);
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.');
if (opts.remoteControl === true) {
throw new RemoteControlDisabledError();
}
const parsed = parseServerOptions(opts);
const run = deps.startServerForeground ?? startServerForeground;
let remoteControl: RemoteControlHandle | undefined;
await run(parsed, {
Expand Down
3 changes: 0 additions & 3 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,9 +602,6 @@ async function handleBuiltInSlashCommand(
case 'desktop':
await handleDesktopCommand(host);
return;
case 'remote-control':
await handleRemoteControlCommand(host);
return;
default:
host.showError(`Unknown slash command: /${String(name)}`);
return;
Expand Down
7 changes: 0 additions & 7 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,6 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 40,
availability: 'always',
},
{
name: 'remote-control',
aliases: ['rc'],
description: 'Open the current session through Kimi Remote Control',
priority: 40,
availability: 'always',
},
{
name: 'exit',
aliases: ['quit', 'q'],
Expand Down
1 change: 0 additions & 1 deletion apps/kimi-code/test/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,6 @@ describe('CLI options parsing', () => {
'acp',
'web',
'server',
'rc',
'login',
'doctor',
'vis',
Expand Down
69 changes: 18 additions & 51 deletions apps/kimi-code/test/cli/web/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ describe('kimi web', () => {
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');
expect(longs).not.toContain('--remote-control');
// 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
Expand Down Expand Up @@ -442,77 +441,45 @@ describe('`kimi web` opens the browser', () => {
expect(openUrl).not.toHaveBeenCalled();
});

it('maps --remote-control and --rc to the same option', () => {
it('does not accept --remote-control or --rc', () => {
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 });
expect(web.parseOptions([flag]).unknown).toContain(flag);
expect(web.opts()).not.toMatchObject({ remoteControl: true });
}
});

it('rejects Remote Control on a non-loopback host', async () => {
it('refuses Remote Control even on a loopback host, which upstream allows', async () => {
const { handleWebCommand } = await import('#/cli/sub/web/run');
const { runner } = makeRunner();
const { runner, calls } = makeRunner();
const { stdout, stderr } = makeIo();

await expect(
handleWebCommand(
{ remoteControl: true, host: '0.0.0.0', open: false },
{ remoteControl: true, host: '127.0.0.1', open: false },
{ startServerForeground: runner, openUrl: vi.fn(), stdout, stderr },
),
).rejects.toThrow('--remote-control requires a loopback host.');
).rejects.toThrow(/disabled in this build/);
expect(calls.options).toBeUndefined();
});

it('shows --remote-control in help', () => {
const remoteControlOption = makeProgram()
it('does not mention Remote Control in help', () => {
const help = makeProgram()
.commands.find((command) => command.name() === 'web')!
.options.find((option) => option.long === '--remote-control');
expect(remoteControlOption?.hidden).toBeFalsy();
.helpInformation();
expect(help).not.toContain('--remote-control');
expect(help).not.toContain('Remote Control');
});
});

describe('kimi rc', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('registers `rc` with the `remote` alias and the web server options, without a --remote-control flag', () => {
it('is not registered: Remote Control is disabled in this fork', () => {
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('shows `rc` in help', () => {
expect(makeProgram().helpInformation()).toContain('rc|remote');
});

it('forces Remote Control for both `rc` and `remote`', async () => {
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, '--host', '0.0.0.0']);
} finally {
errSpy.mockRestore();
exitSpy.mockRestore();
}
// The loopback check only runs when remoteControl was forced on.
expect(stderr).toContain('--remote-control requires a loopback host.');
}
expect(program.commands.find((c) => c.name() === 'rc')).toBeUndefined();
expect(program.commands.find((c) => c.aliases().includes('remote'))).toBeUndefined();
expect(program.helpInformation()).not.toContain('rc|remote');
});
});

Expand Down
8 changes: 3 additions & 5 deletions apps/kimi-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,9 @@ describe('built-in slash command registry', () => {
expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always');
});

it('registers remote-control as always available', () => {
const command = findBuiltInSlashCommand('remote-control');
expect(command).toBeDefined();
expect((command as KimiSlashCommand).experimentalFlag).toBeUndefined();
expect(resolveSlashCommandAvailability(command!, '')).toBe('always');
it('does not register remote-control: the tunnel is disabled in this fork', () => {
expect(findBuiltInSlashCommand('remote-control')).toBeUndefined();
expect(findBuiltInSlashCommand('rc')).toBeUndefined();
});

});
6 changes: 3 additions & 3 deletions apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ describe('resolveSlashCommandInput', () => {
});
});

it('resolves /remote-control and /rc as built-ins', () => {
expect(resolve('/rc')).toMatchObject({ kind: 'builtin', name: 'remote-control' });
expect(resolve('/remote-control')).toMatchObject({ kind: 'builtin', name: 'remote-control' });
it('does not resolve /remote-control or /rc: the tunnel is disabled in this fork', () => {
expect(resolve('/rc')).not.toMatchObject({ kind: 'builtin' });
expect(resolve('/remote-control')).not.toMatchObject({ kind: 'builtin' });
});

it('blocks idle-only built-ins while streaming', () => {
Expand Down
8 changes: 3 additions & 5 deletions apps/kimi-code/test/tui/commands/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,9 @@ describe('web slash command', () => {
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');
it('does not register /remote-control or /rc: the tunnel is disabled in this fork', () => {
expect(findBuiltInSlashCommand('remote-control')).toBeUndefined();
expect(findBuiltInSlashCommand('rc')).toBeUndefined();
});
});

Expand Down
12 changes: 5 additions & 7 deletions packages/kap-server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ import { ProjectionService } from './services/projection';
import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler';
import { startConfigChangedPublisher } from './services/config/configChangedPublisher';
import { createAuthFailureLimiter } from './middleware/rateLimit';
import { createRemoteControlManager } from '@moonshot-ai/remote-control';
import {
createRemoteControlManager,
REMOTE_CONTROL_DISABLED_MESSAGE,
} from '@moonshot-ai/remote-control';

import { createAuthTokenService, type IAuthTokenService } from './services/auth/authTokenService';
import { createCredentialValidator } from './services/auth/credentials';
Expand Down Expand Up @@ -455,12 +458,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
process.env['KIMI_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'),
remoteControl: {
service: remoteControlManager,
staticEnableError:
exposureClass !== 'loopback'
? 'Remote Control requires a loopback host.'
: opts.disableAuth === true
? 'Remote Control cannot be combined with --dangerous-bypass-auth.'
: undefined,
staticEnableError: REMOTE_CONTROL_DISABLED_MESSAGE,
},
onShutdown: () => {
void close().catch((err: unknown) => logger.error({ err }, 'server close failed'));
Expand Down
Loading
Loading