From 9f551319d290d96583caaffde7baa3eebf40daa4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 11:38:22 -0700 Subject: [PATCH] feat(saas): close 4th default-tier site + lift org_import asymmetry + tests (#2910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-model retrospective review of #2901 found three Critical gaps: 1. (#2910 PR-B) template_import.go:79 wrote `tier: 3` hardcoded into generated config.yaml. On SaaS this defeated the T4 default at the create-handler layer — a config-less template import landed at T3 regardless of POST /workspaces' computed default. The 4th default-tier site #2901 missed. 2. (#2910 PR-A) #2901 claimed `go test ... all green` but added zero new tests. Existing structural-pin tests caught dispatch-layer drift but said nothing about tier-default drift. A future refactor that flips DefaultTier() to always return 3 would ship green. 3. (#2910 PR-E) org_import.go fallback returned T2 on self-hosted while workspace.go returned T3. Internally consistent ("bulk vs interactive defaults") but undocumented same-name-different-value drift. Fix: - TemplatesHandler.NewTemplatesHandler now takes `wh *WorkspaceHandler` (nil-tolerant for read-only callers). Import + ReplaceFiles compute tier via h.wh.DefaultTier() and pass it to generateDefaultConfig. generateDefaultConfig gets a `tier int` parameter (bounds-checked, invalid input falls back to T3). - org_import.go fallback lifts to h.workspace.DefaultTier() — single source of truth shared with Create + Templates so a future tier-default change sweeps every entry point at once. - New saas_default_tier_test.go pinning: TestIsSaaS_TrueWhenCPProvWired TestIsSaaS_FalseWhenOnlyDocker TestDefaultTier_SaaS_IsT4 TestDefaultTier_SelfHosted_IsT3 TestGenerateDefaultConfig_RespectsTierParam TestGenerateDefaultConfig_SelfHostedTierT3 TestGenerateDefaultConfig_OutOfRangeFallsBackToT3 - Existing template_import_test.go tests + chat_files_test.go + security_regression_test.go updated to thread the new tier param / wh constructor arg through their NewTemplatesHandler calls. Their pre-#2910 assertion of `tier: 3` is preserved (now passes because the test caller passes `3` explicitly), so no regression. go vet ./... clean. go test ./internal/handlers/ -count 1 — all green (4.2s). Deferred to separate follow-ups (per #2910 plan): - PR-C: MOLECULE_DEPLOYMENT_MODE explicit deployment-mode signal (closes the IsSaaS()=cpProv!=nil structural fragility) - PR-D: Host iptables IMDS block + IMDSv2 hop-limit (paired with molecule-controlplane EC2-IAM-scope audit) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../internal/handlers/chat_files_poll_test.go | 34 +++---- .../internal/handlers/chat_files_test.go | 32 +++--- .../internal/handlers/org_import.go | 22 +++-- .../handlers/saas_default_tier_test.go | 99 +++++++++++++++++++ ...ecurity_regression_685_686_687_688_test.go | 4 +- .../internal/handlers/template_import.go | 34 +++++-- .../internal/handlers/template_import_test.go | 24 ++--- .../internal/handlers/templates.go | 14 ++- .../internal/handlers/templates_test.go | 56 +++++------ workspace-server/internal/router/router.go | 5 +- 10 files changed, 229 insertions(+), 95 deletions(-) create mode 100644 workspace-server/internal/handlers/saas_default_tier_test.go diff --git a/workspace-server/internal/handlers/chat_files_poll_test.go b/workspace-server/internal/handlers/chat_files_poll_test.go index aa5bab34..eb23acf1 100644 --- a/workspace-server/internal/handlers/chat_files_poll_test.go +++ b/workspace-server/internal/handlers/chat_files_poll_test.go @@ -201,7 +201,7 @@ func TestPollUpload_HappyPath_OneFile_StagesAndLogs(t *testing.T) { expectActivityInsert(mock) store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"report.pdf": []byte("PDF-bytes")}) @@ -259,7 +259,7 @@ func TestPollUpload_MultipleFiles_AllStagedAndLogged(t *testing.T) { expectActivityInsert(mock) store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{ @@ -297,7 +297,7 @@ func TestPollUpload_PushModeFallsThroughToForward(t *testing.T) { // URL empty + mode=push → 503 (no inbound secret check needed). store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"x": []byte("data")}) @@ -321,7 +321,7 @@ func TestPollUpload_NotConfigured_FallsThrough(t *testing.T) { wsID := "33333333-2222-3333-4444-555555555555" expectURLAndMode(mock, wsID, "", "poll") // resolveWorkspaceForwardCreds emits 422 - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) // No WithPendingUploads — pendingUploads is nil. body, ct := pollUploadFixture(t, map[string][]byte{"x": []byte("data")}) @@ -342,7 +342,7 @@ func TestPollUpload_WorkspaceMissing_404(t *testing.T) { wsID := "44444444-2222-3333-4444-555555555555" expectPollDeliveryModeMissing(mock, wsID) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(newInMemStorage(), nil) body, ct := pollUploadFixture(t, map[string][]byte{"x": []byte("d")}) @@ -362,7 +362,7 @@ func TestPollUpload_DeliveryModeLookupDBError_500(t *testing.T) { mock.ExpectQuery(`SELECT delivery_mode FROM workspaces WHERE id = \$1`). WithArgs(wsID).WillReturnError(errors.New("connection lost")) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(newInMemStorage(), nil) body, ct := pollUploadFixture(t, map[string][]byte{"x": []byte("d")}) @@ -382,7 +382,7 @@ func TestPollUpload_NoFilesField_400(t *testing.T) { expectPollDeliveryMode(mock, wsID, "poll") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) // Multipart with a non-files field — no actual files. @@ -407,7 +407,7 @@ func TestPollUpload_MalformedMultipart_400(t *testing.T) { expectPollDeliveryMode(mock, wsID, "poll") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) // Body that doesn't match the boundary in Content-Type. @@ -428,7 +428,7 @@ func TestPollUpload_StorageError_500(t *testing.T) { store := newInMemStorage() store.putErr = errors.New("disk full") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"x.bin": []byte("data")}) @@ -449,7 +449,7 @@ func TestPollUpload_StorageTooLarge_413(t *testing.T) { store := newInMemStorage() store.putErr = pendinguploads.ErrTooLarge - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"x.bin": []byte("data")}) @@ -469,7 +469,7 @@ func TestPollUpload_TooManyFiles_400(t *testing.T) { expectPollDeliveryMode(mock, wsID, "poll") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) // 65 files — over the per-batch cap. @@ -504,7 +504,7 @@ func TestPollUpload_NullDeliveryMode_TreatedAsPush(t *testing.T) { expectURLAndMode(mock, wsID, "", "") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"x.bin": []byte("data")}) @@ -537,7 +537,7 @@ func TestPollUpload_PerFileCapPreStorage_413(t *testing.T) { expectPollDeliveryMode(mock, wsID, "poll") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) // 25 MB + 1 byte. Single file, large enough to trip the early @@ -572,7 +572,7 @@ func TestPollUpload_SanitizesFilenameInResponse(t *testing.T) { expectActivityInsert(mock) store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"hello world!.pdf": []byte("data")}) @@ -616,7 +616,7 @@ func TestPollUpload_AtomicRollbackOnSecondFileTooLarge(t *testing.T) { expectPollDeliveryMode(mock, wsID, "poll") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) // Two files: first OK, second over the per-file cap. Pre-validation @@ -653,7 +653,7 @@ func TestPollUpload_AtomicRollbackOnPutBatchError(t *testing.T) { store := newInMemStorage() store.putErr = errors.New("db down mid-batch") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{ @@ -734,7 +734,7 @@ func TestPollUpload_ActivityRowDiscriminator(t *testing.T) { expectActivityInsertWithTypeAndMethod(mock, wsID, "a2a_receive", "chat_upload_receive") store := newInMemStorage() - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)). + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)). WithPendingUploads(store, nil) body, ct := pollUploadFixture(t, map[string][]byte{"x.pdf": []byte("xx")}) diff --git a/workspace-server/internal/handlers/chat_files_test.go b/workspace-server/internal/handlers/chat_files_test.go index e7829f45..6012d3a7 100644 --- a/workspace-server/internal/handlers/chat_files_test.go +++ b/workspace-server/internal/handlers/chat_files_test.go @@ -105,7 +105,7 @@ func TestChatUpload_InvalidWorkspaceID(t *testing.T) { setupTestDB(t) setupTestRedis(t) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeUploadRequest(t, "not-a-uuid", &bytes.Buffer{}, "") h.Upload(c) @@ -122,7 +122,7 @@ func TestChatUpload_WorkspaceNotInDB(t *testing.T) { wsID := "00000000-0000-0000-0000-000000000099" expectURLMissing(mock, wsID) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -166,7 +166,7 @@ func TestChatUpload_NoInboundSecret_LazyHeal(t *testing.T) { WithArgs(sqlmock.AnyArg(), wsID). WillReturnResult(sqlmock.NewResult(0, 1)) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -203,7 +203,7 @@ func TestChatUpload_NoInboundSecret_LazyHealFailure(t *testing.T) { WithArgs(sqlmock.AnyArg(), wsID). WillReturnError(sql.ErrConnDone) // mint fails - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -231,7 +231,7 @@ func TestChatUpload_NoURL(t *testing.T) { wsID := "00000000-0000-0000-0000-000000000042" expectURLAndMode(mock, wsID, "", "push") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -256,7 +256,7 @@ func TestChatUpload_PollModeEmptyURL(t *testing.T) { wsID := "00000000-0000-0000-0000-000000000099" expectURLAndMode(mock, wsID, "", "poll") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -286,7 +286,7 @@ func TestChatUpload_NullModeEmptyURL(t *testing.T) { wsID := "30ba7f0b-b303-4a20-aefe-3a4a675b8aa4" // user's "mac laptop" expectURLNullMode(mock, wsID, "") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -338,7 +338,7 @@ func TestChatUpload_ForwardsToWorkspace_HappyPath(t *testing.T) { expectURL(mock, wsID, srv.URL) expectInboundSecret(mock, wsID, "super-secret-123") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -380,7 +380,7 @@ func TestChatUpload_ForwardsErrorStatusUnchanged(t *testing.T) { expectURL(mock, wsID, srv.URL) expectInboundSecret(mock, wsID, "tok") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -402,7 +402,7 @@ func TestChatUpload_WorkspaceUnreachable(t *testing.T) { expectURL(mock, wsID, "http://127.0.0.1:1") expectInboundSecret(mock, wsID, "tok") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) body, ct := uploadFixture(t) c, w := makeUploadRequest(t, wsID, body, ct) h.Upload(c) @@ -418,7 +418,7 @@ func TestChatDownload_InvalidPath(t *testing.T) { setupTestDB(t) setupTestRedis(t) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) cases := []struct { name, path, wantSubstr string @@ -507,7 +507,7 @@ func TestChatDownload_WorkspaceNotInDB(t *testing.T) { WithArgs(wsID). WillReturnError(sql.ErrNoRows) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeDownloadRequest(t, wsID, "/workspace/foo.txt") h.Download(c) @@ -533,7 +533,7 @@ func TestChatDownload_NoInboundSecret_LazyHeal(t *testing.T) { WithArgs(sqlmock.AnyArg(), wsID). WillReturnResult(sqlmock.NewResult(0, 1)) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeDownloadRequest(t, wsID, "/workspace/foo.txt") h.Download(c) @@ -559,7 +559,7 @@ func TestChatDownload_NoInboundSecret_LazyHealFailure(t *testing.T) { WithArgs(sqlmock.AnyArg(), wsID). WillReturnError(sql.ErrConnDone) - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeDownloadRequest(t, wsID, "/workspace/foo.txt") h.Download(c) @@ -592,7 +592,7 @@ func TestChatDownload_ForwardsToWorkspace_HappyPath(t *testing.T) { expectURL(mock, wsID, srv.URL) expectInboundSecret(mock, wsID, "the-secret") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeDownloadRequest(t, wsID, "/workspace/report.txt") h.Download(c) @@ -634,7 +634,7 @@ func TestChatDownload_404FromWorkspacePropagated(t *testing.T) { expectURL(mock, wsID, srv.URL) expectInboundSecret(mock, wsID, "tok") - h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil)) + h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil)) c, w := makeDownloadRequest(t, wsID, "/workspace/missing.txt") h.Download(c) diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index 94ca0b34..8f4d9a07 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -61,16 +61,20 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX tier = defaults.Tier } if tier == 0 { - // SaaS-aware fallback. SaaS → T4 (one container per sibling - // EC2, no neighbour to protect from). Self-hosted → T2 - // (safe shared-Docker-daemon default — many workspaces in - // one kernel). Templates that want a different floor - // declare `tier:` in their config.yaml or the org-template's - // `defaults.tier`. - if h.workspace != nil && h.workspace.IsSaaS() { - tier = 4 + // Resolved via the same DefaultTier helper Create + Templates + // use (#2910 PR-E). SaaS → T4 (one container per sibling EC2, + // no neighbour to protect from), self-hosted → T3. Pre-#2910 + // this path returned T2 on self-hosted, asymmetric with + // workspace.go's T3 — undocumented drift. Lifting to + // DefaultTier collapses both call sites onto one source of + // truth so a future tier-default change sweeps every entry + // point at once. Templates that want a different floor still + // declare `tier:` in config.yaml or `defaults.tier` in + // org.yaml. + if h.workspace != nil { + tier = h.workspace.DefaultTier() } else { - tier = 2 + tier = 3 } } diff --git a/workspace-server/internal/handlers/saas_default_tier_test.go b/workspace-server/internal/handlers/saas_default_tier_test.go new file mode 100644 index 00000000..c4d32a94 --- /dev/null +++ b/workspace-server/internal/handlers/saas_default_tier_test.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "strings" + "testing" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner" +) + +// Tests for the SaaS-aware default-tier resolution introduced in #2901 +// and hardened in #2910 (multi-model review of #2901 found the original +// claim of "all green" was passing because no SaaS-mode test existed). +// +// These tests pin three invariants: +// +// 1. WorkspaceHandler.IsSaaS() returns true when cpProv is wired, +// false otherwise. +// 2. WorkspaceHandler.DefaultTier() returns 4 on SaaS, 3 self-hosted. +// 3. generateDefaultConfig (TemplatesHandler.Import path) writes the +// passed-in tier into the generated config.yaml — pre-#2910 it +// was hardcoded to 3 and silently disagreed with the create- +// handler default on SaaS. + +// stubCPProv is a minimal stand-in for the CP provisioner — only +// exercises the IsSaaS / HasProvisioner contract, never invoked in +// these tests. +type stubCPProv struct{} + +func (stubCPProv) Start(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) { + return "", nil +} +func (stubCPProv) Stop(_ interface{}, _ string) error { return nil } +func (stubCPProv) Restart(_ interface{}, _ provisioner.WorkspaceConfig) (string, error) { + return "", nil +} + +func TestIsSaaS_TrueWhenCPProvWired(t *testing.T) { + h := &WorkspaceHandler{cpProv: &trackingCPProv{}} + if !h.IsSaaS() { + t.Errorf("IsSaaS()=false with cpProv wired; expected true") + } +} + +func TestIsSaaS_FalseWhenOnlyDocker(t *testing.T) { + // provisioner field set, cpProv nil — the self-hosted path. + // Use a non-nil sentinel so the check actually has something to + // disagree with. trackingCPProv lives in workspace_provision_auto_test.go + // and is the established stub for these handler-level tests. + h := &WorkspaceHandler{provisioner: nil, cpProv: nil} + if h.IsSaaS() { + t.Errorf("IsSaaS()=true with both backends nil; expected false") + } +} + +func TestDefaultTier_SaaS_IsT4(t *testing.T) { + h := &WorkspaceHandler{cpProv: &trackingCPProv{}} + if got := h.DefaultTier(); got != 4 { + t.Errorf("SaaS DefaultTier()=%d; expected 4", got) + } +} + +func TestDefaultTier_SelfHosted_IsT3(t *testing.T) { + h := &WorkspaceHandler{} + if got := h.DefaultTier(); got != 3 { + t.Errorf("self-hosted DefaultTier()=%d; expected 3", got) + } +} + +// generateDefaultConfig — pin that the tier param flows into the +// emitted config.yaml verbatim. Pre-#2910 this was hardcoded "tier: 3" +// regardless of caller intent. +func TestGenerateDefaultConfig_RespectsTierParam(t *testing.T) { + cfg := generateDefaultConfig("Test Agent", map[string]string{"system-prompt.md": ""}, 4) + if !strings.Contains(cfg, "tier: 4\n") { + t.Errorf("expected `tier: 4` in generated config, got:\n%s", cfg) + } + // The pre-#2910 hardcoded `tier: 3` line must NOT appear. + if strings.Contains(cfg, "tier: 3\n") { + t.Errorf("config should not contain `tier: 3` when caller passed 4, got:\n%s", cfg) + } +} + +func TestGenerateDefaultConfig_SelfHostedTierT3(t *testing.T) { + cfg := generateDefaultConfig("Test Agent", map[string]string{"system-prompt.md": ""}, 3) + if !strings.Contains(cfg, "tier: 3\n") { + t.Errorf("expected `tier: 3` in generated config, got:\n%s", cfg) + } +} + +// Bounds check — caller passes 0 or out-of-range, helper falls back +// to T3 (the safer-of-the-two when deployment mode can't be resolved). +func TestGenerateDefaultConfig_OutOfRangeFallsBackToT3(t *testing.T) { + for _, tier := range []int{0, -1, 99} { + cfg := generateDefaultConfig("X", map[string]string{}, tier) + if !strings.Contains(cfg, "tier: 3\n") { + t.Errorf("invalid tier %d should fall back to T3, got:\n%s", tier, cfg) + } + } +} diff --git a/workspace-server/internal/handlers/security_regression_685_686_687_688_test.go b/workspace-server/internal/handlers/security_regression_685_686_687_688_test.go index f8d4fcb9..aa35a517 100644 --- a/workspace-server/internal/handlers/security_regression_685_686_687_688_test.go +++ b/workspace-server/internal/handlers/security_regression_685_686_687_688_test.go @@ -71,7 +71,7 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) { authDB, authMock := newEnrolledAuthDB(t) tmpDir := t.TempDir() - tmplh := NewTemplatesHandler(tmpDir, nil) + tmplh := NewTemplatesHandler(tmpDir, nil, nil) r := gin.New() r.GET("/templates", middleware.AdminAuth(authDB), tmplh.List) @@ -98,7 +98,7 @@ func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) { authDB, authMock := newFreshInstallAuthDB(t) tmpDir := t.TempDir() - tmplh := NewTemplatesHandler(tmpDir, nil) + tmplh := NewTemplatesHandler(tmpDir, nil, nil) r := gin.New() r.GET("/templates", middleware.AdminAuth(authDB), tmplh.List) diff --git a/workspace-server/internal/handlers/template_import.go b/workspace-server/internal/handlers/template_import.go index 7d4ab4d1..95b5854f 100644 --- a/workspace-server/internal/handlers/template_import.go +++ b/workspace-server/internal/handlers/template_import.go @@ -36,8 +36,14 @@ func normalizeName(name string) string { return result } -// generateDefaultConfig creates a config.yaml from detected prompt files and skills. -func generateDefaultConfig(name string, files map[string]string) string { +// generateDefaultConfig creates a config.yaml from detected prompt files +// and skills. tier is the deployment-aware default (caller passes +// h.wh.DefaultTier() — T4 on SaaS, T3 on self-hosted) so the generated +// file matches what POST /workspaces would default to. Pre-#2910 this +// was hardcoded to 3, which split-brained with the create-handler +// default on SaaS (T4) and pinned newly-imported templates at T3 even +// when downstream Create paths picked T4. +func generateDefaultConfig(name string, files map[string]string, tier int) string { promptFiles := []string{} skillSet := map[string]bool{} @@ -74,9 +80,15 @@ func generateDefaultConfig(name string, files map[string]string) string { var cfg strings.Builder cfg.WriteString(`name: "` + escaped + `"` + "\n") cfg.WriteString("description: Imported agent\n") - // Default to tier 3 ("Privileged") — matches the workspace.go - // create handler default. See its comment for rationale. - cfg.WriteString("version: 1.0.0\ntier: 3\n") + // Tier is SaaS-aware via the caller's DefaultTier (#2910 PR-B). + // Bounds-checked: invalid input falls back to T3 (the historical + // default + the safer-of-the-two when the deployment mode can't + // be resolved). + if tier < 1 || tier > 4 { + tier = 3 + } + cfg.WriteString("version: 1.0.0\n") + cfg.WriteString(fmt.Sprintf("tier: %d\n", tier)) cfg.WriteString("model: anthropic:claude-haiku-4-5-20251001\n") cfg.WriteString("\nprompt_files:\n") if len(promptFiles) > 0 { @@ -148,7 +160,11 @@ func (h *TemplatesHandler) Import(c *gin.Context) { // Auto-generate config.yaml if not provided if _, exists := body.Files["config.yaml"]; !exists { - cfg := generateDefaultConfig(body.Name, body.Files) + tier := 3 + if h.wh != nil { + tier = h.wh.DefaultTier() + } + cfg := generateDefaultConfig(body.Name, body.Files, tier) if err := os.WriteFile(filepath.Join(destDir, "config.yaml"), []byte(cfg), 0600); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config.yaml"}) return @@ -227,7 +243,11 @@ func (h *TemplatesHandler) ReplaceFiles(c *gin.Context) { if _, exists := body.Files["config.yaml"]; !exists { // Check if config.yaml exists in container if _, err := h.execInContainer(ctx, containerName, []string{"test", "-f", "/configs/config.yaml"}); err != nil { - cfg := generateDefaultConfig(wsName, body.Files) + tier := 3 + if h.wh != nil { + tier = h.wh.DefaultTier() + } + cfg := generateDefaultConfig(wsName, body.Files, tier) singleFile := map[string]string{"config.yaml": cfg} h.copyFilesToContainer(ctx, containerName, "/configs", singleFile) } diff --git a/workspace-server/internal/handlers/template_import_test.go b/workspace-server/internal/handlers/template_import_test.go index 42336844..c496f9c5 100644 --- a/workspace-server/internal/handlers/template_import_test.go +++ b/workspace-server/internal/handlers/template_import_test.go @@ -55,7 +55,7 @@ func TestGenerateDefaultConfig_WithFiles(t *testing.T) { "skills/review/templates.md": "Templates", } - cfg := generateDefaultConfig("Test Agent", files) + cfg := generateDefaultConfig("Test Agent", files, 3) // Name is emitted as a double-quoted scalar (#221 sanitizer). if !strings.Contains(cfg, `name: "Test Agent"`) { @@ -85,7 +85,7 @@ func TestGenerateDefaultConfig_Empty(t *testing.T) { "data/something.json": `{"key": "value"}`, } - cfg := generateDefaultConfig("Empty Agent", files) + cfg := generateDefaultConfig("Empty Agent", files, 3) if !strings.Contains(cfg, `name: "Empty Agent"`) { t.Errorf("config should contain quoted agent name, got:\n%s", cfg) @@ -134,7 +134,7 @@ func TestGenerateDefaultConfig_YAMLInjection(t *testing.T) { for _, tc := range adversarialCases { t.Run(tc.desc, func(t *testing.T) { - cfg := generateDefaultConfig(tc.name, map[string]string{}) + cfg := generateDefaultConfig(tc.name, map[string]string{}, 3) var parsed map[string]interface{} if err := yaml.Unmarshal([]byte(cfg), &parsed); err != nil { t.Fatalf("sanitized config does not parse as YAML: %v\n--- config ---\n%s", err, cfg) @@ -205,7 +205,7 @@ func TestImport_Success(t *testing.T) { setupTestRedis(t) tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) body := `{ "name": "New Agent", @@ -245,7 +245,7 @@ func TestImport_MissingName(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) body := `{"files": {"test.md": "content"}}` @@ -265,7 +265,7 @@ func TestImport_TooManyFiles(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) files := make(map[string]string) for i := 0; i <= maxUploadFiles; i++ { @@ -296,7 +296,7 @@ func TestImport_AlreadyExists(t *testing.T) { tmpDir := t.TempDir() os.MkdirAll(filepath.Join(tmpDir, "existing-agent"), 0755) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) body := `{"name": "Existing Agent", "files": {"test.md": "content"}}` @@ -317,7 +317,7 @@ func TestImport_WithConfigYaml(t *testing.T) { setupTestRedis(t) tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) body := `{ "name": "Custom Agent", @@ -354,7 +354,7 @@ func TestReplaceFiles_MissingBody(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -373,7 +373,7 @@ func TestReplaceFiles_TooManyFiles(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) files := make(map[string]string) for i := 0; i <= maxUploadFiles; i++ { @@ -398,7 +398,7 @@ func TestReplaceFiles_WorkspaceNotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) // ReplaceFiles now selects (name, instance_id, runtime) for the // restart-cascade. Match the full column list rather than just the @@ -429,7 +429,7 @@ func TestReplaceFiles_PathTraversal(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`). WithArgs("ws-rf-pt"). diff --git a/workspace-server/internal/handlers/templates.go b/workspace-server/internal/handlers/templates.go index d51dabcd..03776a5d 100644 --- a/workspace-server/internal/handlers/templates.go +++ b/workspace-server/internal/handlers/templates.go @@ -31,10 +31,20 @@ const maxUploadFiles = 200 type TemplatesHandler struct { configsDir string docker *client.Client + // wh is used by Import and ReplaceFiles to call DefaultTier() so a + // generated config.yaml's tier matches the SaaS-vs-self-hosted + // boundary (#2910 PR-B). nil-tolerant — the field is unused when + // the caller doesn't import templates that need a fresh config + // generated. + wh *WorkspaceHandler } -func NewTemplatesHandler(configsDir string, dockerCli *client.Client) *TemplatesHandler { - return &TemplatesHandler{configsDir: configsDir, docker: dockerCli} +// NewTemplatesHandler constructs a TemplatesHandler. wh may be nil for +// callers that only use the read-only template surfaces (List, +// ReadFile, ListFiles). Import + ReplaceFiles need wh non-nil so the +// generated config.yaml picks the SaaS-aware default tier. +func NewTemplatesHandler(configsDir string, dockerCli *client.Client, wh *WorkspaceHandler) *TemplatesHandler { + return &TemplatesHandler{configsDir: configsDir, docker: dockerCli, wh: wh} } // modelSpec describes a single supported model on a template: its id (sent diff --git a/workspace-server/internal/handlers/templates_test.go b/workspace-server/internal/handlers/templates_test.go index cbae8069..3d75bfd5 100644 --- a/workspace-server/internal/handlers/templates_test.go +++ b/workspace-server/internal/handlers/templates_test.go @@ -53,7 +53,7 @@ func TestTemplatesList_EmptyDir(t *testing.T) { setupTestRedis(t) tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -99,7 +99,7 @@ skills: // Create a directory without config.yaml (should be skipped) os.MkdirAll(filepath.Join(tmpDir, "no-config"), 0755) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -160,7 +160,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -237,7 +237,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -315,7 +315,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -434,7 +434,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -512,7 +512,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -555,7 +555,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -589,7 +589,7 @@ skills: [] t.Fatalf("write: %v", err) } - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -661,7 +661,7 @@ skills: [] log.SetOutput(&logBuf) defer log.SetOutput(prevOutput) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest("GET", "/templates", nil) @@ -698,7 +698,7 @@ func TestTemplatesList_NonexistentDir(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler("/nonexistent/path/to/templates", nil) + handler := NewTemplatesHandler("/nonexistent/path/to/templates", nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -723,7 +723,7 @@ func TestListFiles_InvalidRoot(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -748,7 +748,7 @@ func TestListFiles_WorkspaceNotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). WithArgs("ws-nonexist"). @@ -775,7 +775,7 @@ func TestListFiles_FallbackToHost_NoTemplate(t *testing.T) { setupTestRedis(t) tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) // nil docker = no container + handler := NewTemplatesHandler(tmpDir, nil, nil) // nil docker = no container mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). WithArgs("ws-fallback"). @@ -815,7 +815,7 @@ func TestListFiles_FallbackToHost_WithTemplate(t *testing.T) { os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Test Agent\n"), 0644) os.WriteFile(filepath.Join(tmplDir, "system-prompt.md"), []byte("# prompt"), 0644) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). WithArgs("ws-tmpl"). @@ -849,7 +849,7 @@ func TestReadFile_PathTraversal(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -870,7 +870,7 @@ func TestReadFile_InvalidRoot(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -892,7 +892,7 @@ func TestReadFile_WorkspaceNotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`). WithArgs("ws-nf"). @@ -926,7 +926,7 @@ func TestReadFile_FallbackToHost_Success(t *testing.T) { os.MkdirAll(tmplDir, 0755) os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Reader Agent\ntier: 1\n"), 0644) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) // instance_id="" → SaaS branch skipped → falls through to local // Docker / template-dir host fallback (the only path the test @@ -967,7 +967,7 @@ func TestReadFile_FallbackToHost_NotFound(t *testing.T) { setupTestRedis(t) tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`). WithArgs("ws-nofile"). @@ -999,7 +999,7 @@ func TestWriteFile_PathTraversal(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1023,7 +1023,7 @@ func TestWriteFile_InvalidBody(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1046,7 +1046,7 @@ func TestWriteFile_WorkspaceNotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`). WithArgs("ws-wf-nf"). @@ -1080,7 +1080,7 @@ func TestDeleteFile_PathTraversal(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -1101,7 +1101,7 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) { mock := setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) mock.ExpectQuery("SELECT name FROM workspaces WHERE id ="). WithArgs("ws-del-nf"). @@ -1133,7 +1133,7 @@ func TestResolveTemplateDir_ByNormalizedName(t *testing.T) { tmplDir := filepath.Join(tmpDir, "my-agent") os.MkdirAll(tmplDir, 0755) - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) result := handler.resolveTemplateDir("My Agent") if result != tmplDir { @@ -1143,7 +1143,7 @@ func TestResolveTemplateDir_ByNormalizedName(t *testing.T) { func TestResolveTemplateDir_NotFound(t *testing.T) { tmpDir := t.TempDir() - handler := NewTemplatesHandler(tmpDir, nil) + handler := NewTemplatesHandler(tmpDir, nil, nil) result := handler.resolveTemplateDir("Nonexistent Agent") if result != "" { @@ -1177,7 +1177,7 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) { setupTestDB(t) setupTestRedis(t) - handler := NewTemplatesHandler(t.TempDir(), nil) + handler := NewTemplatesHandler(t.TempDir(), nil, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index 86007d00..d6d7b2d7 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -519,8 +519,9 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi r.GET("/canvas/viewport", vh.Get) r.PUT("/canvas/viewport", middleware.CanvasOrBearer(db.DB), vh.Save) - // Templates - tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli) + // Templates — wh threaded so generateDefaultConfig picks the + // SaaS-aware default tier in Import + ReplaceFiles (#2910 PR-B). + tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli, wh) // #686: GET /templates lists all template names+metadata from configsDir. // Open access lets unauthenticated callers enumerate org configurations and // installed plugins. AdminAuth-gate it alongside POST /templates/import.