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
77 changes: 67 additions & 10 deletions canvas/src/components/CreateWorkspaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react"
import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { isSaaSTenant } from "@/lib/tenant";
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";

interface WorkspaceOption {
id: string;
Expand Down Expand Up @@ -54,6 +55,13 @@ export function CreateWorkspaceButton() {
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
// External-runtime path: skip docker provision, mint a workspace_auth_token,
// and surface the connection snippet in a modal after create. When
// isExternal is true the template / model / hermes-provider fields are
// hidden (they're meaningless for BYO-compute agents).
const [isExternal, setIsExternal] = useState(false);
const [externalConnection, setExternalConnection] =
useState<ExternalConnectionInfo | null>(null);

// Hermes-specific state
const [hermesProvider, setHermesProvider] = useState("anthropic");
Expand Down Expand Up @@ -185,21 +193,42 @@ export function CreateWorkspaceButton() {
? parseFloat(budgetLimit)
: null;

await api.post("/workspaces", {
const createResp = await api.post<{
id: string;
status: string;
external?: boolean;
connection?: ExternalConnectionInfo;
}>("/workspaces", {
name: name.trim(),
role: role.trim() || undefined,
template: template.trim() || undefined,
// External workspaces don't consume a template — skip it so the
// backend doesn't try to resolve a non-existent dir and log a
// misleading "template not found" warning.
template: isExternal ? undefined : (template.trim() || undefined),
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
...(isHermes && provider
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
// returned in the response for the modal below.
...(isExternal ? { runtime: "external" } : {}),
...(!isExternal && isHermes && provider
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
model: hermesModel.trim(),
}
: {}),
});
// External path: keep the create dialog open just long enough to
// hand control to the connect modal, then close. The connect
// modal holds the token; we CANNOT re-fetch it later. If the
// backend somehow returns external=true without a connection
// payload we still close the create dialog — the operator will
// have to mint a token via POST /workspaces/:id/tokens.
if (isExternal && createResp.connection) {
setExternalConnection(createResp.connection);
}
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to create workspace");
Expand Down Expand Up @@ -265,13 +294,33 @@ export function CreateWorkspaceButton() {
type="number"
helper="Leave blank for unlimited"
/>
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
{/* External toggle — when on, this workspace is BYO-compute:
no template, no model, no hermes provider fields. Backend
returns a copyable connection snippet via the modal. */}
<label className="flex items-start gap-2 rounded-lg border border-zinc-800 p-3 cursor-pointer hover:border-zinc-700 transition-colors">
<input
type="checkbox"
checked={isExternal}
onChange={(e) => setIsExternal(e.target.checked)}
className="mt-0.5"
/>
<div className="text-xs">
<div className="text-zinc-200 font-medium">External agent (bring your own compute)</div>
<div className="text-zinc-500 mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div>
</div>
</label>

{!isExternal && (
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
)}

<div>
<div
Expand Down Expand Up @@ -448,6 +497,14 @@ export function CreateWorkspaceButton() {
</div>
</Dialog.Content>
</Dialog.Portal>
{/* Rendered as a sibling so it stays mounted after the create dialog
closes. Without this the auth_token would disappear the moment
the create modal unmounted its React subtree — the operator
would never see the copy-paste snippet. */}
<ExternalConnectModal
info={externalConnection}
onClose={() => setExternalConnection(null)}
/>
</Dialog.Root>
);
}
Expand Down
226 changes: 226 additions & 0 deletions canvas/src/components/ExternalConnectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
// ExternalConnectModal — shown once after creating a runtime="external"
// workspace. Surfaces the workspace_auth_token + ready-to-paste snippets
// so the operator can hand them to whoever runs their off-host agent
// without piecing together the register payload from docs.
//
// Security posture:
// - The auth_token is visible once. After the modal closes, the value
// is unrecoverable (the /workspaces/:id read endpoints never echo it).
// UI warns the operator before they dismiss.
// - A "copy to clipboard" button uses the navigator.clipboard API which
// is same-origin and requires user gesture — no cross-origin leak.
// - Snippets use placeholders for the operator's own public URL
// ($AGENT_URL). They ARE NOT filled in server-side because the
// server doesn't know where the operator's agent will live.

import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";

export interface ExternalConnectionInfo {
workspace_id: string;
platform_url: string;
auth_token: string;
registry_endpoint: string;
heartbeat_endpoint: string;
curl_register_template: string;
python_snippet: string;
}

interface Props {
info: ExternalConnectionInfo | null;
onClose: () => void;
}

type Tab = "python" | "curl" | "fields";

export function ExternalConnectModal({ info, onClose }: Props) {
const [tab, setTab] = useState<Tab>("python");
const [copiedKey, setCopiedKey] = useState<string | null>(null);

const copy = useCallback(async (value: string, key: string) => {
try {
await navigator.clipboard.writeText(value);
setCopiedKey(key);
// Auto-clear the "Copied!" label after 1.5s so a second copy
// attempt feels responsive — without the reset, the second
// click appears as a no-op.
window.setTimeout(() => setCopiedKey(null), 1500);
} catch {
// Fallback for browsers that refuse clipboard access (http://
// over insecure origin, Safari private mode, etc.). We surface
// a minimal textarea so the operator can manually copy.
const el = document.getElementById(`fallback-${key}`) as HTMLTextAreaElement | null;
if (el) {
el.select();
}
}
}, []);

if (!info) return null;

// Python snippet is stamped server-side with workspace_id +
// platform_url but leaves AUTH_TOKEN as a "<paste …>" placeholder
// (that's what we're showing in the modal). Fill in the real
// token here so the snippet the operator copies is truly ready-to-run.
const filledPython = info.python_snippet.replace(
'AUTH_TOKEN = "<paste from create response>"',
`AUTH_TOKEN = "${info.auth_token}"`,
);
const filledCurl = info.curl_register_template.replace(
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
`WORKSPACE_AUTH_TOKEN="${info.auth_token}"`,
);

return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-zinc-900 border border-zinc-700 p-6 shadow-2xl">
<Dialog.Title className="text-lg font-semibold text-white">
Connect your external agent
</Dialog.Title>
<Dialog.Description className="mt-1 text-sm text-zinc-400">
Paste the snippet below into your agent&apos;s deployment. The
auth token is shown <span className="text-amber-400">only once</span>
{" "}— save it somewhere safe before closing this dialog.
</Dialog.Description>

{/* Tabs */}
<div
role="tablist"
aria-label="Connection snippet format"
className="mt-4 flex gap-1 border-b border-zinc-800"
>
{(["python", "curl", "fields"] as Tab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t
? "border-blue-500 text-white"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
>
{t === "python" ? "Python SDK" : t === "curl" ? "curl" : "Fields"}
</button>
))}
</div>

{/* Snippet area */}
<div className="mt-3">
{tab === "python" && (
<SnippetBlock
value={filledPython}
label="Python (recommended — includes heartbeat loop)"
copyKey="python"
copied={copiedKey === "python"}
onCopy={() => copy(filledPython, "python")}
/>
)}
{tab === "curl" && (
<SnippetBlock
value={filledCurl}
label="curl — one-shot register only (no heartbeat)"
copyKey="curl"
copied={copiedKey === "curl"}
onCopy={() => copy(filledCurl, "curl")}
/>
)}
{tab === "fields" && (
<div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
<Field
label="auth_token"
value={info.auth_token}
onCopy={() => copy(info.auth_token, "tok")}
copied={copiedKey === "tok"}
mono
/>
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
</div>
)}
</div>

<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200"
>
I&apos;ve saved it — close
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

function SnippetBlock({
value,
label,
copied,
onCopy,
}: {
value: string;
label: string;
copyKey: string;
copied: boolean;
onCopy: () => void;
}) {
return (
<div>
<div className="flex items-center justify-between pb-1">
<span className="text-xs text-zinc-500">{label}</span>
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-blue-600/80 hover:bg-blue-500 text-white"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="text-xs bg-zinc-950 border border-zinc-800 rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-zinc-200">
{value}
</pre>
</div>
);
}

function Field({
label,
value,
onCopy,
copied,
mono,
}: {
label: string;
value: string;
onCopy: () => void;
copied: boolean;
mono?: boolean;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 w-36 shrink-0">{label}</span>
<code
className={`flex-1 text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-zinc-200 break-all ${mono ? "font-mono" : ""}`}
>
{value || "(missing)"}
</code>
<button
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-200 disabled:opacity-40"
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
);
}
Loading
Loading