Skip to content
Merged
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
66 changes: 57 additions & 9 deletions src/main/services/RemotePtyService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { EventEmitter } from 'events';
import { SshService } from './ssh/SshService';
import { quoteShellArg, isValidEnvVarName } from '../utils/shellEscape';
import { waitForShellPrompt, PromptWaitHandle } from '../utils/waitForShellPrompt';
import { log } from '../lib/logger';

export interface RemotePtyOptions {
id: string;
Expand Down Expand Up @@ -50,6 +52,7 @@ const ALLOWED_SHELLS = new Set([
*/
export class RemotePtyService extends EventEmitter {
private ptys: Map<string, RemotePty> = new Map();
private promptHandles: Map<string, PromptWaitHandle[]> = new Map();

constructor(private sshService: SshService) {
super();
Expand Down Expand Up @@ -81,7 +84,7 @@ export class RemotePtyService extends EventEmitter {
// Validate env var keys to prevent injection (CRITICAL #1)
const envEntries = Object.entries(options.env || {}).filter(([k]) => {
if (!isValidEnvVarName(k)) {
console.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`);
log.warn(`[RemotePtyService] Skipping invalid env var name: ${k}`);
return false;
}
return true;
Expand All @@ -94,6 +97,7 @@ export class RemotePtyService extends EventEmitter {
// Validate shell against allowlist (HIGH #5)
const shellBinary = options.shell.split(/\s+/)[0];
if (!ALLOWED_SHELLS.has(shellBinary)) {
stream.close();
reject(
new Error(
`Shell not allowed: ${shellBinary}. Allowed: ${[...ALLOWED_SHELLS].join(', ')}`
Expand All @@ -106,15 +110,49 @@ export class RemotePtyService extends EventEmitter {
.filter(Boolean)
.join(' && ');

// Send initial command
stream.write(fullCommand + '\n');
const sshSubscribe = (cb: (chunk: string) => void) => {
const handler = (data: Buffer) => cb(data.toString());
stream.on('data', handler);
return () => {
stream.removeListener('data', handler);
};
};

// Send initial prompt if provided
if (options.initialPrompt) {
setTimeout(() => {
stream.write(options.initialPrompt + '\n');
}, 500);
}
const handles: PromptWaitHandle[] = [];
this.promptHandles.set(options.id, handles);

handles.push(
waitForShellPrompt({
subscribe: sshSubscribe,
write: (d) => {
if (options.initialPrompt) {
handles.push(
waitForShellPrompt({
subscribe: sshSubscribe,
write: (d2) => {
stream.write(d2);
this.promptHandles.delete(options.id);
},
data: options.initialPrompt + '\n',
onTimeout: () =>
log.warn(
'[RemotePtyService] Agent prompt not detected, sending initial prompt anyway'
),
})
);
}
stream.write(d);
if (!options.initialPrompt) {
this.promptHandles.delete(options.id);
}
},
data: fullCommand + '\n',
onTimeout: () =>
log.warn(
'[RemotePtyService] Shell prompt not detected, sending setup commands anyway'
),
})
);

const pty: RemotePty = {
id: options.id,
Expand All @@ -129,6 +167,7 @@ export class RemotePtyService extends EventEmitter {
this.ptys.set(options.id, pty);

stream.on('close', () => {
this.cancelPromptHandles(options.id);
this.ptys.delete(options.id);
this.emit('exit', options.id);
});
Expand Down Expand Up @@ -165,6 +204,14 @@ export class RemotePtyService extends EventEmitter {
}
}

private cancelPromptHandles(ptyId: string): void {
const handles = this.promptHandles.get(ptyId);
if (handles) {
for (const h of handles) h.cancel();
this.promptHandles.delete(ptyId);
}
}

/**
* Kills a remote PTY session.
*
Expand All @@ -173,6 +220,7 @@ export class RemotePtyService extends EventEmitter {
kill(ptyId: string): void {
const pty = this.ptys.get(ptyId);
if (pty) {
this.cancelPromptHandles(ptyId);
pty.kill();
this.ptys.delete(ptyId);
}
Expand Down
45 changes: 43 additions & 2 deletions src/main/services/ptyIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,48 @@ import { createHash, randomUUID } from 'crypto';
import path from 'path';
import { quoteShellArg } from '../utils/shellEscape';
import { agentEventService } from './AgentEventService';
import { waitForShellPrompt, type PromptWaitHandle } from '../utils/waitForShellPrompt';

const owners = new Map<string, WebContents>();
const listeners = new Set<string>();
const promptHandles = new Map<string, PromptWaitHandle[]>();

function cancelPromptHandles(id: string): void {
const handles = promptHandles.get(id);
if (handles) {
for (const h of handles) h.cancel();
promptHandles.delete(id);
}
}

function waitForSshPromptThenWrite(
id: string,
proc: {
onData: (cb: (data: string) => void) => { dispose: () => void };
write: (data: string) => void;
},
data: string,
label: string
): void {
const handles = promptHandles.get(id) ?? [];
promptHandles.set(id, handles);

const handle = waitForShellPrompt({
subscribe: (cb) => {
const disposable = proc.onData(cb);
return () => disposable.dispose();
},
write: (d) => {
proc.write(d);
promptHandles.delete(id);
},
data,
onTimeout: () =>
log.warn(`${label} SSH shell prompt not detected, writing init commands anyway`, { id }),
});
handles.push(handle);
}

const providerPtyTimers = new Map<string, number>();
// Map PTY IDs to provider IDs for multi-agent tracking
const ptyProviderMap = new Map<string, ProviderId>();
Expand Down Expand Up @@ -481,6 +520,7 @@ export function registerPtyIpc(): void {
bufferedSendPtyData(id, data);
});
proc.onExit(({ exitCode, signal }) => {
cancelPromptHandles(id);
flushPtyData(id);
clearPtyData(id);
safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal });
Expand All @@ -497,7 +537,7 @@ export function registerPtyIpc(): void {

const remoteInit = buildRemoteInitKeystrokes({ cwd, tmux: remoteTmuxOpt });
if (remoteInit) {
proc.write(remoteInit);
waitForSshPromptThenWrite(id, proc, remoteInit, 'ptyIpc:start');
}

try {
Expand Down Expand Up @@ -1002,6 +1042,7 @@ export function registerPtyIpc(): void {
bufferedSendPtyData(id, data);
});
proc.onExit(({ exitCode, signal }) => {
cancelPromptHandles(id);
flushPtyData(id);
clearPtyData(id);
safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal });
Expand All @@ -1024,7 +1065,7 @@ export function registerPtyIpc(): void {
preProviderCommands: preProviderCommands.length ? preProviderCommands : undefined,
});
if (remoteInit) {
proc.write(remoteInit);
waitForSshPromptThenWrite(id, proc, remoteInit, 'ptyIpc:startDirect');
}

maybeMarkProviderStart(id);
Expand Down
Loading