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
162 changes: 117 additions & 45 deletions apps/hook/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* Plannotator Ephemeral Server
*
* Spawned by ExitPlanMode hook to serve Plannotator UI and handle approve/deny decisions.
* Uses random port to support multiple concurrent Claude Code sessions.
* Supports both local and SSH remote sessions.
*
* Environment variables:
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 over SSH)
*
* Reads hook event from stdin, extracts plan content, serves UI, returns decision.
*/
Expand All @@ -12,6 +15,34 @@ import { $ } from "bun";
// Embed the built HTML at compile time
import indexHtml from "../dist/index.html" with { type: "text" };

// --- SSH Detection and Port Configuration ---

const DEFAULT_SSH_PORT = 19432;

function isSSHSession(): boolean {
// SSH_TTY is set when SSH allocates a pseudo-terminal
// SSH_CONNECTION contains "client_ip client_port server_ip server_port"
return !!(process.env.SSH_TTY || process.env.SSH_CONNECTION);
}

function getServerPort(): number {
// Explicit port from environment takes precedence
const envPort = process.env.PLANNOTATOR_PORT;
if (envPort) {
const parsed = parseInt(envPort, 10);
if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
return parsed;
}
console.error(`Warning: Invalid PLANNOTATOR_PORT "${envPort}", using default`);
}

// Over SSH, use fixed port for port forwarding; locally use random
return isSSHSession() ? DEFAULT_SSH_PORT : 0;
}

const isRemote = isSSHSession();
const configuredPort = getServerPort();

// Read hook event from stdin
const eventJson = await Bun.stdin.text();

Expand All @@ -35,56 +66,97 @@ const decisionPromise = new Promise<{ approved: boolean; feedback?: string }>(
(resolve) => { resolveDecision = resolve; }
);

const server = Bun.serve({
port: 0, // Random available port - critical for multi-instance support

async fetch(req) {
const url = new URL(req.url);

// API: Get plan content
if (url.pathname === "/api/plan") {
return Response.json({ plan: planContent });
}

// API: Approve plan
if (url.pathname === "/api/approve" && req.method === "POST") {
resolveDecision({ approved: true });
return Response.json({ ok: true });
}

// API: Deny with feedback
if (url.pathname === "/api/deny" && req.method === "POST") {
try {
const body = await req.json() as { feedback?: string };
resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
} catch {
resolveDecision({ approved: false, feedback: "Plan rejected by user" });
// --- Server with port conflict handling ---

const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 500;

async function startServer(): Promise<ReturnType<typeof Bun.serve>> {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return Bun.serve({
port: configuredPort,

async fetch(req) {
const url = new URL(req.url);

// API: Get plan content
if (url.pathname === "/api/plan") {
return Response.json({ plan: planContent });
}

// API: Approve plan
if (url.pathname === "/api/approve" && req.method === "POST") {
resolveDecision({ approved: true });
return Response.json({ ok: true });
}

// API: Deny with feedback
if (url.pathname === "/api/deny" && req.method === "POST") {
try {
const body = await req.json() as { feedback?: string };
resolveDecision({ approved: false, feedback: body.feedback || "Plan rejected by user" });
} catch {
resolveDecision({ approved: false, feedback: "Plan rejected by user" });
}
return Response.json({ ok: true });
}

// Serve embedded HTML for all other routes (SPA)
return new Response(indexHtml, {
headers: { "Content-Type": "text/html" }
});
},
});
} catch (err: unknown) {
const isAddressInUse = err instanceof Error && err.message.includes("EADDRINUSE");
if (isAddressInUse && attempt < MAX_RETRIES) {
console.error(`Port ${configuredPort} in use, retrying in ${RETRY_DELAY_MS}ms... (${attempt}/${MAX_RETRIES})`);
await Bun.sleep(RETRY_DELAY_MS);
continue;
}
if (isAddressInUse) {
console.error(`\nError: Port ${configuredPort} is already in use after ${MAX_RETRIES} retries.`);
if (isRemote) {
console.error(`Another Plannotator session may be running.`);
console.error(`To use a different port, set PLANNOTATOR_PORT environment variable.\n`);
}
process.exit(1);
}
return Response.json({ ok: true });
throw err;
}
}
throw new Error("Unreachable");
}

// Serve embedded HTML for all other routes (SPA)
return new Response(indexHtml, {
headers: { "Content-Type": "text/html" }
});
},
});
const server = await startServer();

// Open browser - cross-platform support
const url = `http://localhost:${server.port}`;
console.error(`Plannotator server running on ${url}`);
// --- Conditional browser opening and messaging ---

try {
const platform = process.platform;
if (platform === "win32") {
await $`cmd /c start ${url}`.quiet();
} else if (platform === "darwin") {
await $`open ${url}`.quiet();
} else {
await $`xdg-open ${url}`.quiet();
const serverUrl = `http://localhost:${server.port}`;
console.error(`\nPlannotator server running on ${serverUrl}`);

if (isRemote) {
// SSH session: print helpful setup instructions
console.error(`\n[SSH Remote Session Detected]`);
console.error(`Add this to your local ~/.ssh/config to access Plannotator:\n`);
console.error(` Host your-server-alias`);
console.error(` LocalForward ${server.port} localhost:${server.port}\n`);
console.error(`Then open ${serverUrl} in your local browser.\n`);
} else {
// Local session: try to open browser (cross-platform)
try {
const platform = process.platform;
if (platform === "win32") {
await $`cmd /c start ${serverUrl}`.quiet();
} else if (platform === "darwin") {
await $`open ${serverUrl}`.quiet();
} else {
await $`xdg-open ${serverUrl}`.quiet();
}
} catch {
console.error(`Open browser manually: ${serverUrl}`);
}
} catch {
console.error(`Open browser manually: ${url}`);
}

// Wait for user decision (blocks until approve/deny)
Expand Down
9 changes: 9 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Tests

## Manual Tests

### SSH Remote Support (`manual/ssh/`)

Tests SSH session detection and port forwarding for remote development scenarios.

See [manual/ssh/DOCKER_SSH_TEST.md](manual/ssh/DOCKER_SSH_TEST.md) for setup instructions.
92 changes: 92 additions & 0 deletions tests/manual/ssh/DOCKER_SSH_TEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Testing SSH Remote Support with Docker

This setup creates a Docker container with SSH server to test Plannotator's SSH remote session detection.

## Build and Run

```bash
# From repo root, cd into this directory
cd tests/manual/ssh

# Build the Docker image
docker-compose build

# Start the SSH server
docker-compose up -d

# Check it's running
docker-compose ps
```

## Test SSH Detection

### Option 1: SSH into the container and run test script

```bash
# SSH into the container (password: testpass)
ssh -p 2222 root@localhost

# Once inside, run the test script
cd /app
chmod +x test-ssh.sh
./test-ssh.sh
```

You should see:
- `[SSH Remote Session Detected]` message
- Instructions for SSH port forwarding
- Server running on port 19432

### Option 2: Test via SSH with port forwarding

```bash
# In one terminal, SSH with port forwarding
ssh -p 2222 -L 19432:localhost:19432 root@localhost

# Inside the SSH session, run:
cd /app
echo '{"tool_input":{"plan":"# Test Plan\n\nTest content"}}' | bun run apps/hook/server/index.ts

# In another terminal on your local machine, open browser
open http://localhost:19432
```

### Option 3: Test local (non-SSH) mode

```bash
# Execute directly in container without SSH
docker-compose exec plannotator-ssh bash -c 'cd /app && echo "{\"tool_input\":{\"plan\":\"# Test Plan\n\nTest content\"}}" | bun run apps/hook/server/index.ts'
```

You should see:
- NO `[SSH Remote Session Detected]` message
- Random port assignment (since SSH_TTY and SSH_CONNECTION are not set)

## Verify SSH Environment Variables

```bash
# SSH into container
ssh -p 2222 root@localhost

# Check SSH env vars are set
echo "SSH_TTY: $SSH_TTY"
echo "SSH_CONNECTION: $SSH_CONNECTION"
```

## Clean Up

```bash
docker-compose down
```

## Environment Variable Override

To test custom port:

```bash
ssh -p 2222 root@localhost
cd /app
PLANNOTATOR_PORT=9999 ./test-ssh.sh
```

Server should use port 9999 instead of 19432.
37 changes: 37 additions & 0 deletions tests/manual/ssh/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Test Dockerfile for SSH remote support
FROM ubuntu:24.04

# Install dependencies
RUN apt-get update && apt-get install -y \
curl \
unzip \
openssh-server \
&& rm -rf /var/lib/apt/lists/*

# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"

# Set up SSH
RUN mkdir -p /var/run/sshd && \
echo 'root:testpass' | chpasswd && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config

# Copy project
WORKDIR /app
COPY package.json bun.lock ./
COPY apps ./apps
COPY packages ./packages
COPY tests/manual/ssh/test-ssh.sh ./

# Install dependencies
RUN bun install

# Build the hook
RUN bun run build:hook

# Expose SSH port
EXPOSE 22

# Start SSH server
CMD ["/usr/sbin/sshd", "-D"]
12 changes: 12 additions & 0 deletions tests/manual/ssh/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: '3.8'

services:
plannotator-ssh:
build:
context: ../../..
dockerfile: tests/manual/ssh/Dockerfile
ports:
- "2222:22" # SSH
- "19432:19432" # Plannotator default SSH port
stdin_open: true
tty: true
8 changes: 8 additions & 0 deletions tests/manual/ssh/test-ssh.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
# Test script to simulate Plannotator running over SSH

# Sample plan JSON input
PLAN_JSON='{"tool_input":{"plan":"# Test Plan\n\n## Overview\nThis is a test plan for SSH remote support.\n\n## Steps\n1. Do something\n2. Do something else\n3. Profit"}}'

# Run plannotator with the test plan
echo "$PLAN_JSON" | bun run /app/apps/hook/server/index.ts