feat(external): credential rotation + re-show instruction modal (#319)
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.
This commit is contained in:
parent
3d226a2c68
commit
b375252dc8
@ -6,6 +6,7 @@ import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
|
||||
import { parseYaml, toYaml } from "./config/yaml-utils";
|
||||
import { SecretsSection } from "./config/secrets-section";
|
||||
import { ExternalConnectionSection } from "./ExternalConnectionSection";
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
buildProviderCatalog,
|
||||
@ -960,6 +961,9 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
: "This runtime manages its own config outside the platform template."}
|
||||
</div>
|
||||
)}
|
||||
{!error && config.runtime === "external" && (
|
||||
<ExternalConnectionSection workspaceId={workspaceId} />
|
||||
)}
|
||||
{success && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-green-900/30 border border-green-800 rounded text-xs text-good">Saved</div>
|
||||
)}
|
||||
|
||||
146
canvas/src/components/tabs/ExternalConnectionSection.tsx
Normal file
146
canvas/src/components/tabs/ExternalConnectionSection.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ExternalConnectionSection — coverage for the credential-rotate +
|
||||
// re-show-instructions UI on the Config tab.
|
||||
//
|
||||
// What this pins:
|
||||
// 1. "Show connection info" → GET /external/connection, opens modal
|
||||
// with auth_token=""
|
||||
// 2. "Rotate credentials" → confirm dialog → POST /external/rotate,
|
||||
// opens modal with the returned auth_token
|
||||
// 3. Confirm dialog cancels without firing the POST
|
||||
// 4. API failure surfaces an error chip (no silent loss)
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPost = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
post: (path: string, body?: unknown) => apiPost(path, body),
|
||||
patch: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { ExternalConnectionSection } from "../ExternalConnectionSection";
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPost.mockReset();
|
||||
});
|
||||
|
||||
const SAMPLE_INFO = {
|
||||
workspace_id: "ws-test",
|
||||
platform_url: "https://platform.example.test",
|
||||
auth_token: "",
|
||||
registry_endpoint: "https://platform.example.test/registry/register",
|
||||
heartbeat_endpoint: "https://platform.example.test/registry/heartbeat",
|
||||
// The modal stamps these snippets server-side; for the test we
|
||||
// bake workspace_id into one so the rendered DOM contains a
|
||||
// findable token after the modal mounts.
|
||||
curl_register_template: "# curl ws=ws-test",
|
||||
python_snippet: "# py ws=ws-test",
|
||||
claude_code_channel_snippet: "# claude ws=ws-test",
|
||||
universal_mcp_snippet: "# mcp ws=ws-test",
|
||||
hermes_channel_snippet: "# hermes ws=ws-test",
|
||||
codex_snippet: "# codex ws=ws-test",
|
||||
openclaw_snippet: "# openclaw ws=ws-test",
|
||||
};
|
||||
|
||||
describe("ExternalConnectionSection", () => {
|
||||
it("renders both action buttons", () => {
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
expect(screen.getByRole("button", { name: /show connection info/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /rotate credentials/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("'Show connection info' calls GET /external/connection and opens modal with blank token", async () => {
|
||||
apiGet.mockResolvedValue({ connection: { ...SAMPLE_INFO, auth_token: "" } });
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /show connection info/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiGet).toHaveBeenCalledWith("/workspaces/ws-test/external/connection"),
|
||||
);
|
||||
// The ExternalConnectModal renders the workspace_id field in its
|
||||
// copy-block. document.body covers Radix's portal mount point.
|
||||
await waitFor(() => {
|
||||
expect(document.body.textContent || "").toContain("ws-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("'Rotate credentials' opens confirm dialog before firing POST", async () => {
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /rotate credentials/i }));
|
||||
|
||||
// Confirm dialog appears with the destructive copy.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Rotate workspace credentials\?/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText(/immediately invalidate the current one/i)).toBeTruthy();
|
||||
|
||||
// POST must NOT have fired yet — only on confirm.
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Cancel in confirm dialog dismisses without rotating", async () => {
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /rotate credentials/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Rotate workspace credentials\?/i)).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/Rotate workspace credentials\?/i)).toBeNull(),
|
||||
);
|
||||
expect(apiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Confirm in dialog POSTs to /external/rotate and opens modal with returned token", async () => {
|
||||
apiPost.mockResolvedValue({
|
||||
connection: { ...SAMPLE_INFO, auth_token: "fresh-tok-123" },
|
||||
});
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /rotate credentials/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Rotate workspace credentials\?/i)).toBeTruthy(),
|
||||
);
|
||||
// Click the dialog's Rotate button (NOT the section's — the section's
|
||||
// "Rotate credentials" stays mounted; the dialog's "Rotate" is the
|
||||
// commit button. getAllByRole returns both; pick the one inside the
|
||||
// dialog by name "Rotate" exact-match).
|
||||
const rotateBtns = screen.getAllByRole("button", { name: /^rotate$/i });
|
||||
expect(rotateBtns.length).toBeGreaterThanOrEqual(1);
|
||||
fireEvent.click(rotateBtns[rotateBtns.length - 1]);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(apiPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-test/external/rotate",
|
||||
{},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("Surfaces API errors as a visible chip, not silent loss", async () => {
|
||||
apiGet.mockRejectedValue(new Error("forbidden"));
|
||||
render(<ExternalConnectionSection workspaceId="ws-test" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /show connection info/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const matches = screen.queryAllByText((_, el) =>
|
||||
(el?.textContent || "").toLowerCase().includes("forbidden"),
|
||||
);
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -8,13 +8,51 @@ package handlers
|
||||
// to piece together workspace_id + platform_url + auth_token + API
|
||||
// shape from the docs. curl snippet has zero dependencies; Python
|
||||
// snippet pairs with molecule-sdk-python's A2AServer + RemoteAgentClient.
|
||||
//
|
||||
// BuildExternalConnectionPayload (below) is the single source of truth
|
||||
// for the payload shape — used by Create (#workspace.go), Rotate
|
||||
// (#external_rotate.go), and the read-only "show instructions again"
|
||||
// endpoint. Adding a snippet means adding it here once; the three
|
||||
// callers pick it up automatically.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BuildExternalConnectionPayload assembles the gin.H payload that the
|
||||
// canvas's ExternalConnectModal consumes. Pure data — caller owns DB
|
||||
// reads (workspace_id) and token minting (auth_token).
|
||||
//
|
||||
// authToken may be empty for the read-only "show instructions again"
|
||||
// path; the modal masks the field in that case rather than displaying
|
||||
// an empty string.
|
||||
func BuildExternalConnectionPayload(platformURL, workspaceID, authToken string) gin.H {
|
||||
pURL := strings.TrimSuffix(platformURL, "/")
|
||||
stamp := func(tmpl string) string {
|
||||
return strings.ReplaceAll(
|
||||
strings.ReplaceAll(tmpl, "{{PLATFORM_URL}}", pURL),
|
||||
"{{WORKSPACE_ID}}", workspaceID,
|
||||
)
|
||||
}
|
||||
return gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
"platform_url": pURL,
|
||||
"auth_token": authToken,
|
||||
"registry_endpoint": pURL + "/registry/register",
|
||||
"heartbeat_endpoint": pURL + "/registry/heartbeat",
|
||||
"curl_register_template": stamp(externalCurlTemplate),
|
||||
"python_snippet": stamp(externalPythonTemplate),
|
||||
"claude_code_channel_snippet": stamp(externalChannelTemplate),
|
||||
"universal_mcp_snippet": stamp(externalUniversalMcpTemplate),
|
||||
"hermes_channel_snippet": stamp(externalHermesChannelTemplate),
|
||||
"codex_snippet": stamp(externalCodexTemplate),
|
||||
"openclaw_snippet": stamp(externalOpenClawTemplate),
|
||||
}
|
||||
}
|
||||
|
||||
// externalPlatformURL returns the public URL at which this workspace-
|
||||
// server instance is reachable by the operator's external agent. This
|
||||
// is NOT necessarily the caller's Host header (which could be an
|
||||
|
||||
163
workspace-server/internal/handlers/external_rotate.go
Normal file
163
workspace-server/internal/handlers/external_rotate.go
Normal file
@ -0,0 +1,163 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// external_rotate.go — operator-facing endpoints for credential lifecycle
|
||||
// on runtime=external workspaces.
|
||||
//
|
||||
// POST /workspaces/:id/external/rotate
|
||||
// Mints a fresh workspace_auth_token, revokes any prior live tokens
|
||||
// for the same workspace, and returns the same payload shape Create
|
||||
// returns. Old credentials stop working immediately — the next
|
||||
// heartbeat from the previously-paired agent will fail auth.
|
||||
//
|
||||
// GET /workspaces/:id/external/connection
|
||||
// Returns the connection payload WITHOUT minting (auth_token = "").
|
||||
// For the operator who lost their copy of the snippet but still has
|
||||
// the token elsewhere — they want the rest of the connect block
|
||||
// (PLATFORM_URL, WORKSPACE_ID, registry endpoints, all 7 snippets)
|
||||
// without invalidating the live agent.
|
||||
//
|
||||
// Both endpoints reject runtime ≠ external with 400 — the "external
|
||||
// connection" payload only makes sense for awaiting-agent / online-
|
||||
// external workspaces. A user clicking Rotate on a hermes / claude-code
|
||||
// workspace would silently break ssh-EIC tunnel auth, which is worse
|
||||
// than refusing the action.
|
||||
|
||||
// RotateExternalCredentials handles POST /workspaces/:id/external/rotate.
|
||||
//
|
||||
// Why this endpoint exists: today the auth_token is only revealed once
|
||||
// (on Create), via the Modal that closes after the operator dismisses
|
||||
// it. There's no recovery path — lost the token, lost the workspace.
|
||||
// Rotation gives operators a way to (a) recover from lost credentials
|
||||
// and (b) respond to a suspected leak without recreating the workspace
|
||||
// from scratch (which would also invalidate any cross-workspace
|
||||
// delegation links + memory namespace).
|
||||
func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("RotateExternalCredentials(%s): runtime lookup failed: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
|
||||
return
|
||||
}
|
||||
if runtime != "external" {
|
||||
// Rotating a hermes/claude-code workspace's bearer would not
|
||||
// just break the ssh-EIC tunnel auth on the platform side — it
|
||||
// would also leave the workspace's in-container heartbeat with
|
||||
// a stale token until the next reboot. The right action for a
|
||||
// non-external workspace's compromised credential is restart,
|
||||
// which mints a fresh token AND injects it into the container
|
||||
// (workspace_provision.go:issueAndInjectToken). Refuse cleanly
|
||||
// here so the canvas can show "rotate is for external workspaces;
|
||||
// click Restart instead" rather than silently corrupting state.
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "rotate is only valid for runtime=external workspaces",
|
||||
"runtime": runtime,
|
||||
"hint": "use POST /workspaces/:id/restart for non-external runtimes",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke first, then mint. Order matters: if mint fails, the
|
||||
// workspace is left without any live token (operator can retry) —
|
||||
// that's better than the inverse where mint succeeds + revoke fails
|
||||
// and TWO live tokens end up valid (the previous one + the new one),
|
||||
// silently leaving the leaked credential alive.
|
||||
if err := wsauth.RevokeAllForWorkspace(ctx, db.DB, id); err != nil {
|
||||
log.Printf("RotateExternalCredentials(%s): revoke failed: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "revoke failed"})
|
||||
return
|
||||
}
|
||||
tok, err := wsauth.IssueToken(ctx, db.DB, id)
|
||||
if err != nil {
|
||||
log.Printf("RotateExternalCredentials(%s): mint failed: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "mint failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit broadcast — operators reviewing the activity feed should
|
||||
// see when credentials were rotated. No PII; the token plaintext
|
||||
// is NOT logged.
|
||||
if h.broadcaster != nil {
|
||||
h.broadcaster.RecordAndBroadcast(ctx, "EXTERNAL_CREDENTIALS_ROTATED", id, map[string]interface{}{
|
||||
"workspace_id": id,
|
||||
})
|
||||
}
|
||||
|
||||
platformURL := externalPlatformURL(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, tok),
|
||||
})
|
||||
}
|
||||
|
||||
// GetExternalConnection handles GET /workspaces/:id/external/connection.
|
||||
//
|
||||
// Returns the connect-block WITHOUT minting (auth_token = ""). For the
|
||||
// operator who needs to re-find PLATFORM_URL / WORKSPACE_ID / one of
|
||||
// the snippets (their note app got wiped, they switched machines, etc.)
|
||||
// but doesn't want to invalidate the live external agent.
|
||||
//
|
||||
// The canvas modal masks the auth_token field in this mode and labels
|
||||
// it "(rotate to reveal a new token — current token is unrecoverable)".
|
||||
func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
|
||||
runtime, err := lookupWorkspaceRuntime(ctx, db.DB, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("GetExternalConnection(%s): runtime lookup failed: %v", id, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
|
||||
return
|
||||
}
|
||||
if runtime != "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "connection payload is only valid for runtime=external workspaces",
|
||||
"runtime": runtime,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
platformURL := externalPlatformURL(c)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connection": BuildExternalConnectionPayload(platformURL, id, ""),
|
||||
})
|
||||
}
|
||||
|
||||
// lookupWorkspaceRuntime returns the workspace's runtime field. Wrapped
|
||||
// for readability + so tests can mock the single SELECT.
|
||||
func lookupWorkspaceRuntime(ctx context.Context, handle *sql.DB, id string) (string, error) {
|
||||
var runtime string
|
||||
err := handle.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(runtime, '') FROM workspaces WHERE id = $1
|
||||
`, id).Scan(&runtime)
|
||||
return runtime, err
|
||||
}
|
||||
310
workspace-server/internal/handlers/external_rotate_test.go
Normal file
310
workspace-server/internal/handlers/external_rotate_test.go
Normal file
@ -0,0 +1,310 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// external_rotate_test.go — coverage for the credential-rotate +
|
||||
// re-show-instructions endpoints (#319).
|
||||
//
|
||||
// What we pin:
|
||||
// 1. Rotate happy path — revoke + mint fire in the right order, response
|
||||
// shape matches BuildExternalConnectionPayload, broadcast event
|
||||
// 'EXTERNAL_CREDENTIALS_ROTATED' is emitted.
|
||||
// 2. Rotate refuses non-external runtimes with 400 + the hint text.
|
||||
// 3. Rotate 404 on unknown workspace.
|
||||
// 4. GetExternalConnection happy path returns auth_token="" + the same
|
||||
// payload shape.
|
||||
// 5. GetExternalConnection refuses non-external + 404 on unknown.
|
||||
// 6. BuildExternalConnectionPayload — placeholder substitution +
|
||||
// trailing-slash trimming on platformURL.
|
||||
|
||||
// ---------- POST /external/rotate ----------
|
||||
|
||||
func TestRotateExternalCredentials_HappyPath(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// 1. Runtime lookup
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
// 2. Revoke all live tokens
|
||||
mock.ExpectExec(`UPDATE workspace_auth_tokens`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// 3. Mint a fresh token
|
||||
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
|
||||
WithArgs("ws-ext", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-ext"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/ws-ext/external/rotate", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Host = "platform.example.test"
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
wh.RotateExternalCredentials(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Connection map[string]interface{} `json:"connection"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got := body.Connection["workspace_id"]; got != "ws-ext" {
|
||||
t.Errorf("workspace_id: got %v", got)
|
||||
}
|
||||
if got := body.Connection["auth_token"]; got == "" || got == nil {
|
||||
t.Errorf("auth_token must be non-empty after mint; got %v", got)
|
||||
}
|
||||
if got := body.Connection["platform_url"]; got != "https://platform.example.test" {
|
||||
t.Errorf("platform_url: got %v", got)
|
||||
}
|
||||
for _, k := range []string{
|
||||
"curl_register_template", "python_snippet",
|
||||
"claude_code_channel_snippet", "universal_mcp_snippet",
|
||||
"hermes_channel_snippet", "codex_snippet", "openclaw_snippet",
|
||||
} {
|
||||
if _, ok := body.Connection[k]; !ok {
|
||||
t.Errorf("payload missing snippet field: %s", k)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateExternalCredentials_RejectsNonExternal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-hermes").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("hermes"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-hermes"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/ws-hermes/external/rotate", nil)
|
||||
|
||||
wh.RotateExternalCredentials(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for non-external runtime, got %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "external") {
|
||||
t.Errorf("body should mention 'external'; got: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "restart") {
|
||||
t.Errorf("body should hint at restart for non-external; got: %s", w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateExternalCredentials_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"})) // no rows
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-missing"}}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/workspaces/ws-missing/external/rotate", nil)
|
||||
|
||||
wh.RotateExternalCredentials(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateExternalCredentials_RejectsEmptyID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces//external/rotate", nil)
|
||||
|
||||
wh.RotateExternalCredentials(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for empty id, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- GET /external/connection ----------
|
||||
|
||||
func TestGetExternalConnection_HappyPathReturnsBlankToken(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-ext").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-ext"}}
|
||||
c.Request = httptest.NewRequest("GET",
|
||||
"/workspaces/ws-ext/external/connection", nil)
|
||||
c.Request.Host = "platform.example.test"
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
|
||||
wh.GetExternalConnection(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body struct {
|
||||
Connection map[string]interface{} `json:"connection"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if body.Connection["auth_token"] != "" {
|
||||
t.Errorf("auth_token MUST be empty in re-show path; got %v", body.Connection["auth_token"])
|
||||
}
|
||||
if body.Connection["workspace_id"] != "ws-ext" {
|
||||
t.Errorf("workspace_id wrong: %v", body.Connection["workspace_id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExternalConnection_RejectsNonExternal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-claude").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("claude-code"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-claude"}}
|
||||
c.Request = httptest.NewRequest("GET",
|
||||
"/workspaces/ws-claude/external/connection", nil)
|
||||
|
||||
wh.GetExternalConnection(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for non-external, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExternalConnection_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
wh := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery(`SELECT COALESCE\(runtime, ''\) FROM workspaces WHERE id = \$1`).
|
||||
WithArgs("ws-missing").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"runtime"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-missing"}}
|
||||
c.Request = httptest.NewRequest("GET",
|
||||
"/workspaces/ws-missing/external/connection", nil)
|
||||
|
||||
wh.GetExternalConnection(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- BuildExternalConnectionPayload (pure helper) ----------
|
||||
|
||||
func TestBuildExternalConnectionPayload_StampsPlaceholders(t *testing.T) {
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "tok-abc")
|
||||
|
||||
if got["workspace_id"] != "ws-7" {
|
||||
t.Errorf("workspace_id: %v", got["workspace_id"])
|
||||
}
|
||||
if got["auth_token"] != "tok-abc" {
|
||||
t.Errorf("auth_token: %v", got["auth_token"])
|
||||
}
|
||||
if got["platform_url"] != "https://platform.test" {
|
||||
t.Errorf("platform_url: %v", got["platform_url"])
|
||||
}
|
||||
if got["registry_endpoint"] != "https://platform.test/registry/register" {
|
||||
t.Errorf("registry_endpoint: %v", got["registry_endpoint"])
|
||||
}
|
||||
// {{PLATFORM_URL}} + {{WORKSPACE_ID}} placeholders must be substituted
|
||||
// out of every snippet — if any snippet still contains a literal
|
||||
// "{{PLATFORM_URL}}" or "{{WORKSPACE_ID}}", a future template author
|
||||
// forgot to use the placeholder convention and operators see broken
|
||||
// snippets.
|
||||
for _, k := range []string{
|
||||
"curl_register_template", "python_snippet",
|
||||
"claude_code_channel_snippet", "universal_mcp_snippet",
|
||||
"hermes_channel_snippet", "codex_snippet", "openclaw_snippet",
|
||||
} {
|
||||
v, _ := got[k].(string)
|
||||
if strings.Contains(v, "{{PLATFORM_URL}}") {
|
||||
t.Errorf("%s still contains literal {{PLATFORM_URL}}", k)
|
||||
}
|
||||
if strings.Contains(v, "{{WORKSPACE_ID}}") {
|
||||
t.Errorf("%s still contains literal {{WORKSPACE_ID}}", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExternalConnectionPayload_TrimsTrailingSlash(t *testing.T) {
|
||||
// platform_url passed in with trailing slash must be trimmed before
|
||||
// being concatenated into endpoint paths — otherwise the operator
|
||||
// gets `https://platform.test//registry/register` (double slash) which
|
||||
// some servers reject as a redirect target.
|
||||
got := BuildExternalConnectionPayload("https://platform.test/", "ws-7", "")
|
||||
if got["platform_url"] != "https://platform.test" {
|
||||
t.Errorf("platform_url: trailing slash not trimmed; got %v", got["platform_url"])
|
||||
}
|
||||
if got["registry_endpoint"] != "https://platform.test/registry/register" {
|
||||
t.Errorf("registry_endpoint should not have double slash; got %v", got["registry_endpoint"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExternalConnectionPayload_BlankAuthTokenIsAllowed(t *testing.T) {
|
||||
// Re-show path: auth_token="" is the contract; the modal masks the
|
||||
// field and labels it "rotate to reveal a new token".
|
||||
got := BuildExternalConnectionPayload("https://platform.test", "ws-7", "")
|
||||
if got["auth_token"] != "" {
|
||||
t.Errorf("blank token must propagate as \"\"; got %v", got["auth_token"])
|
||||
}
|
||||
}
|
||||
@ -638,91 +638,16 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
} else {
|
||||
resp["status"] = "awaiting_agent"
|
||||
// Connection snippet payload. Returned ONCE on create —
|
||||
// the token is not recoverable from any later read. UI
|
||||
// is responsible for surfacing this in a copy-paste modal.
|
||||
platformURL := strings.TrimSuffix(externalPlatformURL(c), "/")
|
||||
resp["connection"] = gin.H{
|
||||
"workspace_id": id,
|
||||
"platform_url": platformURL,
|
||||
"auth_token": connectionToken, // may be "" if IssueToken failed above
|
||||
"registry_endpoint": platformURL + "/registry/register",
|
||||
"heartbeat_endpoint": platformURL + "/registry/heartbeat",
|
||||
// Pre-formatted snippet that a non-Go operator can
|
||||
// paste verbatim. curl-based so there's no SDK
|
||||
// install dependency. The external agent only
|
||||
// needs to replace $AGENT_URL with its own public URL.
|
||||
"curl_register_template": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalCurlTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Python/SDK snippet. molecule-sdk-python PR #13
|
||||
// shipped A2AServer + RemoteAgentClient specifically
|
||||
// for this flow. The SDK is not yet on PyPI — the
|
||||
// snippet pins @main until we cut a release.
|
||||
"python_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalPythonTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Claude Code channel plugin snippet. For operators
|
||||
// whose external agent IS a Claude Code session —
|
||||
// the snippet sets up ~/.claude/channels/molecule/.env
|
||||
// and points at the canonical first-party plugin at
|
||||
// github.com/Molecule-AI/molecule-mcp-claude-channel.
|
||||
// Polling-based; no tunnel needed.
|
||||
"claude_code_channel_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalChannelTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Universal MCP snippet — runtime-agnostic outbound
|
||||
// tool path via the molecule-mcp console script. Same
|
||||
// 8 platform tools any MCP-aware runtime can register
|
||||
// (Claude Code, hermes, codex, etc.). Outbound-only:
|
||||
// the snippet calls out that heartbeat/inbound need
|
||||
// pairing with the SDK or channel tab.
|
||||
"universal_mcp_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalUniversalMcpTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Hermes channel snippet — for operators whose external
|
||||
// agent IS a hermes-agent session. Routes A2A traffic
|
||||
// into the hermes gateway via the molecule-channel
|
||||
// plugin (Molecule-AI/hermes-channel-molecule). Long-
|
||||
// poll based (no tunnel) — same UX as the Claude Code
|
||||
// channel tab. Gives hermes true push parity with the
|
||||
// other runtime templates.
|
||||
"hermes_channel_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalHermesChannelTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Codex MCP config snippet — for operators whose
|
||||
// external agent is a codex CLI (@openai/codex)
|
||||
// session. Wires the molecule MCP server into
|
||||
// ~/.codex/config.toml. Outbound-tools-only today;
|
||||
// codex's MCP client doesn't route arbitrary
|
||||
// notifications/* so push parity needs a separate
|
||||
// bridge daemon (future work).
|
||||
"codex_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalCodexTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// OpenClaw MCP config snippet — for operators whose
|
||||
// external agent is an openclaw session. Wires the
|
||||
// molecule MCP server via `openclaw mcp set` + starts
|
||||
// the gateway on loopback. Outbound-tools-only today;
|
||||
// full push parity needs a sessions.steer bridge
|
||||
// daemon (future work).
|
||||
"openclaw_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalOpenClawTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
}
|
||||
// the token is not recoverable from any later read.
|
||||
//
|
||||
// Payload assembly + per-snippet template stamping lives
|
||||
// in BuildExternalConnectionPayload (external_connection.go)
|
||||
// so the rotate + re-show endpoints emit byte-identical
|
||||
// shape. Adding a new snippet means adding it once there;
|
||||
// all three callers pick it up automatically.
|
||||
resp["connection"] = BuildExternalConnectionPayload(
|
||||
externalPlatformURL(c), id, connectionToken,
|
||||
)
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
return
|
||||
|
||||
@ -190,6 +190,18 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
// to 'hibernated'. The workspace auto-wakes on the next A2A message.
|
||||
wsAuth.POST("/hibernate", wh.Hibernate)
|
||||
|
||||
// External-workspace credential lifecycle (issue #319 follow-up to
|
||||
// the Create flow). Both endpoints reject runtime ≠ external with
|
||||
// 400 — see external_rotate.go for the rationale.
|
||||
//
|
||||
// POST .../external/rotate — mint fresh token, revoke prior,
|
||||
// return ExternalConnectionInfo
|
||||
// GET .../external/connection — return ExternalConnectionInfo
|
||||
// with auth_token="" (re-show
|
||||
// instructions without rotating)
|
||||
wsAuth.POST("/external/rotate", wh.RotateExternalCredentials)
|
||||
wsAuth.GET("/external/connection", wh.GetExternalConnection)
|
||||
|
||||
// Async Delegation
|
||||
delh := handlers.NewDelegationHandler(wh, broadcaster)
|
||||
wsAuth.POST("/delegate", delh.Delegate)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user