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
42 changes: 25 additions & 17 deletions controlplane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,34 +364,42 @@ async function prоxySandbоxWebSоcket(
const body = ['POST', 'PUT', 'PATCH'].includes(request.method)
? request.clone().body
: undefined;
const proxyRequest = new Request(sandboxUrlValue.toString(), {
const makeRequest = () => new Request(sandboxUrlValue.toString(), {
method: request.method,
headers,
body,
redirect: 'manual',
});

const response = await fetch(proxyRequest);
// REVISION: ws-proxy-v2-readiness-retry-no-premature-reconcile
// Readiness retry: right after createPty — or when an autostop:suspend machine
// is resuming — the PTY isn't registered on the sandbox yet, so the upstream
// upgrade 404s. That's a *timing* miss, not a dead PTY. Retry the upgrade for a
// few seconds so the client gets a clean 101 instead of a 404 that triggers a
// disruptive session recreate (the "stuck connecting" / "disappearing terminal"
// first-PTY flake). Only a bodyless (GET) upgrade is safe to replay.
let response = await fetch(makeRequest());
let attempts = 0;
const maxRetries = body ? 0 : 6; // ~3s total (6 × 500ms)
while (response.status === 404 && attempts < maxRetries) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, 500));
response = await fetch(makeRequest());
}

if (response.status !== 101) {
console.warn(
`[ws-proxy] upstream PTY WS failed status=${response.status} dashboardPath=${requestUrl.pathname} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}`
`[ws-proxy] upstream PTY WS failed status=${response.status} attempts=${attempts} dashboardPath=${requestUrl.pathname} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}`
);
// Reconcile: the sandbox no longer has this PTY (process died, or the VM was
// restarted), but D1 may still show the session 'active' ("ghost" session →
// stale 'running' status + "stuck connecting"). Mark it stopped so the client
// recreates instead of hanging, and listings reflect reality. Best-effort.
if (response.status === 404) {
try {
await env.DB.prepare(
`UPDATE sessions SET status = 'stopped', stopped_at = ? WHERE pty_id = ? AND status != 'stopped'`
).bind(new Date().toISOString(), ptyId).run();
} catch {
// ignore — reconciliation is best-effort
}
}
// Do NOT reconcile-to-stopped here. A 404 that's a genuinely dead PTY vs. one
// that's merely not-ready-yet is indistinguishable at this layer, and the sole
// caller already reconciles WITH a creation-age grace period. The previous
// unconditional UPDATE ran *before* that grace check and marked freshly-created
// sessions stopped during the provisioning race — defeating the grace period
// and causing the first-PTY disappear/stuck bug.
} else {
console.log(
`[ws-proxy] upstream PTY WS upgraded sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}`
`[ws-proxy] upstream PTY WS upgraded attempts=${attempts} sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}`
);
}
return response;
Expand Down
2 changes: 1 addition & 1 deletion desktop/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "orcabot-desktop"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
description = "Orcabot Desktop shell"
license = "UNLICENSED"
Expand Down
2 changes: 1 addition & 1 deletion desktop/app/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Orcabot",
"version": "0.3.0",
"version": "0.4.0",
"identifier": "com.orcabot.desktop",
"build": {
"beforeDevCommand": "sh -c \"cd ../../frontend && NEXT_PUBLIC_API_URL=http://localhost:8787 NEXT_PUBLIC_SITE_URL=http://localhost:8788 NEXT_PUBLIC_DEV_MODE_ENABLED=true NEXT_PUBLIC_DESKTOP_MODE=true npx wrangler dev -c wrangler.toml --port 8788\"",
Expand Down
6 changes: 3 additions & 3 deletions desktop/app/src-tauri/vm-image.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "v1",
"sha256": "d5fb4c823f7e92020c9d85a2351898a10162e44400f4b44d92454252f2b43caa",
"url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v1/sandbox.img.gz"
"version": "v2",
"sha256": "eba0a9e5c17778f5de08371dd9bcb46aa3697a827d8c0ed7c801a465c907f633",
"url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v2/sandbox.img.gz"
}
78 changes: 78 additions & 0 deletions desktop/tests/first-prompt-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Orcabot desktop FIRST-PROMPT smoke — the "connected but blank" first-PTY race.
#
# Reproduces the SANDBOX side of the flaky first terminal (frontend recipe 09
# covers the full-stack UI side). A client that attaches to a freshly-created PTY
# must see the shell prompt WITHOUT sending any input: it arrives either via the
# live broadcast (if the client registers before the shell emits its prompt) or
# via the hub's scrollback replay on attach (sandbox/internal/pty/hub.go:787,
# `if raw := h.ScrollbackRaw(0); len(raw) > 0`). If the prompt was emitted after
# the session was created but before this client registered AND wasn't yet in the
# 64KB ring buffer, the client sees a blank terminal until it presses a key.
#
# This loops many fresh terminals, attaches to each immediately with NO input
# (`orcabot tail`, no --send), and reports how often the prompt shows up
# input-free. On the local desktop VM the race window is tiny, so this normally
# passes — its job is to guard the replay path against regressions (e.g. someone
# removing the replay, or breaking the ring buffer) and to be pointable at a
# laggier stack. The statistical repro of the ~30% flake lives in e2e recipe 09.
#
# Usage: desktop/tests/first-prompt-smoke.sh [iterations]
# Requires: stack running (`orcabot up`), python3, curl. Exits non-zero if the
# input-free prompt rate is below FIRST_PROMPT_THRESHOLD (default 100%).
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ORCABOT="${ORCABOT_BIN:-$SCRIPT_DIR/../app/src-tauri/target/release/orcabot}"
CP="http://127.0.0.1:8787"
U="X-User-ID: dev-desktop"
ITER="${1:-15}"
TAIL_SECS="${FIRST_PROMPT_TAIL_SECS:-3}"
THRESHOLD="${FIRST_PROMPT_THRESHOLD:-100}" # percent input-free prompts required

# Per-boot surface token (dev-auth gate), read like the CLI/app do. Empty is
# harmless on stacks that predate surface enforcement.
SURFACE_TOKEN_FILE="${ORCABOT_SURFACE_TOKEN_FILE:-$HOME/Library/Application Support/com.orcabot.desktop/surface-token}"
ST="$(cat "$SURFACE_TOKEN_FILE" 2>/dev/null | tr -d '[:space:]')"
AUTH=(-H "$U" -H "X-Orcabot-Surface: $ST")

curl -s -m3 "$CP/health" >/dev/null \
|| { echo "control plane not reachable on :8787 — run 'orcabot up' first." >&2; exit 2; }

DID="$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \
--data-raw '{"name":"first-prompt-smoke"}' "$CP/dashboards" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['dashboard']['id'])")"
echo "smoke dashboard: $DID (iterations=$ITER, tail=${TAIL_SECS}s, threshold=${THRESHOLD}%)"

# Does captured PTY output contain a real shell prompt (user@host:...#/$)?
# ANSI/OSC escapes are stripped first so styling can't hide the prompt text.
has_prompt() {
python3 - "$1" <<'PY'
import re, sys
s = sys.argv[1]
s = re.sub(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)', '', s) # OSC (e.g. title sets)
s = re.sub(r'\x1b\[[0-9;?]*[ -/]*[@-~]', '', s) # CSI / SGR colour codes
sys.exit(0 if re.search(r'[\w.-]+@[\w.-]+:[^\n]*[#$]', s) else 1)
PY
}

ok=0; bad=0
for i in $(seq 1 "$ITER"); do
ITEM="$("$ORCABOT" new terminal shell --dash "$DID" 2>/dev/null | sed 's/created shell terminal //')"
if [ -z "$ITEM" ]; then echo " [$i] FAIL: could not create terminal"; bad=$((bad + 1)); continue; fi
# Attach IMMEDIATELY and read WITHOUT sending any input.
out="$(timeout $((TAIL_SECS + 6)) "$ORCABOT" tail "$ITEM" --dash "$DID" --secs "$TAIL_SECS" 2>/dev/null)"
if has_prompt "$out"; then
ok=$((ok + 1)); echo " [$i] ok"
else
bad=$((bad + 1)); echo " [$i] BLANK — no input-free prompt"
fi
curl -s -o /dev/null -X DELETE "${AUTH[@]}" "$CP/dashboards/$DID/items/$ITEM"
done

curl -s -o /dev/null -X DELETE "${AUTH[@]}" "$CP/dashboards/$DID"
total=$((ok + bad))
rate="$(python3 -c "print(round(100*$ok/max($total,1)))")"
echo
echo "================ input-free prompt: $ok/$total (${rate}%) ================"
[ "$rate" -ge "$THRESHOLD" ]
8 changes: 1 addition & 7 deletions e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { type Page, type BrowserContext, expect } from "@playwright/test";
import { generateUserId } from "../helpers/api";
import { CONTROLPLANE_URL } from "../helpers/controlplane-url";

const DEFAULT_NAME = process.env.E2E_USER_NAME || "E2E Test User";
const DEFAULT_EMAIL = process.env.E2E_USER_EMAIL || "[email protected]";

/**
* Control plane URL for API calls.
* Default: localhost:8787 (matches frontend/src/config/env.ts for localhost target).
*/
const CONTROLPLANE_URL =
process.env.CONTROLPLANE_URL || "http://localhost:8787";

/**
* Log in by creating a server-side session directly via the control plane API,
* then injecting the session cookie and auth state into the browser.
Expand Down
6 changes: 3 additions & 3 deletions e2e/fixtures/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
waitForOutput,
} from "./terminal";
import { OrcabotAPI } from "../helpers/api";
import { CONTROLPLANE_URL } from "../helpers/controlplane-url";

/** Bundled auth helpers available in every test */
export interface AuthFixture {
Expand Down Expand Up @@ -77,7 +78,7 @@ export const test = base.extend<{
});

// Auto-cleanup: attempt API-based delete for all tracked dashboards
const cpUrl = process.env.CONTROLPLANE_URL || "http://localhost:8787";
const cpUrl = CONTROLPLANE_URL;
if (trackedIds.length > 0) {
const email =
process.env.E2E_USER_EMAIL || "[email protected]";
Expand Down Expand Up @@ -105,8 +106,7 @@ export const test = base.extend<{
api: async ({ page }, use) => {
const email = process.env.E2E_USER_EMAIL || "[email protected]";
const name = process.env.E2E_USER_NAME || "E2E Test User";
const cpUrl = process.env.CONTROLPLANE_URL || "http://localhost:8787";
const client = new OrcabotAPI(page.request, cpUrl, email, name);
const client = new OrcabotAPI(page.request, CONTROLPLANE_URL, email, name);
await use({ client });
},
});
Expand Down
37 changes: 26 additions & 11 deletions e2e/fixtures/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function addTerminal(
* layer underneath (.xterm-rows) with span elements containing text.
* Falls back to other selectors if .xterm-rows is not available.
*/
async function readTerminalText(page: Page): Promise<string> {
export async function readTerminalText(page: Page): Promise<string> {
return page.evaluate(() => {
// Approach 1: .xterm-rows (xterm v4/v5 DOM layer)
const rows = document.querySelector(".xterm-rows");
Expand All @@ -49,7 +49,20 @@ async function readTerminalText(page: Page): Promise<string> {
}

/**
* Wait for the terminal to connect and show a shell prompt.
* A real shell prompt looks like `user@host:path#` (or `$`), e.g.
* `root@78451ddfd73298:/workspace#`.
*
* This deliberately does NOT match the terminal block's own status lines
* ("Connecting to sandbox...", "$ Connected to sandbox"). Those are exactly what
* a *stuck* first-PTY shows — connected but with no shell prompt rendered — and
* the old `content.trim().length > 0` check green-lit that stuck state, hiding
* the flaky-first-connect bug. Requiring the `user@host:…[#$]` shape is what
* makes the stuck state fail a test instead of passing it.
*/
export const SHELL_PROMPT_RE = /[\w.-]+@[\w.-]+:[^\n]*[#$]/;

/**
* Wait for the terminal to connect and show a real shell prompt.
* The sandbox needs to boot (Fly machine start), create a session,
* and establish a WebSocket connection before the prompt appears.
*/
Expand All @@ -58,19 +71,21 @@ export async function waitForPrompt(
timeoutMs = 90_000
): Promise<void> {
const startTime = Date.now();
let last = "";
while (Date.now() - startTime < timeoutMs) {
const content = await readTerminalText(page);

// A connected terminal will have some content — a prompt character
// like $, #, >, or a username/path in the prompt
if (content && content.trim().length > 0) {
return;
}
last = await readTerminalText(page);
if (SHELL_PROMPT_RE.test(last)) return;
await page.waitForTimeout(1_000);
}
const stuck = /Connecting to sandbox|Connected to sandbox/i.test(last);
throw new Error(
`Terminal did not show a prompt within ${timeoutMs}ms. ` +
"Check that the sandbox boots and the WebSocket connects."
`Terminal did not show a shell prompt within ${timeoutMs}ms` +
(stuck
? " — stuck on the connect banner with no prompt (first-PTY render race)"
: "") +
`. Last content (first 300 chars): ${last
.replace(/\n/g, "⏎")
.slice(0, 300)}`
);
}

Expand Down
42 changes: 42 additions & 0 deletions e2e/helpers/controlplane-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Resolve the control-plane base URL for the harness.
*
* The browser target is `ORCABOT_URL` (the frontend origin), but the control
* plane is a *different* origin on the hosted instances — mirroring
* frontend/src/config/env.ts's `API_URL_BY_TARGET`:
* localhost:3000 -> http://localhost:8787
* dev.orcabot.com -> https://api.dev.orcabot.com
* orcabot.com -> https://orcabot-controlplane.orcabot.workers.dev
*
* `devModeLogin` and the `api` fixture used to hardcode a `localhost:8787`
* default, so pointing only `ORCABOT_URL` at a hosted instance silently sent
* auth/API calls to localhost (connection refused). Deriving the control-plane
* URL from `ORCABOT_URL` makes a single knob work; `CONTROLPLANE_URL` still
* overrides for anything non-standard (e.g. a self-hosted split origin).
*/
export function deriveControlPlaneUrl(siteUrl?: string): string {
if (!siteUrl) return "http://localhost:8787";

let url: URL;
try {
url = new URL(siteUrl);
} catch {
return "http://localhost:8787";
}

const host = url.hostname;
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
return "http://localhost:8787";
}
if (host === "dev.orcabot.com") return "https://api.dev.orcabot.com";
if (host === "orcabot.com" || host === "www.orcabot.com") {
return "https://orcabot-controlplane.orcabot.workers.dev";
}
// Unknown host: assume the control plane is same-origin. Override with
// CONTROLPLANE_URL if it lives elsewhere.
return url.origin;
}

/** The resolved control-plane URL: explicit override, else derived from ORCABOT_URL. */
export const CONTROLPLANE_URL =
process.env.CONTROLPLANE_URL || deriveControlPlaneUrl(process.env.ORCABOT_URL);
Loading
Loading