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
25 changes: 21 additions & 4 deletions workspace-server/internal/handlers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,12 +449,29 @@ func (h *RegistryHandler) Register(c *gin.Context) {
// outbound auth_token's "issue once" lifecycle. Returning it here is
// the only delivery path for SaaS, where the platform's CP provisioner
// has no volume to write into.
if secret, secretErr := wsauth.ReadPlatformInboundSecret(ctx, db.DB, payload.ID); secretErr == nil {
//
// Lazy-heal (2026-04-30): if the column is NULL (legacy workspace
// provisioned before the shared-mint refactor), mint it inline and
// include in the response. Without this, legacy workspaces would need
// two round-trips before chat upload works — chat_files lazy-heals
// platform-side on first attempt, then the workspace must heartbeat
// to receive the freshly-minted secret. Heal-on-register collapses
// that to one round-trip.
secret, secretErr := wsauth.ReadPlatformInboundSecret(ctx, db.DB, payload.ID)
if secretErr != nil && errors.Is(secretErr, wsauth.ErrNoInboundSecret) {
minted, mintErr := wsauth.IssuePlatformInboundSecret(ctx, db.DB, payload.ID)
if mintErr != nil {
log.Printf("Registry: lazy-heal mint of platform_inbound_secret failed for %s: %v — workspace will 503 on chat upload until next register", payload.ID, mintErr)
} else {
secret = minted
secretErr = nil
log.Printf("Registry: lazy-healed platform_inbound_secret for %s (#2312 backfill)", payload.ID)
}
}
if secretErr == nil {
response["platform_inbound_secret"] = secret
} else if !errors.Is(secretErr, wsauth.ErrNoInboundSecret) {
// ErrNoInboundSecret is the expected case for legacy workspaces
// that predate migration 044 — quiet. Other errors (DB hiccup, etc.)
// log loud so ops notices.
// Non-NoInboundSecret read errors (DB hiccup, etc.) — log loud.
log.Printf("Registry: read platform_inbound_secret for %s failed: %v", payload.ID, secretErr)
}

Expand Down
85 changes: 81 additions & 4 deletions workspace-server/internal/handlers/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,19 @@ func TestRegister_ReturnsPlatformInboundSecret_RFC2312_PRF(t *testing.T) {
// that predate migration 044 (NULL platform_inbound_secret column) still
// get a successful registration — the field is just omitted from the
// response. The Register handler logs the absence quietly.
func TestRegister_NoInboundSecret_OmitsField(t *testing.T) {
// TestRegister_NoInboundSecret_LazyHeals — legacy workspace path:
// when ReadPlatformInboundSecret returns ErrNoInboundSecret (NULL
// column), Register MUST mint inline and include the freshly-minted
// secret in the response. Without this, legacy workspaces would need
// two round-trips before chat upload works (chat_files heals
// platform-side → workspace must heartbeat → next chat upload).
//
// Pre-fix this test asserted the field was ABSENT; that was correct
// for the missing behavior, but happened to pass even with my
// register-time lazy-heal change because sqlmock unmatched UPDATE
// caused the mint to fail and fall back to omit-field. Splitting
// into success + failure tests pins both branches.
func TestRegister_NoInboundSecret_LazyHeals(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
Expand All @@ -1002,7 +1014,6 @@ func TestRegister_NoInboundSecret_OmitsField(t *testing.T) {
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
// resolveDeliveryMode — no row yet, default push (#2339).
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnError(sql.ErrNoRows)
Expand All @@ -1019,6 +1030,72 @@ func TestRegister_NoInboundSecret_OmitsField(t *testing.T) {
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
// Lazy-heal mint MUST land. If this expectation isn't matched, the
// register handler skipped backfill and legacy workspaces would
// need 2 round-trips before chat upload works.
mock.ExpectExec(`UPDATE workspaces SET platform_inbound_secret = \$1 WHERE id = \$2`).
WithArgs(sqlmock.AnyArg(), wsID).
WillReturnResult(sqlmock.NewResult(0, 1))

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/registry/register",
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","agent_card":{"name":"x"}}`))
c.Request.Header.Set("Content-Type", "application/json")

handler.Register(c)

if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
secret, present := resp["platform_inbound_secret"]
if !present {
t.Errorf("expected platform_inbound_secret to be PRESENT (lazy-healed), got response: %v", resp)
}
if s, ok := secret.(string); !ok || s == "" {
t.Errorf("expected non-empty platform_inbound_secret string, got %T %v", secret, secret)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met — register-time lazy-heal mint did NOT run, regression of #2312 backfill: %v", err)
}
}

// TestRegister_NoInboundSecret_LazyHealMintFailureOmitsField pins the
// alternate branch: if the lazy-heal mint itself fails (DB hiccup),
// Register MUST still respond 200 (workspace is online) but omit the
// field. The next register call will retry the heal.
func TestRegister_NoInboundSecret_LazyHealMintFailureOmitsField(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewRegistryHandler(broadcaster)

const wsID = "00000000-0000-0000-0000-000000002313"

mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO workspaces").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://localhost:9100"))
mock.ExpectExec("INSERT INTO structure_events").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
// Mint fails — handler must NOT 500; just omit field + log.
mock.ExpectExec(`UPDATE workspaces SET platform_inbound_secret = \$1 WHERE id = \$2`).
WithArgs(sqlmock.AnyArg(), wsID).
WillReturnError(sql.ErrConnDone)

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
Expand All @@ -1029,12 +1106,12 @@ func TestRegister_NoInboundSecret_OmitsField(t *testing.T) {
handler.Register(c)

if w.Code != http.StatusOK {
t.Fatalf("expected 200 even without inbound secret, got %d", w.Code)
t.Fatalf("expected 200 even when lazy-heal fails (workspace is online), got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if _, present := resp["platform_inbound_secret"]; present {
t.Errorf("expected platform_inbound_secret to be ABSENT for legacy workspace, got: %v", resp["platform_inbound_secret"])
t.Errorf("expected platform_inbound_secret to be ABSENT when mint failed, got: %v", resp["platform_inbound_secret"])
}
}

Expand Down
Loading