fix(web): runtime terminal port resolution + project-id hardening

This commit is contained in:
suraj-markup 2026-03-28 19:23:15 +05:30
parent 3f4a08b251
commit 5315e4e0ab
8 changed files with 806 additions and 15 deletions

View File

@ -0,0 +1,10 @@
---
"@composio/ao-web": patch
---
Fix runtime terminal websocket connectivity for npm-installed/prebuilt runs and harden spawn project validation.
- add runtime terminal config endpoint (`/api/runtime/terminal`) so the browser can read runtime-selected ports
- make direct terminal client resolve websocket target from runtime config before connect/reconnect
- return deterministic `404 Unknown project` from `/api/spawn` for non-configured project IDs
- normalize dashboard project filter to configured project IDs to prevent invalid query state propagation

View File

@ -0,0 +1,573 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Runtime Terminal Port and Project-ID Hardening</title>
<style>
:root {
--bg: #0b1020;
--panel: #111a33;
--text: #e5ecff;
--muted: #9fb0df;
--accent: #71a2ff;
--border: #2a3d74;
--code: #0e1530;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: radial-gradient(1200px 700px at 10% -20%, #223562 0%, var(--bg) 50%);
color: var(--text);
line-height: 1.55;
}
main {
max-width: 980px;
margin: 32px auto;
padding: 24px;
background: color-mix(in srgb, var(--panel) 92%, black 8%);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
}
h1, h2, h3 { margin-top: 1.25em; line-height: 1.25; }
h1 { margin-top: 0; font-size: 1.9rem; }
h2 {
font-size: 1.3rem;
border-bottom: 1px solid var(--border);
padding-bottom: 0.35rem;
}
h3 { font-size: 1.05rem; color: #c8d8ff; }
p, li { color: var(--text); }
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 10px 20px;
margin: 12px 0 22px;
color: var(--muted);
}
.meta strong { color: var(--text); }
ul, ol { padding-left: 1.2rem; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.95em;
background: var(--code);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.06rem 0.35rem;
color: #c6f0ff;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.4rem 0;
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<main>
<h1>Runtime Terminal Port and Project-ID Hardening</h1>
<div class="meta">
<div><strong>Status:</strong> Draft</div>
<div><strong>Author:</strong> Agent Orchestrator</div>
<div><strong>Date:</strong> 2026-03-28</div>
<div><strong>Target Merge:</strong> <code>main</code></div>
</div>
<hr />
<h2>Overview</h2>
<p>
This design documents two production issues that surfaced in first-run and npm-installed flows:
</p>
<ol>
<li>Direct terminal stuck on <code>CONNECTING...</code> when runtime ports differ from client bundle fallback.</li>
<li><code>/api/spawn</code> receiving a session ID as <code>projectId</code>, leading to deep-core <code>Unknown project</code> failures.</li>
</ol>
<p>
The implementation introduces runtime configuration discovery for terminal WebSocket connection and stricter semantic validation for project identifiers at API and page-data boundaries.
</p>
<h2>Problem Statement</h2>
<h3>Problem A: Terminal WebSocket Port Drift</h3>
<ul>
<li><code>ao start</code> can auto-select terminal ports at runtime (for example <code>14802/14803</code>) when defaults are occupied.</li>
<li>Direct terminal server listens on <code>DIRECT_TERMINAL_PORT</code> at runtime.</li>
<li>Browser client previously relied on build-time <code>NEXT_PUBLIC_DIRECT_TERMINAL_PORT</code>, with fallback <code>14801</code>.</li>
<li>In prebuilt Next.js client bundles, <code>NEXT_PUBLIC_*</code> values are embedded at build time.</li>
<li>Result: client can attempt <code>ws://...:14801</code> while server is on <code>14803</code>, leaving UI in permanent <code>CONNECTING</code>.</li>
</ul>
<h3>Problem B: Project ID / Session ID Domain Confusion</h3>
<ul>
<li>Session IDs and project IDs share syntax (<code>[a-zA-Z0-9_-]+</code>).</li>
<li>Orchestrator session IDs are generated as <code>${project.sessionPrefix}-orchestrator</code>, which can visually resemble project keys.</li>
<li><code>/api/spawn</code> previously validated only identifier shape, not membership in <code>config.projects</code>.</li>
<li>Invalid but syntactically valid IDs reached core spawn logic and failed late with <code>500</code>.</li>
</ul>
<h2>Root Cause Analysis</h2>
<h3>A. Build-Time vs Runtime Config Boundary Mismatch</h3>
<ul>
<li>The server process controls actual runtime port assignment.</li>
<li>The browser bundle cannot safely depend on build-time env values for runtime-selected ports.</li>
<li>There was no first-party runtime endpoint for client port discovery.</li>
</ul>
<h3>B. Namespace Collision and Inconsistent Validation</h3>
<ul>
<li>Project IDs and session IDs were treated as plain strings at API boundaries.</li>
<li>Some routes sanitize project filters against config; others previously did not.</li>
<li>Validation was format-only in <code>/api/spawn</code> instead of semantic (<code>is configured project</code>).</li>
</ul>
<h2>Goals</h2>
<ol>
<li>Make direct terminal connection deterministic across runtime-selected ports in prebuilt deployments.</li>
<li>Ensure <code>/api/spawn</code> rejects non-configured project IDs early and predictably.</li>
<li>Normalize dashboard project filter values so invalid query state cannot poison project context.</li>
<li>Preserve backwards compatibility for existing default-port setups.</li>
</ol>
<h2>Non-Goals</h2>
<ol>
<li>Redesign session ID format.</li>
<li>Introduce full typed ID wrappers across all packages in this change.</li>
<li>Remove existing reverse-proxy path-based WS support.</li>
</ol>
<h2>Proposed Design</h2>
<h3>1. Runtime Terminal Config Endpoint</h3>
<p>Add <code>GET /api/runtime/terminal</code> (dynamic, no-store):</p>
<ul>
<li><code>terminalPort</code> from <code>TERMINAL_PORT</code> (normalized, fallback <code>14800</code>)</li>
<li><code>directTerminalPort</code> from <code>DIRECT_TERMINAL_PORT</code> (normalized, fallback <code>14801</code>)</li>
<li><code>proxyWsPath</code> from <code>TERMINAL_WS_PATH</code>/<code>NEXT_PUBLIC_TERMINAL_WS_PATH</code> (normalized path or <code>null</code>)</li>
</ul>
<h3>2. Runtime-Aware DirectTerminal Connection</h3>
<p>Update client connection flow:</p>
<ol>
<li>Resolve build-time values if available.</li>
<li>Fetch <code>/api/runtime/terminal</code> before socket connect when needed.</li>
<li>Parse and normalize returned values.</li>
<li>Build WS URL from runtime values.</li>
<li>Reuse the same runtime-aware logic on reconnect attempts.</li>
</ol>
<p class="muted">Default ports remain unchanged; runtime-shifted ports become deterministic.</p>
<h3>3. Semantic Project Validation in <code>/api/spawn</code></h3>
<p>Before calling spawn, verify <code>config.projects[projectId]</code> exists. If missing:</p>
<ul>
<li>Return <code>404</code> with <code>Unknown project: &lt;id&gt;</code></li>
<li>Record structured observability failure reason</li>
<li>Do not invoke core spawn path</li>
</ul>
<h3>4. Dashboard Project Filter Normalization</h3>
<ul>
<li>keep <code>"all"</code></li>
<li>keep only configured project IDs</li>
<li>otherwise fallback to first valid configured project (or primary fallback)</li>
</ul>
<h2>Flow Chart</h2>
<style>
.flow-section {
margin: 1.5rem 0 2.5rem;
padding: 1.5rem;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: 12px;
}
.flow-section-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
margin: 0 0 1.2rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.flow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.flow-node {
position: relative;
padding: 12px 20px;
border-radius: 10px;
text-align: center;
max-width: 520px;
width: 100%;
font-size: 0.88rem;
line-height: 1.45;
}
.flow-node strong { color: #fff; }
.flow-node code {
font-size: 0.82rem;
padding: 0.04rem 0.3rem;
}
.node-action {
background: linear-gradient(135deg, #1a2a55, #1e3468);
border: 1px solid #3456a0;
color: #cdd9f5;
}
.node-decision {
background: linear-gradient(135deg, #2a1a44, #3a2060);
border: 1px solid #6a4ca0;
color: #ddd0f5;
border-radius: 10px;
padding: 14px 24px;
}
.node-success {
background: linear-gradient(135deg, #0d2a1a, #143a24);
border: 1px solid #2a8050;
color: #b0e8c8;
}
.node-error {
background: linear-gradient(135deg, #2a0d0d, #3a1414);
border: 1px solid #803030;
color: #e8b0b0;
}
.node-endpoint {
background: linear-gradient(135deg, #1a2040, #1e2855);
border: 1px solid #4070b0;
color: #a8c8f0;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
color: var(--muted);
font-size: 0.75rem;
padding: 2px 0;
line-height: 1;
}
.flow-arrow .arrow-line {
width: 2px;
height: 14px;
background: var(--border);
}
.flow-arrow .arrow-head {
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid var(--border);
}
.flow-arrow .arrow-label {
margin: 2px 0;
color: var(--accent);
font-weight: 600;
}
.flow-branch {
display: flex;
gap: 16px;
width: 100%;
max-width: 560px;
align-items: flex-start;
justify-content: center;
}
.flow-branch-arm {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
gap: 0;
}
.branch-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: 2px;
}
.branch-yes { background: #1a3a24; color: #70d898; border: 1px solid #2a6040; }
.branch-no { background: #3a1a1a; color: #e88080; border: 1px solid #603030; }
.branch-match { background: #1a2a44; color: #70a8e8; border: 1px solid #2a5080; }
.flow-node .sub {
display: block;
font-size: 0.78rem;
color: var(--muted);
margin-top: 4px;
}
/* Filter table */
.filter-table {
width: 100%;
max-width: 540px;
border-collapse: separate;
border-spacing: 0;
margin: 0 auto;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
font-size: 0.85rem;
}
.filter-table th {
background: #1a2550;
color: var(--accent);
text-align: left;
padding: 10px 16px;
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.filter-table td {
padding: 9px 16px;
border-top: 1px solid var(--border);
}
.filter-table tr:nth-child(even) td { background: rgba(0,0,0,0.15); }
.filter-table tr:nth-child(odd) td { background: rgba(0,0,0,0.08); }
.filter-table .result-keep { color: #70d898; }
.filter-table .result-fallback { color: #e8c870; }
</style>
<!-- TERMINAL CONNECTION FLOW -->
<div class="flow-section">
<div class="flow-section-title">Terminal Connection Flow</div>
<div class="flow">
<div class="flow-node node-action">
<strong>1.</strong> User runs <code>ao start &lt;project-path&gt;</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>2.</strong> CLI runtime setup — <code>buildDashboardEnv()</code>
<span class="sub">Picks <code>TERMINAL_PORT</code> / <code>DIRECT_TERMINAL_PORT</code> at runtime</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>3.</strong> <code>start-all</code> launches servers
<span class="sub">Next.js server + direct-terminal-ws on <code>DIRECT_TERMINAL_PORT</code></span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>4.</strong> Browser opens session page
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision">
<strong>5.</strong> <code>resolveConnectionConfig()</code>
<span class="sub">Build-time <code>NEXT_PUBLIC_*</code> values available and valid?</span>
</div>
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
<div class="flow-branch">
<div class="flow-branch-arm">
<span class="branch-label branch-yes">Yes</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success" style="font-size:0.82rem;">
Use build-time values directly
</div>
</div>
<div class="flow-branch-arm">
<span class="branch-label branch-no">No</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-endpoint" style="font-size:0.82rem;">
<code>GET /api/runtime/terminal</code>
<span class="sub">Read runtime env ports, normalize, return config</span>
</div>
</div>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-label">merge</div><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>6.</strong> Client builds WS URL from resolved runtime config
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>7.</strong> WebSocket connect to direct-terminal-ws
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>8.</strong> Resolve tmux session
<span class="sub">Exact match first → hash-prefixed suffix fallback</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success">
<strong>9.</strong> PTY attach succeeds — terminal interactive (<code>CONNECTED</code>)
</div>
</div>
</div>
<!-- SPAWN PATH FLOW -->
<div class="flow-section">
<div class="flow-section-title">Spawn Path</div>
<div class="flow">
<div class="flow-node node-action">
<strong>A.</strong> User triggers spawn
<span class="sub">Dashboard / mobile / API caller</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-endpoint">
<strong>B.</strong> <code>POST /api/spawn</code> with <code>body.projectId</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action">
<strong>C.</strong> <code>validateIdentifier(projectId)</code>
<span class="sub">Syntax check — format only</span>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-decision">
<strong>D.</strong> Semantic guard: <code>config.projects[projectId]</code> exists?
</div>
<div class="flow-arrow"><div class="arrow-line" style="height:6px"></div></div>
<div class="flow-branch">
<div class="flow-branch-arm">
<span class="branch-label branch-yes">Yes</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-action" style="font-size:0.82rem;">
<code>sessionManager.spawn()</code>
</div>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-success" style="font-size:0.82rem;">
<code>201 Created</code> + session payload
<span class="sub">Dashboard/SSE shows active state</span>
</div>
</div>
<div class="flow-branch-arm">
<span class="branch-label branch-no">No</span>
<div class="flow-arrow"><div class="arrow-line"></div><div class="arrow-head"></div></div>
<div class="flow-node node-error" style="font-size:0.82rem;">
<code>404</code><code>Unknown project: &lt;id&gt;</code>
<span class="sub">Stop early — core spawn not invoked</span>
</div>
</div>
</div>
</div>
</div>
<!-- PROJECT FILTER NORMALIZATION -->
<div class="flow-section">
<div class="flow-section-title">Project Filter Normalization</div>
<div class="flow" style="gap: 12px;">
<div class="flow-node node-endpoint" style="margin-bottom:8px;">
Incoming <code>?project=&lt;value&gt;</code> from dashboard query
</div>
<table class="filter-table">
<thead>
<tr>
<th>Condition</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>value == <code>"all"</code></td>
<td class="result-keep">Keep <code>"all"</code></td>
</tr>
<tr>
<td>value ∈ configured projects</td>
<td class="result-keep">Keep value</td>
</tr>
<tr>
<td>otherwise (e.g. session ID like <code>mono-orchestrator</code>)</td>
<td class="result-fallback">Fallback to primary valid project</td>
</tr>
</tbody>
</table>
<p style="font-size:0.82rem; color:var(--muted); text-align:center; margin-top:4px;">
Invalid values cannot become active project context.
</p>
</div>
</div>
<h2>Alternatives Considered</h2>
<ol>
<li>Keep fixed ports only (<code>14800/14801</code>) and disable auto-shift. Rejected: blocks multi-instance startup and fails on legitimate port conflicts.</li>
<li>Continue relying on <code>NEXT_PUBLIC_*</code> runtime injection. Rejected: production client bundles are build-time materialized.</li>
<li>Accept any <code>projectId</code> in <code>/api/spawn</code> and let core throw. Rejected: late 500 errors and poor API ergonomics.</li>
</ol>
<h2>Risks and Mitigations</h2>
<ol>
<li>Runtime endpoint unavailable. Mitigation: client retains safe fallback and reconnect logic.</li>
<li>Reverse-proxy deployments with custom WS path. Mitigation: preserve proxy-path precedence and include runtime proxy field.</li>
<li>Behavior change for invalid project query. Mitigation: normalization affects only unknown IDs; valid IDs and <code>all</code> remain unchanged.</li>
</ol>
<h2>Validation Plan</h2>
<ul>
<li><code>GET /api/runtime/terminal</code> returns runtime env ports.</li>
<li><code>POST /api/spawn</code> returns <code>404</code> for unknown project and does not call spawn.</li>
<li>Project filter normalization keeps valid IDs, keeps <code>all</code>, and falls back on unknown IDs.</li>
<li>Existing <code>DirectTerminal</code> URL construction tests remain green.</li>
</ul>
<h2>Rollout Plan</h2>
<ol>
<li>Merge patch to main.</li>
<li>Release new npm package version containing web/client and API updates.</li>
<li>Announce behavior note:
<ul>
<li>default-port users unaffected</li>
<li>runtime-shifted port users no longer hit <code>CONNECTING</code> deadlock</li>
</ul>
</li>
</ol>
<h2>Release Checklist (Commands)</h2>
<pre><code># 1) Push branch and open PR
git push origin fix-runtime-terminal-projectid-hardening
# 2) Ensure patch changeset exists (this PR includes one)
ls .changeset/five-lamps-heal.md
# 3) CI validation (already required by repo workflows)
pnpm --filter @composio/ao-web test -- \
src/__tests__/api-routes.test.ts \
src/lib/__tests__/dashboard-page-data.test.ts \
src/components/__tests__/DirectTerminal.test.ts
# 4) After PR merge: version packages from changesets
pnpm changeset version
# 5) Commit version bumps and changelogs
git add .
git commit -m "chore(release): version packages for runtime terminal + spawn hardening"
# 6) Publish
pnpm release
# 7) Post-release smoke check
npm i -g @composio/ao@latest
ao start &lt;project-path&gt;
# verify terminal works when direct terminal port is non-default</code></pre>
<h2>Acceptance Criteria</h2>
<ol>
<li>Direct terminal connects in npm prebuilt flow even when runtime direct port is not <code>14801</code>.</li>
<li><code>/api/spawn</code> never returns <code>500</code> for unknown-but-valid-format project IDs.</li>
<li>Invalid <code>project</code> query values do not become active project state.</li>
<li>Existing default-port flows remain functional without configuration changes.</li>
</ol>
</main>
</body>
</html>

View File

@ -3,7 +3,6 @@ import { NextRequest } from "next/server";
import {
SessionNotFoundError,
SessionNotRestorableError,
SessionNotFoundError,
type Session,
type SessionManager,
type OrchestratorConfig,
@ -205,6 +204,7 @@ import { POST as remapPOST } from "@/app/api/sessions/[id]/remap/route";
import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route";
import { GET as eventsGET } from "@/app/api/events/route";
import { GET as observabilityGET } from "@/app/api/observability/route";
import { GET as runtimeTerminalGET } from "@/app/api/runtime/terminal/route";
function makeRequest(url: string, init?: RequestInit): NextRequest {
return new NextRequest(
@ -392,6 +392,35 @@ describe("API Routes", () => {
});
});
describe("GET /api/runtime/terminal", () => {
it("returns runtime direct terminal port from server env", async () => {
const previousDirect = process.env.DIRECT_TERMINAL_PORT;
const previousTerminal = process.env.TERMINAL_PORT;
process.env.DIRECT_TERMINAL_PORT = "14803";
process.env.TERMINAL_PORT = "14802";
try {
const res = await runtimeTerminalGET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.directTerminalPort).toBe("14803");
expect(data.terminalPort).toBe("14802");
} finally {
if (previousDirect === undefined) {
delete process.env.DIRECT_TERMINAL_PORT;
} else {
process.env.DIRECT_TERMINAL_PORT = previousDirect;
}
if (previousTerminal === undefined) {
delete process.env.TERMINAL_PORT;
} else {
process.env.TERMINAL_PORT = previousTerminal;
}
}
});
});
// ── POST /api/spawn ────────────────────────────────────────────────
describe("POST /api/spawn", () => {
@ -422,6 +451,20 @@ describe("API Routes", () => {
expect(data.error).toMatch(/projectId/);
});
it("returns 404 when projectId does not exist in config", async () => {
const req = makeRequest("/api/spawn", {
method: "POST",
body: JSON.stringify({ projectId: "mono-orchestrator" }),
headers: { "Content-Type": "application/json" },
});
const res = await spawnPOST(req);
expect(res.status).toBe(404);
const data = await res.json();
expect(data.error).toBe("Unknown project: mono-orchestrator");
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
});
it("returns 400 with invalid JSON", async () => {
const req = makeRequest("/api/spawn", {
method: "POST",

View File

@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
function normalizePort(input: string | undefined, fallback: number): string {
const parsed = Number.parseInt(input ?? "", 10);
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
return String(parsed);
}
return String(fallback);
}
function normalizeProxyPath(input: string | undefined): string | null {
if (!input) return null;
const trimmed = input.trim();
if (!trimmed.startsWith("/")) return null;
return trimmed;
}
export async function GET() {
const terminalPort = normalizePort(process.env.TERMINAL_PORT, 14800);
const directTerminalPort = normalizePort(process.env.DIRECT_TERMINAL_PORT, 14801);
const proxyWsPath = normalizeProxyPath(
process.env.TERMINAL_WS_PATH ?? process.env.NEXT_PUBLIC_TERMINAL_WS_PATH,
);
return NextResponse.json(
{ terminalPort, directTerminalPort, proxyWsPath },
{ headers: { "Cache-Control": "no-store" } },
);
}

View File

@ -27,8 +27,29 @@ export async function POST(request: NextRequest) {
try {
const { config, sessionManager } = await getServices();
const projectId = body.projectId as string;
if (!config.projects[projectId]) {
recordApiObservation({
config,
method: "POST",
path: "/api/spawn",
correlationId,
startedAt,
outcome: "failure",
statusCode: 404,
projectId,
reason: `Unknown project: ${projectId}`,
data: { issueId: body.issueId },
});
return jsonWithCorrelation(
{ error: `Unknown project: ${projectId}` },
{ status: 404 },
correlationId,
);
}
const session = await sessionManager.spawn({
projectId: body.projectId as string,
projectId,
issueId: (body.issueId as string) ?? undefined,
});

View File

@ -38,8 +38,40 @@ interface DirectTerminalWsUrlOptions {
directTerminalPort?: string;
}
interface RuntimeTerminalConfigResponse {
directTerminalPort?: unknown;
proxyWsPath?: unknown;
}
interface TerminalConnectionConfig {
directTerminalPort?: string;
proxyWsPath?: string;
}
type TerminalVariant = "agent" | "orchestrator";
function normalizePortValue(value: unknown): string | undefined {
if (typeof value !== "string" && typeof value !== "number") return undefined;
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return undefined;
return String(parsed);
}
function normalizePathValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed.startsWith("/")) return undefined;
return trimmed;
}
function parseRuntimeTerminalConfig(payload: unknown): TerminalConnectionConfig {
const response = (payload ?? {}) as RuntimeTerminalConfigResponse;
return {
directTerminalPort: normalizePortValue(response.directTerminalPort),
proxyWsPath: normalizePathValue(response.proxyWsPath),
};
}
export function buildTerminalThemes(variant: TerminalVariant): { dark: ITheme; light: ITheme } {
const agentAccent = {
cursor: "#5b7ef8",
@ -312,15 +344,9 @@ export function DirectTerminal({
// Fit terminal to container
fit.fit();
// WebSocket URL (stable across reconnects)
// When accessed via reverse proxy (HTTPS on standard port), use path-based
// WebSocket endpoint instead of direct port access.
const wsUrl = buildDirectTerminalWsUrl({
location: window.location,
sessionId,
proxyWsPath: process.env.NEXT_PUBLIC_TERMINAL_WS_PATH,
directTerminalPort: process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT,
});
// Runtime WS config cache. We do not rely on build-time NEXT_PUBLIC_* here
// because `ao start` can choose terminal ports dynamically at runtime.
const runtimeConnectionConfig: TerminalConnectionConfig = {};
// ── Preserve selection while terminal receives output ────────
// xterm.js clears the selection on every terminal.write(). We
@ -405,9 +431,45 @@ export function DirectTerminal({
}
});
function connectWebSocket() {
async function resolveConnectionConfig(): Promise<TerminalConnectionConfig> {
const fromBuild: TerminalConnectionConfig = {
proxyWsPath: normalizePathValue(process.env.NEXT_PUBLIC_TERMINAL_WS_PATH),
directTerminalPort: normalizePortValue(process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT),
};
if (!fromBuild.proxyWsPath && !runtimeConnectionConfig.directTerminalPort) {
try {
const response = await fetch("/api/runtime/terminal", { cache: "no-store" });
if (response.ok) {
const runtimeConfig = parseRuntimeTerminalConfig(await response.json());
runtimeConnectionConfig.proxyWsPath = runtimeConfig.proxyWsPath;
runtimeConnectionConfig.directTerminalPort = runtimeConfig.directTerminalPort;
}
} catch {
// Runtime config endpoint is optional; fallback to build-time values.
}
}
return {
proxyWsPath: runtimeConnectionConfig.proxyWsPath ?? fromBuild.proxyWsPath,
directTerminalPort:
runtimeConnectionConfig.directTerminalPort ?? fromBuild.directTerminalPort,
};
}
async function connectWebSocket() {
if (!mounted) return;
const config = await resolveConnectionConfig();
if (!mounted) return;
const wsUrl = buildDirectTerminalWsUrl({
location: window.location,
sessionId,
proxyWsPath: config.proxyWsPath,
directTerminalPort: config.directTerminalPort,
});
console.log("[DirectTerminal] Connecting to:", wsUrl);
const websocket = new WebSocket(wsUrl);
ws.current = websocket;
@ -471,11 +533,13 @@ export function DirectTerminal({
setStatus("connecting");
setError(null);
reconnectTimerRef.current = setTimeout(connectWebSocket, delay);
reconnectTimerRef.current = setTimeout(() => {
void connectWebSocket();
}, delay);
};
}
connectWebSocket();
void connectWebSocket();
// Store cleanup function to be called from useEffect cleanup
cleanup = () => {

View File

@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getAllProjectsMock, getPrimaryProjectIdMock, getProjectNameMock } = vi.hoisted(() => ({
getAllProjectsMock: vi.fn(),
getPrimaryProjectIdMock: vi.fn(),
getProjectNameMock: vi.fn(),
}));
vi.mock("@/lib/project-name", () => ({
getAllProjects: getAllProjectsMock,
getPrimaryProjectId: getPrimaryProjectIdMock,
getProjectName: getProjectNameMock,
}));
vi.mock("@/lib/services", () => ({
getServices: vi.fn(),
getSCM: vi.fn(),
}));
import { resolveDashboardProjectFilter } from "@/lib/dashboard-page-data";
describe("resolveDashboardProjectFilter", () => {
beforeEach(() => {
vi.clearAllMocks();
getAllProjectsMock.mockReturnValue([
{ id: "mono", name: "Mono" },
{ id: "docs", name: "Docs" },
]);
getPrimaryProjectIdMock.mockReturnValue("mono");
getProjectNameMock.mockReturnValue("Mono");
});
it("keeps valid project ids", () => {
expect(resolveDashboardProjectFilter("docs")).toBe("docs");
});
it("keeps the all-projects sentinel", () => {
expect(resolveDashboardProjectFilter("all")).toBe("all");
});
it("falls back to primary project for unknown ids", () => {
expect(resolveDashboardProjectFilter("mono-orchestrator")).toBe("mono");
});
});

View File

@ -35,7 +35,12 @@ export const getDashboardProjectName = cache(function getDashboardProjectName(
});
export function resolveDashboardProjectFilter(project?: string): string {
return project ?? getPrimaryProjectId();
if (project === "all") return "all";
const projects = getAllProjects();
if (project && projects.some((entry) => entry.id === project)) {
return project;
}
return projects[0]?.id ?? getPrimaryProjectId();
}
export const getDashboardPageData = cache(async function getDashboardPageData(project?: string): Promise<DashboardPageData> {