|
| 1 | +import fs from 'fs'; |
| 2 | +import { readAuthToken } from '@studio/common/lib/shared-config'; |
| 3 | +import { getRemoteSessionConfigPath } from '@studio/common/lib/well-known-paths'; |
| 4 | +import { readFile, writeFile } from 'atomically'; |
| 5 | +import { z } from 'zod'; |
| 6 | + |
| 7 | +export const remoteSessionConfigSchema = z.object( { |
| 8 | + base_url: z.string().url().default( 'https://public-api.wordpress.com/wpcom/v2/telegram-bot' ), |
| 9 | + // `token` is the only required field; if omitted everywhere, falls back to the |
| 10 | + // WordPress.com OAuth access token from ~/.studio/shared.json (i.e. /login). |
| 11 | + token: z.string().min( 1 ), |
| 12 | + // Optional pinning. When set, `chat_id` filters polled messages and `bot` overrides |
| 13 | + // the per-message bot identity for outgoing replies. When unset, the controller |
| 14 | + // trusts the server's per-token authorization and echoes chat_id + bot from each |
| 15 | + // polled message back into the response. |
| 16 | + bot: z.string().min( 1 ).optional(), |
| 17 | + chat_id: z.number().int().optional(), |
| 18 | + poll_interval_seconds: z.number().positive().default( 2 ), |
| 19 | + long_poll_timeout_seconds: z.number().positive().default( 25 ), |
| 20 | + max_message_chars: z.number().int().positive().max( 4096 ).default( 3800 ), |
| 21 | + turn_timeout_seconds: z.number().positive().default( 900 ), |
| 22 | +} ); |
| 23 | + |
| 24 | +export type RemoteSessionConfig = z.infer< typeof remoteSessionConfigSchema >; |
| 25 | + |
| 26 | +export interface RemoteSessionOverrides { |
| 27 | + token?: string; |
| 28 | + bot?: string; |
| 29 | + chat_id?: number; |
| 30 | + base_url?: string; |
| 31 | +} |
| 32 | + |
| 33 | +export class RemoteSessionConfigError extends Error { |
| 34 | + constructor( |
| 35 | + message: string, |
| 36 | + public readonly missingFields: string[] = [] |
| 37 | + ) { |
| 38 | + super( message ); |
| 39 | + this.name = 'RemoteSessionConfigError'; |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +function readEnvOverrides(): RemoteSessionOverrides { |
| 44 | + const overrides: RemoteSessionOverrides = {}; |
| 45 | + if ( process.env.STUDIO_REMOTE_BASE_URL ) { |
| 46 | + overrides.base_url = process.env.STUDIO_REMOTE_BASE_URL; |
| 47 | + } |
| 48 | + if ( process.env.STUDIO_REMOTE_TOKEN ) { |
| 49 | + overrides.token = process.env.STUDIO_REMOTE_TOKEN; |
| 50 | + } |
| 51 | + if ( process.env.STUDIO_REMOTE_BOT ) { |
| 52 | + overrides.bot = process.env.STUDIO_REMOTE_BOT; |
| 53 | + } |
| 54 | + if ( process.env.STUDIO_REMOTE_CHAT_ID ) { |
| 55 | + const parsed = Number( process.env.STUDIO_REMOTE_CHAT_ID ); |
| 56 | + if ( Number.isFinite( parsed ) ) { |
| 57 | + overrides.chat_id = parsed; |
| 58 | + } |
| 59 | + } |
| 60 | + return overrides; |
| 61 | +} |
| 62 | + |
| 63 | +async function readConfigFile(): Promise< Record< string, unknown > > { |
| 64 | + const configPath = getRemoteSessionConfigPath(); |
| 65 | + if ( ! fs.existsSync( configPath ) ) { |
| 66 | + return {}; |
| 67 | + } |
| 68 | + try { |
| 69 | + const content = await readFile( configPath, { encoding: 'utf8' } ); |
| 70 | + const parsed = JSON.parse( content ); |
| 71 | + return parsed && typeof parsed === 'object' ? ( parsed as Record< string, unknown > ) : {}; |
| 72 | + } catch { |
| 73 | + return {}; |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +/** |
| 78 | + * Load the remote-session config. Resolution order: |
| 79 | + * 1. CLI overrides |
| 80 | + * 2. Env vars (STUDIO_REMOTE_*) |
| 81 | + * 3. ~/.studio/remote-session.json |
| 82 | + * 4. For `token` only: fall back to the WordPress.com OAuth access token from |
| 83 | + * ~/.studio/shared.json (the same token populated by `studio code` /login). |
| 84 | + * |
| 85 | + * Throws RemoteSessionConfigError listing missing fields when required values are absent. |
| 86 | + */ |
| 87 | +export async function loadRemoteSessionConfig( |
| 88 | + cliOverrides: RemoteSessionOverrides = {} |
| 89 | +): Promise< RemoteSessionConfig > { |
| 90 | + const fileValues = await readConfigFile(); |
| 91 | + const envOverrides = readEnvOverrides(); |
| 92 | + const merged: Record< string, unknown > = { |
| 93 | + ...fileValues, |
| 94 | + ...envOverrides, |
| 95 | + ...cliOverrides, |
| 96 | + }; |
| 97 | + |
| 98 | + if ( typeof merged.token !== 'string' || merged.token.length === 0 ) { |
| 99 | + const stored = await readAuthToken(); |
| 100 | + if ( stored?.accessToken ) { |
| 101 | + merged.token = stored.accessToken; |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + const parseResult = remoteSessionConfigSchema.safeParse( merged ); |
| 106 | + if ( parseResult.success ) { |
| 107 | + return parseResult.data; |
| 108 | + } |
| 109 | + |
| 110 | + const missing: string[] = []; |
| 111 | + for ( const issue of parseResult.error.issues ) { |
| 112 | + const field = issue.path[ 0 ]; |
| 113 | + if ( typeof field === 'string' && ! missing.includes( field ) ) { |
| 114 | + missing.push( field ); |
| 115 | + } |
| 116 | + } |
| 117 | + const required = [ 'token' ].filter( ( f ) => missing.includes( f ) ); |
| 118 | + if ( required.length > 0 ) { |
| 119 | + throw new RemoteSessionConfigError( |
| 120 | + `Remote session needs a bearer token to authenticate to ${ '/local-agent-poll' }. ` + |
| 121 | + `Run \`studio code\` and \`/login\` to authenticate to WordPress.com, ` + |
| 122 | + `or set STUDIO_REMOTE_TOKEN, or put a "token" field in ~/.studio/remote-session.json.`, |
| 123 | + required |
| 124 | + ); |
| 125 | + } |
| 126 | + throw new RemoteSessionConfigError( |
| 127 | + `Remote session config is invalid: ${ parseResult.error.issues |
| 128 | + .map( ( i ) => `${ i.path.join( '.' ) }: ${ i.message }` ) |
| 129 | + .join( '; ' ) }`, |
| 130 | + [] |
| 131 | + ); |
| 132 | +} |
| 133 | + |
| 134 | +/** |
| 135 | + * Write the config file with mode 0600. Used by ops/tests; the runtime path never writes config. |
| 136 | + */ |
| 137 | +export async function saveRemoteSessionConfig( config: RemoteSessionConfig ): Promise< void > { |
| 138 | + const configPath = getRemoteSessionConfigPath(); |
| 139 | + const content = JSON.stringify( config, null, 2 ) + '\n'; |
| 140 | + await writeFile( configPath, content, { encoding: 'utf8', mode: 0o600 } ); |
| 141 | + try { |
| 142 | + await fs.promises.chmod( configPath, 0o600 ); |
| 143 | + } catch { |
| 144 | + // chmod can fail on Windows; atomically already set mode on create. |
| 145 | + } |
| 146 | +} |
0 commit comments