fix(web): runtime terminal port resolution + project-id hardening
This commit is contained in:
parent
3f4a08b251
commit
5315e4e0ab
|
|
@ -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
|
||||
|
|
@ -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: <id></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 <project-path></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: <id></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=<value></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 <project-path>
|
||||
# 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue