Skip to content

Commit 484da49

Browse files
authored
apps/cli: Telegram remote-session bridge for studio code (PoC) (#3196)
1 parent e99e475 commit 484da49

25 files changed

Lines changed: 3759 additions & 9 deletions

‎apps/cli/commands/ai/index.ts‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import { AiChatUI } from 'cli/ai/ui';
2525
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
2626
import { readCliConfig } from 'cli/lib/cli-config/core';
2727
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
28+
import { isRemoteSessionEnabled } from 'cli/lib/feature-flags';
2829
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
30+
import { RemoteSessionConfigError, runRemoteSession } from 'cli/remote-session';
2931
import { StudioArgv } from 'cli/types';
3032

3133
const logger = new Logger< string >();
@@ -45,6 +47,14 @@ function getErrorMessage( error: unknown ): string {
4547
return String( error );
4648
}
4749

50+
async function readAllStdin(): Promise< string > {
51+
const chunks: Buffer[] = [];
52+
for await ( const chunk of process.stdin ) {
53+
chunks.push( typeof chunk === 'string' ? Buffer.from( chunk ) : ( chunk as Buffer ) );
54+
}
55+
return Buffer.concat( chunks ).toString( 'utf8' ).trim();
56+
}
57+
4858
export async function runCommand( options: {
4959
adapter: AiOutputAdapter;
5060
initialMessage?: string;
@@ -713,7 +723,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
713723
command: '$0 [message]',
714724
describe: __( 'AI agent for building WordPress' ),
715725
builder: ( yargs ) => {
716-
return yargs
726+
let chain = yargs
717727
.positional( 'message', {
718728
type: 'string',
719729
description: __( 'Initial message to send to the AI agent' ),
@@ -745,13 +755,40 @@ export const registerCommand = ( yargs: StudioArgv ) => {
745755
type: 'boolean',
746756
default: true,
747757
description: __( 'Record this code session to disk' ),
748-
} )
749-
.check( ( argv ) => {
750-
if ( argv.json && ! argv.message ) {
751-
throw new Error( __( '--json requires an initial message argument' ) );
752-
}
753-
return true;
754758
} );
759+
760+
// Remote-session options are gated behind STUDIO_ENABLE_REMOTE_SESSION so the
761+
// experimental Telegram bridge stays out of `--help` and isn't dispatchable
762+
// for users who haven't opted in.
763+
if ( isRemoteSessionEnabled() ) {
764+
chain = chain
765+
.option( 'remote-session', {
766+
type: 'boolean',
767+
default: false,
768+
description: __( 'Attach to Telegram and drive studio code remotely' ),
769+
} )
770+
.option( 'remote-chat-id', {
771+
type: 'number',
772+
description: __( 'Override the Telegram chat id to bind to' ),
773+
} )
774+
.option( 'remote-bot', {
775+
type: 'string',
776+
description: __( 'Override the Telegram bot name to use for replies' ),
777+
} )
778+
.option( 'message-from-stdin', {
779+
type: 'boolean',
780+
hidden: true,
781+
default: false,
782+
description: __( 'Read the initial message from stdin (for headless drivers)' ),
783+
} );
784+
}
785+
786+
return chain.check( ( argv ) => {
787+
if ( argv.json && ! argv.message && ! argv.remoteSession && ! argv.messageFromStdin ) {
788+
throw new Error( __( '--json requires an initial message argument' ) );
789+
}
790+
return true;
791+
} );
755792
},
756793
handler: async ( argv ) => {
757794
try {
@@ -762,10 +799,44 @@ export const registerCommand = ( yargs: StudioArgv ) => {
762799
resumeSession?: string;
763800
permissionResponse?: string;
764801
siteName?: string;
802+
remoteSession?: boolean;
803+
remoteChatId?: number;
804+
remoteBot?: string;
805+
messageFromStdin?: boolean;
765806
};
807+
808+
if ( typedArgv.remoteSession && isRemoteSessionEnabled() ) {
809+
try {
810+
await runRemoteSession( {
811+
chat_id: typedArgv.remoteChatId,
812+
bot: typedArgv.remoteBot,
813+
} );
814+
} catch ( error ) {
815+
if ( error instanceof RemoteSessionConfigError ) {
816+
process.stderr.write( `${ error.message }\n` );
817+
process.exitCode = 1;
818+
return;
819+
}
820+
throw error;
821+
}
822+
return;
823+
}
824+
766825
const noSessionPersistence = typedArgv.sessionPersistence === false;
767826
const adapter: AiOutputAdapter = typedArgv.json ? new JsonAdapter() : new AiChatUI();
768827

828+
let initialMessage = typedArgv.message;
829+
if ( typedArgv.messageFromStdin && isRemoteSessionEnabled() ) {
830+
initialMessage = await readAllStdin();
831+
if ( ! initialMessage ) {
832+
process.stderr.write(
833+
`${ __( '--message-from-stdin requires non-empty input on stdin' ) }\n`
834+
);
835+
process.exitCode = 1;
836+
return;
837+
}
838+
}
839+
769840
if ( adapter instanceof JsonAdapter && typedArgv.permissionResponse ) {
770841
adapter.permissionResponse = JSON.parse( typedArgv.permissionResponse ) as Record<
771842
string,
@@ -785,7 +856,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
785856
}
786857
await runCommand( {
787858
adapter,
788-
initialMessage: typedArgv.message,
859+
initialMessage,
789860
resumeSessionId: typedArgv.resumeSession,
790861
noSessionPersistence,
791862
showLegacyCommandNotice: argv._[ 0 ] === 'ai',

‎apps/cli/lib/feature-flags.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* CLI feature flags, read from runtime environment variables.
3+
*
4+
* Convention: `STUDIO_ENABLE_<FEATURE>=true` enables the feature. Any other
5+
* value (including unset) leaves it off. Defaults stay off so that the stable
6+
* CLI experience is unaffected for users who haven't opted in.
7+
*/
8+
9+
export function isRemoteSessionEnabled(): boolean {
10+
return process.env.STUDIO_ENABLE_REMOTE_SESSION === 'true';
11+
}

‎apps/cli/lib/import-export/import/handlers/backup-handler-wpress.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ async function readBlockToFile( fd: fs.promises.FileHandle, header: Header, outp
101101
await fse.ensureDir( path.dirname( outputFilePath ) );
102102
const outputStream = fs.createWriteStream( outputFilePath );
103103

104+
// Resolve once the underlying fd is closed — either after end() flushes or
105+
// after an error destroys the stream. Awaiting this before returning prevents
106+
// the writeStream's lazy open + flush from racing with synchronous existence
107+
// checks in the caller (manifested as a Windows-only test flake; libuv's
108+
// worker happens to flush fast enough on Linux/macOS to mask it).
109+
const closed = new Promise< void >( ( resolve ) => {
110+
outputStream.once( 'close', () => resolve() );
111+
} );
112+
104113
let totalBytesToRead = header.size;
105114
let errored = false;
106115
let streamEnded = false;
@@ -139,6 +148,7 @@ async function readBlockToFile( fd: fs.promises.FileHandle, header: Header, outp
139148
errorHandler();
140149
} finally {
141150
endStream();
151+
await closed;
142152
}
143153
}
144154

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { isRemoteSessionEnabled } from 'cli/lib/feature-flags';
3+
4+
describe( 'isRemoteSessionEnabled', () => {
5+
const originalValue = process.env.STUDIO_ENABLE_REMOTE_SESSION;
6+
7+
beforeEach( () => {
8+
delete process.env.STUDIO_ENABLE_REMOTE_SESSION;
9+
} );
10+
11+
afterEach( () => {
12+
if ( originalValue === undefined ) {
13+
delete process.env.STUDIO_ENABLE_REMOTE_SESSION;
14+
} else {
15+
process.env.STUDIO_ENABLE_REMOTE_SESSION = originalValue;
16+
}
17+
} );
18+
19+
it( 'returns false when the env var is unset', () => {
20+
expect( isRemoteSessionEnabled() ).toBe( false );
21+
} );
22+
23+
it( 'returns true only for the literal string "true"', () => {
24+
process.env.STUDIO_ENABLE_REMOTE_SESSION = 'true';
25+
expect( isRemoteSessionEnabled() ).toBe( true );
26+
} );
27+
28+
it.each( [ '1', 'TRUE', 'yes', 'on', '' ] )(
29+
'returns false for non-canonical truthy-looking values: %s',
30+
( value ) => {
31+
process.env.STUDIO_ENABLE_REMOTE_SESSION = value;
32+
expect( isRemoteSessionEnabled() ).toBe( false );
33+
}
34+
);
35+
} );

‎apps/cli/remote-session/config.ts‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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

Comments
 (0)