External workspaces (runtime=external) lose their workspace_auth_token
the moment the create modal closes — the token is unrecoverable from
any later DB read. Operators who lost their copy or want to respond to
a suspected leak had no recovery path short of recreating the workspace
(which also breaks cross-workspace delegation links + memory namespace).
This PR adds two endpoints + a Config-tab section that surfaces them:
POST /workspaces/:id/external/rotate
Revokes any prior live tokens, mints a fresh one, returns the same
ExternalConnectionInfo payload Create returns. Old credentials stop
working immediately — the previously-paired agent will fail auth on
its next heartbeat (~20s).
GET /workspaces/:id/external/connection
Returns the connect block with auth_token="". For the operator who
just needs to re-find PLATFORM_URL / WORKSPACE_ID / one of the
snippets without invalidating the live agent.
Both reject runtime ≠ external with 400 + a hint pointing at /restart
for non-external runtimes (which mints AND injects into the container).
## Why a flag isn't needed
The endpoints are purely additive — Create's behavior is unchanged.
Existing external workspaces don't see anything different until an
operator clicks the new buttons.
## DRY refactor
Extracted BuildExternalConnectionPayload() in external_connection.go
as the single source of truth for the connect payload shape. Create,
Rotate, and GetExternalConnection all call it. Adds a snippet once →
all three endpoints emit it. Trims trailing slash on platform_url so
no double-slash sneaks into registry_endpoint.
## Canvas
ExternalConnectionSection mounts in ConfigTab when runtime=external.
Two buttons:
- "Show connection info" (cosmetic) — fetches GET /external/connection
- "Rotate credentials" (destructive) — confirm dialog explains the
impact, then POST /external/rotate
Both reuse the existing ExternalConnectModal so operators don't learn
a second snippet UX.
## Coverage
10 Go tests:
- Rotate happy path (revoke + mint order, payload shape, broadcast event)
- Rotate refuses non-external runtimes (400 with restart hint)
- Rotate 404 on unknown workspace + 400 on empty id
- GetExternalConnection happy path (auth_token="", same payload shape)
- GetExternalConnection refuses non-external + 404 on unknown
- BuildExternalConnectionPayload — placeholder substitution + trailing
slash trimming + blank-token contract
6 canvas tests:
- both action buttons render
- "Show" calls GET /external/connection and opens modal
- "Rotate" opens confirm dialog before firing POST
- Cancel dismisses without rotating
- Confirm POSTs and opens modal with returned token
- API failures surface as visible error chips
Migration: existing external workspaces gain new abilities; no data
migration. The DRY refactor preserves byte-identical Create response
shape (8 ConfigTab tests + all existing handler tests still pass).
Closes #319.
147 lines
5.6 KiB
TypeScript
147 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
// ExternalConnectionSection — credential lifecycle controls for runtime=external
|
|
// workspaces. Surfaced inside ConfigTab when the workspace's runtime is
|
|
// "external"; ignored for hermes/claude-code/etc. (those have their own
|
|
// restart-mints-token path).
|
|
//
|
|
// Two affordances:
|
|
//
|
|
// 1. "Show connection info" (read-only)
|
|
// Fetches GET /workspaces/:id/external/connection. Returns the
|
|
// connect block (PLATFORM_URL, WORKSPACE_ID, all 7 snippets) WITH
|
|
// auth_token="". The modal masks the token field and labels it
|
|
// "rotate to reveal a new token — current token is unrecoverable".
|
|
//
|
|
// 2. "Rotate credentials" (destructive)
|
|
// POST /workspaces/:id/external/rotate. Revokes any prior live
|
|
// tokens, mints a fresh one, returns the same connect block with
|
|
// auth_token populated. Old credentials stop working IMMEDIATELY —
|
|
// the previously-paired agent will fail auth on its next heartbeat.
|
|
// Confirm dialog explains this before firing.
|
|
//
|
|
// Reuses the existing ExternalConnectModal so the snippet UX is the
|
|
// same as on Create — operators don't have to learn a second modal.
|
|
|
|
import { useState } from "react";
|
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
|
|
import { api } from "@/lib/api";
|
|
import {
|
|
ExternalConnectModal,
|
|
type ExternalConnectionInfo,
|
|
} from "../ExternalConnectModal";
|
|
|
|
interface Props {
|
|
workspaceId: string;
|
|
}
|
|
|
|
export function ExternalConnectionSection({ workspaceId }: Props) {
|
|
const [info, setInfo] = useState<ExternalConnectionInfo | null>(null);
|
|
const [busy, setBusy] = useState<"show" | "rotate" | null>(null);
|
|
const [confirmRotate, setConfirmRotate] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function showConnection() {
|
|
setError(null);
|
|
setBusy("show");
|
|
try {
|
|
const resp = await api.get<{ connection: ExternalConnectionInfo }>(
|
|
`/workspaces/${workspaceId}/external/connection`,
|
|
);
|
|
setInfo(resp.connection);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
async function doRotate() {
|
|
setError(null);
|
|
setBusy("rotate");
|
|
setConfirmRotate(false);
|
|
try {
|
|
const resp = await api.post<{ connection: ExternalConnectionInfo }>(
|
|
`/workspaces/${workspaceId}/external/rotate`,
|
|
{},
|
|
);
|
|
setInfo(resp.connection);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-3 mt-3 p-3 bg-surface-sunken/50 border border-line rounded">
|
|
<h3 className="text-xs text-ink-mid font-medium mb-1">External Connection</h3>
|
|
<p className="text-[10px] text-ink-soft mb-2">
|
|
This workspace runs an external agent. Use these controls to
|
|
re-show the setup snippets or rotate the workspace token.
|
|
</p>
|
|
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={showConnection}
|
|
disabled={busy !== null}
|
|
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
|
>
|
|
{busy === "show" ? "Loading…" : "Show connection info"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmRotate(true)}
|
|
disabled={busy !== null}
|
|
className="px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 border border-red-800/60 text-xs rounded text-bad disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-600/60"
|
|
>
|
|
{busy === "rotate" ? "Rotating…" : "Rotate credentials"}
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<Dialog.Root open={confirmRotate} onOpenChange={setConfirmRotate}>
|
|
<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(440px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-surface-sunken border border-line p-5 shadow-2xl">
|
|
<Dialog.Title className="text-sm font-medium text-ink mb-2">
|
|
Rotate workspace credentials?
|
|
</Dialog.Title>
|
|
<Dialog.Description className="text-xs text-ink-mid mb-4 leading-relaxed">
|
|
This will mint a new <code className="font-mono">workspace_auth_token</code> and{' '}
|
|
<strong>immediately invalidate the current one</strong>. Your external
|
|
agent will start failing authentication on its next heartbeat
|
|
until you redeploy it with the new token.
|
|
</Dialog.Description>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmRotate(false)}
|
|
className="px-3 py-1.5 bg-surface-card text-xs rounded text-ink-mid"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={doRotate}
|
|
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white"
|
|
>
|
|
Rotate
|
|
</button>
|
|
</div>
|
|
</Dialog.Content>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
|
|
<ExternalConnectModal info={info} onClose={() => setInfo(null)} />
|
|
</div>
|
|
);
|
|
}
|