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..4ae42aeb5 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('refresh-token'); + 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('refresh-token'); + + 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); + }); +});