molecule-core/workspace-server/internal/handlers/external_rotate.go
Hongming Wang 9ceda9d81f refactor(events): migrate 18 files to typed EventType constants (RFC #2945 PR-B-1)
Mechanical migration of bare event-name strings in BroadcastOnly /
RecordAndBroadcast call sites to the typed constants from
internal/events/types.go (RFC #2945 PR-B). Wire format unchanged
(both shapes serialize to identical WSMessage.Event literals); pinned
by TestAllEventTypes_IsSnapshot in #2965.

Migrated (18 files, scope: handlers/, scheduler/, registry/, bundle/,
channels/):
- handlers/{approvals,a2a_proxy_helpers,a2a_queue,activity,agent,
  delegation,external_rotate,org_import,registry,workspace,
  workspace_bootstrap,workspace_crud,workspace_provision_shared,
  workspace_restart}.go
- channels/manager.go (caught by hostile-reviewer pass — initial
  scope missed channels/, found via grep on the post-migration tree)
- scheduler/scheduler.go
- registry/provisiontimeout.go
- bundle/importer.go

Hostile self-review (3 weakest spots, addressed)
------------------------------------------------

1. Missed call sites — initial scope omitted channels/. Post-migration
   `grep -rEn 'BroadcastOnly\([^,]+,[^,]*"[A-Z_]+"|RecordAndBroadcast\([^,]+,[^,]*"[A-Z_]+"' internal/`
   found 2 stragglers in channels/manager.go. Migrated. Final grep
   on the same pattern returns only the docstring example in
   types.go (intentional).

2. gofmt drift — auto-import injection produced non-canonical import
   ordering. `gofmt -w` applied ONLY to the 18 modified files (NOT
   the whole tree, to avoid sweeping unrelated pre-existing drift
   into this PR's diff). Three pre-existing un-gofmt'd files in
   handlers/ (a2a_proxy.go, a2a_proxy_test.go, a2a_queue_test.go)
   left as-is — they're unchanged by this PR and their drift
   predates it.

3. Wire format — paranoia check: do the constants serialize to the
   exact strings consumers (canvas TS, hermes plugin, anything
   parsing WSMessage.Event) expect? Yes. Pinned by the snapshot
   test. The migration is name-only; not a single character of
   wire output changes.

Verified
- go build ./... clean
- go vet ./internal/... clean
- gofmt -l on the 5 migrated package dirs: only pre-existing files
- Full tests: handlers/, channels/, scheduler/, registry/, events/,
  bundle/ all green (5 ok, 0 fail)

PR-B-2 (canvas TS mirror + cross-language parity gate) remains as
the final piece of RFC #2945 PR-B. Tracked separately so this PR
stays mechanical + reviewable.

Refs RFC #2945, PR #2965 (PR-B types).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:05:03 -07:00

165 lines
6.3 KiB
Go

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/events"
"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, string(events.EventExternalCredentialsRotated), 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
}