diff --git a/controlplane/src/auth/api-token.ts b/controlplane/src/auth/api-token.ts new file mode 100644 index 00000000..1766543c --- /dev/null +++ b/controlplane/src/auth/api-token.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: api-token-v1-pat + +/** + * Personal access tokens (PATs) for CLI / external tools (`orcabot push`/`pull`). + * + * Issuance sits behind the normal user auth (Cloudflare Access in prod, dev-auth + * locally) — see the routes in index.ts. The plaintext token is shown exactly + * once at creation; only its SHA-256 hash is stored. Bearer presentation is + * validated in auth/middleware.ts (prefix-gated so it never collides with the + * PTY/gateway JWT bearer tokens, which are verified elsewhere). + * + * A PAT inherits the owning user's identity — and therefore their subscription + * status — so the paywall applies to CLI writes exactly as it does in the UI. + */ + +import type { Env, User } from '../types'; + +export const PAT_PREFIX = 'orca_pat_'; + +/** SHA-256 hex of the given string. */ +export async function hashToken(token: string): Promise { + const data = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function randomToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const body = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `${PAT_PREFIX}${body}`; +} + +export interface ApiTokenMeta { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; +} + +/** Create a PAT for a user. Returns the plaintext token (shown once) + metadata. */ +export async function createApiToken( + env: Env, + userId: string, + name: string, + ttlDays?: number +): Promise<{ token: string; meta: ApiTokenMeta }> { + const token = randomToken(); + const tokenHash = await hashToken(token); + const id = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const expiresAt = + ttlDays && ttlDays > 0 + ? new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000).toISOString() + : null; + + await env.DB.prepare( + `INSERT INTO api_tokens (id, user_id, token_hash, name, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .bind(id, userId, tokenHash, name || 'cli', createdAt, expiresAt) + .run(); + + return { + token, + meta: { id, name: name || 'cli', createdAt, lastUsedAt: null, expiresAt }, + }; +} + +/** List a user's PATs (metadata only — never the hash or plaintext). */ +export async function listApiTokens(env: Env, userId: string): Promise { + const rows = await env.DB.prepare( + `SELECT id, name, created_at, last_used_at, expires_at + FROM api_tokens + WHERE user_id = ? AND revoked_at IS NULL + ORDER BY created_at DESC` + ) + .bind(userId) + .all<{ + id: string; + name: string; + created_at: string; + last_used_at: string | null; + expires_at: string | null; + }>(); + return (rows.results || []).map((r) => ({ + id: r.id, + name: r.name, + createdAt: r.created_at, + lastUsedAt: r.last_used_at, + expiresAt: r.expires_at, + })); +} + +/** Revoke a PAT by id (must belong to the user). Returns true if a row was revoked. */ +export async function revokeApiToken(env: Env, userId: string, id: string): Promise { + const res = await env.DB.prepare( + `UPDATE api_tokens SET revoked_at = datetime('now') + WHERE id = ? AND user_id = ? AND revoked_at IS NULL` + ) + .bind(id, userId) + .run(); + return (res.meta?.changes ?? 0) > 0; +} + +/** + * Resolve a bearer PAT to a user. Returns null if the token is malformed, + * unknown, revoked, or expired. Fail-closed. Updates last_used_at on success. + */ +export async function getUserForApiToken(env: Env, token: string): Promise { + if (!token.startsWith(PAT_PREFIX)) return null; + const tokenHash = await hashToken(token); + const row = await env.DB.prepare( + `SELECT t.id as token_id, t.expires_at as expires_at, t.revoked_at as revoked_at, + u.id as id, u.email as email, u.name as name, + u.created_at as created_at, u.trial_started_at as trial_started_at + FROM api_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ?` + ) + .bind(tokenHash) + .first<{ + token_id: string; + expires_at: string | null; + revoked_at: string | null; + id: string; + email: string; + name: string; + created_at: string; + trial_started_at: string | null; + }>(); + + if (!row) return null; + if (row.revoked_at) return null; + if (row.expires_at && new Date(row.expires_at).getTime() <= Date.now()) return null; + + // Best-effort last-used stamp (don't fail auth if it errors). + try { + await env.DB.prepare(`UPDATE api_tokens SET last_used_at = datetime('now') WHERE id = ?`) + .bind(row.token_id) + .run(); + } catch { + // ignore + } + + return { + id: row.id, + email: row.email, + name: row.name, + createdAt: row.trial_started_at || row.created_at, + }; +} diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index d41b9bbf..c42dfa2b 100644 --- a/controlplane/src/auth/middleware.ts +++ b/controlplane/src/auth/middleware.ts @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: auth-middleware-v1-email-fallback +// REVISION: auth-middleware-v2-pat-method-gate /** * Auth Middleware @@ -14,10 +14,17 @@ import type { Env, User } from '../types'; import { validateCfAccessTоken, cfAccessUserIdFrоmSub } from './cf-access'; import { getUserForSession } from './sessions'; +import { getUserForApiToken, PAT_PREFIX } from './api-token'; + +/** How the request authenticated. Used to gate sensitive routes (e.g. token + * management must not be reachable with a PAT — a leaked PAT could otherwise mint + * a replacement and survive revocation). */ +export type AuthMethod = 'session' | 'pat' | 'cf-access' | 'dev'; export interface AuthContext { user: User | null; isAuthenticated: boolean; + method?: AuthMethod; } // Extract user from request @@ -27,7 +34,22 @@ export async function authenticate( ): Promise { const sessionUser = await getUserForSession(request, env); if (sessionUser) { - return { user: sessionUser, isAuthenticated: true }; + return { user: sessionUser, isAuthenticated: true, method: 'session' }; + } + + // Personal access token (CLI / external tools). Prefix-gated so it never + // collides with the PTY/gateway JWT bearer tokens (verified elsewhere). + const authHeader = request.headers.get('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const bearer = authHeader.slice('Bearer '.length).trim(); + if (bearer.startsWith(PAT_PREFIX)) { + const patUser = await getUserForApiToken(env, bearer); + if (patUser) { + return { user: patUser, isAuthenticated: true, method: 'pat' }; + } + // Malformed/expired/revoked PAT: fail closed (don't fall through to other modes). + return { user: null, isAuthenticated: false }; + } } // Try Cloudflare Access first (production mode) @@ -96,6 +118,7 @@ async function authenticateWithCfAccеss( return { user, isAuthenticated: true, + method: 'cf-access', }; } @@ -179,6 +202,7 @@ async function authenticateDevMоde( return { user, isAuthenticated: true, + method: 'dev', }; } @@ -193,6 +217,19 @@ export function requireAuth(ctx: AuthContext): Response | null { return null; } +// Reject requests authenticated by a PAT. Token management (mint/list/revoke) +// must be done with a real session (browser/CF Access) or dev auth — never with a +// PAT, or a leaked token could mint a replacement and survive its own revocation. +export function rejectPatAuth(ctx: AuthContext): Response | null { + if (ctx.method === 'pat') { + return Response.json( + { error: 'E79411: Personal access tokens cannot manage tokens — use the web app' }, + { status: 403 } + ); + } + return null; +} + // Validate internal API token for service-to-service calls export function requireInternalAuth( request: Request, diff --git a/controlplane/src/db/schema.ts b/controlplane/src/db/schema.ts index ee84eb31..94da32a5 100644 --- a/controlplane/src/db/schema.ts +++ b/controlplane/src/db/schema.ts @@ -610,6 +610,22 @@ CREATE TABLE IF NOT EXISTS user_sessions ( CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); +-- Personal access tokens for CLI / external tools (orcabot push/pull). +-- Only the SHA-256 hash of the token is stored; the plaintext is shown once at creation. +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT 'cli', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); + -- OAuth login states (short-lived) CREATE TABLE IF NOT EXISTS auth_states ( state TEXT PRIMARY KEY, @@ -1127,7 +1143,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_link_edge_b ON link_edge_map(link_id, edge `; // Initialize the database -const SCHEMA_REVISION = "schema-v14-egress-blocklist"; +const SCHEMA_REVISION = "schema-v15-api-tokens"; export async function initializeDatabase(db: D1Database): Promise { console.log(`[schema] REVISION: ${SCHEMA_REVISION} loaded at ${new Date().toISOString()}`); diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 5c78cb9b..83fa5036 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -11,7 +11,7 @@ console.log(`[controlplane] REVISION: controlplane-v17-pty-ws-edge-debug loaded */ import type { Env, DashboardItem, RecipeStep, Session } from './types'; -import { authenticate, requireAuth, requireInternalAuth, validateMcpAuth, type AuthContext } from './auth/middleware'; +import { authenticate, requireAuth, rejectPatAuth, requireInternalAuth, validateMcpAuth, type AuthContext } from './auth/middleware'; import { checkRateLimitIp, checkRateLimitUser } from './ratelimit/middleware'; import { initializeDatabase } from './db/schema'; import { ensureDb, type EnvWithDb } from './db/remote'; @@ -48,6 +48,7 @@ import { isAdminEmail } from './auth/admin'; import { getSubscriptionStatus, hasActiveAccess, isExemptEmail } from './subscriptions/check'; import * as subscriptions from './subscriptions/handler'; import { buildSessionCookie, createUserSession } from './auth/sessions'; +import { createApiToken, listApiTokens, revokeApiToken } from './auth/api-token'; import { checkAndCacheSandbоxHealth, getCachedHealth } from './health/checker'; import { sendEmail, buildInterestThankYouEmail, buildInterestNotificationEmail, buildTemplateReviewEmail } from './email/resend'; import * as blog from './blog/handler'; @@ -1166,6 +1167,50 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick = std::env::args().collect(); // Bare `orcabot` opens the interactive TUI — the primary interface. let cmd = args.get(1).map(String::as_str).unwrap_or("tui"); @@ -45,6 +64,14 @@ fn main() { let code = match cmd { "tui" | "ui" => cmd_tui(), + "desktop" | "gui" => cmd_desktop(), + "cli" => cmd_cli(rest), + "web" => cmd_web(rest), + "export" => cmd_export(rest), + "import" => cmd_import(rest), + "push" => cmd_push(rest), + "pull" => cmd_pull(rest), + "token" => cmd_token(rest), "ls" | "components" => cmd_ls(rest), "tail" => cmd_tail(rest), "new" | "create" => cmd_new(rest), @@ -77,15 +104,23 @@ fn print_help() { "orcabot — control the Orcabot desktop stack from the terminal\n\n\ USAGE:\n orcabot [args]\n\n\ COMMANDS:\n\ - \x20 (no args) / tui Open the interactive TUI (component list + command line)\n\ + \x20 (no args) / tui Open the interactive TUI (starts the stack if needed; stops it on quit)\n\ + \x20 desktop Switch the running stack to the desktop GUI (show window)\n\ + \x20 cli Switch to the CLI surface (hide the GUI) and open the TUI\n\ + \x20 web Open the web surface (requires transferring the session)\n\ + \x20 export [--out f] Package a dashboard + workspace into a .orcabot bundle\n\ + \x20 import Recreate a dashboard + workspace from a .orcabot bundle\n\ + \x20 push [id] --remote URL [--token T] Copy a local dashboard to a remote control plane\n\ + \x20 pull --remote URL [--token T] Copy a remote dashboard to the local stack\n\ + \x20 token [list|revoke ] [--remote URL] Mint/list/revoke a personal access token for push/pull\n\ \x20 ls Print the dashboard's components + status (non-interactive)\n\ - \x20 up [--timeout N] Launch the stack headlessly (background) and wait until ready\n\ - \x20 down Stop the headless stack\n\ + \x20 up [--timeout N] Keep the stack running across commands (explicit; survives until `down`)\n\ + \x20 down Stop a stack started by `up`\n\ \x20 status Show service health (control plane, sandbox, frontend)\n\ \x20 exec Run a shell command inside the sandbox VM\n\ \x20 version Print CLI revision\n\n\ EXAMPLES:\n\ - \x20 orcabot up && orcabot # bring up the stack, then open the TUI\n\ + \x20 orcabot # open the TUI (starts the stack if it isn't up)\n\ \x20 orcabot ls\n\ \x20 orcabot exec 'ip -4 addr show eth0'" ); @@ -265,14 +300,14 @@ fn cmd_up(rest: &[String]) -> i32 { } fn cmd_down() -> i32 { - let pid = fs::read_to_string(pid_file()) - .ok() - .and_then(|s| s.trim().parse::().ok()); - let Some(pid) = pid else { - eprintln!("orcabot: no pid file — stack not started by this CLI (nothing to stop)"); + // Find the backend via the pid file, else by process name — so a desktop app + // launched directly (no pid file) can still be stopped. + let Some(pid) = app_pid().map(|p| p as i32) else { + eprintln!("orcabot: no running stack found (nothing to stop)"); + let _ = fs::remove_file(pid_file()); return 1; }; - // SIGINT triggers the desktop app's ctrlc handler → clean shutdown (stops VM). + // SIGINT triggers the desktop app's ctrlc/Exit handler → clean shutdown (stops VM). let rc = unsafe { libc::kill(pid, libc::SIGINT) }; if rc != 0 { eprintln!("orcabot: process {pid} not running (already stopped?)"); @@ -297,6 +332,1209 @@ fn cmd_down() -> i32 { 0 } +// ---- session packaging (export / import a .orcabot bundle) ----------------- +// +// A bundle is a .tar.gz: manifest.json (dashboard + items + edges = the canvas) + +// workspace/ (the files). Both ops speak the control-plane API, which is identical +// locally and in the cloud — so `push`/`pull` will reuse this, just retargeted. +// Secrets/integrations are intentionally NOT included (re-add on the destination). + +fn workspace_dir() -> PathBuf { + data_dir().join("workspace") +} + +fn cmd_export(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let (pos, dash) = split_dash_flag(rest); + let mut out: Option = None; + if let Some(i) = pos.iter().position(|a| a == "--out") { + out = pos.get(i + 1).cloned(); + } + let did = match first_or_named_dash(dash) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + let v = match cp_call("GET", &format!("/dashboards/{}", did), None) { + Ok(v) => v, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + let name = v + .get("dashboard") + .and_then(|d| d.get("name")) + .and_then(|x| x.as_str()) + .unwrap_or("dashboard") + .to_string(); + let manifest = serde_json::json!({ + "version": 1, + "name": name, + "items": v.get("items").cloned().unwrap_or(serde_json::json!([])), + "edges": v.get("edges").cloned().unwrap_or(serde_json::json!([])), + }); + + let stage = std::env::temp_dir().join(format!("orcabot-export-{}", std::process::id())); + let _ = fs::remove_dir_all(&stage); + if let Err(e) = fs::create_dir_all(&stage) { + eprintln!("orcabot: {e}"); + return 1; + } + if let Err(e) = fs::write( + stage.join("manifest.json"), + serde_json::to_string_pretty(&manifest).unwrap_or_default(), + ) { + eprintln!("orcabot: {e}"); + return 1; + } + + let safe: String = name + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect(); + let out_path = out.unwrap_or_else(|| format!("{}.orcabot", safe.trim_matches('-'))); + let ws = workspace_dir(); + let mut args: Vec = vec!["-czf".into(), out_path.clone()]; + // Exclude regenerable caches/transients + live sandbox runtime state (.orcabot) + // — not user content, and re-importing .orcabot would clobber/stall the target. + for pat in ["workspace/.browser", "workspace/.npm", "workspace/.orcabot", "workspace/.claude/cache", "*/node_modules", "workspace/node_modules"] { + args.push("--exclude".into()); + args.push(pat.into()); + } + args.push("-C".into()); + args.push(stage.to_string_lossy().into()); + args.push("manifest.json".into()); + if ws.exists() { + args.push("-C".into()); + args.push(data_dir().to_string_lossy().into()); + args.push("workspace".into()); + } + let status = Command::new("tar").args(&args).status(); + let _ = fs::remove_dir_all(&stage); + match status { + Ok(s) if s.success() => { + let n_items = manifest["items"].as_array().map(|a| a.len()).unwrap_or(0); + let sz = fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0); + println!( + "orcabot: exported '{name}' ({n_items} components, {} KB) -> {out_path}", + sz / 1024 + ); + 0 + } + _ => { + eprintln!("orcabot: tar failed creating {out_path}"); + 1 + } + } +} + +fn cmd_import(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first"); + return 1; + } + let (pos, _) = split_dash_flag(rest); + let mut name_override: Option = None; + if let Some(i) = pos.iter().position(|a| a == "--name") { + name_override = pos.get(i + 1).cloned(); + } + let Some(bundle) = pos.first() else { + eprintln!("usage: orcabot import [--name ]"); + return 2; + }; + if !PathBuf::from(bundle).exists() { + eprintln!("orcabot: bundle not found: {bundle}"); + return 1; + } + + let stage = std::env::temp_dir().join(format!("orcabot-import-{}", std::process::id())); + let _ = fs::remove_dir_all(&stage); + let _ = fs::create_dir_all(&stage); + let untar = Command::new("tar") + .args(["-xzf", bundle, "-C", &stage.to_string_lossy()]) + .status(); + if !matches!(untar, Ok(s) if s.success()) { + eprintln!("orcabot: failed to extract bundle"); + let _ = fs::remove_dir_all(&stage); + return 1; + } + let manifest: serde_json::Value = match fs::read_to_string(stage.join("manifest.json")) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + { + Some(m) => m, + None => { + eprintln!("orcabot: bundle has no valid manifest.json"); + let _ = fs::remove_dir_all(&stage); + return 1; + } + }; + + let name = name_override.unwrap_or_else(|| { + format!( + "{} (imported)", + manifest.get("name").and_then(|x| x.as_str()).unwrap_or("dashboard") + ) + }); + let nd = match cp_call("POST", "/dashboards", Some(serde_json::json!({ "name": name }))) { + Ok(v) => v + .get("dashboard") + .and_then(|d| d.get("id")) + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + Err(e) => { + eprintln!("orcabot: create dashboard: {e}"); + let _ = fs::remove_dir_all(&stage); + return 1; + } + }; + + // Recreate items, mapping old id -> new id (for edges). + let mut idmap: HashMap = HashMap::new(); + let empty = vec![]; + for it in manifest.get("items").and_then(|x| x.as_array()).unwrap_or(&empty) { + let body = serde_json::json!({ + "type": it.get("type"), + "content": it.get("content").cloned().unwrap_or(serde_json::json!("")), + "position": it.get("position"), + "size": it.get("size"), + "metadata": it.get("metadata"), + }); + match cp_call("POST", &format!("/dashboards/{}/items", nd), Some(body)) { + Ok(r) => { + if let (Some(old), Some(new)) = ( + it.get("id").and_then(|x| x.as_str()), + r.get("item").and_then(|i| i.get("id")).and_then(|x| x.as_str()), + ) { + idmap.insert(old.to_string(), new.to_string()); + } + } + Err(e) => eprintln!("orcabot: skip item: {e}"), + } + } + // Recreate edges with mapped ids. + let mut edge_n = 0; + for e in manifest.get("edges").and_then(|x| x.as_array()).unwrap_or(&empty) { + let s = e.get("sourceItemId").and_then(|x| x.as_str()).and_then(|o| idmap.get(o)); + let t = e.get("targetItemId").and_then(|x| x.as_str()).and_then(|o| idmap.get(o)); + if let (Some(s), Some(t)) = (s, t) { + let body = serde_json::json!({ + "sourceItemId": s, "targetItemId": t, + "sourceHandle": e.get("sourceHandle"), "targetHandle": e.get("targetHandle"), + }); + if cp_call("POST", &format!("/dashboards/{}/edges", nd), Some(body)).is_ok() { + edge_n += 1; + } + } + } + + // Restore workspace files (merge into the shared workspace). + let src_ws = stage.join("workspace"); + if src_ws.exists() { + let dest = workspace_dir(); + let _ = fs::create_dir_all(&dest); + // cp -R /. / (merge, preserve) + let _ = Command::new("cp") + .args(["-R", &format!("{}/.", src_ws.to_string_lossy()), &dest.to_string_lossy()]) + .status(); + } + let _ = fs::remove_dir_all(&stage); + + println!( + "orcabot: imported '{name}' -> dashboard {} ({} components, {edge_n} edges, workspace restored)", + nd, + idmap.len() + ); + 0 +} + +// ---- push / pull (export/import retargeted at a remote control plane) ------ +// +// `push` copies a LOCAL dashboard to a remote control plane; `pull` copies a +// REMOTE dashboard down to the local stack. Both reuse the manifest logic from +// export/import (items + edges, old->new id remap) but move the workspace over +// the file API (GET/PUT /sessions/:id/file) instead of a host cp -R, since a +// remote sandbox lives in its own VM and isn't host-mounted. +// +// The remote is just another control plane, addressed by base URL + auth: +// --remote (or env ORCABOT_REMOTE_URL) +// --token (or env ORCABOT_REMOTE_TOKEN) -> Authorization: Bearer +// --user -> dev auth X-User-ID (for local/dev targets) +// With no --remote it targets the local stack — useful for verifying the path. +// +// Secrets/integrations are intentionally NOT transferred (re-add on the dest). +// PRODUCTION NOTE: the public cloud uses Cloudflare Access (browser-only) and a +// subscription paywall, so a real `--token` flow needs a CLI token endpoint + +// paywall handling on the server first; the client path here is complete. + +/// How the CLI authenticates to a control plane. +enum RemoteAuth { + /// Local/dev control plane: the full dev-auth identity. Email + name are + /// REQUIRED, not optional — the control plane only auto-creates a user on a + /// clean DB when an email is present, and it resolves an existing user by + /// email, so sending the desktop email is what makes the CLI converge with the + /// GUI's user (both end up on desktop@localhost). Sending only X-User-ID 401s + /// on a clean DB. + Dev { user: String }, + /// Cloud control plane: a personal access token. + Bearer { token: String }, +} + +/// A target control plane for push/pull/token: base URL + how to authenticate. +struct Remote { + base: String, + auth: RemoteAuth, +} + +impl Remote { + fn local() -> Remote { + Remote { + base: format!("http://127.0.0.1:{}", CONTROLPLANE_PORT), + auth: RemoteAuth::Dev { user: DEV_USER.into() }, + } + } + fn is_local(&self) -> bool { + self.base.contains("127.0.0.1") || self.base.contains("localhost") + } + /// Apply the auth headers for this remote to a ureq request builder. + fn apply_auth(&self, req: ureq::Request) -> ureq::Request { + match &self.auth { + RemoteAuth::Dev { user } => req + .set("X-User-ID", user) + .set("X-User-Email", DEV_EMAIL) + .set("X-User-Name", DEV_NAME), + RemoteAuth::Bearer { token } => req.set("Authorization", &format!("Bearer {token}")), + } + } +} + +/// Build a Remote from `--remote/--token/--user` flags (env fallbacks). +fn remote_from(rest: &[String]) -> Remote { + let mut base = std::env::var("ORCABOT_REMOTE_URL").ok(); + let mut token = std::env::var("ORCABOT_REMOTE_TOKEN").ok(); + let mut user: Option = None; + let mut i = 0; + while i < rest.len() { + match rest[i].as_str() { + "--remote" => { + base = rest.get(i + 1).cloned(); + i += 1; + } + "--token" => { + token = rest.get(i + 1).cloned(); + i += 1; + } + "--user" => { + user = rest.get(i + 1).cloned(); + i += 1; + } + _ => {} + } + i += 1; + } + let base = base + .unwrap_or_else(|| format!("http://127.0.0.1:{}", CONTROLPLANE_PORT)) + .trim_end_matches('/') + .to_string(); + match token { + Some(t) => Remote { base, auth: RemoteAuth::Bearer { token: t } }, + None => Remote { + base, + auth: RemoteAuth::Dev { user: user.unwrap_or_else(|| DEV_USER.into()) }, + }, + } +} + +/// First positional (non-flag) token after the subcommand, treated as a dashboard id. +fn first_positional(rest: &[String]) -> Option { + let flags_with_val = ["--remote", "--token", "--user", "--dash"]; + let mut i = 0; + while i < rest.len() { + let a = &rest[i]; + if flags_with_val.contains(&a.as_str()) { + i += 2; + continue; + } + if a.starts_with("--") { + i += 1; + continue; + } + return Some(a.clone()); + } + None +} + +fn has_flag(rest: &[String], flag: &str) -> bool { + rest.iter().any(|a| a == flag) +} + +// JSON API call against an arbitrary remote (mirror of cp_call). +fn api_json( + r: &Remote, + method: &str, + path: &str, + body: Option, +) -> Result { + let url = format!("{}{}", r.base, path); + let req = r + .apply_auth(agent(Duration::from_secs(30)).request(method, &url)) + .set("Origin", &r.base) + .set("Content-Type", "application/json"); + let resp = match body { + Some(b) => req.send_json(b), + None => req.call(), + }; + match resp { + Ok(rp) => Ok(rp.into_json().unwrap_or(serde_json::Value::Null)), + Err(ureq::Error::Status(c, rp)) => { + let body = rp.into_string().unwrap_or_default(); + // The cloud control plane gates mutating requests behind the + // subscription paywall (403 SUBSCRIPTION_REQUIRED). Surface a clean, + // recognizable error so push/pull/web can show actionable guidance + // instead of a raw JSON blob. + if c == 403 && body.contains("SUBSCRIPTION_REQUIRED") { + return Err(PAYWALL_ERR.to_string()); + } + Err(format!("HTTP {c}: {}", body.trim())) + } + Err(e) => Err(e.to_string()), + } +} + +/// Sentinel carried in error strings so callers can detect a paywall denial. +const PAYWALL_ERR: &str = "SUBSCRIPTION_REQUIRED"; + +fn is_paywall(err: &str) -> bool { + err.contains(PAYWALL_ERR) +} + +/// Actionable message for a paywall denial. `web` is where the user manages billing. +fn paywall_message(remote_base: &str) -> String { + let web = std::env::var("ORCABOT_WEB_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| remote_base.to_string()); + format!( + "cloud push/pull needs an active OrcaBot subscription or trial on this account.\n\ + \x20 Manage your subscription in the web app: {web}\n\ + \x20 (Tip: `orcabot token` mints a token regardless, but creating dashboards on the cloud requires a subscription.)" + ) +} + +// Workspace file API helpers (path is workspace-relative; ureq percent-encodes the query). +fn file_put(r: &Remote, sid: &str, rel: &str, data: &[u8]) -> Result<(), String> { + let url = format!("{}/sessions/{}/file", r.base, sid); + match r + .apply_auth(agent(Duration::from_secs(120)).put(&url).query("path", rel)) + .set("Content-Type", "application/octet-stream") + .send_bytes(data) + { + Ok(_) => Ok(()), + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(e.to_string()), + } +} + +fn file_get(r: &Remote, sid: &str, rel: &str) -> Result, String> { + let url = format!("{}/sessions/{}/file", r.base, sid); + match r + .apply_auth(agent(Duration::from_secs(120)).get(&url).query("path", rel)) + .call() + { + Ok(rp) => { + let mut buf = Vec::new(); + rp.into_reader().read_to_end(&mut buf).map_err(|e| e.to_string())?; + Ok(buf) + } + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(e.to_string()), + } +} + +fn file_list(r: &Remote, sid: &str) -> Result, String> { + let v = api_json(r, "GET", &format!("/sessions/{}/files?path=/&recursive=true", sid), None)?; + Ok(v.get("files").and_then(|x| x.as_array()).cloned().unwrap_or_default()) +} + +/// Whether the target sandbox supports bulk workspace import. Probes with a tiny +/// body so we never stream a multi-MB tar to a sandbox that lacks the route — an +/// old sandbox 404s *without draining the body*, which stalls a large upload. +/// 404 => unsupported; anything else (e.g. 400 "bad gzip") => the route exists. +fn workspace_import_supported(r: &Remote, sid: &str) -> bool { + let url = format!("{}/sessions/{}/workspace/import", r.base, sid); + match r + .apply_auth(agent(Duration::from_secs(15)).post(&url)) + .set("Content-Type", "application/gzip") + .send_bytes(b"\x00\x00") + { + Ok(_) => true, + Err(ureq::Error::Status(404, _)) => false, + Err(ureq::Error::Status(_, _)) => true, + Err(_) => false, + } +} + +/// Bulk-import a tar.gz of the workspace in one request. Returns (written, skipped). +/// Bounded: on the desktop the workspace is a host-shared virtiofs mount, and a +/// guest write to a file the macOS host holds open (Spotlight/QuickLook) can +/// stall; on timeout the caller falls back to the resilient per-file path. Cloud +/// (ext4) never stalls, so a normal upload completes well within this bound. +fn workspace_import(r: &Remote, sid: &str, tar_path: &str) -> Result<(u64, u64), String> { + let data = fs::read(tar_path).map_err(|e| e.to_string())?; + let url = format!("{}/sessions/{}/workspace/import", r.base, sid); + let resp = r + .apply_auth(agent(Duration::from_secs(90)).post(&url)) + .set("Content-Type", "application/gzip") + .send_bytes(&data); + match resp { + Ok(rp) => { + let v: serde_json::Value = rp.into_json().unwrap_or(serde_json::Value::Null); + Ok(( + v.get("written").and_then(|x| x.as_u64()).unwrap_or(0), + v.get("skipped").and_then(|x| x.as_u64()).unwrap_or(0), + )) + } + Err(ureq::Error::Status(c, rp)) => { + Err(format!("HTTP {c}: {}", rp.into_string().unwrap_or_default().trim())) + } + Err(e) => Err(e.to_string()), + } +} + +/// Tar the workspace (workspace-relative entries) with the cache excludes applied. +fn tar_workspace(ws: &std::path::Path, out: &str) -> Result<(), String> { + let mut args: Vec = vec!["-czf".into(), out.into()]; + // .orcabot is live sandbox runtime state (PTY tracking, run-bridge scripts the + // sandbox executes, mcp secrets) — never user content, and writing into it on + // the target blocks the import. Exclude it like the other runtime/cache dirs. + for pat in ["./.browser", "./.npm", "./.orcabot", "./.claude/cache", "./.git", "./node_modules", "*/node_modules"] { + args.push("--exclude".into()); + args.push(pat.into()); + } + args.push("-C".into()); + args.push(ws.to_string_lossy().into()); + args.push(".".into()); + let status = Command::new("tar").args(&args).status().map_err(|e| e.to_string())?; + if status.success() { + Ok(()) + } else { + Err("tar failed".into()) + } +} + +/// Regenerable caches / transients / runtime state we never transfer. +fn ws_excluded(rel: &str) -> bool { + let rel = rel.trim_start_matches('/'); + rel.starts_with(".browser") + || rel.starts_with(".npm") + || rel == ".orcabot" + || rel.starts_with(".orcabot/") // live sandbox runtime state (PTYs, secrets) + || rel.starts_with(".claude/cache") // regenerable cache; often host-held (virtiofs stall) + || rel == ".git" + || rel.starts_with(".git/") + || rel.split('/').any(|seg| seg == "node_modules") +} + +/// Resolve a workspace-relative path (from a REMOTE `pull`) to a host path, +/// rejecting anything that would escape the workspace. `pull` writes files the +/// remote control plane lists, so a malicious/compromised remote could return +/// `../...` or a path through an in-workspace symlink and clobber arbitrary host +/// files. Returns None (caller skips) for unsafe paths. Does NOT create anything. +/// (The sandbox import path is already scoped via Workspace.Write; this is the +/// equivalent guard for the local write side.) +fn safe_workspace_dest(ws_canon: &std::path::Path, rel: &str) -> Option { + use std::path::Component; + let rel_path = std::path::Path::new(rel); + // 1) Lexical: only plain names (reject "..", absolute/root, drive prefix). + for c in rel_path.components() { + if !matches!(c, Component::Normal(_) | Component::CurDir) { + return None; + } + } + let dest = ws_canon.join(rel_path); + // 2) Symlink-safe: the nearest EXISTING ancestor must resolve inside the + // workspace (an in-ws symlink pointing out would otherwise be followed on + // write). Non-existent tail components are created later under a verified + // ancestor, and contain no symlinks (they don't exist yet). + let mut anc = dest.parent(); + while let Some(a) = anc { + if a.exists() { + match a.canonicalize() { + Ok(real) if real.starts_with(ws_canon) => break, + _ => return None, + } + } + anc = a.parent(); + } + Some(dest) +} + +/// Recreate a dashboard (items + edges, old->new item-id remap) on `dest`. +/// Returns (new dashboard id, idmap, edges created). +fn recreate_dashboard( + dest: &Remote, + name: &str, + items: &[serde_json::Value], + edges: &[serde_json::Value], +) -> Result<(String, HashMap, usize), String> { + let created = api_json(dest, "POST", "/dashboards", Some(serde_json::json!({ "name": name })))?; + let nd = created + .get("dashboard") + .and_then(|d| d.get("id")) + .and_then(|x| x.as_str()) + .ok_or("remote did not return a dashboard id")? + .to_string(); + + let mut idmap: HashMap = HashMap::new(); + for it in items { + let body = serde_json::json!({ + "type": it.get("type"), + "content": it.get("content").cloned().unwrap_or(serde_json::json!("")), + "position": it.get("position"), + "size": it.get("size"), + "metadata": it.get("metadata"), + }); + match api_json(dest, "POST", &format!("/dashboards/{}/items", nd), Some(body)) { + Ok(rp) => { + if let (Some(old), Some(new)) = ( + it.get("id").and_then(|x| x.as_str()), + rp.get("item").and_then(|i| i.get("id")).and_then(|x| x.as_str()), + ) { + idmap.insert(old.to_string(), new.to_string()); + } + } + Err(e) => eprintln!("orcabot: skip item: {e}"), + } + } + + let mut edge_n = 0; + for e in edges { + let s = e.get("sourceItemId").and_then(|x| x.as_str()).and_then(|o| idmap.get(o)); + let t = e.get("targetItemId").and_then(|x| x.as_str()).and_then(|o| idmap.get(o)); + if let (Some(s), Some(t)) = (s, t) { + let body = serde_json::json!({ + "sourceItemId": s, "targetItemId": t, + "sourceHandle": e.get("sourceHandle"), "targetHandle": e.get("targetHandle"), + }); + if api_json(dest, "POST", &format!("/dashboards/{}/edges", nd), Some(body)).is_ok() { + edge_n += 1; + } + } + } + Ok((nd, idmap, edge_n)) +} + +/// Ensure `dash` on `r` has an active session (a live sandbox) and return its +/// control-plane session id, so we can read/write the workspace over the file API. +/// Reuses an active session, else starts an existing terminal, else creates one. +fn ensure_session(r: &Remote, dash: &str) -> Result { + let snapshot = |r: &Remote| -> Result<(Option, Option), String> { + // -> (active session id, an item id of some terminal without an active session) + let v = api_json(r, "GET", &format!("/dashboards/{}", dash), None)?; + let sessions = v.get("sessions").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + for s in &sessions { + if s.get("status").and_then(|x| x.as_str()) == Some("active") { + if let Some(id) = s.get("id").and_then(|x| x.as_str()) { + return Ok((Some(id.to_string()), None)); + } + } + } + let items = v.get("items").and_then(|x| x.as_array()).cloned().unwrap_or_default(); + let term = items + .iter() + .find(|it| it.get("type").and_then(|x| x.as_str()) == Some("terminal")) + .and_then(|it| it.get("id").and_then(|x| x.as_str()).map(String::from)); + Ok((None, term)) + }; + + let (active, term) = snapshot(r)?; + if let Some(sid) = active { + return Ok(sid); + } + // Need a terminal item to host a session; create a transfer terminal if none. + let item_id = match term { + Some(t) => t, + None => { + let body = serde_json::json!({ + "type": "terminal", + "content": "{\"name\":\"transfer\",\"bootCommand\":\"\"}", + }); + let rp = api_json(r, "POST", &format!("/dashboards/{}/items", dash), Some(body))?; + rp.get("item") + .and_then(|i| i.get("id")) + .and_then(|x| x.as_str()) + .ok_or("could not create a transfer terminal")? + .to_string() + } + }; + let _ = api_json( + r, + "POST", + &format!("/dashboards/{}/items/{}/session", dash, item_id), + Some(serde_json::json!({})), + )?; + // Poll for the session to go active (cloud spins up a VM — allow generous time). + for _ in 0..60 { + std::thread::sleep(Duration::from_secs(2)); + let v = api_json(r, "GET", &format!("/dashboards/{}", dash), None)?; + if let Some(sessions) = v.get("sessions").and_then(|x| x.as_array()) { + for s in sessions { + if s.get("itemId").and_then(|x| x.as_str()) == Some(item_id.as_str()) + && s.get("status").and_then(|x| x.as_str()) == Some("active") + { + if let Some(id) = s.get("id").and_then(|x| x.as_str()) { + return Ok(id.to_string()); + } + } + } + } + } + Err("timed out waiting for a sandbox session on the target".into()) +} + +/// Copy a local dashboard to `dest`. Returns the new remote dashboard id. +/// Recreates canvas (items+edges) and, unless `include_workspace` is false, +/// transfers the workspace over the file API. Prints progress. +fn push_dashboard(dest: &Remote, did: &str, include_workspace: bool) -> Result { + let v = cp_call("GET", &format!("/dashboards/{}", did), None) + .map_err(|e| format!("read local dashboard: {e}"))?; + let name = v + .get("dashboard") + .and_then(|d| d.get("name")) + .and_then(|x| x.as_str()) + .unwrap_or("dashboard") + .to_string(); + let empty = vec![]; + let items = v.get("items").and_then(|x| x.as_array()).unwrap_or(&empty).clone(); + let edges = v.get("edges").and_then(|x| x.as_array()).unwrap_or(&empty).clone(); + + if dest.is_local() { + eprintln!("orcabot: note — target is the local control plane (verification mode; same VM/workspace)"); + } + println!("orcabot: pushing '{name}' ({} components) -> {}", items.len(), dest.base); + + let (nd, idmap, edge_n) = recreate_dashboard(dest, &name, &items, &edges) + .map_err(|e| format!("recreate on remote failed: {e}"))?; + println!("orcabot: created remote dashboard {nd} ({} components, {edge_n} edges)", idmap.len()); + + if !include_workspace { + println!("orcabot: pushed (canvas only; --no-workspace)."); + return Ok(nd); + } + + let sid = ensure_session(dest, &nd) + .map_err(|e| format!("could not get a remote sandbox for workspace transfer: {e}"))?; + + let ws = workspace_dir(); + // Prefer one bulk tar import (orders of magnitude faster than per-file PUT). + // Probe first (tiny body) so we never stream a big tar to a sandbox that + // lacks the route; fall back to per-file PUT when it's unavailable. + let mut did_bulk = false; + if workspace_import_supported(dest, &sid) { + let tar_path = std::env::temp_dir() + .join(format!("orcabot-push-{}.tgz", std::process::id())) + .to_string_lossy() + .to_string(); + match tar_workspace(&ws, &tar_path).and_then(|_| workspace_import(dest, &sid, &tar_path)) { + Ok((written, skipped)) => { + println!( + "orcabot: pushed '{name}' -> {} (dashboard {nd}; {written} files, {skipped} skipped, bulk)", + dest.base + ); + did_bulk = true; + } + Err(e) => eprintln!("orcabot: bulk import failed ({e}); falling back to per-file…"), + } + let _ = fs::remove_file(&tar_path); + } else { + eprintln!("orcabot: bulk import not supported by the target; using per-file…"); + } + if !did_bulk { + let (n, bytes, skipped) = push_workspace_perfile(dest, &sid, &ws); + println!( + "orcabot: pushed '{name}' -> {} (dashboard {nd}; {n} files, {} MB, {skipped} skipped)", + dest.base, + bytes / 1_048_576 + ); + } + Ok(nd) +} + +/// Per-file workspace upload (PUT /file each). Fallback when bulk import is absent. +/// Returns (files, bytes, skipped). +fn push_workspace_perfile(dest: &Remote, sid: &str, ws: &std::path::Path) -> (u64, u64, u64) { + let (mut n, mut bytes, mut skipped) = (0u64, 0u64, 0u64); + for entry in walkdir::WalkDir::new(ws).follow_links(false).into_iter().filter_map(|e| e.ok()) { + if !entry.file_type().is_file() { + continue; + } + let rel = match entry.path().strip_prefix(ws) { + Ok(r) => r.to_string_lossy().to_string(), + Err(_) => continue, + }; + if ws_excluded(&rel) { + continue; + } + let data = match fs::read(entry.path()) { + Ok(d) => d, + Err(_) => continue, + }; + if data.len() as u64 > 100 * 1024 * 1024 { + eprintln!("orcabot: skip (>100MB): {rel}"); + skipped += 1; + continue; + } + match file_put(dest, sid, &rel, &data) { + Ok(()) => { + n += 1; + bytes += data.len() as u64; + if n % 50 == 0 { + println!("orcabot: …{n} files ({} MB)", bytes / 1_048_576); + } + } + Err(e) => { + eprintln!("orcabot: skip {rel}: {e}"); + skipped += 1; + } + } + } + (n, bytes, skipped) +} + +fn cmd_push(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first (push reads the local stack)"); + return 1; + } + let dest = remote_from(rest); + let include_ws = !has_flag(rest, "--no-workspace"); + let did = match rest + .iter() + .position(|a| a == "--dash") + .and_then(|i| rest.get(i + 1).cloned()) + .or_else(|| first_positional(rest)) + { + Some(d) => d, + None => match first_or_named_dash(None) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }, + }; + match push_dashboard(&dest, &did, include_ws) { + Ok(_) => 0, + Err(e) if is_paywall(&e) => { + eprintln!("orcabot: {}", paywall_message(&dest.base)); + 1 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } +} + +fn cmd_pull(rest: &[String]) -> i32 { + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first (pull writes the local stack)"); + return 1; + } + let src = remote_from(rest); + let no_ws = has_flag(rest, "--no-workspace"); + let did = match rest + .iter() + .position(|a| a == "--dash") + .and_then(|i| rest.get(i + 1).cloned()) + .or_else(|| first_positional(rest)) + { + Some(d) => d, + None => { + eprintln!("usage: orcabot pull --remote [--token ]"); + return 2; + } + }; + + let v = match api_json(&src, "GET", &format!("/dashboards/{}", did), None) { + Ok(v) => v, + Err(e) => { + eprintln!("orcabot: read remote dashboard: {e}"); + return 1; + } + }; + let name = format!( + "{} (pulled)", + v.get("dashboard") + .and_then(|d| d.get("name")) + .and_then(|x| x.as_str()) + .unwrap_or("dashboard") + ); + let empty = vec![]; + let items = v.get("items").and_then(|x| x.as_array()).unwrap_or(&empty).clone(); + let edges = v.get("edges").and_then(|x| x.as_array()).unwrap_or(&empty).clone(); + + let local = Remote::local(); + println!("orcabot: pulling '{name}' ({} components) <- {}", items.len(), src.base); + let (nd, idmap, edge_n) = match recreate_dashboard(&local, &name, &items, &edges) { + Ok(x) => x, + Err(e) => { + eprintln!("orcabot: recreate locally failed: {e}"); + return 1; + } + }; + println!("orcabot: created local dashboard {nd} ({} components, {edge_n} edges)", idmap.len()); + + if no_ws { + println!("orcabot: pulled (canvas only; --no-workspace)."); + return 0; + } + + let sid = match ensure_session(&src, &did) { + Ok(s) => s, + Err(e) if is_paywall(&e) => { + eprintln!("orcabot: canvas pulled, but the workspace needs a live sandbox — {}", paywall_message(&src.base)); + return 1; + } + Err(e) => { + eprintln!("orcabot: could not get a remote sandbox to read the workspace: {e}"); + return 1; + } + }; + let files = match file_list(&src, &sid) { + Ok(f) => f, + Err(e) => { + eprintln!("orcabot: list remote workspace: {e}"); + return 1; + } + }; + let ws = workspace_dir(); + let _ = fs::create_dir_all(&ws); + // Canonicalize the workspace root once for containment checks against the + // remote-supplied paths below. + let ws_canon = match ws.canonicalize() { + Ok(c) => c, + Err(e) => { + eprintln!("orcabot: cannot resolve workspace {}: {e}", ws.display()); + return 1; + } + }; + let (mut n, mut bytes, mut skipped) = (0u64, 0u64, 0u64); + for f in &files { + if f.get("is_dir").and_then(|x| x.as_bool()).unwrap_or(false) { + continue; + } + let path = match f.get("path").and_then(|x| x.as_str()) { + Some(p) => p.trim_start_matches('/').to_string(), + None => continue, + }; + if path.is_empty() || ws_excluded(&path) { + continue; + } + // Reject remote paths that would escape the workspace (traversal/symlink). + let dest_path = match safe_workspace_dest(&ws_canon, &path) { + Some(d) => d, + None => { + eprintln!("orcabot: skip unsafe remote path: {path}"); + skipped += 1; + continue; + } + }; + let data = match file_get(&src, &sid, &path) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: skip {path}: {e}"); + skipped += 1; + continue; + } + }; + if let Some(parent) = dest_path.parent() { + let _ = fs::create_dir_all(parent); + } + if fs::write(&dest_path, &data).is_ok() { + n += 1; + bytes += data.len() as u64; + if n % 50 == 0 { + println!("orcabot: …{n} files ({} MB)", bytes / 1_048_576); + } + } else { + skipped += 1; + } + } + println!( + "orcabot: pulled '{name}' <- {} (dashboard {nd}; {n} files, {} MB, {skipped} skipped)", + src.base, + bytes / 1_048_576 + ); + 0 +} + +/// Mint / list / revoke a personal access token (PAT) for `push`/`pull`. +/// Targets a remote (or the local stack). On the public cloud, issuance is +/// gated by Cloudflare Access (browser), so there you generate the PAT in the +/// web UI; this command is for local/dev/self-hosted control planes. +fn cmd_token(rest: &[String]) -> i32 { + let r = remote_from(rest); + let sub = first_positional(rest); + match sub.as_deref() { + Some("list") => match api_json(&r, "GET", "/auth/api-tokens", None) { + Ok(v) => { + let empty = vec![]; + let toks = v.get("tokens").and_then(|x| x.as_array()).unwrap_or(&empty); + if toks.is_empty() { + println!("orcabot: no tokens on {}", r.base); + } else { + for t in toks { + println!( + " {} {} created {} last_used {}", + t.get("id").and_then(|x| x.as_str()).unwrap_or("?"), + t.get("name").and_then(|x| x.as_str()).unwrap_or(""), + t.get("createdAt").and_then(|x| x.as_str()).unwrap_or("?"), + t.get("lastUsedAt").and_then(|x| x.as_str()).unwrap_or("never"), + ); + } + } + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + }, + Some("revoke") => { + // positional after "revoke": find the token id (next non-flag token) + let id = rest + .iter() + .skip_while(|a| a.as_str() != "revoke") + .nth(1) + .cloned(); + let Some(id) = id else { + eprintln!("usage: orcabot token revoke [--remote URL]"); + return 2; + }; + match api_json(&r, "DELETE", &format!("/auth/api-tokens/{}", id), None) { + Ok(_) => { + println!("orcabot: revoked {id}"); + 0 + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } + } + _ => { + // mint a new token + let mut name = "cli".to_string(); + if let Some(i) = rest.iter().position(|a| a == "--name") { + if let Some(n) = rest.get(i + 1) { + name = n.clone(); + } + } + match api_json(&r, "POST", "/auth/api-token", Some(serde_json::json!({ "name": name }))) { + Ok(v) => match v.get("token").and_then(|x| x.as_str()) { + Some(tok) => { + println!("{tok}"); + eprintln!("orcabot: PAT created (shown once). Use it with: --token {tok}"); + 0 + } + None => { + eprintln!("orcabot: unexpected response: {v}"); + 1 + } + }, + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } + } + } +} + +// ---- surface switching (cli / desktop / web) ------------------------------ + +/// PID of the running desktop backend process, if any (pid file, else pgrep). +fn app_pid() -> Option { + if let Ok(s) = fs::read_to_string(pid_file()) { + if let Ok(p) = s.trim().parse::() { + if unsafe { libc::kill(p, 0) } == 0 { + return Some(p as u32); + } + } + } + let out = Command::new("pgrep").arg("-f").arg("orcabot-desktop").output().ok()?; + String::from_utf8_lossy(&out.stdout) + .lines() + .filter_map(|l| l.trim().parse::().ok()) + .next() +} + +fn send_sig(pid: u32, sig: i32) -> bool { + unsafe { libc::kill(pid as i32, sig) == 0 } +} + +/// Spawn the desktop binary detached. headless=false launches it with the GUI shown. +fn spawn_desktop_detached(headless: bool) -> Result { + let bin = desktop_binary().ok_or("could not find the orcabot-desktop binary")?; + let log = File::create(headless_log()).map_err(|e| e.to_string())?; + let log_err = log.try_clone().map_err(|e| e.to_string())?; + let mut command = Command::new(&bin); + command.env("VZ_CONSOLE_DIRECT", "1"); + if headless { + command.env("ORCABOT_DESKTOP_HEADLESS", "1"); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(log_err)); + unsafe { + command.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let child = command.spawn().map_err(|e| e.to_string())?; + let pid = child.id(); + let _ = fs::write(pid_file(), pid.to_string()); + Ok(pid) +} + +fn wait_ready(timeout_secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + while Instant::now() < deadline { + if controlplane_healthy() && sandbox_health().is_some() { + return true; + } + std::thread::sleep(Duration::from_secs(2)); + } + false +} + +fn cmd_desktop() -> i32 { + if let Some(pid) = app_pid() { + if send_sig(pid, libc::SIGUSR1) { + println!("orcabot: switched to desktop — GUI window shown (backend pid {pid})."); + return 0; + } + eprintln!("orcabot: failed to signal app {pid}"); + return 1; + } + println!("orcabot: no running stack — launching the desktop GUI…"); + match spawn_desktop_detached(false) { + Ok(pid) => { + print!("orcabot: starting (pid {pid})"); + let _ = std::io::stdout().flush(); + if wait_ready(150) { + println!("\norcabot: desktop up."); + 0 + } else { + eprintln!("\norcabot: timed out waiting for services"); + 1 + } + } + Err(e) => { + eprintln!("orcabot: {e}"); + 1 + } + } +} + +fn cmd_cli(rest: &[String]) -> i32 { + // `--owns` marks a desktop→CLI hand-off (the GUI's "Switch to CLI" button): + // the CLI becomes the active surface, so closing it stops the session. + let takeover = has_flag(rest, "--owns"); + let force_own = match app_pid() { + Some(pid) => { + send_sig(pid, libc::SIGUSR2); + eprintln!("orcabot: switched to cli — GUI hidden (backend pid {pid})."); + takeover // attach; own only if this is an explicit hand-off + } + None => { + eprintln!("orcabot: no running stack — starting headless…"); + if spawn_desktop_detached(true).is_err() || !wait_ready(150) { + eprintln!("orcabot: failed to start stack"); + return 1; + } + true // we started it → we own it + } + }; + run_tui(force_own) +} + +fn cmd_web(rest: &[String]) -> i32 { + // The web surface lives in the cloud. Switching to it = push the local + // dashboard up, then open it in the browser. Requires a remote target + // (--remote/--token or ORCABOT_REMOTE_URL/_TOKEN). The browser URL is the + // web app origin (ORCABOT_WEB_URL), falling back to the remote API base. + let dest = remote_from(rest); + let web_base = std::env::var("ORCABOT_WEB_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| dest.base.clone()); + + if dest.is_local() && std::env::var("ORCABOT_REMOTE_URL").is_err() { + eprintln!( + "orcabot: no cloud target set. Configure ORCABOT_REMOTE_URL=\n\ + and ORCABOT_REMOTE_TOKEN=, then `orcabot web`." + ); + if !web_base.is_empty() && web_base != dest.base { + let _ = Command::new("open").arg(&web_base).status(); + eprintln!("opened {web_base} (your local dashboard is not transferred)."); + } + return 1; + } + + if !controlplane_healthy() { + eprintln!("orcabot: run `orcabot up` first (web reads the local stack to push it)."); + return 1; + } + let include_ws = !has_flag(rest, "--no-workspace"); + let did = match rest + .iter() + .position(|a| a == "--dash") + .and_then(|i| rest.get(i + 1).cloned()) + .or_else(|| first_positional(rest)) + { + Some(d) => d, + None => match first_or_named_dash(None) { + Ok(d) => d, + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }, + }; + + let nd = match push_dashboard(&dest, &did, include_ws) { + Ok(id) => id, + Err(e) if is_paywall(&e) => { + eprintln!("orcabot: {}", paywall_message(&web_base)); + return 1; + } + Err(e) => { + eprintln!("orcabot: {e}"); + return 1; + } + }; + + let url = format!("{}/dashboards/{}", web_base.trim_end_matches('/'), nd); + let _ = Command::new("open").arg(&url).status(); + println!("orcabot: switched to web — opened {url}"); + 0 +} + fn cmd_status() -> i32 { let cp = controlplane_healthy(); let sb = sandbox_health(); @@ -400,13 +1638,23 @@ fn cmd_exec(rest: &[String]) -> i32 { // Control-plane client (the engine) — dev-auth, components, mutations. // =========================================================================== +// Dev-auth identity for the local control plane. The email/name MUST match the +// desktop frontend's dev login (`loginDevMode("Desktop User", "desktop@localhost")`) +// so the CLI and the GUI resolve to the SAME user: the control plane's dev-auth +// matches an existing user by email, so sending the email makes both surfaces +// converge on one account (shared dashboards). Sending only X-User-ID also 401s on +// a clean D1 — dev-auth only auto-creates a user when X-User-Email is present. const DEV_USER: &str = "dev-desktop"; +const DEV_EMAIL: &str = "desktop@localhost"; +const DEV_NAME: &str = "Desktop User"; fn cp_call(method: &str, path: &str, body: Option) -> Result { let url = format!("http://127.0.0.1:{}{}", CONTROLPLANE_PORT, path); let req = agent(Duration::from_secs(15)) .request(method, &url) .set("X-User-ID", DEV_USER) + .set("X-User-Email", DEV_EMAIL) + .set("X-User-Name", DEV_NAME) .set("Content-Type", "application/json"); let resp = match body { Some(b) => req.send_json(b), @@ -694,10 +1942,21 @@ fn open_pty_ws( CONTROLPLANE_PORT, session_id, pty_id, DEV_USER ); let mut req = url.into_client_request().map_err(|e| e.to_string())?; + // This tungstenite client (unlike a browser) CAN set headers on the handshake, + // so we pass the full dev-auth identity as headers — incl. email/name so the + // control plane resolves the same user as the GUI and auto-creates on a clean DB. req.headers_mut().insert( "X-User-ID", tungstenite::http::HeaderValue::from_static(DEV_USER), ); + req.headers_mut().insert( + "X-User-Email", + tungstenite::http::HeaderValue::from_static(DEV_EMAIL), + ); + req.headers_mut().insert( + "X-User-Name", + tungstenite::http::HeaderValue::from_static(DEV_NAME), + ); req.headers_mut().insert( "Origin", tungstenite::http::HeaderValue::from_static("http://localhost:8788"), @@ -1033,7 +2292,13 @@ fn connect_url(provider: &str) -> Result { } } }; - match ag.get(&url).set("X-User-ID", DEV_USER).call() { + match ag + .get(&url) + .set("X-User-ID", DEV_USER) + .set("X-User-Email", DEV_EMAIL) + .set("X-User-Name", DEV_NAME) + .call() + { Ok(resp) => loc_from(resp.status(), resp), Err(ureq::Error::Status(code, resp)) => loc_from(code, resp), Err(e) => Err(e.to_string()), @@ -1266,6 +2531,9 @@ struct App { input: String, log: Vec, quit: bool, + /// Set when leaving the TUI to hand the session to the desktop GUI — the + /// stack must stay up (the GUI now owns it), so skip the owned-session teardown. + handoff_to_desktop: bool, /// When set, we're in "terminal mode" streaming a component's live PTY. term: Option, } @@ -1278,9 +2546,10 @@ impl App { components: Vec::new(), input: String::new(), log: vec![ - "orcabot TUI — `help` for commands, Enter to run, Esc to quit.".to_string(), + "orcabot TUI — `help` for commands, Enter to run, Esc to quit. `desktop` to pop the GUI.".to_string(), ], quit: false, + handoff_to_desktop: false, term: None, }; a.reload_dashboards(); @@ -1436,7 +2705,8 @@ impl App { " rm delete a component by id", " exec run a shell command in the sandbox VM", " status service health", - " quit|q | Esc exit the TUI", + " desktop|gui pop the desktop GUI (keeps the session; closes the TUI)", + " quit|q | Esc exit the TUI (stops the session if this TUI started it)", ] { self.logln(l); } @@ -1575,6 +2845,18 @@ impl App { self.logln(l.to_string()); } } + ["desktop"] | ["gui"] => { + // Pop the desktop GUI for this same running session, then leave the + // TUI without tearing the stack down (the GUI takes over ownership). + match app_pid() { + Some(pid) if send_sig(pid, libc::SIGUSR1) => { + self.logln("switching to the desktop GUI (this session keeps running)…"); + self.handoff_to_desktop = true; + self.quit = true; + } + _ => self.logln("! couldn't reach the backend to show the desktop window"), + } + } _ => self.logln(format!("! unknown command: {line} (try `help`)")), } } @@ -1733,17 +3015,109 @@ fn run_in_vm(command: &str) -> String { } fn cmd_tui() -> i32 { + run_tui(false) +} + +/// Run the TUI with ownership semantics. +/// +/// Ownership model: a TUI that OWNS the session tears the stack down when it +/// closes — nothing lingers as a daemon. A TUI owns the session if it started +/// the stack itself, OR if `force_own` is set (e.g. a desktop→CLI hand-off via +/// the GUI's "Switch to CLI" button, where the CLI becomes the active surface). +/// Otherwise it just attaches and leaves the stack alone (e.g. `orcabot` against +/// an `up`'d persistent stack). Typing `desktop` in the TUI overrides ownership +/// (hand back to the GUI without tearing down). +fn run_tui(force_own: bool) -> i32 { + let mut we_own = force_own; if !controlplane_healthy() { - eprintln!("orcabot: stack not running — start it with `orcabot up` first."); - return 1; + println!("orcabot: starting the stack (it will stop when you quit)…"); + let rc = cmd_up(&[]); + if rc != 0 || !controlplane_healthy() { + eprintln!("orcabot: could not start the stack (see the log above); not opening the TUI."); + return if rc != 0 { rc } else { 1 }; + } + we_own = true; + } + // The TUI needs a real terminal. If stdout isn't a TTY (piped/redirected), + // don't panic in ratatui::init. If we own the stack but can't open a TUI, + // tear it back down so we don't leave it running for nothing. + if unsafe { libc::isatty(libc::STDOUT_FILENO) } != 1 { + eprintln!("orcabot: can't open the interactive TUI (stdout isn't a TTY)."); + if we_own { + cmd_down(); + } else { + eprintln!("orcabot: stack is running; use `orcabot ls` / `orcabot tail ` or run `orcabot` in a terminal."); + } + return 0; } let mut app = App::new(); let mut terminal = ratatui::init(); let res = app.run(&mut terminal); ratatui::restore(); + if app.handoff_to_desktop { + // Handed the session to the desktop GUI — it owns the stack now; don't stop it. + println!("orcabot: handed off to the desktop GUI. Quit the app (or `orcabot down`) to stop the session."); + } else if we_own { + // Owned-session teardown: stop the stack once the active TUI closes. + println!("orcabot: stopping the stack…"); + cmd_down(); + } if let Err(e) = res { eprintln!("orcabot tui error: {e}"); return 1; } 0 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_workspace_dest_blocks_traversal() { + let base = std::env::temp_dir().join(format!("orca-swd-{}", std::process::id())); + let _ = fs::create_dir_all(&base); + let ws = base.canonicalize().expect("canon temp ws"); + // Safe relative paths resolve under the workspace. + for ok in ["a.txt", "sub/deep/x.txt", "./a.txt", "etc/passwd"] { + let d = safe_workspace_dest(&ws, ok); + assert!(d.is_some(), "should allow {ok}"); + assert!(d.unwrap().starts_with(&ws)); + } + // Traversal / absolute / escaping paths are rejected. + for bad in ["../escape.txt", "../../etc/passwd", "a/../../b", "/etc/passwd"] { + assert!(safe_workspace_dest(&ws, bad).is_none(), "should reject {bad}"); + } + let _ = fs::remove_dir_all(&base); + } + + #[test] + fn paywall_detection() { + assert!(is_paywall(PAYWALL_ERR)); + assert!(is_paywall("recreate on remote failed: SUBSCRIPTION_REQUIRED")); + assert!(!is_paywall("HTTP 404: not found")); + assert!(!is_paywall("HTTP 401: unauthorized")); + } + + #[test] + fn paywall_message_uses_web_url_then_falls_back_to_base() { + // Falls back to the remote base when ORCABOT_WEB_URL is unset. + std::env::remove_var("ORCABOT_WEB_URL"); + let m = paywall_message("https://api.example.com"); + assert!(m.contains("https://api.example.com")); + assert!(m.contains("subscription")); + } + + #[test] + fn ws_excluded_covers_runtime_and_cache_dirs() { + for p in [".orcabot/pty/x", ".claude/cache/changelog.md", ".browser/a", ".npm/b", "x/node_modules/y"] { + assert!(ws_excluded(p), "should exclude {p}"); + } + for p in [".claude/settings.json", ".env", "src/main.rs", "dinosaurs/a.psd"] { + assert!(!ws_excluded(p), "should NOT exclude {p}"); + } + } +} + +// ---- end mod unix_cli ---- +} diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 88cf86bb..ed4e160c 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: folder-import-v7-safe-dir-win-nofollow -const MODULE_REVISION: &str = "folder-import-v7-safe-dir-win-nofollow"; +// REVISION: folder-import-v9-shell-quote-cli +const MODULE_REVISION: &str = "folder-import-v9-shell-quote-cli"; use serde::Serialize; use std::path::{Component, Path, PathBuf}; @@ -596,3 +596,53 @@ fn chrono_now() -> String { .unwrap_or_default(); format!("{}s", d.as_secs()) } + +/// Switch the desktop GUI back to the CLI surface: open Terminal.app running the +/// sibling `orcabot cli` (which attaches to this same running session and opens +/// the TUI), then hide the GUI window. macOS-only (the desktop app is macOS-only +/// today); other platforms return an error. +#[tauri::command] +pub fn switch_to_cli(app: tauri::AppHandle) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let orcabot = exe + .parent() + .ok_or("could not resolve exe directory")? + .join("orcabot"); + if !orcabot.exists() { + return Err(format!("orcabot CLI not found next to the app at {}", orcabot.display())); + } + // Escape the path for the AppleScript string literal, then wrap it in + // `quoted form of` so it's also SHELL-safe — `do script` runs its argument + // as a shell command, and the packaged bundle path ("Orcabot Desktop.app") + // contains a space that would otherwise word-split. + let esc = orcabot + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + // `--owns`: the CLI becomes the active surface and stops the session when + // it is closed (desktop→CLI ownership hand-off). + let script = format!( + "tell application \"Terminal\"\nactivate\ndo script ((quoted form of \"{}\") & \" cli --owns\")\nend tell", + esc + ); + std::process::Command::new("osascript") + .arg("-e") + .arg(script) + .spawn() + .map_err(|e| format!("could not open Terminal: {e}"))?; + // Hide the GUI — same end state as the SIGUSR2 'switch to cli' path. + for (_, w) in app.webview_windows() { + let _ = w.hide(); + } + let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory); + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + Err("switch_to_cli is only supported on macOS".into()) + } +} diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index b521bba1..1faeb27f 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -// REVISION: main-v5-vm-eth0-dhcp-bringup -const MODULE_REVISION: &str = "main-v5-vm-eth0-dhcp-bringup"; +// REVISION: main-v6-apply-schema-on-boot +const MODULE_REVISION: &str = "main-v6-apply-schema-on-boot"; mod commands; mod vm; @@ -359,7 +359,7 @@ impl DesktopServices { ("D1_HTTP_URL", "http://d1-shim".to_string()), ("SANDBOX_URL", sandbox_url), ("SANDBOX_INTERNAL_TOKEN", sandbox_internal_token), - ("INTERNAL_API_TOKEN", internal_api_token), + ("INTERNAL_API_TOKEN", internal_api_token.clone()), ("DEV_AUTH_ENABLED", dev_auth_enabled), ("SECRETS_ENCRYPTION_KEY", secrets_key), ("OAUTH_REDIRECT_BASE", std::env::var("OAUTH_REDIRECT_BASE") @@ -434,6 +434,11 @@ impl DesktopServices { wait_for_health(&controlplane_port); + // Apply the D1 schema on every launch (idempotent CREATE TABLE IF NOT EXISTS). + // Without this, schema changes shipped in an app update never reach an existing + // user's DB — the worker only runs init on a brand-new DB's first /health. + apply_schema(&controlplane_port, &internal_api_token); + // Write PID file so next launch can clean up orphans if we crash if let Ok(children) = self.children.lock() { write_pid_file(&data_dir, &children, None); @@ -711,6 +716,31 @@ fn wait_for_health(port: &str) { } } +/// POST /init-db to apply the D1 schema (idempotent). Best-effort: logs and +/// continues on failure so a transient hiccup never blocks app startup. +fn apply_schema(port: &str, internal_token: &str) { + let addr = format!("127.0.0.1:{}", port); + match std::net::TcpStream::connect(&addr) { + Ok(mut stream) => { + let req = format!( + "POST /init-db HTTP/1.1\r\nHost: localhost\r\nX-Internal-Token: {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + internal_token + ); + let _ = stream.write_all(req.as_bytes()); + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).unwrap_or(0); + let head = String::from_utf8_lossy(&buf[..n]); + let status = head.lines().next().unwrap_or(""); + if status.contains(" 200") { + eprintln!("[schema] applied via /init-db"); + } else { + eprintln!("[schema] /init-db unexpected response: {status}"); + } + } + Err(err) => eprintln!("[schema] could not reach control plane for /init-db: {err}"), + } +} + fn main() { eprintln!( "[main] REVISION: {} loaded at {}", @@ -726,6 +756,7 @@ fn main() { .invoke_handler(tauri::generate_handler![ commands::get_workspace_path, commands::import_folder, + commands::switch_to_cli, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); @@ -785,6 +816,45 @@ fn main() { let _ = w.set_focus(); } } + + // Surface toggle: SIGUSR1 -> show the GUI (switch to "desktop"), SIGUSR2 -> + // hide it (switch to "cli"/headless). Lets `orcabot desktop` / `orcabot cli` + // flip the surface of the already-running backend without a restart. + { + use signal_hook::consts::{SIGUSR1, SIGUSR2}; + let handle = app.handle().clone(); + if let Ok(mut signals) = signal_hook::iterator::Signals::new([SIGUSR1, SIGUSR2]) { + std::thread::spawn(move || { + for sig in signals.forever() { + let show = sig == SIGUSR1; + let h = handle.clone(); + let _ = handle.run_on_main_thread(move || { + for (_, w) in h.webview_windows() { + if show { + let _ = w.show(); + let _ = w.set_focus(); + } else { + let _ = w.hide(); + } + } + #[cfg(target_os = "macos")] + { + let _ = h.set_activation_policy(if show { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }); + } + }); + eprintln!( + "[surface] signal {} -> {}", + sig, + if show { "desktop (show window)" } else { "cli (hide window)" } + ); + } + }); + } + } Ok(()) }) .build(tauri::generate_context!()) diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json index 1a373987..4e908052 100644 --- a/desktop/app/src-tauri/tauri.conf.json +++ b/desktop/app/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "frontendDist": "../dist" }, "app": { + "withGlobalTauri": true, "windows": [ { "title": "Orcabot Desktop", diff --git a/frontend/src/app/(app)/dashboards/page.tsx b/frontend/src/app/(app)/dashboards/page.tsx index 28602035..9bad650e 100644 --- a/frontend/src/app/(app)/dashboards/page.tsx +++ b/frontend/src/app/(app)/dashboards/page.tsx @@ -29,6 +29,7 @@ import { Clock, BarChart3, Link2, + Terminal, } from "lucide-react"; import { toast } from "sonner"; @@ -54,6 +55,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { PaywallDialog } from "@/components/subscription/PaywallDialog"; import { TrialBanner } from "@/components/subscription/TrialBanner"; import { API, DESKTOP_MODE } from "@/config/env"; +import { switchToCli } from "@/lib/tauri-bridge"; import { listDashboards, createDashboard, @@ -396,6 +398,33 @@ export default function DashboardsPage() { + {DESKTOP_MODE && ( + + + + )} + + + + + + + + +
+ +
+ + ); +} diff --git a/frontend/src/components/PersonalAccessTokensPanel.tsx b/frontend/src/components/PersonalAccessTokensPanel.tsx new file mode 100644 index 00000000..048ec26b --- /dev/null +++ b/frontend/src/components/PersonalAccessTokensPanel.tsx @@ -0,0 +1,199 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: pat-panel-v1 +"use client"; + +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Check, Copy, KeyRound, Loader2, Trash2 } from "lucide-react"; + +import { + listApiTokens, + mintApiToken, + revokeApiToken, + type ApiToken, + type MintedApiToken, +} from "@/lib/api/cloudflare/api-tokens"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +const MODULE_REVISION = "pat-panel-v1"; +if (typeof window !== "undefined") { + console.log(`[pat-panel] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}`); +} + +function formatDate(iso: string | null): string { + if (!iso) return "never"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +export function PersonalAccessTokensPanel() { + const queryClient = useQueryClient(); + const [newName, setNewName] = React.useState(""); + const [minted, setMinted] = React.useState(null); + const [copied, setCopied] = React.useState(false); + + const { + data: tokens = [], + isLoading, + isError, + } = useQuery({ + queryKey: ["api-tokens"], + queryFn: listApiTokens, + }); + + const mintMutation = useMutation({ + mutationFn: (name: string) => mintApiToken(name), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + setMinted(data); + setCopied(false); + setNewName(""); + }, + onError: (err) => toast.error(`Failed to create token: ${(err as Error).message}`), + }); + + const revokeMutation = useMutation({ + mutationFn: (id: string) => revokeApiToken(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["api-tokens"] }); + toast.success("Token revoked"); + }, + onError: (err) => toast.error(`Failed to revoke token: ${(err as Error).message}`), + }); + + const copyToken = async () => { + if (!minted) return; + try { + await navigator.clipboard.writeText(minted.token); + setCopied(true); + toast.success("Token copied to clipboard"); + } catch { + toast.error("Could not copy — select and copy manually"); + } + }; + + return ( +
+
+
+

+ Personal Access Tokens +

+

+ Authenticate the orcabot CLI to this account for{" "} + push/pull. A token + carries your full account access — treat it like a password. +

+
+
+ +
{ + e.preventDefault(); + mintMutation.mutate(newName.trim() || "cli"); + }} + > + setNewName(e.target.value)} + placeholder="Token name (e.g. laptop)" + className="max-w-xs" + disabled={mintMutation.isPending} + /> + +
+ +
+ {isLoading ? ( +
+ Loading… +
+ ) : isError ? ( +
Could not load tokens.
+ ) : tokens.length === 0 ? ( +
No tokens yet.
+ ) : ( +
    + {tokens.map((t: ApiToken) => ( +
  • +
    +

    {t.name}

    +

    + Created {formatDate(t.createdAt)} · Last used {formatDate(t.lastUsedAt)} + {t.expiresAt ? ` · Expires ${formatDate(t.expiresAt)}` : ""} +

    +
    + +
  • + ))} +
+ )} +
+ + {/* One-time reveal of the freshly minted token */} + !open && setMinted(null)}> + + + Copy your new token + + This is the only time the token is shown. Store it somewhere safe — you can't + retrieve it again. + + +
+
+ + {minted?.token} + + +
+
+

Use it with the CLI:

+ + orcabot push <dashboard> --remote {typeof window !== "undefined" ? window.location.origin : "https://app.orcabot.com"} --token {minted?.token ? minted.token.slice(0, 16) + "…" : ""} + +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 51edac0e..799a1da0 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -138,6 +138,8 @@ export const API = { modelProviders: `${CLOUDFLARE_API_URL}/model-providers`, templates: `${CLOUDFLARE_API_URL}/templates`, secrets: `${CLOUDFLARE_API_URL}/secrets`, + apiTokens: `${CLOUDFLARE_API_URL}/auth/api-tokens`, + apiTokenMint: `${CLOUDFLARE_API_URL}/auth/api-token`, pendingApprovals: `${CLOUDFLARE_API_URL}/pending-approvals`, asrKeys: `${CLOUDFLARE_API_URL}/asr/keys`, asrAssemblyAIToken: `${CLOUDFLARE_API_URL}/asr/assemblyai/token`, diff --git a/frontend/src/lib/api/cloudflare/api-tokens.ts b/frontend/src/lib/api/cloudflare/api-tokens.ts new file mode 100644 index 00000000..bdc3a3c4 --- /dev/null +++ b/frontend/src/lib/api/cloudflare/api-tokens.ts @@ -0,0 +1,54 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: api-tokens-client-v1 +// Personal access tokens (PATs) for the orcabot CLI (push/pull). +// Mint is POST /auth/api-token (plaintext returned once); list/revoke use +// /auth/api-tokens. Auth is attached automatically by the shared apiFetch. + +import { API } from "@/config/env"; +import { apiGet, apiPost, apiDelete } from "../client"; + +export interface ApiToken { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; +} + +/** Returned only at creation — `token` is the plaintext, shown once. */ +export interface MintedApiToken extends ApiToken { + token: string; +} + +interface ListResponse { + tokens: ApiToken[]; +} + +export async function listApiTokens(): Promise { + const response = await apiGet(API.cloudflare.apiTokens); + return response.tokens || []; +} + +export async function mintApiToken(name: string): Promise { + const response = await apiPost<{ + token: string; + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + }>(API.cloudflare.apiTokenMint, { name }); + return { + id: response.id, + name: response.name, + token: response.token, + createdAt: response.createdAt, + expiresAt: response.expiresAt, + lastUsedAt: null, + }; +} + +export async function revokeApiToken(id: string): Promise { + await apiDelete(`${API.cloudflare.apiTokens}/${id}`); +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 14602045..108293b1 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: tauri-bridge-v4-bundler-safe -const MODULE_REVISION = "tauri-bridge-v4-bundler-safe"; +// REVISION: tauri-bridge-v5-global-invoke +const MODULE_REVISION = "tauri-bridge-v5-global-invoke"; console.log( `[tauri-bridge] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -19,11 +19,24 @@ const TAURI_DIALOG = "@tauri-apps/plugin-dialog"; type TauriInvoke = (cmd: string, args?: Record) => Promise; /** - * Dynamically import Tauri invoke to avoid breaking web/Cloudflare builds. - * Returns null if not in desktop mode or if Tauri APIs are unavailable. + * Resolve Tauri's `invoke` in the desktop webview. Prefer the runtime globals + * Tauri injects (withGlobalTauri → `window.__TAURI__.core.invoke`; and the + * always-present `window.__TAURI_INTERNALS__.invoke`) over a dynamic + * `import("@tauri-apps/api/core")`, which is a bare specifier the browser can't + * resolve at runtime (it throws → every command would silently no-op). Returns + * null on web/Cloudflare builds or if Tauri APIs are unavailable. */ async function getTauriInvoke(): Promise { - if (!DESKTOP_MODE) return null; + if (!DESKTOP_MODE || typeof window === "undefined") return null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as any; + if (typeof w.__TAURI__?.core?.invoke === "function") { + return w.__TAURI__.core.invoke as TauriInvoke; + } + if (typeof w.__TAURI_INTERNALS__?.invoke === "function") { + return ((cmd: string, args?: Record) => + w.__TAURI_INTERNALS__.invoke(cmd, args ?? {})) as TauriInvoke; + } try { const mod = await import(/* webpackIgnore: true */ TAURI_CORE); return mod.invoke as TauriInvoke; @@ -77,6 +90,18 @@ export async function importFolder( }) as Promise; } +/** + * Switch from the desktop GUI to the CLI surface: opens a terminal running + * `orcabot cli` (same session) and hides the GUI. Desktop-only; no-op on web. + * Returns true if the switch was triggered. + */ +export async function switchToCli(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return false; + await invoke("switch_to_cli"); + return true; +} + /** Open a native folder picker dialog. Returns the selected path or null if cancelled. */ export async function pickFolder(): Promise { if (!DESKTOP_MODE) return null; diff --git a/sandbox/cmd/server/main.go b/sandbox/cmd/server/main.go index 3786a5e6..eba94517 100644 --- a/sandbox/cmd/server/main.go +++ b/sandbox/cmd/server/main.go @@ -1,12 +1,14 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: main-v35-pty-close-reap +// REVISION: main-v38-import-write-timeout package main import ( + "archive/tar" "bytes" + "compress/gzip" "context" "crypto/rand" "crypto/subtle" @@ -38,7 +40,7 @@ import ( "github.com/Hyper-Int/OrcaBot/sandbox/internal/ws" ) -const mainRevision = "main-v35-pty-close-reap" +const mainRevision = "main-v38-import-write-timeout" // 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 @@ -49,6 +51,9 @@ var debugExecToken string const ( maxFileSizeBytes = 50 * 1024 * 1024 maxRecursiveEntries = 100_000 + // Upper bound on a bulk workspace import body (compressed tar.gz). Big enough + // for real project workspaces; caps memory/disk a single request can consume. + maxWorkspaceImportBytes = 1024 * 1024 * 1024 ) var errTooManyEntries = errors.New("too many files to list") @@ -638,6 +643,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /sessions/{sessionId}/file", s.requireMachine(s.auth.RequireAuthFunc(s.handlePutFile))) mux.HandleFunc("DELETE /sessions/{sessionId}/file", s.requireMachine(s.auth.RequireAuthFunc(s.handleDeleteFile))) mux.HandleFunc("GET /sessions/{sessionId}/file/stat", s.requireMachine(s.auth.RequireAuthFunc(s.handleStatFile))) + mux.HandleFunc("POST /sessions/{sessionId}/workspace/import", s.requireMachine(s.auth.RequireAuthFunc(s.handleImportWorkspace))) mux.HandleFunc("POST /sessions/{sessionId}/drive/sync", s.requireMachine(s.auth.RequireAuthFunc(s.handleDriveSync))) // MCP proxy - allows agents to call MCP UI tools via the control plane @@ -1259,6 +1265,99 @@ func (s *Server) handlePutFile(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +// handleImportWorkspace extracts a gzipped tar (request body) into the session +// workspace — the bulk equivalent of many PUT /file calls (one request instead +// of one per file). Each entry is written via Workspace().Write, which scopes +// to /workspace and rejects traversal/symlink escapes, so path safety is +// inherited (no separate tar-slip handling needed). Directories and non-regular +// entries are skipped (parent dirs are created on file write). +func (s *Server) handleImportWorkspace(w http.ResponseWriter, r *http.Request) { + session := s.getSessiоnOrErrоr(w, r.PathValue("sessionId")) + if session == nil { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceImportBytes) + written, skipped, err := extractTarGzToWоrkspace(session.Wоrkspace(), r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + http.Error(w, "E79761: import too large", http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "E79762: "+err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"written": written, "skipped": skipped}) +} + +// extractTarGzToWоrkspace extracts a gzipped tar stream into the workspace. +// Regular files are written via Workspace.Write (which scopes to /workspace and +// rejects ".." / symlink escapes — so a hostile entry is counted as skipped, not +// an error). Directories and other entry types are ignored. Returns +// (written, skipped). A malformed gzip/tar (or oversized body) is a hard error. +func extractTarGzToWоrkspace(ws *fs.Workspace, body io.Reader) (int, int, error) { + gz, err := gzip.NewReader(body) + if err != nil { + return 0, 0, fmt.Errorf("invalid gzip stream: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + written, skipped := 0, 0 + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return written, skipped, err + } + if hdr.Typeflag != tar.TypeReg { + continue // dirs/symlinks/etc — files carry their own parent path + } + if hdr.Size > maxFileSizeBytes { + skipped++ + continue + } + data, err := io.ReadAll(io.LimitReader(tr, maxFileSizeBytes+1)) + if err != nil { + return written, skipped, err + } + if int64(len(data)) > maxFileSizeBytes { + skipped++ + continue + } + // Write() scopes to the workspace and rejects ".." / symlink escapes. + // Bound each write: on the desktop, the workspace is a host-shared mount + // (virtiofs) and a guest write to a file the *host* holds open (Spotlight, + // QuickLook, …) blocks indefinitely — even via temp+rename. A per-file + // timeout skips such a file so one stuck entry can't hang the whole import + // (matching the per-file PUT path's resilience). On real cloud ext4 writes + // never stall, so this never trips. The goroutine for a stuck write leaks + // until the host releases the file — bounded and desktop-only. + done := make(chan error, 1) + name := hdr.Name + go func() { done <- ws.Write(name, data) }() + select { + case err := <-done: + if err != nil { + if errors.Is(err, fs.ErrPathTraversal) { + skipped++ + continue // hostile entry — skip, don't abort the whole import + } + return written, skipped, err + } + written++ + case <-time.After(10 * time.Second): + skipped++ // host-held file over virtiofs; skip and move on + } + } + return written, skipped, nil +} + func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") if path == "" { diff --git a/sandbox/cmd/server/workspace_import_test.go b/sandbox/cmd/server/workspace_import_test.go new file mode 100644 index 00000000..d32f3aa7 --- /dev/null +++ b/sandbox/cmd/server/workspace_import_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" + + "github.com/Hyper-Int/OrcaBot/sandbox/internal/fs" +) + +// buildTarGz builds a gzipped tar from (name -> content). A name ending in "/" +// is emitted as a directory entry. +func buildTarGz(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range entries { + if len(name) > 0 && name[len(name)-1] == '/' { + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeDir, Mode: 0o755}); err != nil { + t.Fatal(err) + } + continue + } + if err := tw.WriteHeader(&tar.Header{Name: name, Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len(content))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExtractTarGzToWorkspace(t *testing.T) { + root := t.TempDir() + ws := fs.NewWоrkspace(root) + + tgz := buildTarGz(t, map[string]string{ + "./hello.txt": "hi", + "./sub/deep/nested.md": "nested-ok", + "./adir/": "", // directory entry — ignored + "../escape.txt": "PWNED", // traversal — must be skipped, not written + }) + + written, skipped, err := extractTarGzToWоrkspace(ws, bytes.NewReader(tgz)) + if err != nil { + t.Fatalf("extract failed: %v", err) + } + if written != 2 { + t.Errorf("written = %d, want 2", written) + } + if skipped != 1 { + t.Errorf("skipped = %d, want 1 (the traversal entry)", skipped) + } + + // Good files landed with correct content + nested path. + if b, err := os.ReadFile(filepath.Join(root, "hello.txt")); err != nil || string(b) != "hi" { + t.Errorf("hello.txt = %q, %v", b, err) + } + if b, err := os.ReadFile(filepath.Join(root, "sub/deep/nested.md")); err != nil || string(b) != "nested-ok" { + t.Errorf("nested.md = %q, %v", b, err) + } + + // The traversal entry must NOT have escaped the workspace root. + if _, err := os.Stat(filepath.Join(filepath.Dir(root), "escape.txt")); !os.IsNotExist(err) { + t.Errorf("traversal entry escaped the workspace (escape.txt exists outside root)") + } +} + +func TestExtractTarGzRejectsGarbage(t *testing.T) { + ws := fs.NewWоrkspace(t.TempDir()) + _, _, err := extractTarGzToWоrkspace(ws, bytes.NewReader([]byte("not a gzip stream"))) + if err == nil { + t.Fatal("expected error on non-gzip body, got nil") + } +} diff --git a/sandbox/internal/fs/workspace.go b/sandbox/internal/fs/workspace.go index fca437bc..5fbb9eea 100644 --- a/sandbox/internal/fs/workspace.go +++ b/sandbox/internal/fs/workspace.go @@ -200,7 +200,33 @@ func (w *Workspace) Write(path string, content []byte) error { return err } - return os.WriteFile(resolved, content, 0644) + // Write atomically: a fresh temp file + rename. Writing directly with + // O_TRUNC blocks indefinitely when the destination is held open by another + // process over a shared mount (virtiofs) — e.g. a cache file the host has + // mmap'd. rename() replaces the dir entry without opening the held file. + tmp, err := os.CreateTemp(dir, ".orcawrite-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(content); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Chmod(tmpName, 0644); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, resolved); err != nil { + os.Remove(tmpName) + return err + } + return nil } // Delete removes a file or directory (recursively)