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
33 changes: 25 additions & 8 deletions packages/happy-app/sources/sync/apiSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,32 +112,46 @@ class ApiSocket {
* RPC call for sessions - uses session-specific encryption
*/
async sessionRPC<R, A>(sessionId: string, method: string, params: A): Promise<R> {
const sessionEncryption = this.encryption!.getSessionEncryption(sessionId);
const socket = this.socket;
if (!socket) {
throw new Error('Socket not connected');
}
if (!this.encryption) {
throw new Error('Encryption not initialized');
}
const sessionEncryption = this.encryption.getSessionEncryption(sessionId);
if (!sessionEncryption) {
throw new Error(`Session encryption not found for ${sessionId}`);
}
const result = await this.socket!.emitWithAck('rpc-call', {

const result = await socket.emitWithAck('rpc-call', {
method: `${sessionId}:${method}`,
params: await sessionEncryption.encryptRaw(params)
});

if (result.ok) {
return await sessionEncryption.decryptRaw(result.result) as R;
}
throw new Error('RPC call failed');
throw new Error(result.error || 'RPC call failed');
}

/**
* RPC call for machines - uses legacy/global encryption (for now)
*/
async machineRPC<R, A>(machineId: string, method: string, params: A): Promise<R> {
const machineEncryption = this.encryption!.getMachineEncryption(machineId);
const socket = this.socket;
if (!socket) {
throw new Error('Socket not connected');
}
if (!this.encryption) {
throw new Error('Encryption not initialized');
}
const machineEncryption = this.encryption.getMachineEncryption(machineId);
if (!machineEncryption) {
throw new Error(`Machine encryption not found for ${machineId}`);
}

const result = await this.socket!.emitWithAck('rpc-call', {
const result = await socket.emitWithAck('rpc-call', {
method: `${machineId}:${method}`,
params: await machineEncryption.encryptRaw(params)
});
Expand All @@ -149,7 +163,10 @@ class ApiSocket {
}

send(event: string, data: any) {
this.socket!.emit(event, data);
if (!this.socket) {
throw new Error('Socket not connected');
}
this.socket.emit(event, data);
return true;
}

Expand Down
27 changes: 24 additions & 3 deletions packages/happy-cli/src/claude/utils/startHappyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,25 +77,46 @@ export async function startHappyServer(client: ApiSessionClient) {
const server = createServer(async (req, res) => {
const mcp = createMcpServer(handler);
try {
// Pre-parse body to avoid @hono/node-server stream conversion issues
let parsedBody: unknown;
if (req.method === 'POST') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
try {
parsedBody = JSON.parse(Buffer.concat(chunks).toString());
Comment on lines +83 to +88
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request body is fully buffered into memory with no size limit before parsing. Even though the server binds to 127.0.0.1, a local client can still send an arbitrarily large POST and cause high memory usage. Add a maximum body size (and return 413 / abort reading) to prevent accidental or malicious oversized requests from impacting the CLI process.

Copilot uses AI. Check for mistakes.
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }));
mcp.close();
return;
}
}

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined
});
await mcp.connect(transport);
await transport.handleRequest(req, res);
await transport.handleRequest(req, res, parsedBody);
res.on('close', () => {
transport.close();
mcp.close();
});
} catch (error) {
logger.debug("Error handling request:", error);
logger.debug("[happyMCP] Error handling request:", error);
if (!res.headersSent) {
res.writeHead(500).end();
}
mcp.close();
}
});

const baseUrl = await new Promise<URL>((resolve) => {
const baseUrl = await new Promise<URL>((resolve, reject) => {
server.on('error', (err) => {
logger.debug("[happyMCP] server:error", err);
reject(err);
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
Expand Down
45 changes: 36 additions & 9 deletions packages/happy-cli/src/codex/happyMcpStdioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,42 @@ async function main() {
}

let httpClient: Client | null = null;
let connectPromise: Promise<Client> | null = null;

async function ensureHttpClient(): Promise<Client> {
if (httpClient) return httpClient;
const client = new Client(
{ name: 'happy-stdio-bridge', version: '1.0.0' },
{ capabilities: {} }
);
if (connectPromise) return connectPromise;

connectPromise = (async () => {
const client = new Client(
{ name: 'happy-stdio-bridge', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(baseUrl));
try {
transport.onclose = () => {
if (httpClient === client) {
httpClient = null;
}
};
await client.connect(transport);
client.onclose = () => {
if (httpClient === client) {
httpClient = null;
}
};
httpClient = client;
return client;
} catch (error) {
// Clean up on connect failure to avoid leaking SSE streams/timers
try { await client.close(); } catch { /* ignore */ }
throw error;
} finally {
connectPromise = null;
}
})();

const transport = new StreamableHTTPClientTransport(new URL(baseUrl));
await client.connect(transport);
httpClient = client;
return client;
return connectPromise;
}

// Create STDIO MCP server
Expand All @@ -77,9 +101,12 @@ async function main() {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'change_title', arguments: args });
// Pass-through response from HTTP server
return response as any;
} catch (error) {
// Clean up stale client so the next invocation reconnects
const staleClient = httpClient;
httpClient = null;
try { await staleClient?.close(); } catch { /* ignore close errors */ }
return {
content: [
{ type: 'text', text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
Expand Down