From ce3357f534f20819f92ec26d5a4acee523381319 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Thu, 17 Sep 2026 13:15:18 -0600 Subject: [PATCH 1/2] feat: disable Remote Control Remote Control opened a reverse tunnel to a relay operated by a third party and forwarded public-internet traffic into the local server. That server has the terminal, file and approval APIs enabled, because it is only reachable on a loopback bind and the tunnel requires one, so a caller the relay let through could run shell commands, read and write files, and answer the agent's own approval prompts. Three properties made it worth removing rather than documenting: - The tunnel client does no local authorization. It strips the caller's Authorization, Cookie, Host and Origin, then injects the local server token and Host, so the server's bearer, Host and Origin checks pass by construction. The only real check is the relay operator's. - It authenticates with 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. - POST /api/v1/remote-control started it with no terminal interaction and no second confirmation, so anything holding the local server token could put the machine on the public internet. The refusal sits in startRemoteControl, which is the single point every caller reaches: the CLI, the TUI and the server route all start a tunnel through it, so a caller added by a later upstream merge is covered too. Upstream's implementation is kept as startRemoteControlTunnel, unreachable, so upstream's own tunnel tests keep running against it and upstream changes still merge cleanly instead of conflicting with a deleted function. Also removed so the feature is not merely unreachable but absent: the `kimi rc` / `kimi remote` subcommand, the `--rc` / `--remote-control` flag, and the `/rc` slash command. The server route now returns a refusal rather than starting a tunnel. `kimi web` is unaffected. 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, which is the same weakness noted for upstream's KIMI_CODE_DANGEROUS_COMMAND_GUARD. Re-enabling is a source change. Verified: every CLI path refuses in the built binary (`rc` and `remote` are unknown commands, `--rc` and `--remote-control` are unknown options), and a live `kimi web` returns the refusal for POST /api/v1/remote-control with the tunnel state still off. The new unit tests were negative-controlled by removing the guard: all three fail without it. Co-Authored-By: Claude Opus 5 --- .changeset/disable-remote-control.md | 12 ++ HARDENING.md | 22 ++++ apps/kimi-code/src/cli/sub/web/index.ts | 11 +- .../src/cli/sub/web/remote-control.ts | 2 + apps/kimi-code/src/cli/sub/web/run.ts | 30 ++--- apps/kimi-code/src/tui/commands/dispatch.ts | 3 - apps/kimi-code/src/tui/commands/registry.ts | 7 -- apps/kimi-code/test/cli/options.test.ts | 1 - apps/kimi-code/test/cli/web/web.test.ts | 69 +++--------- .../test/tui/commands/registry.test.ts | 8 +- .../test/tui/commands/resolve.test.ts | 6 +- apps/kimi-code/test/tui/commands/web.test.ts | 8 +- packages/kap-server/src/start.ts | 12 +- .../kap-server/test/remoteControl.test.ts | 103 ++++-------------- packages/remote-control/src/remote-control.ts | 51 +++++++++ .../test/remote-control.test.ts | 76 ++++++++++--- 16 files changed, 215 insertions(+), 206 deletions(-) create mode 100644 .changeset/disable-remote-control.md diff --git a/.changeset/disable-remote-control.md b/.changeset/disable-remote-control.md new file mode 100644 index 000000000..fc8a0257c --- /dev/null +++ b/.changeset/disable-remote-control.md @@ -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. diff --git a/HARDENING.md b/HARDENING.md index 066fa0b11..1c6513a8c 100644 --- a/HARDENING.md +++ b/HARDENING.md @@ -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. diff --git a/apps/kimi-code/src/cli/sub/web/index.ts b/apps/kimi-code/src/cli/sub/web/index.ts index 30240210b..736bee5da 100644 --- a/apps/kimi-code/src/cli/sub/web/index.ts +++ b/apps/kimi-code/src/cli/sub/web/index.ts @@ -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'; @@ -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 }, - ); } diff --git a/apps/kimi-code/src/cli/sub/web/remote-control.ts b/apps/kimi-code/src/cli/sub/web/remote-control.ts index d71976776..1c9848ef6 100644 --- a/apps/kimi-code/src/cli/sub/web/remote-control.ts +++ b/apps/kimi-code/src/cli/sub/web/remote-control.ts @@ -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 { diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index b10a666d9..51bcd14b3 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -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'; @@ -40,6 +40,7 @@ import { type NetworkAddress } from './networks'; import { formatRemoteControlOutput, formatRemoteControlStatus, + RemoteControlDisabledError, startRemoteControl, type RemoteControlHandle, type RemoteControlOptions, @@ -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 ', @@ -165,21 +162,11 @@ export function buildWebCommand( '--web-title ', 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Kimi Code").', ); - if (!forceRemoteControl) { - withServerOptions.addOption( - new Option( - '--rc, --remote-control', - 'Expose the web UI through Kimi Remote Control.', - ).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); @@ -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, { diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 50729d4ab..04f4f8d0c 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -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; diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index e4ad7ab48..2ca55c07c 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -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'], diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index d22229d2c..573a6a090 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -588,7 +588,6 @@ describe('CLI options parsing', () => { 'acp', 'web', 'server', - 'rc', 'login', 'doctor', 'vis', diff --git a/apps/kimi-code/test/cli/web/web.test.ts b/apps/kimi-code/test/cli/web/web.test.ts index 816f5d787..2d94113c5 100644 --- a/apps/kimi-code/test/cli/web/web.test.ts +++ b/apps/kimi-code/test/cli/web/web.test.ts @@ -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 @@ -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'); }); }); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 4fff07e8e..8acffa544 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -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(); }); }); diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 9cd106758..a4259af29 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -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', () => { diff --git a/apps/kimi-code/test/tui/commands/web.test.ts b/apps/kimi-code/test/tui/commands/web.test.ts index d31e10ca4..1ee241ab0 100644 --- a/apps/kimi-code/test/tui/commands/web.test.ts +++ b/apps/kimi-code/test/tui/commands/web.test.ts @@ -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(); }); }); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 5ad117df2..9e2fc34f7 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -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'; @@ -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')); diff --git a/packages/kap-server/test/remoteControl.test.ts b/packages/kap-server/test/remoteControl.test.ts index 0a979d63b..e12019cbb 100644 --- a/packages/kap-server/test/remoteControl.test.ts +++ b/packages/kap-server/test/remoteControl.test.ts @@ -84,93 +84,36 @@ describe('server-v2 /api/v1/remote-control', () => { return (await res.json()) as Envelope<RemoteControlStatusWire>; } - it('starts and stops the tunnel at runtime, dedupes concurrent enables, and tracks relay-initiated shutdown', async () => { + it('refuses to enable the tunnel', async () => { const relay = await startRegisterAckRelay(); vi.stubEnv('KIMI_CODE_REMOTE_CONTROL_RELAY_URL', `http://127.0.0.1:${relay.port}`); + try { + const body = await postRemoteControl(true); + + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); + expect(body.msg).toMatch(/disabled in this build/); + expect(relay.managementSockets).toHaveLength(0); + expect(relay.registrations).toHaveLength(0); + + const status = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); + const statusBody = (await status.json()) as Envelope<RemoteControlStatusWire>; + expect(statusBody.data.state).toBe('off'); + } finally { + await relay.close(); + } + }); - const initial = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); - const initialBody = (await initial.json()) as Envelope<RemoteControlStatusWire>; - expect(initialBody.code).toBe(0); - expect(initialBody.data.state).toBe('off'); - - const [first, second] = await Promise.all([postRemoteControl(true), postRemoteControl(true)]); - expect(first.code).toBe(0); - expect(second.code).toBe(0); - expect(first.data.state).toBe('on'); - expect(second.data.state).toBe('on'); - expect(first.data.url).toContain('/devices/'); - expect(first.data.device_id).toBeTruthy(); - expect(first.data.device_name).toBeTruthy(); - expect(relay.registrations).toHaveLength(1); - - const res = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); - const fetched = (await res.json()) as Envelope<RemoteControlStatusWire>; - expect(fetched.data.state).toBe('on'); - - const stopped = await postRemoteControl(false); - expect(stopped.code).toBe(0); - expect(stopped.data.state).toBe('off'); - expect(stopped.data.enabled).toBe(false); - - const restarted = await postRemoteControl(true); - expect(restarted.code).toBe(0); - expect(restarted.data.state).toBe('on'); - - await writeServerToken(home as string, 'rotated-server-token'); - const httpSocket = relay.httpSockets.at(-1)!; - const rotatedResponsePromise = nextJsonMessage(httpSocket); - httpSocket.send( - JSON.stringify({ - request_id: 'request-rotated', - type: 'request', - is_last: true, - body_base64: Buffer.from( - 'GET /api/v1/healthz HTTP/1.1\r\nHost: relay.test\r\n\r\n', - ).toString('base64'), - }), - ); - const rotatedMessage = await rotatedResponsePromise; - const rotatedResponse = Buffer.from( - rotatedMessage['body_base64'] as string, - 'base64', - ).toString(); - expect(rotatedResponse).toContain('HTTP/1.1 200'); - expect(rotatedResponse).toContain('"ok":true'); - - relay.managementSockets.at(-1)!.send( - JSON.stringify({ type: 'disconnect', payload: { reason: 'user_requested' } }), - ); - await waitFor(async () => { - const after = await authedFetch(server as RunningServer, base, '/api/v1/remote-control'); - const body = (await after.json()) as Envelope<RemoteControlStatusWire>; - return body.data.state === 'off'; - }); - - const reenabled = await postRemoteControl(true); - expect(reenabled.code).toBe(0); - expect(reenabled.data.state).toBe('on'); + it('refuses even on a loopback bind with auth enabled, which upstream allows', async () => { + const body = await postRemoteControl(true); - await postRemoteControl(false); - await relay.close(); + expect(body.code).toBe(ErrorCode.VALIDATION_FAILED); }); - it('reports REMOTE_CONTROL_ALREADY_RUNNING when another live process holds the lock', async () => { - await mkdir(join(home as string, 'server'), { recursive: true }); - await writeFile( - remoteControlLockPath(home as string), - JSON.stringify({ - pid: process.pid, - nonce: 'other-process', - local_origin: 'http://127.0.0.1:58627', - device_id: 'other-device', - url: 'https://code-rc.kimi.com/devices/other-device/', - started_at: Date.now(), - }), - ); + it('still answers a disable request so the route is not broken', async () => { + const body = await postRemoteControl(false); - const posted = await postRemoteControl(true); - expect(posted.code).toBe(ErrorCode.REMOTE_CONTROL_ALREADY_RUNNING); - expect(posted.msg).toContain('already running'); + expect(body.code).toBe(0); + expect(body.data.state).toBe('off'); }); }); diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index 10989c784..2ffe405aa 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -269,7 +269,58 @@ function requestMatchesETag( return false; } +/** + * 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 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 does no local authorization: it strips the caller's credentials and + * injects the local server token, so the bearer, Host and Origin checks pass + * by construction rather than by decision. + * + * Relay authentication also uses the long-lived Kimi *refresh* token, carried + * as a WebSocket subprotocol value where intermediaries log it far more + * readily than an Authorization header, and re-sent on every reconnect. + * + * This function 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 here, so the refusal also covers any caller a later upstream merge + * introduces. + * + * 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. Re-enabling is a source change, reviewed as one. + */ +export const REMOTE_CONTROL_DISABLED_MESSAGE = + 'Remote Control is disabled in this build. It exposes this machine through a ' + + 'third-party relay and grants whoever reaches it the local terminal, file and ' + + 'approval APIs. To use the web UI locally, run `kimi web`.'; + +export class RemoteControlDisabledError extends Error { + constructor() { + super(REMOTE_CONTROL_DISABLED_MESSAGE); + this.name = 'RemoteControlDisabledError'; + } +} + export async function startRemoteControl( + _options: RemoteControlOptions, +): Promise<RemoteControlHandle> { + throw new RemoteControlDisabledError(); +} + +/** + * Upstream's tunnel implementation, unchanged and unreachable in this fork. + * + * It is kept so upstream's own tests (header blocklist, absolute-URI + * rejection, reconnect behaviour) keep running against it, and so upstream + * changes to the tunnel still merge cleanly rather than landing as a conflict + * against a deleted function. + */ +export async function startRemoteControlTunnel( options: RemoteControlOptions, ): Promise<RemoteControlHandle> { const localServerToken = diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index eca858e48..d765f7fff 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -22,6 +22,8 @@ import { resolveRemoteControlRelayOrigin, rewriteRemoteControlResponse, startRemoteControl, + startRemoteControlTunnel, + RemoteControlDisabledError, type RemoteControlHandle, } from '../src/remote-control'; import { remoteControlLockPath } from '../src/lock'; @@ -167,7 +169,7 @@ describe('Remote Control tunnel', () => { cleanups.push(() => closeServer(relayServer)); await expect( - startRemoteControl({ + startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -184,7 +186,7 @@ describe('Remote Control tunnel', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -206,7 +208,7 @@ describe('Remote Control tunnel', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -233,7 +235,7 @@ describe('Remote Control tunnel', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -252,7 +254,7 @@ describe('Remote Control tunnel', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -377,7 +379,7 @@ describe('Remote Control tunnel', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); let currentToken = 'local-server-token'; - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: `http://127.0.0.1:${localPort}`, localServerToken: () => currentToken, @@ -658,7 +660,7 @@ describe('Remote Control tunnel', () => { cleanups.push(async () => handle?.close()); let logs = ''; - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -686,7 +688,7 @@ describe('Remote Control tunnel', () => { cleanups.push(async () => handle?.close()); let logs = ''; - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', @@ -721,7 +723,7 @@ describe('Remote Control single-instance lock', () => { const relay = await startAuthRelay(); let first: RemoteControlHandle | undefined; cleanups.push(async () => first?.close()); - first = await startRemoteControl({ + first = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:58627', localServerToken: 'local-server-token', @@ -731,7 +733,7 @@ describe('Remote Control single-instance lock', () => { }); await expect( - startRemoteControl({ + startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:58628', localServerToken: 'local-server-token', @@ -761,7 +763,7 @@ describe('Remote Control single-instance lock', () => { let handle: RemoteControlHandle | undefined; cleanups.push(async () => handle?.close()); - handle = await startRemoteControl({ + handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:58627', localServerToken: 'local-server-token', @@ -787,12 +789,12 @@ describe('Remote Control single-instance lock', () => { relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }; - const first = await startRemoteControl(options); + const first = await startRemoteControlTunnel(options); await first.close(); let second: RemoteControlHandle | undefined; cleanups.push(async () => second?.close()); - second = await startRemoteControl(options); + second = await startRemoteControlTunnel(options); expect(second.url).toContain('/devices/'); relay.managementSockets[relay.managementSockets.length - 1]!.send( @@ -805,7 +807,7 @@ describe('Remote Control single-instance lock', () => { it('does not remove a successor lock when closing', async () => { const homeDir = await createRemoteControlHome(TOKEN.refreshToken); const relay = await startAuthRelay(); - const handle = await startRemoteControl({ + const handle = await startRemoteControlTunnel({ homeDir, localOrigin: 'http://127.0.0.1:58627', localServerToken: 'local-server-token', @@ -985,3 +987,49 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void await new Promise((resolve) => setTimeout(resolve, 10)); } } + +describe('remote control is disabled in this fork', () => { + it('refuses without opening a relay connection or reading the refresh token', async () => { + const homeDir = await createRemoteControlHome(); + const relay = await startAuthRelay(); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + clientVersion: CLIENT_VERSION, + relayOrigin: `http://127.0.0.1:${relay.port}`, + stderr: { write: () => true }, + }), + ).rejects.toBeInstanceOf(RemoteControlDisabledError); + + expect(relay.requests).toHaveLength(0); + }); + + it('refuses before the checks that upstream fails on, so no input can satisfy it', async () => { + await expect( + startRemoteControl({ + homeDir: join(tmpdir(), 'kimi-rc-does-not-exist'), + localOrigin: 'http://127.0.0.1:1', + localServerToken: '', + clientVersion: CLIENT_VERSION, + }), + ).rejects.toThrow(/disabled in this build/); + }); + + it('does not leave a machine-wide lock behind', async () => { + const homeDir = await createRemoteControlHome(); + + await expect( + startRemoteControl({ + homeDir, + localOrigin: 'http://127.0.0.1:1', + localServerToken: 'local-server-token', + clientVersion: CLIENT_VERSION, + }), + ).rejects.toBeInstanceOf(RemoteControlDisabledError); + + expect(existsSync(remoteControlLockPath(homeDir))).toBe(false); + }); +}); From 9624ecf5f6670a8da01bf46749e2a6fae4418adf Mon Sep 17 00:00:00 2001 From: Greg Anderson <greg@Gregs-MacBook-Pro.local> Date: Thu, 17 Sep 2026 13:20:28 -0600 Subject: [PATCH 2/2] test: give the disabled-path tests a real refresh token createRemoteControlHome takes the refresh token to plant; calling it with no argument typechecked locally only because vitest does not typecheck, so CI caught it and the suite did not. Planting a valid token also strengthens the tests: the credential the tunnel would use is present on disk and it still refuses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- packages/remote-control/test/remote-control.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index d765f7fff..4ae42aeb5 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -990,7 +990,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void describe('remote control is disabled in this fork', () => { it('refuses without opening a relay connection or reading the refresh token', async () => { - const homeDir = await createRemoteControlHome(); + const homeDir = await createRemoteControlHome('refresh-token'); const relay = await startAuthRelay(); await expect( @@ -1019,7 +1019,7 @@ describe('remote control is disabled in this fork', () => { }); it('does not leave a machine-wide lock behind', async () => { - const homeDir = await createRemoteControlHome(); + const homeDir = await createRemoteControlHome('refresh-token'); await expect( startRemoteControl({