Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
42 changes: 33 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,39 @@ 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 () => {
try {
const client = new Client(
{ name: 'happy-stdio-bridge', version: '1.0.0' },
{ capabilities: {} }
);
const transport = new StreamableHTTPClientTransport(new URL(baseUrl));
transport.onclose = () => {
// Reset client on transport close so next call reconnects
if (httpClient === client) {
httpClient = null;
}
};
await client.connect(transport);
client.onclose = () => {
Comment on lines +52 to +65
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.

In ensureHttpClient(), if client.connect(transport) throws, the newly created Client/transport are never closed (the reference is lost when the promise rejects). That can leave behind open SSE streams/timers in failure cases. Consider wrapping the connect in a try/catch and explicitly close()/dispose the client (and/or transport) before rethrowing so retries don't accumulate resources.

Copilot uses AI. Check for mistakes.
if (httpClient === client) {
httpClient = null;
}
};
httpClient = client;
return client;
} 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 +98,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
Loading