diff --git a/.changeset/remote-control-relay-key.md b/.changeset/remote-control-relay-key.md new file mode 100644 index 000000000..5f9e365cf --- /dev/null +++ b/.changeset/remote-control-relay-key.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remote Control now authenticates to the relay with its own key instead of the local server token. Pass `--relay-key` or set `PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY`. diff --git a/apps/pythinker-code/src/cli/sub/web/remote-control.ts b/apps/pythinker-code/src/cli/sub/web/remote-control.ts index ccbf35638..a93a3cf95 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -17,6 +17,8 @@ export const REMOTE_CONTROL_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTR export const REMOTE_CONTROL_RELAY_ENV = 'PYTHINKER_CODE_REMOTE_CONTROL_RELAY'; +export const REMOTE_CONTROL_RELAY_KEY_ENV = 'PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY'; + /** * Resolve the relay to tunnel through. Pythinker ships no relay, so an operator * running their own points at it with `--relay-origin` or the env var; the @@ -26,7 +28,7 @@ export function resolveRelayOrigin( explicit?: string, env: Readonly> = process.env, ): string { - const candidate = explicit?.trim() ?? env[REMOTE_CONTROL_RELAY_ENV]?.trim() ?? ''; + const candidate = explicit?.trim() || env[REMOTE_CONTROL_RELAY_ENV]?.trim() || ''; if (candidate.length === 0) return REMOTE_CONTROL_RELAY_ORIGIN; const url = new URL(candidate); if (url.protocol !== 'http:' && url.protocol !== 'https:') { @@ -35,6 +37,24 @@ export function resolveRelayOrigin( return candidate; } +/** + * Resolve the secret the relay itself demands. It is deliberately separate from + * the local server token, so a relay operator can admit known machines without + * ever holding a credential that controls one. + */ +export function resolveRelayKey( + explicit?: string, + env: Readonly> = process.env, +): string { + const candidate = explicit?.trim() || env[REMOTE_CONTROL_RELAY_KEY_ENV]?.trim() || ''; + if (candidate.length === 0) { + throw new Error( + `Remote Control needs a relay key. Pass --relay-key or set ${REMOTE_CONTROL_RELAY_KEY_ENV}.`, + ); + } + return candidate; +} + const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); export function isRemoteControlEnabled( @@ -109,6 +129,7 @@ export interface RemoteControlOptions { readonly homeDir: string; readonly localOrigin: string; readonly localServerToken: string; + readonly relayKey: string; readonly relayOrigin?: string; readonly stderr?: Pick; readonly onStatus?: (status: RemoteControlStatus) => void; @@ -335,6 +356,11 @@ export async function startRemoteControl( if (options.localServerToken.length === 0) { throw new Error('Remote Control requires local server authentication.'); } + if (options.relayKey.length === 0) { + throw new Error( + `Remote Control needs a relay key. Pass --relay-key or set ${REMOTE_CONTROL_RELAY_KEY_ENV}.`, + ); + } const relayOrigin = options.relayOrigin ?? REMOTE_CONTROL_RELAY_ORIGIN; const deviceId = createPythinkerDeviceId(options.homeDir); const deviceName = hostname(); @@ -348,7 +374,7 @@ export async function startRemoteControl( ...options, relayOrigin, deviceId, - relayToken: options.localServerToken, + relayToken: options.relayKey, }); try { await client.start(); diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 804bfe199..6906f573a 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -45,6 +45,7 @@ import { formatRemoteControlStatus, isRemoteControlEnabled, REMOTE_CONTROL_FLAG_ENV, + resolveRelayKey, resolveRelayOrigin, startRemoteControl, type RemoteControlHandle, @@ -80,6 +81,7 @@ export interface WebCliOptions extends ServerCliOptions { open?: boolean; remoteControl?: boolean; relayOrigin?: string; + relayKey?: string; } export interface StartForegroundHooks { @@ -184,6 +186,12 @@ export function buildWebCommand( .hideHelp(!isRemoteControlEnabled()), ); } + withServerOptions.addOption( + new Option( + '--relay-key ', + 'Secret the Remote Control relay requires. Defaults to $PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY.', + ).hideHelp(!isRemoteControlEnabled()), + ); withServerOptions.addOption( new Option( '--relay-origin ', @@ -221,6 +229,7 @@ export async function handleWebCommand( throw new Error('--remote-control requires a loopback host.'); } const relayOrigin = opts.remoteControl === true ? resolveRelayOrigin(opts.relayOrigin) : undefined; + const relayKey = opts.remoteControl === true ? resolveRelayKey(opts.relayKey) : ''; const run = deps.startServerForeground ?? startServerForeground; let remoteControl: RemoteControlHandle | undefined; await run(parsed, { @@ -250,6 +259,7 @@ export async function handleWebCommand( homeDir: dataDir, localOrigin: origin, localServerToken: token, + relayKey, relayOrigin, stderr: deps.stderr, onStatus, diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 2ef0e4780..9b98828cd 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -5,6 +5,7 @@ import { buildRemoteControlUrl, formatRemoteControlOutput, formatRemoteControlStatus, + resolveRelayKey, resolveRelayOrigin, startRemoteControl, type RemoteControlStatus, @@ -60,6 +61,16 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis return; } + // Before the takeover: a missing relay key is a configuration problem the + // user can still fix, so it must not cost them the terminal UI. + let relayKey: string; + try { + relayKey = resolveRelayKey(); + } catch (error) { + host.showError(formatErrorMessage(error)); + return; + } + host.setExitForegroundTask(async () => { const options = parseServerOptions({}); let remoteControl: Awaited> | undefined; @@ -83,6 +94,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis homeDir: dataDir, localOrigin: origin, localServerToken: token, + relayKey, relayOrigin, onStatus, }); diff --git a/apps/pythinker-code/test/cli/web/remote-control.test.ts b/apps/pythinker-code/test/cli/web/remote-control.test.ts index cdb34cc92..c06d7414b 100644 --- a/apps/pythinker-code/test/cli/web/remote-control.test.ts +++ b/apps/pythinker-code/test/cli/web/remote-control.test.ts @@ -205,6 +205,11 @@ describe('resolveRelayOrigin', () => { expect(resolveRelayOrigin(' ', { PYTHINKER_CODE_REMOTE_CONTROL_RELAY: ' ' })).toBe( REMOTE_CONTROL_RELAY_ORIGIN, ); + expect( + resolveRelayOrigin(' ', { + PYTHINKER_CODE_REMOTE_CONTROL_RELAY: 'https://env.example.test', + }), + ).toBe('https://env.example.test'); }); it('rejects a relay that is not http(s)', async () => { @@ -216,6 +221,54 @@ describe('resolveRelayOrigin', () => { }); }); +describe('resolveRelayKey', () => { + it('prefers the explicit key, then the environment', async () => { + const { resolveRelayKey } = await import('#/cli/sub/web/remote-control'); + expect(resolveRelayKey('explicit-key', {})).toBe('explicit-key'); + expect( + resolveRelayKey(undefined, { PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY: 'env-key' }), + ).toBe('env-key'); + expect(resolveRelayKey(' spaced-key ', {})).toBe('spaced-key'); + expect( + resolveRelayKey(' ', { PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY: 'env-key' }), + ).toBe('env-key'); + }); + + it('refuses to fall back to another credential when no key is given', async () => { + const { resolveRelayKey } = await import('#/cli/sub/web/remote-control'); + expect(() => resolveRelayKey(undefined, {})).toThrow( + 'PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', + ); + expect(() => resolveRelayKey(' ', { PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY: ' ' })).toThrow( + 'Remote Control needs a relay key', + ); + }); +}); + +describe('relay credential separation', () => { + it('never sends the local server token to the relay', async () => { + const homeDir = createRemoteControlHome(); + 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', + relayKey: RELAY_TOKEN, + relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, + stderr: { write: () => true }, + }); + + expect(relay.requests).toHaveLength(2); + for (const request of relay.requests) { + expect(JSON.stringify(request)).not.toContain('local-server-token'); + expect(request.protocol).toBe(`pythinker-code.bearer.${RELAY_TOKEN}`); + } + }); +}); + describe('Remote Control tunnel', () => { it('surfaces register_nak details', async () => { const homeDir = mkdtempSync(join(tmpdir(), 'pythinker-rc-nak-')); @@ -248,6 +301,7 @@ describe('Remote Control tunnel', () => { homeDir, localOrigin: 'http://127.0.0.1:1', localServerToken: 'local-server-token', + relayKey: RELAY_TOKEN, relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, stderr: { write: () => true }, }), @@ -264,7 +318,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, }); @@ -286,7 +341,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, }); @@ -313,7 +369,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, }); @@ -332,7 +389,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: () => true }, }); @@ -414,6 +472,7 @@ describe('Remote Control tunnel', () => { homeDir, localOrigin: `http://127.0.0.1:${localPort}`, localServerToken: 'local-server-token', + relayKey: RELAY_TOKEN, relayOrigin: `http://127.0.0.1:${relayPort}/coding-relay`, stderr: { write: () => true }, }); @@ -519,7 +578,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: (text) => ((logs += String(text)), true) }, pingIntervalMs: 50, @@ -547,7 +607,8 @@ describe('Remote Control tunnel', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:1', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}/coding-relay`, stderr: { write: (text) => ((logs += String(text)), true) }, }); @@ -582,7 +643,8 @@ describe('Remote Control single-instance lock', () => { first = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:58627', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }); @@ -591,7 +653,8 @@ describe('Remote Control single-instance lock', () => { startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:58628', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }), @@ -621,7 +684,8 @@ describe('Remote Control single-instance lock', () => { handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:58627', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }); @@ -639,7 +703,8 @@ describe('Remote Control single-instance lock', () => { const options = { homeDir, localOrigin: 'http://127.0.0.1:58627', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }; @@ -659,7 +724,8 @@ describe('Remote Control single-instance lock', () => { const handle = await startRemoteControl({ homeDir, localOrigin: 'http://127.0.0.1:58627', - localServerToken: relayToken, + localServerToken: 'local-server-token', + relayKey: relayToken, relayOrigin: `http://127.0.0.1:${relay.port}`, stderr: { write: () => true }, }); diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index c85f85844..89e96be7f 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -417,7 +417,12 @@ describe('`pythinker web` opens the browser', () => { })); await handleWebCommand( - { remoteControl: true, relayOrigin: 'https://relay.example.test', open: false }, + { + remoteControl: true, + relayOrigin: 'https://relay.example.test', + relayKey: 'relay-key-1', + open: false, + }, { startServerForeground: runner, openUrl: vi.fn(), @@ -429,10 +434,38 @@ describe('`pythinker web` opens the browser', () => { ); expect(startRemoteControl).toHaveBeenCalledWith( - expect.objectContaining({ relayOrigin: 'https://relay.example.test' }), + expect.objectContaining({ + relayOrigin: 'https://relay.example.test', + relayKey: 'relay-key-1', + localServerToken: 'tok-1', + }), ); }); + it('refuses to start Remote Control without a relay key', async () => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + vi.stubEnv('PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', ''); + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const startRemoteControl = vi.fn(); + + await expect( + handleWebCommand( + { remoteControl: true, relayOrigin: 'https://relay.example.test', open: false }, + { + startServerForeground: runner, + openUrl: vi.fn(), + resolveToken: () => 'tok-1', + startRemoteControl, + stdout, + stderr, + }, + ), + ).rejects.toThrow('Remote Control needs a relay key'); + expect(startRemoteControl).not.toHaveBeenCalled(); + }); + it('rejects Remote Control on a non-loopback host', async () => { vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); const { handleWebCommand } = await import('#/cli/sub/web/run'); diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index c05c54f05..2b33188b3 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -147,6 +147,21 @@ describe('handleWebCommand', () => { }); describe('handleRemoteControlCommand', () => { + beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', 'relay-key-1'); + }); + + it('stays in the TUI with a readable error when no relay key is configured', async () => { + vi.clearAllMocks(); + vi.stubEnv('PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY', ''); + const host = makeHost(); + + await handleRemoteControlCommand(host); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('relay key')); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + }); + 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'); diff --git a/docs/guides/remote-control.md b/docs/guides/remote-control.md index 0f4d225fc..08605f7be 100644 --- a/docs/guides/remote-control.md +++ b/docs/guides/remote-control.md @@ -21,6 +21,7 @@ The terminal prints a QR code, a link, and the path of a PNG copy of the QR code - The server must bind a loopback host. Remote Control refuses a `--host` bind. - Bearer-token auth must stay on. Remote Control refuses `--dangerous-bypass-auth`. - One Remote Control session per machine. A second start reports the link the first one is using. +- A relay key. Remote Control refuses to start without one. ## Security @@ -28,17 +29,24 @@ The link grants control of this machine. Do not share the link or the QR code. The link itself carries no access token: requests arrive through the tunnel, and the Pythinker Code process on this machine adds the bearer token to each one before it reaches the local server. The QR code, the printed link, and the PNG on disk hold no credential. -The relay is a different matter. Pythinker Code authenticates to it with the same token, sent in the WebSocket handshake, and every request and response passes through it in the clear. Use a relay you operate or otherwise trust. Rotate the token with `pythinker web rotate-token` if a relay is ever compromised. +The relay never receives the bearer token. Pythinker Code presents a separate relay key in the WebSocket handshake, so a relay can admit known machines without holding a credential that controls one. + +Every request and response still passes through the relay in the clear, so use a relay you operate or otherwise trust. Rotate the bearer token with `pythinker web rotate-token` if a relay is ever compromised. ## Relay Traffic reaches the remote device through a relay. Point Remote Control at your own relay with `--relay-origin`: ```sh -pythinker rc --relay-origin https://relay.example.com +pythinker rc --relay-origin https://relay.example.com --relay-key YOUR_RELAY_KEY ``` -`PYTHINKER_CODE_REMOTE_CONTROL_RELAY` sets the same thing for `/rc` in the terminal UI and for every run in a shell. +The relay operator issues the key; it admits your machine to the relay and nothing else. `PYTHINKER_CODE_REMOTE_CONTROL_RELAY` and `PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY` set the same two values for `/rc` in the terminal UI and for every run in a shell: + +```sh +export PYTHINKER_CODE_REMOTE_CONTROL_RELAY=https://relay.example.com +export PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY=YOUR_RELAY_KEY +``` ## Stop it