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>
165 lines
6.3 KiB
Go
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
|
|
}
|