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
15 changes: 8 additions & 7 deletions controlplane/src/chat/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,13 +910,14 @@ async function executeTool(
const sessionId = sb.sandbox_session_id;
const machineId = sb.sandbox_machine_id || undefined;
const runId = generateId();
// Relative names for the file API (it resolves paths under /workspace — an
// absolute /workspace/... would double-nest to /workspace/workspace/...).
// The shell redirects still use the absolute path (correct inside the VM).
// Relative names for BOTH the shell redirect and the file API. The run PTY cwds
// into the session workspace root, and the file API resolves relative to that
// same root — so a relative name lands in one place regardless of whether the
// root is /workspace (cloud) or /workspace/<dashboardId> (desktop's per-dashboard
// session root). An absolute /workspace/... would miss the desktop root and the
// marker would never be found (command times out).
const outRel = `.orc-run-${runId}.out`;
const exitRel = `.orc-run-${runId}.exit`;
const outPath = `/workspace/${outRel}`;
const exitPath = `/workspace/${exitRel}`;

// base64 the command so arbitrary quoting/metachars can't break the wrapper.
const bytes = new TextEncoder().encode(command);
Expand All @@ -925,7 +926,7 @@ async function executeTool(
const b64 = btoa(bin);
// Run it, capture stdout+stderr to .out, then write the exit code to .exit
// last (so the marker never appears before the output is flushed).
const wrapped = `echo ${b64} | base64 -d | bash > ${outPath} 2>&1; echo $? > ${exitPath}`;
const wrapped = `echo ${b64} | base64 -d | bash > ${outRel} 2>&1; echo $? > ${exitRel}`;

const client = new SandboxClient(env.SANDBOX_URL, env.SANDBOX_INTERNAL_TOKEN);
// rel = path relative to the workspace root (/workspace).
Expand All @@ -937,7 +938,7 @@ async function executeTool(
let pty: { id: string } | null = null;
try {
pty = await client.createPty(sessionId, '', wrapped, machineId);
console.log(`[run_command] pty created id=${pty?.id} session=${sessionId} machine=${machineId ?? '(none)'} — polling ${exitPath}`);
console.log(`[run_command] pty created id=${pty?.id} session=${sessionId} machine=${machineId ?? '(none)'} — polling ${exitRel}`);

// Poll the (internal-token) file API for the exit marker — no new sandbox
// endpoint needed. Interval kept at 2s to bound subrequest count.
Expand Down
61 changes: 56 additions & 5 deletions controlplane/src/sessions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,36 @@ async function ensureDashbоardSandbоxCreate(
}

// ── Legacy path: shared SANDBOX_URL ──────────────────────────────
const sandboxSession = await sandbox.createSessiоn(dashboardId, mcpToken);
// On desktop this is the local VM, which may still be booting on a cold app launch
// (the window loads immediately and fires session-create before the VM is up). Retry
// for up to ~30s — but ONLY connection/transient failures ("fetch error:" from the
// client, i.e. the VM isn't listening yet), never deterministic HTTP errors like a
// 4xx auth mismatch. Bounded by an overall deadline so a hung connection can't stack
// up 12×15s of client timeouts.
let sandboxSession: { id: string; machineId?: string } | undefined;
let lastCreateErr: unknown;
const retryDeadline = Date.now() + 30_000;
while (Date.now() < retryDeadline) {
try {
sandboxSession = await sandbox.createSessiоn(dashboardId, mcpToken);
break;
} catch (createErr) {
lastCreateErr = createErr;
const msg = createErr instanceof Error ? createErr.message : String(createErr);
// Only "fetch error:" (connection refused/reset/timeout) is transient; an HTTP
// status error (createSession throws "<status> <statusText>") is deterministic.
if (!msg.includes('fetch error:') || Date.now() + 3000 >= retryDeadline) {
throw createErr;
}
console.warn(
`[ensureDashboardSandboxCreate] createSession failed (sandbox may still be booting), retrying in 3s: ${msg}`
);
await new Promise((r) => setTimeout(r, 3000));
}
}
if (!sandboxSession) {
throw lastCreateErr instanceof Error ? lastCreateErr : new Error('Failed to create sandbox session');
}
const insertResult = await env.DB.prepare(`
INSERT OR IGNORE INTO dashboard_sandboxes (dashboard_id, sandbox_session_id, sandbox_machine_id, created_at)
VALUES (?, ?, ?, ?)
Expand Down Expand Up @@ -1361,6 +1390,10 @@ async function createSessionImpl(
try {
const terminalConfig = parseTerminalConfig(item.content);
const { bootCommand, workingDir, modelSelection } = terminalConfig;
// NOTE: per-dashboard workspace isolation on desktop is handled in the SANDBOX
// (manager.Create roots each session at /workspace/<dashboardID>), so it applies
// uniformly to every dashboard-scoped execution path, not just this UI-terminal
// one. `workingDir` here is only the terminal's own relative sub-path (if any).
let appliedSecretNames: string[] = [];

const applyDashboardSecretsBeforePtyStart = async (
Expand Down Expand Up @@ -1860,16 +1893,34 @@ export async function getDashbоardSandbоxStatus(
}

const sandbox = await getDashbоardSandbоx(env, dashboardId);
if (!sandbox?.sandbox_machine_id) {
// No VM (or no machine id yet) — asleep until the first terminal/browser opens.
// "No sandbox yet" keys on the SESSION id, not the machine id: on desktop the local
// VM has no Fly machine, so sandbox_machine_id is empty even when the VM is up. Using
// machine id here made a healthy desktop sandbox show red "Sandbox asleep".
if (!sandbox?.sandbox_session_id) {
// No VM yet — asleep until the first terminal/browser opens.
return Response.json({ state: 'asleep', flyState: null, machineId: null });
}

const flyProvisioningEnabled = env.FLY_PROVISIONING_ENABLED === 'true'
&& Boolean(env.FLY_API_TOKEN) && Boolean(env.FLY_APP_NAME);
if (!flyProvisioningEnabled) {
// Can't query Fly (e.g. local/desktop). A recorded session implies it's up.
return Response.json({ state: 'ready', flyState: null, machineId: sandbox.sandbox_machine_id });
// Can't query Fly (e.g. local/desktop). The dashboard_sandboxes row PERSISTS across
// app restarts / sandbox crashes, but the actual sandbox session is in-memory — so
// trusting the row alone shows green while the VM is still booting or has crashed.
// Probe the local sandbox /health instead; not-yet-serving reads as "starting".
const localSandbox = new SandboxClient(env.SANDBOX_URL, env.SANDBOX_INTERNAL_TOKEN);
const healthy = await localSandbox.health(undefined, 3000);
return Response.json({
state: healthy ? 'ready' : 'starting',
flyState: null,
machineId: sandbox.sandbox_machine_id || null,
});
}

// Fly path needs a machine id to query; if we somehow have a session without one,
// treat it as asleep so the code below can reprovision.
if (!sandbox.sandbox_machine_id) {
return Response.json({ state: 'asleep', flyState: null, machineId: null });
}

const fly = new FlyMachinesClient(env.FLY_APP_NAME!, env.FLY_API_TOKEN!);
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.5.0"
version = "0.6.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.5.0",
"version": "0.6.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
54 changes: 28 additions & 26 deletions frontend/src/app/(app)/dashboards/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3626,32 +3626,34 @@ export default function DashboardPage() {
<SandboxStatusLight dashboardId={dashboardId} />
</div>

{/* Presence indicators */}
<div className="flex items-center gap-2">
<Tooltip
content={
isCollaborationConnected
? `${presenceUsers.length} online`
: "Connecting..."
}
>
<div className="flex items-center gap-1.5 px-2 py-1 bg-[var(--background)] rounded">
<Users className="w-3.5 h-3.5 text-[var(--foreground-subtle)]" />
<span className="text-xs text-[var(--foreground-muted)]">
{presenceUsers.length}
</span>
{/* Connection status dot */}
<div
className={`w-2 h-2 rounded-full ${
isCollaborationConnected
? "bg-[var(--status-success)]"
: "bg-[var(--status-warning)] animate-pulse"
}`}
/>
</div>
</Tooltip>
<PresenceList users={presenceUsers} maxVisible={4} size="sm" />
</div>
{/* Presence indicators — hidden on desktop (always a single local user) */}
{!DESKTOP_MODE && (
<div className="flex items-center gap-2">
<Tooltip
content={
isCollaborationConnected
? `${presenceUsers.length} online`
: "Connecting..."
}
>
<div className="flex items-center gap-1.5 px-2 py-1 bg-[var(--background)] rounded">
<Users className="w-3.5 h-3.5 text-[var(--foreground-subtle)]" />
<span className="text-xs text-[var(--foreground-muted)]">
{presenceUsers.length}
</span>
{/* Connection status dot */}
<div
className={`w-2 h-2 rounded-full ${
isCollaborationConnected
? "bg-[var(--status-success)]"
: "bg-[var(--status-warning)] animate-pulse"
}`}
/>
</div>
</Tooltip>
<PresenceList users={presenceUsers} maxVisible={4} size="sm" />
</div>
)}

{/* Dev-only sandbox metrics (inline in title bar) */}
{process.env.NODE_ENV === "development" && (
Expand Down
60 changes: 13 additions & 47 deletions frontend/src/lib/cloud-sync.ts
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: cloud-sync-v3-pin-legacy-atomic
// REVISION: cloud-sync-v4-session-root-isolation
"use client";

import {
Expand All @@ -19,7 +19,7 @@ import type { Dashboard, DashboardItem, DashboardEdge } from "@/types";

if (typeof window !== "undefined") {
console.log(
`[cloud-sync] REVISION: cloud-sync-v3-pin-legacy-atomic loaded at ${new Date().toISOString()}`
`[cloud-sync] REVISION: cloud-sync-v4-session-root-isolation loaded at ${new Date().toISOString()}`
);
}

Expand All @@ -38,47 +38,15 @@ export interface DownloadResult {
}

/**
* Sanitize a workspace-relative path to plain segments: strip leading slashes and
* `.`/empty segments, and REJECT any `..` (returns "" so the caller falls back to
* the bare subfolder). Without this, a terminal whose content has
* `workingDir: "../other"` would become `<subdir>/../other` and the sandbox would
* normalize it OUT of the dashboard's isolated subfolder.
*/
function sanitizeRel(p: string): string {
const parts = p
.replace(/^\/+/, "")
.split("/")
.filter((seg) => seg !== "" && seg !== ".");
if (parts.some((seg) => seg === "..")) return "";
return parts.join("/");
}

function pinTerminalToSubdir(content: string, subdir: string): string {
const trimmed = content.trim();
// Legacy / malformed terminal content is a plain string (the parser treats it as
// the terminal's display name). Wrap it into valid JSON so it, too, is pinned to
// this dashboard's subfolder instead of starting at the shared workspace root.
if (!trimmed.startsWith("{")) {
return JSON.stringify({ name: trimmed || "Terminal", workingDir: subdir });
}
let obj: Record<string, unknown>;
try {
obj = JSON.parse(content) as Record<string, unknown>;
} catch {
return JSON.stringify({ name: trimmed, workingDir: subdir });
}
const existing =
typeof obj.workingDir === "string" ? sanitizeRel(obj.workingDir) : "";
obj.workingDir = existing ? `${subdir}/${existing}` : subdir;
return JSON.stringify(obj);
}

/**
* Download a cloud dashboard into the LOCAL control-plane DB and copy its
* workspace files. Fetches the cloud dashboard's structure (items + edges) via the
* native layer (PAT) and recreates it locally with `cloudId` set (so it shows as
* downloaded and Phase-2 sync can map it back), then pulls the cloud workspace
* files into a per-dashboard subfolder. The local copy runs on the local VM.
* Download a cloud dashboard into the LOCAL control-plane DB and copy its workspace
* files. Fetches the cloud dashboard's structure (items + edges) via the native
* layer (PAT) and recreates it locally with `cloudId` set (so it shows as downloaded
* and Phase-2 sync can map it back), then pulls the cloud workspace files into this
* dashboard's subfolder (`<workspace>/<localDashboardId>`). The local sandbox roots
* this dashboard's session at that same subfolder, so terminals see the files with
* no per-terminal working-dir rewriting; a cloud terminal's own relative workingDir
* (e.g. "src") stays relative to the dashboard root. The local copy runs on the
* local VM.
*
* The workspace copy is best-effort: if it fails (cloud VM won't start, no
* subscription, timeout), the canvas is still downloaded and `workspaceError` is
Expand All @@ -98,14 +66,12 @@ export async function downloadCloudDashboard(
// dashboard so it doesn't linger marked "downloaded" (with a cloudId) but broken.
try {
// Recreate items, mapping each cloud item id → the new local id (edges use it).
// Terminal items are pinned to this dashboard's workspace subfolder.
// Content is copied as-is — the sandbox session root provides the isolation.
const idMap = new Map<string, string>();
for (const item of data.items || []) {
const content =
item.type === "terminal" ? pinTerminalToSubdir(item.content, subdir) : item.content;
const local = await createItem(dashboard.id, {
type: item.type,
content,
content: item.content,
position: item.position,
size: item.size,
metadata: item.metadata,
Expand Down
72 changes: 72 additions & 0 deletions sandbox/internal/agenthooks/codex_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2026 Rob Macrae. All rights reserved.
// SPDX-License-Identifier: LicenseRef-Proprietary

package agenthooks

import (
"strings"
"testing"
)

// Regression guard for the OrcaBot-managed Codex system config:
// - `notify` is a TOP-LEVEL key and MUST precede every [table] header
// - existing [mcp_servers.*] (written by GenerateSettingsForAgent) is PRESERVED
// - per-dashboard trust accumulates, with exactly one notify line
func TestBuildCodexSystemConfig(t *testing.T) {
const script = "/etc/codex/orcabot-codex-stop.sh"

// A config as GenerateSettingsForAgent leaves it: an MCP server table (+ a stray
// notify that a prior bug nested inside a project table, which must be cleaned up).
existing := `check_for_updates = false
notify = ["/old/script"]

[mcp_servers.orcabot]
command = "/opt/orcabot/mcp-bridge"
args = ["--session", "s1"]
env = { ORCABOT_MCP_SECRET = "x" }

[projects."/workspace/aaa"]
trust_level = "trusted"
notify = ["/stray/should/be/removed"]
`

cfg := buildCodexSystemConfig(existing, "/workspace/bbb", script)

// notify present, single, and references only the global script.
if got := strings.Count(cfg, "notify = ["); got != 1 {
t.Fatalf("expected exactly one notify line, got %d:\n%s", got, cfg)
}
if !strings.Contains(cfg, script) || strings.Contains(cfg, "/old/script") || strings.Contains(cfg, "/stray/should/be/removed") {
t.Fatalf("notify must be the single global script, no strays:\n%s", cfg)
}
// notify is top-level: before EVERY table header (mcp_servers and projects).
nIdx := strings.Index(cfg, "notify = [")
firstTable := strings.Index(cfg, "[")
if nIdx < 0 || firstTable < 0 || nIdx > firstTable {
t.Fatalf("notify must precede the first [table]:\n%s", cfg)
}
// MCP server section preserved verbatim.
if !strings.Contains(cfg, "[mcp_servers.orcabot]") ||
!strings.Contains(cfg, `command = "/opt/orcabot/mcp-bridge"`) ||
!strings.Contains(cfg, `ORCABOT_MCP_SECRET = "x"`) {
t.Fatalf("mcp_servers section must be preserved:\n%s", cfg)
}
// Both dashboard roots trusted; the stray nested notify is gone.
for _, root := range []string{`[projects."/workspace/aaa"]`, `[projects."/workspace/bbb"]`} {
if !strings.Contains(cfg, root) {
t.Fatalf("missing trust table %s:\n%s", root, cfg)
}
}

// Idempotent + still preserves MCP when re-fed its own output for an existing root.
cfg2 := buildCodexSystemConfig(cfg, "/workspace/aaa", script)
if strings.Count(cfg2, `[projects."/workspace/aaa"]`) != 1 {
t.Fatalf("trust table /workspace/aaa duplicated:\n%s", cfg2)
}
if !strings.Contains(cfg2, "[mcp_servers.orcabot]") {
t.Fatalf("mcp_servers lost on regeneration:\n%s", cfg2)
}
if strings.Count(cfg2, "notify = [") != 1 {
t.Fatalf("notify duplicated on regeneration:\n%s", cfg2)
}
}
Loading
Loading