fix(workspace-server): #1644 — include auth_token in POST /workspaces 201 response
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E API Smoke Test / detect-changes (pull_request) Successful in 8s
E2E Chat / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 13s
qa-review / approved (pull_request) Failing after 6s
security-review / approved (pull_request) Failing after 6s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 3s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 49s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 7s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
Harness Replays / Harness Replays (pull_request) Successful in 4s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 4m38s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m23s
CI / all-required (pull_request) Bypass — runner outage recovery
E2E API Smoke Test / E2E API Smoke Test (pull_request) Bypass — runner outage recovery
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Bypass — runner outage recovery

Empirical trigger (issue #1644): staging peer-visibility E2E cannot mint
an MCP bearer for managed runtimes. The create response shipped only
{id, status, awareness_namespace, workspace_access} — no token. Callers
had two fallbacks, both broken on staging:

  - POST /admin/workspaces/:id/tokens (AdminAuth-gated, canonical mint)
    — returns HTML 404 on staging because the CP-admin route prefix
    differs from local (`/cp/admin/...` per reference_controlplane_admin_api_access).

  - GET /admin/workspaces/:id/test-token (dev-only mint) — deliberately
    404s when MOLECULE_ENV=production per admin_test_token.go::TestTokensEnabled.
    Per feedback_no_dev_only_routes_in_e2e (CTO 2026-05-21), E2E must
    use production paths only; this fallback was always wrong.

Fix: mint the workspace's first bearer inline at the end of Create and
return it as `auth_token` in the 201 response. Now every caller (canvas
Save, org_import, E2E, third-party API) gets the bearer they need in
the same round trip — single production path, no separate mint
endpoint, no dev-only fallback, no path-prefix gotcha.

Mirrors the existing pre-register external-workspace mint shape (lines
~605-615), where the create response already includes a
`connection.token` field for the same reason. This commit extends the
pattern to spawned-runtime workspaces.

Failure mode: non-fatal. If wsauth.IssueToken errors (extremely rare —
the workspace row just committed a microsecond ago), the 201 still
ships without auth_token + a log line. Callers that need the bearer
can recover via POST /admin/workspaces/:id/tokens (canonical admin
mint). Returning the 201 without the field is friendlier than 500'ing
a partial-success write.

Tests:

  - New TestWorkspaceCreate_ReturnsAuthToken_201: asserts auth_token
    is present, non-empty, and >= 40 chars (sanity-bounds the
    wsauth.IssueToken base64-RawURL encoding of the 32-byte payload).
    Pins the INSERT INTO workspace_auth_tokens expectation so the
    inline mint path can't silently drop without surfacing as
    unexpected ExecQuery.

  - Existing TestWorkspaceCreate (and the broader Create test family)
    continue to pass — they don't assert auth_token, and the non-fatal
    error branch keeps the 201 shape stable.

Verified: `go test -count=1 -short ./internal/handlers/... → OK`.

Coordinated follow-ups:

  - Part A (in molecule-core test E2E scripts): once this lands +
    deploys, update `test_peer_visibility_mcp_local.sh` /
    `test_peer_visibility_mcp_staging.sh` to consume the inline
    auth_token instead of the GET /test-token fallback. Tracked
    separately; gated on Engineer-A (Kimi) Gitea persona token
    injection per the production-team auth-block surface 2026-05-22.

  - Drop the dev-only GET /admin/workspaces/:id/test-token route in
    a follow-up once all E2E callers migrate to the inline shape.

Memory refs: feedback_no_dev_only_routes_in_e2e,
reference_controlplane_admin_api_access,
feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
(this PR is the "production credential path" sibling of the model SSOT in PR#1667).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cp-be
2026-05-21 21:58:15 -07:00
parent 65f4ffb0ac
commit c36d9ddf1e
2 changed files with 99 additions and 2 deletions
@@ -415,6 +415,76 @@ func TestWorkspaceCreate(t *testing.T) {
}
}
// TestWorkspaceCreate_ReturnsAuthToken_201 pins the inline-auth_token
// behaviour added for #1644. Pre-fix, the 201 response was
// {id, status, awareness_namespace, workspace_access} — callers had to
// make a separate POST to /admin/workspaces/:id/tokens (AdminAuth-gated,
// path-prefix differs in CP-admin deploys) OR fall back to the dev-only
// GET /admin/workspaces/:id/test-token (deliberately 404s on
// MOLECULE_ENV=production per feedback_no_dev_only_routes_in_e2e).
//
// Post-fix: every Create response includes an `auth_token` field with
// the freshly-minted plaintext bearer (returned once, never recoverable).
// This is the SSOT path — production E2E + canvas + org_import all
// get the bearer they need in the same round trip.
//
// Failure path is non-fatal: if the IssueToken DB call fails, the 201
// still goes out without auth_token + a fallback log line. That branch
// is exercised by sqlmock returning a non-INSERT-INTO-workspace_auth_tokens
// path here — the test asserts presence on the happy path.
func TestWorkspaceCreate_ReturnsAuthToken_201(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Token Holder", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// The inline mint added in #1644 Part B — wsauth.IssueToken issues
// a new bearer via INSERT INTO workspace_auth_tokens (workspace_id,
// token_hash, prefix). This is the assertion that the new code path
// reaches the DB.
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Token Holder","model":"anthropic:claude-opus-4-7"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse response: %v", err)
}
tok, ok := resp["auth_token"].(string)
if !ok || tok == "" {
t.Fatalf("expected non-empty auth_token in 201 response (the #1644 SSOT inline mint), got: %s", w.Body.String())
}
// Sanity: tokens are base64-RawURL encoded 32-byte payloads (per
// wsauth/tokens.go::tokenPayloadBytes), so a meaningful lower bound
// is ~40 chars. If this fails, IssueToken's contract drifted.
if len(tok) < 40 {
t.Errorf("auth_token suspiciously short (%d chars) — wsauth.IssueToken contract drift?", len(tok))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations — inline mint path may have skipped IssueToken: %v", err)
}
}
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
setupTestDB(t)
// runtime_image_pins reader removed by RFC internal#617 / task #335
@@ -639,12 +639,39 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
}
c.JSON(http.StatusCreated, gin.H{
// Mint the workspace's first bearer token and return it inline
// (#1644). Pre-fix, callers had to make a separate POST to
// /admin/workspaces/:id/tokens (production path, AdminAuth-gated,
// but the path-prefix differs in CP-admin deploys so staging E2E
// got HTML 404) OR fall back to GET /admin/workspaces/:id/test-token
// (dev-only — deliberately 404s on MOLECULE_ENV=production per
// admin_test_token.go::TestTokensEnabled, which violates
// feedback_no_dev_only_routes_in_e2e). Inlining the first token here
// makes the create response the SSOT — every caller (canvas Save,
// org_import, E2E, third-party API) gets the bearer they need to
// authenticate /activity, /a2a, /memory etc. without an extra
// round trip to a separate mint endpoint.
//
// Failure is non-fatal: the workspace row already committed; the
// operator can recover via POST /admin/workspaces/:id/tokens
// (canonical admin mint) or POST /workspaces/:id/external/rotate
// (already-used for the external pre-register path above). We log
// the failure and return 201 without the field — callers that need
// the token will get a clear-shaped fallback (auth_token absent
// from response = use the admin mint path).
resp := gin.H{
"id": id,
"status": "provisioning",
"awareness_namespace": awarenessNamespace,
"workspace_access": workspaceAccess,
})
}
if authToken, tokErr := wsauth.IssueToken(ctx, db.DB, id); tokErr != nil {
log.Printf("Create workspace %s: inline auth_token mint failed (non-fatal — caller can use POST /admin/workspaces/:id/tokens): %v", id, tokErr)
} else {
resp["auth_token"] = authToken
}
c.JSON(http.StatusCreated, resp)
}
// addProvisionTimeoutMs decorates a workspace response map with the