diff --git a/.changeset/five-lamps-heal.md b/.changeset/five-lamps-heal.md new file mode 100644 index 000000000..94f2c5a43 --- /dev/null +++ b/.changeset/five-lamps-heal.md @@ -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 diff --git a/docs/specs/runtime-terminal-port-and-project-id-hardening.html b/docs/specs/runtime-terminal-port-and-project-id-hardening.html new file mode 100644 index 000000000..01958b0b8 --- /dev/null +++ b/docs/specs/runtime-terminal-port-and-project-id-hardening.html @@ -0,0 +1,573 @@ + + + + + + Runtime Terminal Port and Project-ID Hardening + + + +
+

Runtime Terminal Port and Project-ID Hardening

+ +
+
Status: Draft
+
Author: Agent Orchestrator
+
Date: 2026-03-28
+
Target Merge: main
+
+ +
+ +

Overview

+

+ This design documents two production issues that surfaced in first-run and npm-installed flows: +

+
    +
  1. Direct terminal stuck on CONNECTING... when runtime ports differ from client bundle fallback.
  2. +
  3. /api/spawn receiving a session ID as projectId, leading to deep-core Unknown project failures.
  4. +
+

+ The implementation introduces runtime configuration discovery for terminal WebSocket connection and stricter semantic validation for project identifiers at API and page-data boundaries. +

+ +

Problem Statement

+ +

Problem A: Terminal WebSocket Port Drift

+ + +

Problem B: Project ID / Session ID Domain Confusion

+ + +

Root Cause Analysis

+

A. Build-Time vs Runtime Config Boundary Mismatch

+ + +

B. Namespace Collision and Inconsistent Validation

+ + +

Goals

+
    +
  1. Make direct terminal connection deterministic across runtime-selected ports in prebuilt deployments.
  2. +
  3. Ensure /api/spawn rejects non-configured project IDs early and predictably.
  4. +
  5. Normalize dashboard project filter values so invalid query state cannot poison project context.
  6. +
  7. Preserve backwards compatibility for existing default-port setups.
  8. +
+ +

Non-Goals

+
    +
  1. Redesign session ID format.
  2. +
  3. Introduce full typed ID wrappers across all packages in this change.
  4. +
  5. Remove existing reverse-proxy path-based WS support.
  6. +
+ +

Proposed Design

+ +

1. Runtime Terminal Config Endpoint

+

Add GET /api/runtime/terminal (dynamic, no-store):

+ + +

2. Runtime-Aware DirectTerminal Connection

+

Update client connection flow:

+
    +
  1. Resolve build-time values if available.
  2. +
  3. Fetch /api/runtime/terminal before socket connect when needed.
  4. +
  5. Parse and normalize returned values.
  6. +
  7. Build WS URL from runtime values.
  8. +
  9. Reuse the same runtime-aware logic on reconnect attempts.
  10. +
+

Default ports remain unchanged; runtime-shifted ports become deterministic.

+ +

3. Semantic Project Validation in /api/spawn

+

Before calling spawn, verify config.projects[projectId] exists. If missing:

+ + +

4. Dashboard Project Filter Normalization

+ + +

Flow Chart

+ + + + +
+
Terminal Connection Flow
+
+ +
+ 1. User runs ao start <project-path> +
+
+ +
+ 2. CLI runtime setup — buildDashboardEnv() + Picks TERMINAL_PORT / DIRECT_TERMINAL_PORT at runtime +
+
+ +
+ 3. start-all launches servers + Next.js server + direct-terminal-ws on DIRECT_TERMINAL_PORT +
+
+ +
+ 4. Browser opens session page +
+
+ +
+ 5. resolveConnectionConfig() + Build-time NEXT_PUBLIC_* values available and valid? +
+
+ +
+
+ Yes +
+
+ Use build-time values directly +
+
+
+ No +
+
+ GET /api/runtime/terminal + Read runtime env ports, normalize, return config +
+
+
+ +
merge
+ +
+ 6. Client builds WS URL from resolved runtime config +
+
+ +
+ 7. WebSocket connect to direct-terminal-ws +
+
+ +
+ 8. Resolve tmux session + Exact match first → hash-prefixed suffix fallback +
+
+ +
+ 9. PTY attach succeeds — terminal interactive (CONNECTED) +
+ +
+
+ + +
+
Spawn Path
+
+ +
+ A. User triggers spawn + Dashboard / mobile / API caller +
+
+ +
+ B. POST /api/spawn with body.projectId +
+
+ +
+ C. validateIdentifier(projectId) + Syntax check — format only +
+
+ +
+ D. Semantic guard: config.projects[projectId] exists? +
+
+ +
+
+ Yes +
+
+ sessionManager.spawn() +
+
+
+ 201 Created + session payload + Dashboard/SSE shows active state +
+
+
+ No +
+
+ 404Unknown project: <id> + Stop early — core spawn not invoked +
+
+
+ +
+
+ + +
+
Project Filter Normalization
+
+
+ Incoming ?project=<value> from dashboard query +
+ + + + + + + + + + + + + + + + + + + + + +
ConditionResult
value == "all"Keep "all"
value ∈ configured projectsKeep value
otherwise (e.g. session ID like mono-orchestrator)Fallback to primary valid project
+

+ Invalid values cannot become active project context. +

+
+
+ +

Alternatives Considered

+
    +
  1. Keep fixed ports only (14800/14801) and disable auto-shift. Rejected: blocks multi-instance startup and fails on legitimate port conflicts.
  2. +
  3. Continue relying on NEXT_PUBLIC_* runtime injection. Rejected: production client bundles are build-time materialized.
  4. +
  5. Accept any projectId in /api/spawn and let core throw. Rejected: late 500 errors and poor API ergonomics.
  6. +
+ +

Risks and Mitigations

+
    +
  1. Runtime endpoint unavailable. Mitigation: client retains safe fallback and reconnect logic.
  2. +
  3. Reverse-proxy deployments with custom WS path. Mitigation: preserve proxy-path precedence and include runtime proxy field.
  4. +
  5. Behavior change for invalid project query. Mitigation: normalization affects only unknown IDs; valid IDs and all remain unchanged.
  6. +
+ +

Validation Plan

+ + +

Rollout Plan

+
    +
  1. Merge patch to main.
  2. +
  3. Release new npm package version containing web/client and API updates.
  4. +
  5. Announce behavior note: +
      +
    • default-port users unaffected
    • +
    • runtime-shifted port users no longer hit CONNECTING deadlock
    • +
    +
  6. +
+ +

Release Checklist (Commands)

+
# 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
+ +

Acceptance Criteria

+
    +
  1. Direct terminal connects in npm prebuilt flow even when runtime direct port is not 14801.
  2. +
  3. /api/spawn never returns 500 for unknown-but-valid-format project IDs.
  4. +
  5. Invalid project query values do not become active project state.
  6. +
  7. Existing default-port flows remain functional without configuration changes.
  8. +
+
+ + diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 42c93b46f..7afa272fd 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -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", diff --git a/packages/web/src/app/api/runtime/terminal/route.ts b/packages/web/src/app/api/runtime/terminal/route.ts new file mode 100644 index 000000000..122d2d87f --- /dev/null +++ b/packages/web/src/app/api/runtime/terminal/route.ts @@ -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" } }, + ); +} diff --git a/packages/web/src/app/api/spawn/route.ts b/packages/web/src/app/api/spawn/route.ts index 238ec4550..f8692e115 100644 --- a/packages/web/src/app/api/spawn/route.ts +++ b/packages/web/src/app/api/spawn/route.ts @@ -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, }); diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index 7191b7294..a12bc6b14 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -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 { + 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 = () => { diff --git a/packages/web/src/lib/__tests__/dashboard-page-data.test.ts b/packages/web/src/lib/__tests__/dashboard-page-data.test.ts new file mode 100644 index 000000000..03963c46d --- /dev/null +++ b/packages/web/src/lib/__tests__/dashboard-page-data.test.ts @@ -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"); + }); +}); diff --git a/packages/web/src/lib/dashboard-page-data.ts b/packages/web/src/lib/dashboard-page-data.ts index aa37f5c82..04c22b76f 100644 --- a/packages/web/src/lib/dashboard-page-data.ts +++ b/packages/web/src/lib/dashboard-page-data.ts @@ -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 {