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
5 changes: 5 additions & 0 deletions .changeset/remote-control-relay-key.md
Original file line number Diff line number Diff line change
@@ -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`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
30 changes: 28 additions & 2 deletions apps/pythinker-code/src/cli/sub/web/remote-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,7 +28,7 @@ export function resolveRelayOrigin(
explicit?: string,
env: Readonly<Record<string, string | undefined>> = 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:') {
Expand All @@ -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<Record<string, string | undefined>> = 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(
Expand Down Expand Up @@ -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<NodeJS.WriteStream, 'write'>;
readonly onStatus?: (status: RemoteControlStatus) => void;
Expand Down Expand Up @@ -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();
Expand All @@ -348,7 +374,7 @@ export async function startRemoteControl(
...options,
relayOrigin,
deviceId,
relayToken: options.localServerToken,
relayToken: options.relayKey,
});
try {
await client.start();
Expand Down
10 changes: 10 additions & 0 deletions apps/pythinker-code/src/cli/sub/web/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
formatRemoteControlStatus,
isRemoteControlEnabled,
REMOTE_CONTROL_FLAG_ENV,
resolveRelayKey,
resolveRelayOrigin,
startRemoteControl,
type RemoteControlHandle,
Expand Down Expand Up @@ -80,6 +81,7 @@ export interface WebCliOptions extends ServerCliOptions {
open?: boolean;
remoteControl?: boolean;
relayOrigin?: string;
relayKey?: string;
}

export interface StartForegroundHooks {
Expand Down Expand Up @@ -184,6 +186,12 @@ export function buildWebCommand(
.hideHelp(!isRemoteControlEnabled()),
);
}
withServerOptions.addOption(
new Option(
'--relay-key <key>',
'Secret the Remote Control relay requires. Defaults to $PYTHINKER_CODE_REMOTE_CONTROL_RELAY_KEY.',
).hideHelp(!isRemoteControlEnabled()),
);
withServerOptions.addOption(
new Option(
'--relay-origin <url>',
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -250,6 +259,7 @@ export async function handleWebCommand(
homeDir: dataDir,
localOrigin: origin,
localServerToken: token,
relayKey,
relayOrigin,
stderr: deps.stderr,
onStatus,
Expand Down
12 changes: 12 additions & 0 deletions apps/pythinker-code/src/tui/commands/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildRemoteControlUrl,
formatRemoteControlOutput,
formatRemoteControlStatus,
resolveRelayKey,
resolveRelayOrigin,
startRemoteControl,
type RemoteControlStatus,
Expand Down Expand Up @@ -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<ReturnType<typeof startRemoteControl>> | undefined;
Expand All @@ -83,6 +94,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis
homeDir: dataDir,
localOrigin: origin,
localServerToken: token,
relayKey,
relayOrigin,
onStatus,
});
Expand Down
88 changes: 77 additions & 11 deletions apps/pythinker-code/test/cli/web/remote-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('rejects a relay that is not http(s)', async () => {
Expand All @@ -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-'));
Expand Down Expand Up @@ -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 },
}),
Expand All @@ -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 },
});
Expand All @@ -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 },
});
Expand All @@ -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 },
});
Expand All @@ -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 },
});
Expand Down Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) },
});
Expand Down Expand Up @@ -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 },
});
Expand All @@ -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 },
}),
Expand Down Expand Up @@ -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 },
});
Expand All @@ -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 },
};
Expand All @@ -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 },
});
Expand Down
Loading
Loading