diff --git a/.changeset/graduate-database-and-remote-control.md b/.changeset/graduate-database-and-remote-control.md new file mode 100644 index 000000000..74dc7cead --- /dev/null +++ b/.changeset/graduate-database-and-remote-control.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": major +--- + +Remote Control is always available — `pythinker rc`, `pythinker web --remote-control` and `/remote-control` no longer need an experimental flag. Session indexing and global search move to the new `[database]` section: set `PYTHINKER_CODE_PERSISTENCE_MINIDB_READMODEL` (was `PYTHINKER_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL`) and `PYTHINKER_CODE_SEARCH_WORKER` (was `PYTHINKER_CODE_EXPERIMENTAL_SEARCH_WORKER`), or `[database] base` and `[database] search` in `config.toml`. diff --git a/.changeset/graduate-subagent-model-pool.md b/.changeset/graduate-subagent-model-pool.md new file mode 100644 index 000000000..d95e5cd57 --- /dev/null +++ b/.changeset/graduate-subagent-model-pool.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": major +--- + +The subagent model pool is always available. Remove `PYTHINKER_CODE_EXPERIMENTAL_SECONDARY_MODEL` from your environment — it no longer does anything, and `[secondary_model]` takes effect with no opt-in. diff --git a/.changeset/remote-control-local-ui-token.md b/.changeset/remote-control-local-ui-token.md new file mode 100644 index 000000000..3fe5cc5b7 --- /dev/null +++ b/.changeset/remote-control-local-ui-token.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +The Remote Control banner's Local UI link now carries the server token, so it opens without a second sign-in. diff --git a/.changeset/remote-control-toggle-api.md b/.changeset/remote-control-toggle-api.md new file mode 100644 index 000000000..7ffdd037f --- /dev/null +++ b/.changeset/remote-control-toggle-api.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +The server can now start and stop Remote Control while it runs, through `GET` and `POST /api/v1/remote-control`. diff --git a/.changeset/remote-control-tunnel-gzip.md b/.changeset/remote-control-tunnel-gzip.md new file mode 100644 index 000000000..be7781da1 --- /dev/null +++ b/.changeset/remote-control-tunnel-gzip.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remote Control now gzips text, JSON, JavaScript, XML and SVG responses over the tunnel. diff --git a/.changeset/remove-now-template-variable.md b/.changeset/remove-now-template-variable.md new file mode 100644 index 000000000..377cda9cc --- /dev/null +++ b/.changeset/remove-now-template-variable.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": major +--- + +Remove the `${now}` variable from custom system prompt templates. Delete `${now}` from your `SYSTEM.md` and agent files — the agent still receives the current date. diff --git a/.changeset/search-index-self-heal.md b/.changeset/search-index-self-heal.md new file mode 100644 index 000000000..6729fc7c8 --- /dev/null +++ b/.changeset/search-index-self-heal.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Global search now rebuilds its index instead of staying broken when the stored data is corrupt or a write keeps failing. diff --git a/.changeset/silent-late-task-settlement.md b/.changeset/silent-late-task-settlement.md new file mode 100644 index 000000000..000303141 --- /dev/null +++ b/.changeset/silent-late-task-settlement.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +A background task that finishes after its agent is closed no longer emits stray task events. diff --git a/AGENTS.md b/AGENTS.md index e99e4666c..6f367be21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add | `packages/acp-server` | Agent Client Protocol host over engine v2 | Drives the engine through a `klient` memory-transport facade. | | `packages/pi-tui` | Vendored TUI library | Upstream fork with local divergences; tests run with `node --test`, not vitest. See its `AGENTS.md`. | | `packages/protocol` | Shared REST + WS protocol schemas | Envelope, error codes, pagination, WS-control types. | +| `packages/remote-control` | Remote Control tunnel client | Registers this machine with a relay and forwards HTTP/WebSocket traffic to the local server, behind a machine-wide single-instance lock. Consumed by agent-gateway (the `/api/v1/remote-control` toggle) and the CLI (`pythinker web --remote-control`). | The web bundle: `apps/pythinker-code/dist-web` is the committed, prebuilt bundle of `apps/pythinker-web` (built with `pnpm --filter @pymodel/pythinker-web run build` and copied via `scripts/copy-web-assets.mjs`). `apps/pythinker-code/scripts/check-web-assets.mjs` fails when the bundle is missing **or stale** (it compares a fingerprint of every `apps/pythinker-web` build input against the one recorded at copy time); it runs in pre-push, in the CLI `build`, and on `prepack`. Whenever you touch the web UI, run `pnpm run build:web` and commit the restaged bundle in the same change. `packages/server` and `packages/server-e2e` are empty leftover directories excluded from the workspace — not packages. diff --git a/apps/desktop/scripts/finalize-mac-artifacts.ts b/apps/desktop/scripts/finalize-mac-artifacts.ts index 8acdccf6c..a742d0185 100644 --- a/apps/desktop/scripts/finalize-mac-artifacts.ts +++ b/apps/desktop/scripts/finalize-mac-artifacts.ts @@ -153,7 +153,13 @@ export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void .sort() if (dmgs.length === 0) throw new Error(`No DMG artifacts found in ${options.distDir}`) - const manifestName = options.manifestName ?? DEFAULT_MAC_MANIFEST + // The workflow always passes the channel manifest positionally, so an unresolved + // channel output arrives as an empty string rather than a missing argument; `??` + // would keep it and read the distribution directory itself. + const manifestName = + options.manifestName === undefined || options.manifestName === '' + ? DEFAULT_MAC_MANIFEST + : options.manifestName const metadataPath = join(options.distDir, manifestName) let metadata = readFileSync(metadataPath, 'utf8') const credentialArgs = buildNotarytoolArguments(options.env) diff --git a/apps/desktop/tests/finalize-mac-artifacts.spec.ts b/apps/desktop/tests/finalize-mac-artifacts.spec.ts index cb24ebbac..0015990ca 100644 --- a/apps/desktop/tests/finalize-mac-artifacts.spec.ts +++ b/apps/desktop/tests/finalize-mac-artifacts.spec.ts @@ -158,6 +158,32 @@ sha512: old expect(readFileSync(join(distDir, 'nightly-mac.yml'), 'utf8')).toContain(`sha512: ${checksum}`) }) + it('falls back to the default manifest when the channel argument arrives empty', () => { + const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) + directories.push(distDir) + const filename = 'Pythinker-0.1.3-arm64.dmg' + const dmg = Buffer.from('empty argument dmg fixture') + writeFileSync(join(distDir, filename), dmg) + writeFileSync(join(distDir, 'latest-mac.yml'), `files: + - url: ${filename} + sha512: old + size: 1 +path: ${filename} +sha512: old +`) + + finalizeMacArtifacts({ + distDir, + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + log: () => {}, + manifestName: '', + runCommand: () => ({ status: 0, stderr: '', stdout: '{"status":"Accepted"}' }), + }) + + const checksum = createHash('sha512').update(dmg).digest('base64') + expect(readFileSync(join(distDir, 'latest-mac.yml'), 'utf8')).toContain(`sha512: ${checksum}`) + }) + it('prints and rejects a non-accepted notarytool result before stapling', () => { const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) directories.push(distDir) diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index b327e72ac..505200736 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "86bbfea2180a13d6184f7bb2bb2bf4f62f3ff4098cfd522de348482ef887d842", + "sourceHash": "3856336183464397291f1fe4b79cf621f9251cfe1d579884874392027f2c376c", "sourceFileCount": 493 } diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index a090434f6..3aa9c3292 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -91,6 +91,7 @@ "@pymodel/pythinker-code-oauth": "workspace:^", "@pymodel/pythinker-code-sdk": "workspace:^", "@pymodel/pythinker-telemetry": "workspace:^", + "@pymodel/remote-control": "workspace:^", "@pymodel/vis-server": "workspace:^", "@pymodel/vis-web": "workspace:*", "@types/qrcode": "^1.5.6", diff --git a/apps/pythinker-code/src/cli/sub/web/index.ts b/apps/pythinker-code/src/cli/sub/web/index.ts index 4eec0fd08..b364f84d1 100644 --- a/apps/pythinker-code/src/cli/sub/web/index.ts +++ b/apps/pythinker-code/src/cli/sub/web/index.ts @@ -15,7 +15,6 @@ import type { Command } from 'commander'; import { registerDeprecatedServerCommand } from './deprecated-server'; import { registerRotateTokenCommand } from './rotate-token'; import { buildWebCommand } from './run'; -import { isRemoteControlEnabled } from './remote-control'; export function registerWebCommand(program: Command): void { const web = buildWebCommand( @@ -26,10 +25,10 @@ export function registerWebCommand(program: Command): void { registerRotateTokenCommand(web); buildWebCommand( program - .command('rc', { hidden: !isRemoteControlEnabled() }) + .command('rc') .alias('remote') .description( - 'Run the local Pythinker server and open the web UI through Remote Control (experimental).', + 'Run the local Pythinker server and open the web UI through Remote Control.', ), { forceRemoteControl: true }, ); 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 e4e5a178d..b4ac0a747 100644 --- a/apps/pythinker-code/src/cli/sub/web/remote-control.ts +++ b/apps/pythinker-code/src/cli/sub/web/remote-control.ts @@ -1,159 +1,42 @@ -import { hostname, platform } from 'node:os'; -import { request as httpRequest, validateHeaderName, validateHeaderValue } from 'node:http'; -import { setTimeout as sleep } from 'node:timers/promises'; - -import { createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; -import { WebSocket, type RawData } from 'ws'; import chalk from 'chalk'; import { getVersion } from '../../version'; import { darkColors } from '../../../tui/theme/colors'; import { supportsHyperlinks, toTerminalHyperlink } from '../../../utils/terminal-hyperlink'; -import { acquireRemoteControlLock } from './remote-control-lock'; - -export const REMOTE_CONTROL_RELAY_ORIGIN = 'https://code-rc.pythinker.com'; - -export const REMOTE_CONTROL_FLAG_ENV = 'PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL'; - -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 - * default constant is the last resort. - */ -export function resolveRelayOrigin( - explicit?: string, - env: Readonly> = process.env, -): string { - 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:') { - throw new Error(`Remote Control relay must be an http(s) URL: ${candidate}`); - } - 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( - env: Readonly> = process.env, -): boolean { - const truthy = (key: string): boolean => - TRUTHY_ENV_VALUES.has((env[key] ?? '').trim().toLowerCase()); - return truthy('PYTHINKER_CODE_EXPERIMENTAL_FLAG') || truthy(REMOTE_CONTROL_FLAG_ENV); -} - -const MAX_HTTP_HEADER_BYTES = 64 * 1024; -const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024; -const HTTP_REQUEST_TIMEOUT_MS = 30_000; -const REGISTER_TIMEOUT_MS = 10_000; -const MAX_RECONNECT_DELAY_MS = 30_000; -const RELAY_PING_INTERVAL_MS = 30_000; -const RELAY_SILENCE_TIMEOUT_MS = 300_000; -const BLOCKED_REQUEST_HEADERS = new Set([ - 'authorization', - 'content-length', - 'cookie', - 'host', - 'origin', - 'proxy-authorization', - 'proxy-authenticate', - 'accept-encoding', - 'connection', - 'keep-alive', - 'proxy-connection', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', -]); -const BLOCKED_RESPONSE_HEADERS = new Set([ - 'connection', - 'content-length', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'proxy-connection', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', -]); - -interface RelayMessage { - readonly type: string; - readonly payload?: Record; -} - -interface PendingHttpRequest { - readonly chunks: Buffer[]; - size: number; -} - -export interface ParsedRawHttpRequest { - readonly method: string; - readonly path: string; - readonly headers: readonly [string, string][]; - readonly body: Buffer; -} - -export type RemoteControlStatus = - | 'relay_connected' - | 'relay_disconnected' - | 'device_connected' - | 'device_disconnected'; - -export interface RemoteControlOptions { - readonly homeDir: string; - readonly localOrigin: string; - readonly localServerToken: string | (() => string); - readonly relayKey: string; - readonly relayOrigin?: string; - readonly stderr?: Pick; - readonly onStatus?: (status: RemoteControlStatus) => void; - readonly pingIntervalMs?: number; - readonly silenceTimeoutMs?: number; -} - -export interface RemoteControlHandle { - readonly deviceId: string; - readonly deviceName: string; - readonly url: string; - close(): Promise; -} - -interface ActiveStream { - readonly local: WebSocket; - readonly tunnel: WebSocket; -} - -class RegistrationError extends Error {} +import type { RemoteControlStatus } from '@pymodel/remote-control'; + +import { buildOpenableUrl, splitTokenFragment } from './access-urls'; + +export { + acquireRemoteControlLock, + buildRemoteControlUrl, + filterForwardRequestHeaders, + formatRemoteControlAlreadyRunning, + inspectRemoteControlLock, + parseRawHttpRequest, + remoteControlLockPath, + RemoteControlAlreadyRunningError, + REMOTE_CONTROL_RELAY_ENV, + REMOTE_CONTROL_RELAY_KEY_ENV, + REMOTE_CONTROL_RELAY_ORIGIN, + resolveRelayKey, + resolveRelayOrigin, + rewriteRemoteControlResponse, + startRemoteControl, +} from '@pymodel/remote-control'; +export type { + ParsedRawHttpRequest, + RemoteControlHandle, + RemoteControlLock, + RemoteControlLockInfo, + RemoteControlOptions, + RemoteControlStatus, +} from '@pymodel/remote-control'; export interface RemoteControlOutputOptions { readonly url: string; readonly localOrigin: string; + readonly localServerToken: string; readonly deviceName: string; readonly qrCode: string; readonly pngPath: string; @@ -163,15 +46,19 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions): const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); const status = (text: string): string => chalk.hex(darkColors.success)(text); const link = (url: string): string => supportsHyperlinks() ? toTerminalHyperlink(accent(url), url) : accent(url); const docs = toTerminalHyperlink('docs', 'https://code.pythinker.com/guides/remote-control.html'); const feedback = toTerminalHyperlink('feedback', 'https://github.com/PyModel/pythinker-code/issues'); + const [localBase, localFrag] = splitTokenFragment( + buildOpenableUrl(options.localOrigin, options.localServerToken), + ); return [ '', - ` ${title('Pythinker Remote Control ready')} ${muted(`${getVersion()} (experimental)`)}`, + ` ${title('Pythinker Remote Control ready')} ${muted(getVersion())}`, ` ${muted('Use Pythinker Code on this machine from your phone or another computer.')}`, '', ` ${label('1.')} Scan the QR code, or open ${link(options.url)}`, @@ -183,9 +70,9 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions): '', options.qrCode.trimEnd().replaceAll(/^/gm, ' '), ` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`, - ` ${label('Local UI: ')}${muted(options.localOrigin)} ${muted('(LAN: --host)')}`, + ` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`, '', - ` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`, + ` ${muted('Docs:')} ${docs} ${muted('·')} ${feedback}`, ` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`, '', ].join('\n'); @@ -205,910 +92,3 @@ export function formatRemoteControlStatus(status: RemoteControlStatus): string { return ` ${value('→')} ${label('Remote device disconnected')}\n`; } } - -export function buildRemoteControlUrl( - deviceId: string, - sessionId?: string, - relayOrigin = REMOTE_CONTROL_RELAY_ORIGIN, -): string { - const url = new URL(relayOrigin); - const relayPath = url.pathname.replace(/\/+$/, ''); - const devicePath = `${relayPath}/devices/${encodeURIComponent(deviceId)}`; - url.pathname = - sessionId === undefined - ? `${devicePath}/` - : `${devicePath}/sessions/${encodeURIComponent(sessionId)}`; - url.search = new URLSearchParams({ rc: '1', from: 'pythinker_code_cli' }).toString(); - url.hash = ''; - return url.toString(); -} - -export function parseRawHttpRequest(raw: Buffer): ParsedRawHttpRequest { - const separator = raw.indexOf('\r\n\r\n'); - if (separator < 0 || separator > MAX_HTTP_HEADER_BYTES) { - throw new SyntaxError('invalid HTTP request headers'); - } - const head = raw.subarray(0, separator).toString('latin1'); - const lines = head.split('\r\n'); - const requestLine = lines.shift(); - const match = requestLine?.match( - /^([!#$%&'*+.^_`|~0-9A-Za-z-]+) (\/[^\u0000-\u0020]*) HTTP\/1\.[01]$/, - ); - if (match === null || match === undefined || match[2]!.startsWith('//')) { - throw new SyntaxError('invalid HTTP request line'); - } - const headers: [string, string][] = []; - for (const line of lines) { - const colon = line.indexOf(':'); - if (colon <= 0) throw new SyntaxError('invalid HTTP request header'); - const name = line.slice(0, colon).trim(); - const value = line.slice(colon + 1).trim(); - try { - validateHeaderName(name); - validateHeaderValue(name, value); - } catch { - throw new SyntaxError('invalid HTTP request header'); - } - headers.push([name, value]); - } - // `transfer-encoding` is stripped before forwarding, so a chunked body would - // reach the local server with its chunk framing as entity data. The relay - // sends whole requests, so refuse the framing instead of decoding it. - if ( - headers.some( - ([name, value]) => - name.toLowerCase() === 'transfer-encoding' && value.toLowerCase().includes('chunked'), - ) - ) { - throw new SyntaxError('chunked HTTP request bodies are not supported'); - } - return { - method: match[1]!, - path: match[2]!, - headers, - body: raw.subarray(separator + 4), - }; -} - -export function filterForwardRequestHeaders( - headers: readonly [string, string][], - serverToken: string, -): string[] { - const connectionHeaders = new Set(); - for (const [name, value] of headers) { - if (name.toLowerCase() === 'connection') { - for (const token of value.split(',')) connectionHeaders.add(token.trim().toLowerCase()); - } - } - const result: string[] = []; - for (const [name, value] of headers) { - const lower = name.toLowerCase(); - if (BLOCKED_REQUEST_HEADERS.has(lower) || connectionHeaders.has(lower)) continue; - result.push(name, value); - } - result.push('Authorization', `Bearer ${serverToken}`); - return result; -} - -export function rewriteRemoteControlResponse( - contentType: string, - body: Buffer, - publicPrefix: string, -): Buffer { - const normalizedPrefix = publicPrefix.replace(/\/+$/, ''); - if (contentType.toLowerCase().includes('text/html')) { - const prefixLiteral = scriptStringLiteral(normalizedPrefix); - const injected = ``; - let text = body.toString('utf8'); - const headMatch = /]*)?>/i.exec(text); - text = - headMatch === null - ? injected + text - : text.slice(0, headMatch.index + headMatch[0].length) + - injected + - text.slice(headMatch.index + headMatch[0].length); - text = text.replaceAll(/\bsrc="\//g, `src="${normalizedPrefix}/`); - text = text.replaceAll(/\bhref="\//g, `href="${normalizedPrefix}/`); - return Buffer.from(text); - } - const lower = contentType.toLowerCase(); - if (lower.includes('javascript') || lower.includes('text/css')) { - let text = body.toString('utf8'); - text = text.replaceAll('"/assets/', `"${normalizedPrefix}/assets/`); - text = text.replaceAll("'/assets/", `'${normalizedPrefix}/assets/`); - text = text.replaceAll('(/assets/', `(${normalizedPrefix}/assets/`); - text = text.replaceAll('"/sessions/"', `"${normalizedPrefix}/sessions/"`); - text = text.replaceAll('return"/"+', `return"${normalizedPrefix}/"+`); - return Buffer.from(text); - } - return body; -} - -/** - * Embed a value in an inline `` in the value would close the element, and U+2028/U+2029 are line - * terminators inside a JavaScript string literal. - */ -function scriptStringLiteral(value: string): string { - return JSON.stringify(value) - .replaceAll('<', '\\u003c') - .replaceAll('>', '\\u003e') - .replaceAll('\u2028', '\\u2028') - .replaceAll('\u2029', '\\u2029'); -} - -export async function startRemoteControl( - options: RemoteControlOptions, -): Promise { - const tokenOption = options.localServerToken; - const resolveServerToken: () => string = - typeof tokenOption === 'function' ? tokenOption : () => tokenOption; - if (resolveServerToken().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(); - const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin); - const lock = await acquireRemoteControlLock(options.homeDir, { - localOrigin: options.localOrigin.replace(/\/+$/, ''), - deviceId, - url, - }); - const client = new RemoteControlClient({ - ...options, - localServerToken: resolveServerToken, - relayOrigin, - deviceId, - relayToken: options.relayKey, - }); - try { - await client.start(); - } catch (error) { - await lock.release(); - throw error; - } - return { - deviceId, - deviceName, - url, - close: async () => { - try { - await client.close(); - } finally { - await lock.release(); - } - }, - }; -} - -class RemoteControlClient { - private readonly localOrigin: string; - private readonly localServerToken: () => string; - private readonly relayOrigin: string; - private readonly deviceId: string; - private readonly relayToken: string; - private readonly stderr: Pick; - private readonly onStatus: (status: RemoteControlStatus) => void; - private readonly streams = new Map(); - private readonly pendingHttpRequests = new Map(); - private management: WebSocket | undefined; - private reconnectAbort: AbortController | undefined; - private http: WebSocket | undefined; - private pendingHttpBytes = 0; - private reconnectAttempt = 0; - private reconnectImmediately = false; - private readonly pingIntervalMs: number; - private readonly silenceTimeoutMs: number; - private stopped = false; - private connected = false; - private relayOnline = false; - private runPromise: Promise | undefined; - private initialResolve: (() => void) | undefined; - private initialReject: ((error: unknown) => void) | undefined; - - constructor( - options: RemoteControlOptions & { - readonly relayOrigin: string; - readonly deviceId: string; - readonly relayToken: string; - readonly localServerToken: () => string; - }, - ) { - this.localOrigin = options.localOrigin.replace(/\/+$/, ''); - this.localServerToken = options.localServerToken; - this.relayOrigin = options.relayOrigin; - this.deviceId = options.deviceId; - this.relayToken = options.relayToken; - this.stderr = options.stderr ?? process.stderr; - this.onStatus = options.onStatus ?? (() => {}); - this.pingIntervalMs = options.pingIntervalMs ?? RELAY_PING_INTERVAL_MS; - this.silenceTimeoutMs = options.silenceTimeoutMs ?? RELAY_SILENCE_TIMEOUT_MS; - } - - async start(): Promise { - const initial = new Promise((resolve, reject) => { - this.initialResolve = resolve; - this.initialReject = reject; - }); - // `run()` settles `initial` from inside its loop, but a throw from outside - // that loop's try would leave the caller waiting forever. - this.runPromise = this.run().catch((error: unknown) => { - this.rejectInitial(error instanceof Error ? error : new Error(String(error))); - }); - await initial; - } - - async close(): Promise { - if (this.stopped) { - await this.runPromise; - return; - } - this.stopped = true; - if (!this.connected) this.rejectInitial(new Error('Remote Control closed before ready.')); - if (this.management?.readyState === WebSocket.OPEN) { - this.management.send( - JSON.stringify({ type: 'disconnect', payload: { reason: 'local_server_stopped' } }), - ); - } - this.closeCycle(); - this.reconnectAbort?.abort(); - await this.runPromise; - } - - private async run(): Promise { - while (!this.stopped) { - try { - await this.serveCycle(); - } catch (error) { - if (error instanceof RegistrationError) { - if (!this.connected) { - this.rejectInitial(error); - this.stopped = true; - return; - } - this.stderr.write(`${error.message}\n`); - } else if (!this.stopped && !this.reconnectImmediately) { - this.stderr.write(`Remote Control disconnected: ${errorMessage(error)}\n`); - } - } finally { - this.closeCycle(); - } - if (this.stopped) { - if (!this.connected) this.rejectInitial(new Error('Remote Control stopped before ready.')); - return; - } - if (this.reconnectImmediately) { - this.reconnectImmediately = false; - continue; - } - this.reconnectAttempt += 1; - const delay = Math.min( - MAX_RECONNECT_DELAY_MS, - 1000 * 2 ** Math.min(this.reconnectAttempt - 1, 5), - ); - await this.waitForReconnect(delay); - } - } - - private async serveCycle(): Promise { - const management = await this.connectRelay('/v1/remote/create'); - this.management = management; - this.watchSocket(management, 'management'); - management.send( - JSON.stringify({ - type: 'register', - payload: { - device_id: this.deviceId, - alias: hostname(), - platform: platform(), - client_version: `pythinker-code/${getVersion()}`, - local_base_url: this.localOrigin, - }, - }), - ); - const registration = await waitForRelayMessage(management, REGISTER_TIMEOUT_MS); - if (registration.type === 'register_nak') { - const code = stringField(registration.payload, 'error_code') ?? 'REGISTRATION_REJECTED'; - const message = stringField(registration.payload, 'error_message') ?? 'registration rejected'; - throw new RegistrationError(`Remote Control registration failed (${code}): ${message}`); - } - if (registration.type !== 'register_ack') { - throw new Error(`Remote Control expected register_ack, received ${registration.type}`); - } - - const managementEnd = waitForSocketEnd(management); - // The relay may send `open_ws` the moment it acknowledges registration. - // `waitForRelayMessage` has just detached its own listener, so buffer - // everything that lands before the HTTP tunnel is up and replay it. - const earlyManagement: RawData[] = []; - const bufferManagement = (data: RawData): void => { - earlyManagement.push(data); - }; - management.on('message', bufferManagement); - const http = await this.connectRelay( - `/v1/remote/http?device_id=${encodeURIComponent(this.deviceId)}`, - ); - this.http = http; - this.watchSocket(http, 'http'); - if (management.readyState !== WebSocket.OPEN) { - throw new Error('management connection closed'); - } - management.off('message', bufferManagement); - management.on('message', (data) => this.handleManagementMessage(data)); - http.on('message', (data) => this.handleHttpMessage(data)); - for (const data of earlyManagement) this.handleManagementMessage(data); - this.reconnectAttempt = 0; - this.relayOnline = true; - this.onStatus('relay_connected'); - - if (!this.connected) { - this.connected = true; - this.initialResolve?.(); - this.initialResolve = undefined; - this.initialReject = undefined; - } - - await Promise.race([managementEnd, waitForSocketEnd(http)]); - if (!this.stopped) throw new Error('relay connection closed'); - } - - private connectRelay(path: string): Promise { - return connectWebSocket(relayWebSocketUrl(this.relayOrigin, path), this.relayToken); - } - - private watchSocket(socket: WebSocket, label: string): void { - const pingTimer = setInterval(() => { - if (socket.readyState === WebSocket.OPEN) socket.ping(); - }, this.pingIntervalMs); - pingTimer.unref(); - let silenceTimer: NodeJS.Timeout | undefined; - const armSilenceTimer = (): void => { - if (silenceTimer !== undefined) clearTimeout(silenceTimer); - silenceTimer = setTimeout(() => { - this.stderr.write( - `Remote Control ${label} connection silent for ${Math.round(this.silenceTimeoutMs / 1000)}s; reconnecting…\n`, - ); - socket.terminate(); - }, this.silenceTimeoutMs); - silenceTimer.unref(); - }; - armSilenceTimer(); - socket.on('message', armSilenceTimer); - socket.on('ping', armSilenceTimer); - socket.on('pong', armSilenceTimer); - socket.once('close', () => { - clearInterval(pingTimer); - if (silenceTimer !== undefined) clearTimeout(silenceTimer); - }); - } - - private rejectInitial(error: Error): void { - this.initialReject?.(error); - this.initialReject = undefined; - this.initialResolve = undefined; - } - - private handleManagementMessage(data: RawData): void { - let message: RelayMessage; - try { - message = parseRelayMessage(data); - } catch (error) { - this.stderr.write(`Remote Control message error: ${errorMessage(error)}\n`); - return; - } - if (message.type === 'open_ws') { - void this.openStream(message.payload ?? {}); - return; - } - if (message.type === 'close_ws') { - const streamId = stringField(message.payload, 'stream_id'); - if (streamId !== undefined) this.closeStream(streamId); - return; - } - if (message.type === 'disconnect') { - const reason = stringField(message.payload, 'reason'); - if (reason === 'user_requested') this.stopped = true; - if (reason === 'server_shutting_down') this.reconnectImmediately = true; - this.closeCycle(); - } - } - - private handleHttpMessage(data: RawData): void { - const text = rawDataText(data).trim(); - if (text.length === 0) return; - let requestId: string | undefined; - try { - const parsed = JSON.parse(text) as Record; - if (parsed['type'] !== 'request') return; - requestId = typeof parsed['request_id'] === 'string' ? parsed['request_id'] : undefined; - if ( - requestId === undefined || - typeof parsed['body_base64'] !== 'string' || - typeof parsed['is_last'] !== 'boolean' - ) { - throw new SyntaxError('invalid HTTP tunnel request message'); - } - const chunk = decodeBase64(parsed['body_base64']); - const pending = this.pendingHttpRequests.get(requestId) ?? { chunks: [], size: 0 }; - if (this.pendingHttpBytes + chunk.length > MAX_HTTP_REQUEST_BYTES) { - throw new SyntaxError('HTTP tunnel request exceeds 10 MiB'); - } - pending.chunks.push(chunk); - pending.size += chunk.length; - this.pendingHttpBytes += chunk.length; - this.pendingHttpRequests.set(requestId, pending); - if (!parsed['is_last']) return; - const rawRequest = Buffer.concat(pending.chunks, pending.size); - this.clearPendingHttpRequest(requestId); - void this.forwardHttpRequest(requestId, rawRequest); - } catch (error) { - if (requestId !== undefined) { - this.clearPendingHttpRequest(requestId); - this.sendHttpResponse(requestId, buildErrorResponse(400)); - } - this.stderr.write(`Remote Control HTTP message error: ${errorMessage(error)}\n`); - } - } - - private async forwardHttpRequest(requestId: string, rawRequest: Buffer): Promise { - try { - const parsed = parseRawHttpRequest(rawRequest); - const response = await requestLocalHttp( - this.localOrigin, - parsed, - this.localServerToken(), - this.publicPrefix(), - ); - this.sendHttpResponse(requestId, response); - } catch (error) { - const status = error instanceof SyntaxError ? 400 : 502; - this.sendHttpResponse(requestId, buildErrorResponse(status)); - this.stderr.write(`Remote Control HTTP forwarding failed: ${errorMessage(error)}\n`); - } - } - - private sendHttpResponse(requestId: string, response: Buffer): void { - if (this.http?.readyState !== WebSocket.OPEN) return; - this.http.send( - JSON.stringify({ - request_id: requestId, - type: 'response', - is_last: true, - body_base64: response.toString('base64'), - }), - ); - } - - private async openStream(payload: Record): Promise { - const streamId = stringField(payload, 'stream_id'); - const path = stringField(payload, 'path'); - if (streamId === undefined || path === undefined || !path.startsWith('/') || path.startsWith('//')) { - if (streamId !== undefined) { - this.sendOpenStreamResult(streamId, false, 'LOCAL_WS_FAILED', 'invalid local WebSocket path'); - } - return; - } - - let local: WebSocket | undefined; - let tunnel: WebSocket | undefined; - const earlyLocalFrames: [RawData, boolean][] = []; - try { - local = await connectWebSocket( - localWebSocketUrl(this.localOrigin, path), - this.localServerToken(), - relayHeaders(payload['headers']), - earlyLocalFrames, - ); - tunnel = await this.connectRelay(`/v1/remote/stream/${encodeURIComponent(streamId)}`); - if (this.stopped || this.management?.readyState !== WebSocket.OPEN) { - throw new Error('management connection closed'); - } - this.streams.set(streamId, { local, tunnel }); - this.onStatus('device_connected'); - bridgeSockets( - local, - tunnel, - () => { - if (this.streams.get(streamId)?.local === local) { - this.streams.delete(streamId); - this.onStatus('device_disconnected'); - } - }, - earlyLocalFrames, - ); - this.sendOpenStreamResult(streamId, true); - } catch (error) { - local?.close(); - tunnel?.close(); - this.sendOpenStreamResult( - streamId, - false, - local === undefined ? 'LOCAL_WS_FAILED' : 'TUNNEL_STREAM_FAILED', - errorMessage(error), - ); - } - } - - private sendOpenStreamResult( - streamId: string, - success: boolean, - errorCode?: string, - error?: string, - ): void { - if (this.management?.readyState !== WebSocket.OPEN) return; - this.management.send( - JSON.stringify({ - type: 'open_ws_result', - payload: { - stream_id: streamId, - success, - error_code: errorCode, - error_message: error, - }, - }), - ); - } - - private closeStream(streamId: string): void { - const stream = this.streams.get(streamId); - if (stream === undefined) return; - this.streams.delete(streamId); - this.onStatus('device_disconnected'); - stream.local.close(); - stream.tunnel.close(); - } - - private clearPendingHttpRequest(requestId: string): void { - const pending = this.pendingHttpRequests.get(requestId); - if (pending === undefined) return; - this.pendingHttpRequests.delete(requestId); - this.pendingHttpBytes -= pending.size; - } - - private closeCycle(): void { - for (const streamId of this.streams.keys()) this.closeStream(streamId); - this.pendingHttpRequests.clear(); - this.pendingHttpBytes = 0; - this.management?.close(); - this.http?.close(); - if (this.relayOnline) { - this.relayOnline = false; - this.onStatus('relay_disconnected'); - } - this.management = undefined; - this.http = undefined; - } - - private publicPrefix(): string { - const relayPath = new URL(this.relayOrigin).pathname.replace(/\/+$/, ''); - return `${relayPath}/devices/${encodeURIComponent(this.deviceId)}`; - } - - private async waitForReconnect(ms: number): Promise { - if (this.stopped) return; - const controller = new AbortController(); - this.reconnectAbort = controller; - try { - await sleep(ms, undefined, { signal: controller.signal }); - } catch (error) { - if (!(error instanceof Error) || error.name !== 'AbortError') throw error; - } finally { - if (this.reconnectAbort === controller) this.reconnectAbort = undefined; - } - } -} - -async function connectWebSocket( - url: string, - token: string, - headers: Record = {}, - earlyFrames?: [RawData, boolean][], -): Promise { - const protocol = `pythinker-code.bearer.${token}`; - if (isWebSocketProtocolToken(protocol)) { - try { - return await connectWebSocketAttempt(url, [protocol], headers, earlyFrames); - } catch {} - } - return connectWebSocketAttempt( - url, - undefined, - { - ...headers, - Authorization: `Bearer ${token}`, - }, - earlyFrames, - ); -} - -function connectWebSocketAttempt( - url: string, - protocols: string[] | undefined, - headers: Record, - earlyFrames?: [RawData, boolean][], -): Promise { - return new Promise((resolve, reject) => { - const socket = new WebSocket(url, protocols, { - headers, - handshakeTimeout: REGISTER_TIMEOUT_MS, - }); - if (earlyFrames !== undefined) { - socket.on('message', (data, isBinary) => { - earlyFrames.push([data, isBinary]); - }); - } - let settled = false; - const cleanup = (): void => { - socket.off('open', onOpen); - socket.off('error', onError); - socket.off('close', onClose); - }; - const finish = (error?: Error): void => { - if (settled) return; - settled = true; - cleanup(); - if (error === undefined) resolve(socket); - else reject(error); - }; - const onOpen = (): void => finish(); - const onError = (error: Error): void => finish(error); - const onClose = (code: number, reason: Buffer): void => { - finish(new Error(`WebSocket closed during handshake (${code} ${reason.toString()})`)); - }; - socket.once('open', onOpen); - socket.once('error', onError); - socket.once('close', onClose); - }); -} - -function isWebSocketProtocolToken(value: string): boolean { - return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(value); -} - -function waitForRelayMessage(socket: WebSocket, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => finish(new Error('Remote Control registration timed out')), timeoutMs); - const onMessage = (data: RawData): void => { - try { - finish(undefined, parseRelayMessage(data)); - } catch (error) { - finish(error); - } - }; - const onClose = (code: number, reason: Buffer): void => { - finish(new Error(`Remote Control registration closed (${code} ${reason.toString()})`)); - }; - const onError = (error: Error): void => finish(error); - const finish = (error?: unknown, message?: RelayMessage): void => { - clearTimeout(timer); - socket.off('message', onMessage); - socket.off('close', onClose); - socket.off('error', onError); - if (error !== undefined) reject(error); - else resolve(message!); - }; - socket.once('message', onMessage); - socket.once('close', onClose); - socket.once('error', onError); - }); -} - -function waitForSocketEnd(socket: WebSocket): Promise { - return new Promise((resolve) => { - socket.once('close', () => resolve()); - socket.once('error', () => resolve()); - }); -} - -function parseRelayMessage(data: RawData): RelayMessage { - const parsed = JSON.parse(rawDataText(data)) as Record; - if (typeof parsed['type'] !== 'string') throw new Error('relay message has no type'); - const payload = isRecord(parsed['payload']) ? parsed['payload'] : undefined; - return { type: parsed['type'], payload }; -} - -function requestLocalHttp( - localOrigin: string, - parsed: ParsedRawHttpRequest, - serverToken: string, - publicPrefix: string, -): Promise { - const origin = new URL(localOrigin); - return new Promise((resolve, reject) => { - const request = httpRequest( - { - protocol: origin.protocol, - hostname: origin.hostname, - port: origin.port, - method: parsed.method, - path: parsed.path, - headers: [ - ...filterForwardRequestHeaders(parsed.headers, serverToken), - 'Content-Length', - String(parsed.body.length), - 'Host', - origin.host, - ], - timeout: HTTP_REQUEST_TIMEOUT_MS, - }, - (response) => { - const chunks: Buffer[] = []; - response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk))); - response.once('error', reject); - response.once('end', () => { - const contentType = response.headers['content-type'] ?? ''; - const receivedBody = Buffer.concat(chunks); - const body = - response.headers['content-encoding'] === undefined - ? rewriteRemoteControlResponse(contentType, receivedBody, publicPrefix) - : receivedBody; - const rewritten = body !== receivedBody; - const headers = filterResponseHeaders(response.rawHeaders, rewritten); - if (rewritten) headers.push('Cache-Control', 'no-cache'); - headers.push('Content-Length', String(body.length)); - const statusCode = response.statusCode ?? 502; - const statusMessage = response.statusMessage ?? 'Bad Gateway'; - resolve( - Buffer.concat([ - Buffer.from(`HTTP/1.1 ${statusCode} ${statusMessage}\r\n${headerLines(headers)}\r\n\r\n`), - body, - ]), - ); - }); - }, - ); - request.once('timeout', () => request.destroy(new Error('local HTTP request timed out'))); - request.once('error', reject); - request.end(parsed.body); - }); -} - -function filterResponseHeaders(rawHeaders: readonly string[], blockCacheControl = false): string[] { - const connectionHeaders = new Set(); - for (let index = 0; index < rawHeaders.length; index += 2) { - if (rawHeaders[index]!.toLowerCase() === 'connection') { - for (const token of rawHeaders[index + 1]!.split(',')) { - connectionHeaders.add(token.trim().toLowerCase()); - } - } - } - const result: string[] = []; - for (let index = 0; index < rawHeaders.length; index += 2) { - const name = rawHeaders[index]!; - const lower = name.toLowerCase(); - if (BLOCKED_RESPONSE_HEADERS.has(lower) || connectionHeaders.has(lower)) { - continue; - } - if (blockCacheControl && lower === 'cache-control') continue; - result.push(name, rawHeaders[index + 1]!); - } - return result; -} - -function relayHeaders(value: unknown): Record { - if (!isRecord(value)) return {}; - const entries: [string, string][] = []; - for (const [name, raw] of Object.entries(value)) { - if (typeof raw !== 'string') continue; - const lower = name.toLowerCase(); - if (BLOCKED_REQUEST_HEADERS.has(lower)) continue; - try { - validateHeaderName(name); - validateHeaderValue(name, raw); - entries.push([name, raw]); - } catch {} - } - return Object.fromEntries(entries); -} - -function bridgeSockets( - left: WebSocket, - right: WebSocket, - onClose: () => void, - earlyLeftFrames?: [RawData, boolean][], -): void { - let closed = false; - const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => { - if (closed) return; - closed = true; - onClose(); - const safeCode = isValidCloseCode(code) ? code : 1000; - if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason); - if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason); - }; - if (earlyLeftFrames !== undefined) { - left.removeAllListeners('message'); - for (const [data, isBinary] of earlyLeftFrames) { - if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); - } - } - left.on('message', (data, isBinary) => { - if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary }); - }); - right.on('message', (data, isBinary) => { - if (left.readyState === WebSocket.OPEN) left.send(data, { binary: isBinary }); - }); - left.once('close', closeBoth); - right.once('close', closeBoth); - left.once('error', () => closeBoth(1011)); - right.once('error', () => closeBoth(1011)); -} - -function isValidCloseCode(code: number): boolean { - return ( - code === 1000 || - code === 1001 || - code === 1002 || - code === 1003 || - (code >= 1007 && code <= 1014) || - (code >= 3000 && code <= 4999) - ); -} - -function relayWebSocketUrl(origin: string, path: string): string { - const url = new URL(origin); - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - const relayPath = url.pathname.replace(/\/+$/, ''); - const [pathname, query] = path.split('?', 2); - url.pathname = `${relayPath}${pathname}`; - url.search = query === undefined ? '' : query; - url.hash = ''; - return url.toString(); -} - -function localWebSocketUrl(origin: string, path: string): string { - const url = new URL(origin); - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; - url.pathname = path.split('?', 1)[0]!; - const query = path.includes('?') ? path.slice(path.indexOf('?') + 1) : ''; - url.search = query; - url.hash = ''; - return url.toString(); -} - -function headerLines(headers: readonly string[]): string { - let result = ''; - for (let index = 0; index < headers.length; index += 2) { - result += `${headers[index]}: ${headers[index + 1]}\r\n`; - } - return result.replace(/\r\n$/, ''); -} - -function buildErrorResponse(status: number): Buffer { - const reason = status === 400 ? 'Bad Request' : 'Bad Gateway'; - return Buffer.from(`HTTP/1.1 ${status} ${reason}\r\nContent-Length: 0\r\n\r\n`); -} - -function stringField( - value: Record | undefined, - key: string, -): string | undefined { - const field = value?.[key]; - return typeof field === 'string' ? field : undefined; -} - -function decodeBase64(value: string): Buffer { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - throw new SyntaxError('invalid HTTP tunnel request base64'); - } - return Buffer.from(value, 'base64'); -} - -function rawDataText(data: RawData): string { - if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); - return Buffer.from(data as ArrayBuffer).toString('utf8'); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts index 4abff9fee..728eba949 100644 --- a/apps/pythinker-code/src/cli/sub/web/run.ts +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -44,8 +44,6 @@ import { formatHostForUrl, type NetworkAddress } from './networks'; import { formatRemoteControlOutput, formatRemoteControlStatus, - isRemoteControlEnabled, - REMOTE_CONTROL_FLAG_ENV, resolveRelayKey, resolveRelayOrigin, startRemoteControl, @@ -181,23 +179,21 @@ export function buildWebCommand( withServerOptions.addOption( new Option( '--rc, --remote-control', - 'Expose the web UI through Pythinker Remote Control (experimental).', - ) - .default(false) - .hideHelp(!isRemoteControlEnabled()), + 'Expose the web UI through Pythinker Remote Control.', + ).default(false), ); } 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 ', 'Remote Control relay to tunnel through. Defaults to $PYTHINKER_CODE_REMOTE_CONTROL_RELAY.', - ).hideHelp(!isRemoteControlEnabled()), + ), ); return withServerOptions .option('--no-open', 'Do not open the web UI in the default browser.', true) @@ -218,11 +214,6 @@ export async function handleWebCommand( deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, ): Promise { const parsed = parseServerOptions(opts); - if (opts.remoteControl === true && !isRemoteControlEnabled()) { - throw new Error( - `--remote-control is experimental: set ${REMOTE_CONTROL_FLAG_ENV}=1 (or PYTHINKER_CODE_EXPERIMENTAL_FLAG=1) to enable it.`, - ); - } if (opts.remoteControl === true && parsed.dangerousBypassAuth) { throw new Error('--remote-control cannot be combined with --dangerous-bypass-auth.'); } @@ -260,6 +251,7 @@ export async function handleWebCommand( homeDir: dataDir, localOrigin: origin, localServerToken: () => deps.resolveToken?.() ?? '', + clientVersion: `pythinker-code/${getVersion()}`, relayKey, relayOrigin, stderr: deps.stderr, @@ -270,6 +262,7 @@ export async function handleWebCommand( formatRemoteControlOutput({ url: remoteControl.url, localOrigin: origin, + localServerToken: token ?? '', deviceName: remoteControl.deviceName, qrCode: qrCode.terminal, pngPath: qrCode.pngPath, diff --git a/apps/pythinker-code/src/main.ts b/apps/pythinker-code/src/main.ts index 8c089d8a3..db08c458d 100644 --- a/apps/pythinker-code/src/main.ts +++ b/apps/pythinker-code/src/main.ts @@ -165,7 +165,7 @@ function bootstrap(): void { ); // Same pattern for the global-search worker: extracted from the SEA blob so // the search index runs off the main thread; a failure leaves the search - // surface degraded (the `search_worker` flag restores the inline host). + // surface degraded ([database] search = false restores the inline host). const searchWorkerInstall = installKapSearchWorker(); startupTrace( searchWorkerInstall.status === 'installed' diff --git a/apps/pythinker-code/src/native/search-worker.ts b/apps/pythinker-code/src/native/search-worker.ts index c94b84a97..a1c25c3ab 100644 --- a/apps/pythinker-code/src/native/search-worker.ts +++ b/apps/pythinker-code/src/native/search-worker.ts @@ -36,8 +36,8 @@ function errorCode(error: unknown): string { /** * Install the SEA-bundled global-search worker without making optional * extraction fatal. Without it the search service resolves no worker entry - * inside the single-file binary and reports the index as degraded; the - * `search_worker` experimental flag restores the in-process host. + * inside the single-file binary and reports the index as degraded; + * `[database] search = false` restores the in-process host. */ export function installKapSearchWorker( options: NativeAssetOptions = {}, diff --git a/apps/pythinker-code/src/tui/commands/registry.ts b/apps/pythinker-code/src/tui/commands/registry.ts index 4031c361c..a9176e10f 100644 --- a/apps/pythinker-code/src/tui/commands/registry.ts +++ b/apps/pythinker-code/src/tui/commands/registry.ts @@ -448,10 +448,9 @@ export const BUILTIN_SLASH_COMMANDS = [ { name: 'remote-control', aliases: ['rc'], - description: 'Open the current session through Pythinker Remote Control (experimental)', + description: 'Open the current session through Pythinker Remote Control', priority: 40, availability: 'always', - experimentalFlag: 'remote-control', }, { name: 'exit', diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index 38588086f..61af5316b 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -1,19 +1,18 @@ import chalk from 'chalk'; import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { getVersion } from '#/cli/version'; import { buildRemoteControlUrl, + formatRemoteControlAlreadyRunning, formatRemoteControlOutput, formatRemoteControlStatus, + inspectRemoteControlLock, resolveRelayKey, resolveRelayOrigin, startRemoteControl, type RemoteControlStatus, } from '#/cli/sub/web/remote-control'; -import { - formatRemoteControlAlreadyRunning, - inspectRemoteControlLock, -} from '#/cli/sub/web/remote-control-lock'; import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; @@ -94,6 +93,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis homeDir: dataDir, localOrigin: origin, localServerToken: () => tryResolveServerToken(dataDir) ?? '', + clientVersion: `pythinker-code/${getVersion()}`, relayKey, relayOrigin, onStatus, @@ -104,6 +104,7 @@ export async function handleRemoteControlCommand(host: SlashCommandHost): Promis formatRemoteControlOutput({ url, localOrigin: origin, + localServerToken: token, deviceName: remoteControl.deviceName, qrCode: qrCode.terminal, pngPath: qrCode.pngPath, diff --git a/apps/pythinker-code/test/cli/options.test.ts b/apps/pythinker-code/test/cli/options.test.ts index 77b1ec291..2a742bb9c 100644 --- a/apps/pythinker-code/test/cli/options.test.ts +++ b/apps/pythinker-code/test/cli/options.test.ts @@ -5,7 +5,7 @@ * Run: pnpm -C apps/pythinker-code exec vitest run test/cli/options.test.ts */ -import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; import type { CLIOptions } from '#/cli/options'; @@ -574,11 +574,6 @@ describe('CLI options parsing', () => { }); it('registers the visible sub-commands', () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); - onTestFinished(() => { - vi.unstubAllEnvs(); - }); const program = createProgram( '0.0.0', () => {}, @@ -597,6 +592,7 @@ describe('CLI options parsing', () => { 'session', 'acp', 'web', + 'rc', 'server', 'doctor', 'vis', diff --git a/apps/pythinker-code/test/cli/web/remote-control-output.test.ts b/apps/pythinker-code/test/cli/web/remote-control-output.test.ts new file mode 100644 index 000000000..f737d8bbe --- /dev/null +++ b/apps/pythinker-code/test/cli/web/remote-control-output.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + formatRemoteControlOutput, + formatRemoteControlStatus, +} from '#/cli/sub/web/remote-control'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('Remote Control output', () => { + const outputOptions = { + url: 'https://example.test/devices/example-device/?rc=1&from=pythinker_code_cli', + localOrigin: 'http://127.0.0.1:1234', + localServerToken: 'example-token', + deviceName: 'example-device', + qrCode: 'QR\n', + pngPath: '/tmp/example-qr.png', + }; + + it('shows the full URL as the clickable link text and the setup contract', () => { + vi.stubEnv('FORCE_HYPERLINK', '1'); + const output = formatRemoteControlOutput(outputOptions); + const url = outputOptions.url; + expect(output).toContain('Use Pythinker Code on this machine'); + expect(output).toContain('1.'); + expect(output).toContain('2.'); + expect(output).toContain(`\u001B]8;;${url}`); + const plain = output + .replaceAll(/\u001B\]8;;.*?\u0007/g, '') + .replaceAll(/\u001B\[[0-9;]*m/g, ''); + expect(plain).toContain(`open ${url}`); + expect(plain).not.toContain('exampl…'); + expect(plain).toContain('http://127.0.0.1:1234/#token=example-token'); + expect(output).toContain('#token=example-token'); + expect(url).not.toContain('example-token'); + expect(plain).not.toMatch(/^\s*3\.\s/m); + expect(output).toContain('Connected to example.test'); + expect(output).toContain('This device:'); + expect(output).not.toContain('Manage devices'); + expect(output).toContain('PNG:'); + expect(output).toContain('\n QR'); + expect(output).toContain('grants control of this machine'); + expect(output).toContain('docs'); + expect(output).toContain('feedback'); + expect(output).toContain('Logs: off'); + expect(output).not.toContain('stream-1'); + }); + + it('prints the full URL as plain text when the terminal cannot render hyperlinks', () => { + vi.stubEnv('FORCE_HYPERLINK', '0'); + const output = formatRemoteControlOutput(outputOptions); + expect(output).toContain(`open ${outputOptions.url}`); + expect(output).toContain('#token=example-token'); + expect(output).not.toContain('exampl…vice'); + expect(output).not.toContain('Manage devices'); + }); + + it('formats relay and device lifecycle states', () => { + expect(formatRemoteControlStatus('relay_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('relay_disconnected')).toContain('disconnected'); + expect(formatRemoteControlStatus('device_connected').toLowerCase()).toContain('connected'); + expect(formatRemoteControlStatus('device_disconnected')).toContain('disconnected'); + }); +}); diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts index 2de97f966..410cb479b 100644 --- a/apps/pythinker-code/test/cli/web/web.test.ts +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -465,7 +465,6 @@ describe('`pythinker web` opens the browser', () => { }); it('passes the resolved relay origin to the tunnel', async () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); const { handleWebCommand } = await import('#/cli/sub/web/run'); const { runner } = makeRunner(); const { stdout, stderr } = makeIo(); @@ -473,6 +472,7 @@ describe('`pythinker web` opens the browser', () => { deviceId: 'device-1', deviceName: 'example-device', url: 'https://relay.example.test/devices/device-1/?rc=1&from=pythinker_code_cli', + closed: new Promise(() => {}), close: async () => {}, })); @@ -507,7 +507,6 @@ describe('`pythinker web` opens the browser', () => { }); 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(); @@ -531,7 +530,6 @@ describe('`pythinker web` opens the browser', () => { }); 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'); const { runner } = makeRunner(); const { stdout, stderr } = makeIo(); @@ -544,32 +542,12 @@ describe('`pythinker web` opens the browser', () => { ).rejects.toThrow('--remote-control requires a loopback host.'); }); - it('rejects --remote-control while the experimental flag is off', async () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); - const { handleWebCommand } = await import('#/cli/sub/web/run'); - const { runner } = makeRunner(); - const { stdout, stderr } = makeIo(); - - await expect( - handleWebCommand( - { remoteControl: true, open: false }, - { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, - ), - ).rejects.toThrow('--remote-control is experimental:'); - }); - - it('hides --remote-control from help unless the experimental flag is on', () => { - const remoteControlOption = () => - makeProgram() - .commands.find((command) => command.name() === 'web')! - .options.find((option) => option.long === '--remote-control'); - - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); - expect(remoteControlOption()?.hidden).toBe(true); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); - expect(remoteControlOption()?.hidden).toBe(false); + it('shows --remote-control in help', () => { + const remoteControlOption = makeProgram() + .commands.find((command) => command.name() === 'web')! + .options.find((option) => option.long === '--remote-control'); + expect(remoteControlOption).toBeDefined(); + expect(remoteControlOption?.hidden).toBeFalsy(); }); }); @@ -1240,17 +1218,11 @@ describe('pythinker rc', () => { expect(longs).not.toContain('--remote-control'); }); - it('hides `rc` from help unless the experimental flag is on', () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); - expect(makeProgram().helpInformation()).not.toContain('rc|remote'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '1'); + it('shows `rc` in help', () => { expect(makeProgram().helpInformation()).toContain('rc|remote'); }); it('forces Remote Control for both `rc` and `remote`', async () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_REMOTE_CONTROL', '0'); for (const name of ['rc', 'remote']) { const program = makeProgram(); let stderr = ''; @@ -1262,14 +1234,13 @@ describe('pythinker rc', () => { .spyOn(process, 'exit') .mockImplementation(() => undefined as never); try { - await program.parseAsync(['node', 'pythinker', name]); + await program.parseAsync(['node', 'pythinker', name, '--host', '0.0.0.0']); } finally { errSpy.mockRestore(); exitSpy.mockRestore(); } - // The flag-off experimental error proves remoteControl was forced before - // the runner could start. - expect(stderr).toContain('--remote-control is experimental:'); + // The loopback check only runs when remoteControl was forced on. + expect(stderr).toContain('--remote-control requires a loopback host.'); } }); }); diff --git a/apps/pythinker-code/test/tui/commands/registry.test.ts b/apps/pythinker-code/test/tui/commands/registry.test.ts index 00902a598..a3fa17ef8 100644 --- a/apps/pythinker-code/test/tui/commands/registry.test.ts +++ b/apps/pythinker-code/test/tui/commands/registry.test.ts @@ -233,10 +233,10 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(command!, 'Ship feature X')).toBe('always'); }); - it('gates remote-control behind the remote-control experiment, always available', () => { + it('exposes remote-control ungated and always available', () => { const command = findBuiltInSlashCommand('remote-control'); expect(command).toBeDefined(); - expect((command as PythinkerSlashCommand).experimentalFlag).toBe('remote-control'); + expect((command as PythinkerSlashCommand).experimentalFlag).toBeUndefined(); expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); }); }); diff --git a/apps/pythinker-code/test/tui/commands/resolve.test.ts b/apps/pythinker-code/test/tui/commands/resolve.test.ts index 21b446a0e..81f337f44 100644 --- a/apps/pythinker-code/test/tui/commands/resolve.test.ts +++ b/apps/pythinker-code/test/tui/commands/resolve.test.ts @@ -65,9 +65,7 @@ describe('resolveSlashCommandInput', () => { }); - it('gates /remote-control behind the remote-control experimental flag', () => { - expect(resolve('/rc')).toEqual({ kind: 'message', input: '/rc' }); - setExperimentalFeatures([{ id: 'remote-control', enabled: true }]); + it('resolves /remote-control without any experimental flag', () => { expect(resolve('/rc')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); expect(resolve('/remote-control')).toMatchObject({ kind: 'builtin', name: 'remote-control' }); }); diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index afd4b0994..f9a8fdac1 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -261,8 +261,9 @@ describe('handleRemoteControlCommand', () => { const png = readFileSync(pngPath); expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); expect(png).toEqual(await QRCode.toBuffer(sessionUrl)); - expect(written).not.toContain('local-server-token'); - expect(written).not.toContain('#token='); + expect(sessionUrl).not.toContain('local-server-token'); + expect(indentedQr(sessionUrl)).not.toContain('#token='); + expect(written).toContain('http://127.0.0.1:58627/#token=local-server-token'); expect(close).toHaveBeenCalledOnce(); } finally { writeSpy.mockRestore(); diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index e75dd5a9e..bca968310 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -216,7 +216,7 @@ Subagents inherit the model the main agent is running by default. The `[secondar ### Subagent model pool -Secondary-model routing is enabled by default in every launch mode, including the interactive TUI. Set `PYTHINKER_CODE_EXPERIMENTAL_SECONDARY_MODEL=false` to disable it. While routing is disabled, the pool keys stay inert: subagents inherit the caller's model and session startup skips the pool validation. +The pool is always available and needs no opt-in; with no `[secondary_model]` keys configured, subagents simply inherit the caller's model. The minimal configuration is one line — a lone `default_model` is a pool with a single entry: @@ -465,6 +465,17 @@ Like the `tools` / `disallowedTools` fields of an agent file, this section shape `max_edge_px` can be overridden by the `PYTHINKER_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `PYTHINKER_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`. +## `database` + +`database` controls the embedded storage engines behind session indexing and global search. Both keys default to `true` and act as kill switches that fall back to the legacy behavior when set to `false`. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `base` | `boolean` | `true` | Use the minidb-backed read model for session indexing; `false` falls back to reading session metadata directly | +| `search` | `boolean` | `true` | Run the global search index in a dedicated worker thread; `false` runs it in the server process | + +`base` can be overridden by the `PYTHINKER_CODE_PERSISTENCE_MINIDB_READMODEL` environment variable and `search` by `PYTHINKER_CODE_SEARCH_WORKER`; both take higher priority than `config.toml`. +