Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 79 additions & 8 deletions apps/cli/commands/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import { AiChatUI } from 'cli/ai/ui';
import { runCommand as runLoginCommand } from 'cli/commands/auth/login';
import { readCliConfig } from 'cli/lib/cli-config/core';
import { findSiteByFolder } from 'cli/lib/cli-config/sites';
import { isRemoteSessionEnabled } from 'cli/lib/feature-flags';
import { Logger, LoggerError, setProgressCallback } from 'cli/logger';
import { RemoteSessionConfigError, runRemoteSession } from 'cli/remote-session';
import { StudioArgv } from 'cli/types';

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

async function readAllStdin(): Promise< string > {
const chunks: Buffer[] = [];
for await ( const chunk of process.stdin ) {
chunks.push( typeof chunk === 'string' ? Buffer.from( chunk ) : ( chunk as Buffer ) );
}
return Buffer.concat( chunks ).toString( 'utf8' ).trim();
}

export async function runCommand( options: {
adapter: AiOutputAdapter;
initialMessage?: string;
Expand Down Expand Up @@ -713,7 +723,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
command: '$0 [message]',
describe: __( 'AI agent for building WordPress' ),
builder: ( yargs ) => {
return yargs
let chain = yargs
.positional( 'message', {
type: 'string',
description: __( 'Initial message to send to the AI agent' ),
Expand Down Expand Up @@ -745,13 +755,40 @@ export const registerCommand = ( yargs: StudioArgv ) => {
type: 'boolean',
default: true,
description: __( 'Record this code session to disk' ),
} )
.check( ( argv ) => {
if ( argv.json && ! argv.message ) {
throw new Error( __( '--json requires an initial message argument' ) );
}
return true;
} );

// Remote-session options are gated behind STUDIO_ENABLE_REMOTE_SESSION so the
// experimental Telegram bridge stays out of `--help` and isn't dispatchable
// for users who haven't opted in.
if ( isRemoteSessionEnabled() ) {
chain = chain
.option( 'remote-session', {
type: 'boolean',
default: false,
description: __( 'Attach to Telegram and drive studio code remotely' ),
} )
.option( 'remote-chat-id', {
type: 'number',
description: __( 'Override the Telegram chat id to bind to' ),
} )
.option( 'remote-bot', {
type: 'string',
description: __( 'Override the Telegram bot name to use for replies' ),
} )
.option( 'message-from-stdin', {
type: 'boolean',
hidden: true,
default: false,
description: __( 'Read the initial message from stdin (for headless drivers)' ),
} );
}

return chain.check( ( argv ) => {
if ( argv.json && ! argv.message && ! argv.remoteSession && ! argv.messageFromStdin ) {
throw new Error( __( '--json requires an initial message argument' ) );
}
return true;
} );
},
handler: async ( argv ) => {
try {
Expand All @@ -762,10 +799,44 @@ export const registerCommand = ( yargs: StudioArgv ) => {
resumeSession?: string;
permissionResponse?: string;
siteName?: string;
remoteSession?: boolean;
remoteChatId?: number;
remoteBot?: string;
messageFromStdin?: boolean;
};

if ( typedArgv.remoteSession && isRemoteSessionEnabled() ) {
try {
await runRemoteSession( {
chat_id: typedArgv.remoteChatId,
bot: typedArgv.remoteBot,
} );
} catch ( error ) {
if ( error instanceof RemoteSessionConfigError ) {
process.stderr.write( `${ error.message }\n` );
process.exitCode = 1;
return;
}
throw error;
}
return;
}

const noSessionPersistence = typedArgv.sessionPersistence === false;
const adapter: AiOutputAdapter = typedArgv.json ? new JsonAdapter() : new AiChatUI();

let initialMessage = typedArgv.message;
if ( typedArgv.messageFromStdin && isRemoteSessionEnabled() ) {
initialMessage = await readAllStdin();
if ( ! initialMessage ) {
process.stderr.write(
`${ __( '--message-from-stdin requires non-empty input on stdin' ) }\n`
);
process.exitCode = 1;
return;
}
}

if ( adapter instanceof JsonAdapter && typedArgv.permissionResponse ) {
adapter.permissionResponse = JSON.parse( typedArgv.permissionResponse ) as Record<
string,
Expand All @@ -785,7 +856,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
}
await runCommand( {
adapter,
initialMessage: typedArgv.message,
initialMessage,
resumeSessionId: typedArgv.resumeSession,
noSessionPersistence,
showLegacyCommandNotice: argv._[ 0 ] === 'ai',
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/lib/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* CLI feature flags, read from runtime environment variables.
*
* Convention: `STUDIO_ENABLE_<FEATURE>=true` enables the feature. Any other
* value (including unset) leaves it off. Defaults stay off so that the stable
* CLI experience is unaffected for users who haven't opted in.
*/

export function isRemoteSessionEnabled(): boolean {
return process.env.STUDIO_ENABLE_REMOTE_SESSION === 'true';
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ async function readBlockToFile( fd: fs.promises.FileHandle, header: Header, outp
await fse.ensureDir( path.dirname( outputFilePath ) );
const outputStream = fs.createWriteStream( outputFilePath );

// Resolve once the underlying fd is closed — either after end() flushes or
// after an error destroys the stream. Awaiting this before returning prevents
// the writeStream's lazy open + flush from racing with synchronous existence
// checks in the caller (manifested as a Windows-only test flake; libuv's
// worker happens to flush fast enough on Linux/macOS to mask it).
const closed = new Promise< void >( ( resolve ) => {
outputStream.once( 'close', () => resolve() );
} );

let totalBytesToRead = header.size;
let errored = false;
let streamEnded = false;
Expand Down Expand Up @@ -139,6 +148,7 @@ async function readBlockToFile( fd: fs.promises.FileHandle, header: Header, outp
errorHandler();
} finally {
endStream();
await closed;
}
}

Expand Down
35 changes: 35 additions & 0 deletions apps/cli/lib/tests/feature-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isRemoteSessionEnabled } from 'cli/lib/feature-flags';

describe( 'isRemoteSessionEnabled', () => {
const originalValue = process.env.STUDIO_ENABLE_REMOTE_SESSION;

beforeEach( () => {
delete process.env.STUDIO_ENABLE_REMOTE_SESSION;
} );

afterEach( () => {
if ( originalValue === undefined ) {
delete process.env.STUDIO_ENABLE_REMOTE_SESSION;
} else {
process.env.STUDIO_ENABLE_REMOTE_SESSION = originalValue;
}
} );

it( 'returns false when the env var is unset', () => {
expect( isRemoteSessionEnabled() ).toBe( false );
} );

it( 'returns true only for the literal string "true"', () => {
process.env.STUDIO_ENABLE_REMOTE_SESSION = 'true';
expect( isRemoteSessionEnabled() ).toBe( true );
} );

it.each( [ '1', 'TRUE', 'yes', 'on', '' ] )(
'returns false for non-canonical truthy-looking values: %s',
( value ) => {
process.env.STUDIO_ENABLE_REMOTE_SESSION = value;
expect( isRemoteSessionEnabled() ).toBe( false );
}
);
} );
146 changes: 146 additions & 0 deletions apps/cli/remote-session/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import fs from 'fs';
import { readAuthToken } from '@studio/common/lib/shared-config';
import { getRemoteSessionConfigPath } from '@studio/common/lib/well-known-paths';
import { readFile, writeFile } from 'atomically';
import { z } from 'zod';

export const remoteSessionConfigSchema = z.object( {
base_url: z.string().url().default( 'https://public-api.wordpress.com/wpcom/v2/telegram-bot' ),
// `token` is the only required field; if omitted everywhere, falls back to the
// WordPress.com OAuth access token from ~/.studio/shared.json (i.e. /login).
token: z.string().min( 1 ),
// Optional pinning. When set, `chat_id` filters polled messages and `bot` overrides
// the per-message bot identity for outgoing replies. When unset, the controller
// trusts the server's per-token authorization and echoes chat_id + bot from each
// polled message back into the response.
bot: z.string().min( 1 ).optional(),
chat_id: z.number().int().optional(),
poll_interval_seconds: z.number().positive().default( 2 ),
long_poll_timeout_seconds: z.number().positive().default( 25 ),
max_message_chars: z.number().int().positive().max( 4096 ).default( 3800 ),
turn_timeout_seconds: z.number().positive().default( 900 ),
} );

export type RemoteSessionConfig = z.infer< typeof remoteSessionConfigSchema >;

export interface RemoteSessionOverrides {
token?: string;
bot?: string;
chat_id?: number;
base_url?: string;
}

export class RemoteSessionConfigError extends Error {
constructor(
message: string,
public readonly missingFields: string[] = []
) {
super( message );
this.name = 'RemoteSessionConfigError';
}
}

function readEnvOverrides(): RemoteSessionOverrides {
const overrides: RemoteSessionOverrides = {};
if ( process.env.STUDIO_REMOTE_BASE_URL ) {
overrides.base_url = process.env.STUDIO_REMOTE_BASE_URL;
}
if ( process.env.STUDIO_REMOTE_TOKEN ) {
overrides.token = process.env.STUDIO_REMOTE_TOKEN;
}
if ( process.env.STUDIO_REMOTE_BOT ) {
overrides.bot = process.env.STUDIO_REMOTE_BOT;
}
if ( process.env.STUDIO_REMOTE_CHAT_ID ) {
const parsed = Number( process.env.STUDIO_REMOTE_CHAT_ID );
if ( Number.isFinite( parsed ) ) {
overrides.chat_id = parsed;
}
}
return overrides;
}

async function readConfigFile(): Promise< Record< string, unknown > > {
const configPath = getRemoteSessionConfigPath();
if ( ! fs.existsSync( configPath ) ) {
return {};
}
try {
const content = await readFile( configPath, { encoding: 'utf8' } );
const parsed = JSON.parse( content );
return parsed && typeof parsed === 'object' ? ( parsed as Record< string, unknown > ) : {};
} catch {
return {};
}
}

/**
* Load the remote-session config. Resolution order:
* 1. CLI overrides
* 2. Env vars (STUDIO_REMOTE_*)
* 3. ~/.studio/remote-session.json
* 4. For `token` only: fall back to the WordPress.com OAuth access token from
* ~/.studio/shared.json (the same token populated by `studio code` /login).
*
* Throws RemoteSessionConfigError listing missing fields when required values are absent.
*/
export async function loadRemoteSessionConfig(
cliOverrides: RemoteSessionOverrides = {}
): Promise< RemoteSessionConfig > {
const fileValues = await readConfigFile();
const envOverrides = readEnvOverrides();
const merged: Record< string, unknown > = {
...fileValues,
...envOverrides,
...cliOverrides,
};

if ( typeof merged.token !== 'string' || merged.token.length === 0 ) {
const stored = await readAuthToken();
if ( stored?.accessToken ) {
merged.token = stored.accessToken;
}
}

const parseResult = remoteSessionConfigSchema.safeParse( merged );
if ( parseResult.success ) {
return parseResult.data;
}

const missing: string[] = [];
for ( const issue of parseResult.error.issues ) {
const field = issue.path[ 0 ];
if ( typeof field === 'string' && ! missing.includes( field ) ) {
missing.push( field );
}
}
const required = [ 'token' ].filter( ( f ) => missing.includes( f ) );
if ( required.length > 0 ) {
throw new RemoteSessionConfigError(
`Remote session needs a bearer token to authenticate to ${ '/local-agent-poll' }. ` +
`Run \`studio code\` and \`/login\` to authenticate to WordPress.com, ` +
`or set STUDIO_REMOTE_TOKEN, or put a "token" field in ~/.studio/remote-session.json.`,
required
);
}
throw new RemoteSessionConfigError(
`Remote session config is invalid: ${ parseResult.error.issues
.map( ( i ) => `${ i.path.join( '.' ) }: ${ i.message }` )
.join( '; ' ) }`,
[]
);
}

/**
* Write the config file with mode 0600. Used by ops/tests; the runtime path never writes config.
*/
export async function saveRemoteSessionConfig( config: RemoteSessionConfig ): Promise< void > {
const configPath = getRemoteSessionConfigPath();
const content = JSON.stringify( config, null, 2 ) + '\n';
await writeFile( configPath, content, { encoding: 'utf8', mode: 0o600 } );
try {
await fs.promises.chmod( configPath, 0o600 );
} catch {
// chmod can fail on Windows; atomically already set mode on create.
}
}
Loading
Loading