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
17 changes: 17 additions & 0 deletions controlplane/src/dashboards/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,23 @@ export async function deleteDashbоard(
return Response.json({ error: 'E79304: Not found or not owner' }, { status: 404 });
}

// Stop any active sessions first so their PTYs are killed in the sandbox.
// CASCADE-deleting the session rows would otherwise orphan live processes
// (shells, and agent children like node/chromium) in the VM. Mirrors deleteItem.
try {
const activeSessions = await env.DB.prepare(`
SELECT id FROM sessions WHERE dashboard_id = ? AND status IN ('creating', 'active')
`).bind(dashboardId).all<{ id: string }>();
if (activeSessions.results.length > 0) {
const { stоpSessiоn } = await import('../sessions/handler');
for (const session of activeSessions.results) {
await stоpSessiоn(env as EnvWithDriveCache, session.id, userId);
}
}
} catch {
// Best-effort — don't block dashboard deletion if session cleanup fails.
}

// Delete dependent records that don't have ON DELETE CASCADE
// Order matters: delete from most dependent tables first

Expand Down
49 changes: 46 additions & 3 deletions controlplane/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,19 @@ async function prоxySandbоxWebSоcket(
console.warn(
`[ws-proxy] upstream PTY WS failed status=${response.status} 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
}
}
} else {
console.log(
`[ws-proxy] upstream PTY WS upgraded sandboxSessionId=${sandboxSessionId} ptyId=${ptyId} machineId=${machineId || ''} origin=${origin}`
Expand Down Expand Up @@ -1360,7 +1373,20 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick<E
if (segments[0] === 'dashboards' && segments.length === 3 && segments[2] === 'items' && method === 'POST') {
const authError = requireAuth(auth);
if (authError) return authError;
const data = await request.json() as Partial<DashboardItem>;
let data: Partial<DashboardItem>;
try {
data = await request.json() as Partial<DashboardItem>;
} catch {
return Response.json({ error: 'E79305: Invalid JSON body' }, { status: 400 });
}
const VALID_ITEM_TYPES = new Set([
'note', 'todo', 'terminal', 'link', 'browser', 'workspace', 'prompt', 'schedule',
'gmail', 'calendar', 'contacts', 'sheets', 'forms', 'slack', 'discord', 'telegram',
'whatsapp', 'teams', 'matrix', 'google_chat', 'twitter', 'outlook',
]);
if (!data || typeof data.type !== 'string' || !VALID_ITEM_TYPES.has(data.type)) {
return Response.json({ error: 'E79306: Missing or invalid item type' }, { status: 400 });
}
return dashboards.upsertItem(env, segments[1], auth.user!.id, data);
}

Expand Down Expand Up @@ -1690,14 +1716,31 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick<E
if (segments[0] === 'dashboards' && segments.length === 5 && segments[2] === 'terminals' && segments[4] === 'integrations' && method === 'POST') {
const authError = requireAuth(auth);
if (authError) return authError;
const data = await request.json() as {
let data: {
provider: string;
userIntegrationId?: string;
policy?: Record<string, unknown>;
accountLabel?: string;
highRiskConfirmations?: string[];
};
return integrationPolicies.attachIntegration(env, segments[1], segments[3], auth.user!.id, data as Parameters<typeof integrationPolicies.attachIntegration>[4]);
try {
data = await request.json() as typeof data;
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!data || typeof data.provider !== 'string' || !data.provider) {
return Response.json({ error: 'Missing or invalid provider' }, { status: 400 });
}
try {
return await integrationPolicies.attachIntegration(env, segments[1], segments[3], auth.user!.id, data as Parameters<typeof integrationPolicies.attachIntegration>[4]);
} catch (e) {
// attachIntegration throws on an unknown provider (unguarded switch) — that's
// bad input, not a server fault. Surface as 400 rather than 500.
return Response.json(
{ error: `Invalid integration request: ${e instanceof Error ? e.message : 'unknown'}` },
{ status: 400 }
);
}
}

// PUT /dashboards/:id/terminals/:terminalId/integrations/:provider - Update integration policy
Expand Down
31 changes: 31 additions & 0 deletions controlplane/src/sessions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,15 @@ async function isExistingSessionReusable(
}

// Create a session for a terminal item
// Per-item in-flight gate: coalesce concurrent session creates for the same
// terminal. The reuse check in the impl is check-then-act, so concurrent first
// creates race past it and each provisions a PTY (duplicate PTYs/leak). This
// serializes creates per item within the worker isolate (desktop = single
// isolate, so fully effective). The while-loop has no await between the final
// has()-check and set(), so exactly one caller becomes the leader at a time;
// waiters fall through to the impl's reuse check and return the existing session.
const sessionCreateGates = new Map<string, Promise<void>>();

export async function createSessiоn(
env: EnvWithDriveCache,
dashboardId: string,
Expand All @@ -1237,6 +1246,28 @@ export async function createSessiоn(
userName: string,
preferredRegion?: string,
ctx?: Pick<ExecutionContext, 'waitUntil'>
): Promise<Response> {
while (sessionCreateGates.has(itemId)) {
await sessionCreateGates.get(itemId)!.catch(() => {});
}
let release!: () => void;
sessionCreateGates.set(itemId, new Promise<void>((r) => { release = r; }));
try {
return await createSessionImpl(env, dashboardId, itemId, userId, userName, preferredRegion, ctx);
} finally {
sessionCreateGates.delete(itemId);
release();
}
}

async function createSessionImpl(
env: EnvWithDriveCache,
dashboardId: string,
itemId: string,
userId: string,
userName: string,
preferredRegion?: string,
ctx?: Pick<ExecutionContext, 'waitUntil'>
): Promise<Response> {
// Check access
const access = await env.DB.prepare(`
Expand Down
12 changes: 9 additions & 3 deletions desktop/app/src-tauri/src/vm/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,21 @@ pub fn stage_image(src: &Path, dest: &Path) -> Result<PathBuf, VMError> {
}

/// Stable signature of the SOURCE file (modification time + size).
///
/// Uses NANOSECOND mtime, not seconds: a rebuild that lands in the same wall-clock
/// second as the previous one (fast `SKIP_KERNEL` iterations) keeps the same size
/// for the raw `sandbox.img`, so a seconds-resolution stamp would treat it as
/// "unchanged" and silently boot a stale image. A content hash would be more
/// robust still, but hashing a multi-GB image on every launch is too slow.
fn source_signature(src: &Path) -> Result<String, VMError> {
let meta = fs::metadata(src)?;
let mtime = meta
let mtime_nanos = meta
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.map(|d| d.as_nanos())
.unwrap_or(0);
Ok(format!("{}:{}", mtime, meta.len()))
Ok(format!("{}:{}", mtime_nanos, meta.len()))
}

/// Path of the sidecar stamp file recording the source signature at last stage.
Expand Down
18 changes: 15 additions & 3 deletions desktop/app/src-tauri/src/vm/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

/// Control-plane port for the guest→host reverse vsock bridge.
///
/// Fixed at 8787 on BOTH sides on purpose: the host registers
/// `--reverse-port-forward {CONTROLPLANE_PORT}:{CONTROLPLANE_PORT}` and the guest
/// (systemd → `/etc/rc.local`, see `vm/scripts/build-images.sh`) runs the matching
/// `socat TCP-LISTEN:8787 -> VSOCK-CONNECT:2:8787` + sets `CONTROLPLANE_URL`. The
/// guest value is baked into the image, and we can't securely deliver a per-boot
/// port to the guest (kernel cmdline / workspace are agent-readable, so the token
/// can't ride along). Making this configurable end-to-end requires a secure guest
/// env channel first; until then both sides must use this constant.
const CONTROLPLANE_PORT: u16 = 8787;

/// macOS VM using Virtualization.framework.
///
/// On macOS 13+, uses native Virtualization.framework for optimal performance.
Expand Down Expand Up @@ -144,10 +156,10 @@ impl MacOSVM {
// Reverse forward: guest vsock:8787 -> host 127.0.0.1:8787 (control plane).
// Gives the sandbox a guest->host route so it can call the control plane
// (integration gateway, egress/secret approvals, event callbacks). The
// guest runs socat TCP-LISTEN:8787 -> VSOCK-CONNECT:2:8787 and sets
// CONTROLPLANE_URL=http://127.0.0.1:8787. (8787 = default CONTROLPLANE_PORT.)
// guest init runs the matching socat + sets CONTROLPLANE_URL. Both sides
// are pinned to CONTROLPLANE_PORT (see the const for why it's fixed).
"--reverse-port-forward",
"8787:8787",
&format!("{}:{}", CONTROLPLANE_PORT, CONTROLPLANE_PORT),
]);

cmd.stdout(Stdio::inherit());
Expand Down
158 changes: 158 additions & 0 deletions desktop/tests/regression-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Orcabot desktop regression smoke test.
#
# Drives the LIVE desktop stack (control plane :8787, sandbox :8080) the same way
# the `orcabot` CLI does and asserts that the bugs found in the bug hunt stay fixed.
# These are stateful / cross-component behaviors that the control-plane mock-D1 unit
# harness can't represent faithfully (it ignores UPDATEs and mis-maps session inserts),
# so they're covered here against real processes + real D1.
#
# Coverage:
# #1 deleting a terminal reaps its PTY (no zombie / no orphan)
# #2 concurrent session-create for one item -> exactly one session
# #3 POST item with invalid JSON -> 400 (not 500)
# #4 attach with unknown provider -> 4xx (not 500)
# #5 POST item with missing/invalid type -> 400; valid -> 201
# #6 ghost session: PTY death -> status reconciled to stopped (not stuck 'running')
# #7 dashboard delete reaps its terminals' PTYs
# + invariants: file-API path traversal blocked; broker-secret output redaction
#
# Usage: desktop/tests/regression-smoke.sh
# Requires: the stack running (`orcabot up`), python3, curl. Exits non-zero on any failure.
# NOTE: not part of the fast PR lane (needs the desktop VM). Run locally / nightly.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ORCABOT="$SCRIPT_DIR/../app/src-tauri/target/release/orcabot"
CP="http://127.0.0.1:8787"
SB="http://127.0.0.1:8080"
U="X-User-ID: dev-desktop"
VZ_LOG="/tmp/vz-console.log"

PASS=0
FAIL=0
ok() { echo " PASS: $1"; PASS=$((PASS + 1)); }
bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }

jq_py() { python3 -c "$1"; }
code() { curl -s -o /dev/null -w "%{http_code}" -m 10 "$@"; }

TOK="$(grep -a 'debug-exec] auth token:' "$VZ_LOG" 2>/dev/null | tail -1 | sed 's/.*auth token: //' | tr -cd 'a-f0-9')"
# Run a shell command in the sandbox VM, print stdout.
X() {
local body
body="$(python3 -c "import json,sys;print(json.dumps({'cmd':sys.argv[1],'timeout_ms':8000}))" "$1")"
curl -s -m 12 -X POST -H "X-Debug-Exec-Token: $TOK" -H 'Content-Type: application/json' \
-d "$body" "$SB/debug/exec" | python3 -c "import json,sys;print(json.load(sys.stdin).get('stdout','').strip(),end='')"
}

# ---- preflight ------------------------------------------------------------
if ! curl -s -m 3 "$CP/health" >/dev/null; then
echo "control plane not reachable on :8787 — run 'orcabot up' first." >&2
exit 2
fi
if [ -z "$TOK" ]; then
echo "no /debug/exec token in $VZ_LOG — stack must be launched with VZ_CONSOLE_DIRECT=1 (orcabot up does this)." >&2
exit 2
fi

# dedicated throwaway dashboard
DID="$(curl -s -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"name":"regression-smoke"}' "$CP/dashboards" \
| jq_py "import json,sys;print(json.load(sys.stdin)['dashboard']['id'])")"
echo "smoke dashboard: $DID"
new_term() { "$ORCABOT" new terminal shell --dash "$DID" 2>/dev/null | sed 's/created shell terminal //'; }
sess_field() { # $1=itemId $2=field
curl -s -H "$U" "$CP/dashboards/$DID" | jq_py "import json,sys;d=json.load(sys.stdin);print(next((s.get('$2','') for s in d['sessions'] if s['itemId']=='$1'),'GONE'))"
}

# ---- #1 delete reaps PTY (no zombie) --------------------------------------
echo "[#1] terminal delete reaps the PTY"
b0="$(X 'pgrep -c bash')"; ITEM="$(new_term)"; sleep 3
b1="$(X 'pgrep -c bash')"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$ITEM"; sleep 3
b2="$(X 'pgrep -c bash')"; z="$(X 'ps -eo stat | grep -c Z')"
{ [ "$b1" -gt "$b0" ] && [ "$b2" -le "$b0" ] && [ "$z" -eq 0 ]; } \
&& ok "PTY reaped (bash $b0->$b1->$b2, zombies=$z)" \
|| bad "PTY leaked/zombied (bash $b0->$b1->$b2, zombies=$z)"

# ---- #2 concurrent session-create -> one session --------------------------
echo "[#2] concurrent session-create coalesces to one"
IT="$(curl -s -X POST -H "$U" -H 'Content-Type: application/json' \
--data-raw '{"type":"terminal","content":"{\"name\":\"race\",\"bootCommand\":\"\"}"}' "$CP/dashboards/$DID/items" \
| jq_py "import json,sys;print(json.load(sys.stdin)['item']['id'])")"
for i in 1 2 3; do curl -s -o /dev/null -X POST -H "$U" -H 'Content-Type: application/json' -d '{}' "$CP/dashboards/$DID/items/$IT/session" & done; wait
sleep 3
n="$(curl -s -H "$U" "$CP/dashboards/$DID" | jq_py "import json,sys;print(sum(1 for s in json.load(sys.stdin)['sessions'] if s['itemId']=='$IT'))")"
[ "$n" = "1" ] && ok "exactly one session ($n)" || bad "duplicate sessions ($n)"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$IT"

# ---- #3/#5 item input validation ------------------------------------------
echo "[#3/#5] item input validation"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw 'not-json{' "$CP/dashboards/$DID/items")"
[ "$c" = "400" ] && ok "invalid JSON -> 400" || bad "invalid JSON -> $c (want 400)"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"content":"x"}' "$CP/dashboards/$DID/items")"
[ "$c" = "400" ] && ok "missing type -> 400" || bad "missing type -> $c (want 400)"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"type":"bogus"}' "$CP/dashboards/$DID/items")"
[ "$c" = "400" ] && ok "invalid type -> 400" || bad "invalid type -> $c (want 400)"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"type":"note","content":"ok"}' "$CP/dashboards/$DID/items")"
[ "$c" = "201" ] && ok "valid note -> 201" || bad "valid note -> $c (want 201)"

# ---- #4 attach validation (no 500) ----------------------------------------
echo "[#4] attach input validation (never 500)"
ITEM4="$(new_term)"; sleep 3
PTY4="$(sess_field "$ITEM4" ptyId)"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw 'not-json{' "$CP/dashboards/$DID/terminals/$PTY4/integrations")"
{ [ "$c" -ge 400 ] && [ "$c" -lt 500 ]; } && ok "malformed body -> $c (4xx)" || bad "malformed body -> $c (want 4xx)"
c="$(code -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"provider":"bogus","userIntegrationId":"fake"}' "$CP/dashboards/$DID/terminals/$PTY4/integrations")"
{ [ "$c" -ge 400 ] && [ "$c" -lt 500 ]; } && ok "unknown provider -> $c (4xx)" || bad "unknown provider -> $c (want 4xx)"

# ---- #6 ghost session reconcile -------------------------------------------
echo "[#6] PTY death reconciles session to stopped"
before="$(X 'pgrep bash | sort | tr "\n" ","')"
ITEM6="$(new_term)"; sleep 3
after="$(X 'pgrep bash | sort | tr "\n" ","')"
PID6="$(python3 -c "b=set('$before'.split(','))-{''};a=set('$after'.split(','))-{''};print(next(iter(a-b),''))")"
X "kill -9 $PID6 2>/dev/null; echo ." >/dev/null; sleep 1
st_before="$(sess_field "$ITEM6" status)"
timeout 8 "$ORCABOT" tail "$ITEM6" --dash "$DID" --secs 2 >/dev/null 2>&1 # 404 -> reconcile
sleep 2
st_after="$(sess_field "$ITEM6" status)"
{ [ "$st_before" = "active" ] && { [ "$st_after" = "stopped" ] || [ "$st_after" = "GONE" ]; }; } \
&& ok "reconciled ($st_before -> $st_after)" \
|| bad "not reconciled ($st_before -> $st_after; want active -> stopped/GONE)"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$ITEM6"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$ITEM4"

# ---- #7 dashboard delete reaps PTYs ---------------------------------------
echo "[#7] dashboard delete reaps its PTYs"
ND="$(curl -s -X POST -H "$U" -H 'Content-Type: application/json' --data-raw '{"name":"regression-smoke-del"}' "$CP/dashboards" \
| jq_py "import json,sys;print(json.load(sys.stdin)['dashboard']['id'])")"
d0="$(X 'pgrep -c bash')"; "$ORCABOT" new terminal shell --dash "$ND" >/dev/null 2>&1; sleep 3
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$ND"; sleep 3
d2="$(X 'pgrep -c bash')"; z="$(X 'ps -eo stat | grep -c Z')"
{ [ "$d2" -le "$d0" ] && [ "$z" -eq 0 ]; } && ok "PTY reaped on dashboard delete (bash $d0->$d2, zombies=$z)" \
|| bad "PTY leaked on dashboard delete (bash $d0->$d2, zombies=$z)"

# ---- invariants: path traversal + output redaction ------------------------
echo "[inv] file-API path traversal blocked"
ITEM8="$(new_term)"; sleep 3
SSID="$(curl -s -H "$U" "$CP/dashboards/$DID" | jq_py "import json,sys;d=json.load(sys.stdin);print(next(s['sandboxSessionId'] for s in d['sessions'] if s['itemId']=='$ITEM8'))")"
out="$(curl -s -m6 -H 'X-Internal-Token: dev-sandbox-token' "$SB/sessions/$SSID/file?path=../../../../etc/passwd")"
echo "$out" | grep -q "root:" && bad "path traversal LEAKED /etc/passwd" || ok "traversal blocked"

echo "[inv] broker-secret output redaction"
SEC="REDACTME_smoke_55512345"
curl -s -o /dev/null -X POST -H "$U" -H 'Content-Type: application/json' \
--data-raw "{\"name\":\"SMOKE_SECRET\",\"value\":\"$SEC\",\"type\":\"secret\",\"brokerProtected\":true,\"dashboardId\":\"$DID\"}" "$CP/secrets"
ITEM9="$(new_term)"; sleep 3
red="$(timeout 12 "$ORCABOT" tail "$ITEM9" --dash "$DID" --secs 5 --send "echo MK:${SEC}:MK" 2>/dev/null)"
echo "$red" | grep -q "$SEC" && bad "secret LEAKED in output" || ok "secret redacted"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$ITEM8"
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID/items/$ITEM9"

# ---- teardown -------------------------------------------------------------
curl -s -o /dev/null -X DELETE -H "$U" "$CP/dashboards/$DID"
echo
echo "================ $PASS passed, $FAIL failed ================"
[ "$FAIL" -eq 0 ]
4 changes: 2 additions & 2 deletions sandbox/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

// REVISION: main-v34-debug-exec-token
// REVISION: main-v35-pty-close-reap

package main

Expand Down Expand Up @@ -38,7 +38,7 @@ import (
"github.com/Hyper-Int/OrcaBot/sandbox/internal/ws"
)

const mainRevision = "main-v34-debug-exec-token"
const mainRevision = "main-v35-pty-close-reap"

// debugExecToken is a random per-boot secret guarding POST /debug/exec. Generated
// once at startup when ORCABOT_DEBUG_EXEC=1 and delivered to the host ONLY via the
Expand Down
Loading
Loading