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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/cli/remote-session/config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
import fs from 'fs';
import { hostname } from 'os';
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';

/**
* Sanitize the host's name down to the wpcom-friendly charset
* (`[a-z0-9_]`, max 32 chars) so it's safe to use as a `machine_id` cache key
* suffix without escaping. Falls back to `unknown_host` if `os.hostname()`
* yields nothing usable.
*/
function deriveMachineId(): string {
const sanitized = hostname()
.toLowerCase()
.replace( /[^a-z0-9_]/g, '_' )
.replace( /^_+|_+$/g, '' )
.slice( 0, 32 );
return sanitized || 'unknown_host';
}

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
Expand All @@ -15,6 +31,14 @@ export const remoteSessionConfigSchema = z.object( {
// polled message back into the response.
bot: z.string().min( 1 ).optional(),
chat_id: z.number().int().optional(),
// Stable identifier for this CLI host, sent as `machine_id` on outbound
// replies to studio-mobile bots. Defaults to a sanitized `os.hostname()`;
// override via `STUDIO_REMOTE_MACHINE_ID` if multiple installs on the same
// host need to be distinguished (or to pin a friendly name for tests).
machine_id: z
.string()
.regex( /^[a-z0-9_]{1,32}$/, 'machine_id must match [a-z0-9_]{1,32}' )
.default( deriveMachineId ),
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 ),
Expand All @@ -28,6 +52,7 @@ export interface RemoteSessionOverrides {
bot?: string;
chat_id?: number;
base_url?: string;
machine_id?: string;
}

export class RemoteSessionConfigError extends Error {
Expand Down Expand Up @@ -57,6 +82,9 @@ function readEnvOverrides(): RemoteSessionOverrides {
overrides.chat_id = parsed;
}
}
if ( process.env.STUDIO_REMOTE_MACHINE_ID ) {
overrides.machine_id = process.env.STUDIO_REMOTE_MACHINE_ID;
}
return overrides;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/remote-session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { installDaemonChildHooks, isDaemonChild } from 'cli/remote-session/daemon';
import { RemoteSessionLogger } from 'cli/remote-session/logger';
import { runPollLoop } from 'cli/remote-session/poll-loop';
import { respondMessage } from 'cli/remote-session/telegram-client';
import { respondMessage } from 'cli/remote-session/respond-router';

export { RemoteSessionConfigError };

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/remote-session/media-streamer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JsonEvent } from '@studio/common/ai/json-events';
import type { RemoteSessionConfig } from 'cli/remote-session/config';
import type { RemoteSessionLogger } from 'cli/remote-session/logger';
import type { respondMessage } from 'cli/remote-session/telegram-client';
import type { respondMessage } from 'cli/remote-session/respond-router';

interface ValidMediaShare {
dataBase64: string;
Expand Down
24 changes: 12 additions & 12 deletions apps/cli/remote-session/poll-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { type RemoteSessionConfig } from 'cli/remote-session/config';
import { RemoteSessionLogger } from 'cli/remote-session/logger';
import { MediaStreamer } from 'cli/remote-session/media-streamer';
import { ProgressStreamer } from 'cli/remote-session/progress-streamer';
import {
RemoteAuthError,
RemoteBadRequestError,
RemoteTransientError,
} from 'cli/remote-session/remote-http';
import { chunkReply, extractReply } from 'cli/remote-session/reply-formatter';
import { respondMessage } from 'cli/remote-session/respond-router';
import { clearSessionId, readStateForChat, writeSessionId } from 'cli/remote-session/state';
import {
TelegramAuthError,
TelegramBadRequestError,
TelegramTransientError,
pollMessages,
respondMessage,
} from 'cli/remote-session/telegram-client';
import { pollMessages } from 'cli/remote-session/telegram-client';
import { runTurn, type TurnOutcome, type TurnRunOptions } from 'cli/remote-session/turn-runner';

/** Injected for tests. */
Expand Down Expand Up @@ -403,7 +403,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
batch = await deps.poll( config, abortController.signal, { logger: deps.logger } );
backoffAttempt = 0;
} catch ( error ) {
if ( error instanceof TelegramAuthError ) {
if ( error instanceof RemoteAuthError ) {
deps.logger.error( 'Auth error; detaching', { status: error.status } );
if ( config.chat_id !== undefined ) {
await postBestEffort(
Expand All @@ -418,7 +418,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
process.exitCode = 1;
break;
}
if ( error instanceof TelegramTransientError ) {
if ( error instanceof RemoteTransientError ) {
const delay = Math.min( 30_000, 1000 * Math.pow( 2, backoffAttempt ) );
deps.logger.warn( 'Transient poll error; backing off', {
status: error.status,
Expand Down Expand Up @@ -506,7 +506,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
{ logger: deps.logger }
);
} catch ( error ) {
if ( error instanceof TelegramBadRequestError ) {
if ( error instanceof RemoteBadRequestError ) {
deps.logger.warn( 'Respond 4xx on /new ack', { status: error.status } );
} else {
throw error;
Expand All @@ -525,7 +525,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
telemetryEnabled
);
} catch ( error ) {
if ( error instanceof TelegramAuthError ) {
if ( error instanceof RemoteAuthError ) {
deps.logger.error( 'Auth error during respond; detaching', {
status: error.status,
} );
Expand All @@ -534,7 +534,7 @@ export async function runPollLoop( options: RunPollLoopOptions ): Promise< PollL
process.exitCode = 1;
break batchLoop;
}
if ( error instanceof TelegramBadRequestError ) {
if ( error instanceof RemoteBadRequestError ) {
deps.logger.warn( 'Respond 4xx; dropping chunk', { status: error.status } );
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/remote-session/progress-streamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import type { JsonEvent } from '@studio/common/ai/json-events';
import type { RemoteSessionConfig } from 'cli/remote-session/config';
import type { RemoteSessionLogger } from 'cli/remote-session/logger';
import type { respondMessage } from 'cli/remote-session/telegram-client';
import type { respondMessage } from 'cli/remote-session/respond-router';

export interface ProgressTarget {
chatId: number;
Expand Down
84 changes: 84 additions & 0 deletions apps/cli/remote-session/remote-http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Transport-layer primitives shared by the Telegram and studio-mobile clients
* — error classes, URL helpers, and fetch/backoff utilities. None of this
* knows about message shapes; it just gets bytes to and from the wpcom proxy.
*/

export class RemoteAuthError extends Error {
constructor( public readonly status: number ) {
super( `Remote server returned auth error (HTTP ${ status })` );
this.name = 'RemoteAuthError';
}
}

export class RemoteTransientError extends Error {
constructor(
message: string,
public readonly status?: number
) {
super( message );
this.name = 'RemoteTransientError';
}
}

export class RemoteBadRequestError extends Error {
constructor(
message: string,
public readonly status: number
) {
super( message );
this.name = 'RemoteBadRequestError';
}
}

export function assertSameHost( urlString: string, allowedHost: string ): void {
const u = new URL( urlString );
if ( u.host !== allowedHost ) {
throw new RemoteTransientError(
`Refusing to follow redirect to different host: ${ u.host } (allowed: ${ allowedHost })`
);
}
}

function normalizeBase( base: string ): URL {
// Ensure a trailing slash so relative path joins work predictably.
return new URL( base.endsWith( '/' ) ? base : `${ base }/` );
}

export function buildUrl( baseUrl: string, pathName: string ): string {
const base = normalizeBase( baseUrl );
const joined = new URL( pathName.replace( /^\//, '' ), base );
return joined.toString();
}

export async function safeReadText( response: Response ): Promise< string > {
try {
return await response.text();
} catch {
return '';
}
}

export async function backoff( attempt: number ): Promise< void > {
const baseMs = Math.min( 30_000, 500 * Math.pow( 2, attempt ) );
const jitter = Math.random() * 200;
await new Promise( ( resolve ) => setTimeout( resolve, baseMs + jitter ) );
}

export function composeSignals( a?: AbortSignal, b?: AbortSignal ): AbortSignal | undefined {
if ( ! a ) {
return b;
}
if ( ! b ) {
return a;
}
const controller = new AbortController();
const onAbort = () => controller.abort();
if ( a.aborted || b.aborted ) {
controller.abort();
} else {
a.addEventListener( 'abort', onAbort, { once: true } );
b.addEventListener( 'abort', onAbort, { once: true } );
}
return controller.signal;
}
Loading