feat(quickstart): default new agents to T3 (Privileged)

Default tier for a newly-created workspace was T1 (Sandboxed) on
self-hosted and T4 (Full Access) on SaaS. Real work needs at minimum
a read_write workspace mount + Docker daemon access — that's T3
("Privileged") per the tier ladder in CreateWorkspaceDialog. The
user-visible consequence was that clicking "Deploy" on almost any
template landed in a sandbox that couldn't actually run the agent's
tooling until the user knew to bump the tier manually.

### Changes

**Platform (Go)** — default tier flipped from 1→3 in two places so
API callers (Canvas, molecli, org import) all get the same default:

- `handlers/workspace.go`: `POST /workspaces` default when `tier` is
  omitted from the request body.
- `handlers/template_import.go`: `generateDefaultConfig` writes
  `tier: 3` into the auto-generated `config.yaml` for bundle imports
  that don't declare one.

**Canvas** — `CreateWorkspaceDialog.tsx` self-hosted form default
flipped from T1→T3. SaaS stays at T4 (each SaaS workspace runs on
its own sibling EC2, so the shared-blast-radius reasoning doesn't
apply and we can safely go a tier higher).

### Tests

Updated every sqlmock assertion that anchored on the old `tier=1`
default:

- `handlers_test.go::TestWorkspaceCreate` — default-path INSERT now
  expects `3`.
- `handlers_additional_test.go::TestWorkspaceCreate_WithParentID` —
  same.
- `workspace_test.go::TestWorkspaceCreate_DBInsertError` /
  `TestWorkspaceCreate_WithSecrets_Persists` — same.
- `workspace_test.go::TestWorkspaceCreate_TemplateDefaults*` — same
  (current handler semantics ignore the template's `tier:` field and
  fall through to the default; kept tests faithful to the
  implementation, left a comment flagging the latent inconsistency).
- `workspace_budget_test.go::TestWorkspaceBudget_Create_WithLimit` —
  same.
- `template_import_test.go::TestGenerateDefaultConfig` — asserts
  `tier: 3` now.

All `go test -race ./internal/handlers/` pass.

Canvas `CreateWorkspaceDialog` tests don't assert the default tier
(they only reference `tier` as prop data on stub workspaces) so no
test update needed on that side.

### SaaS parity

Zero behaviour change on hosted SaaS. The Go-side default only fires
when the Canvas (or any caller) omits `tier` from the request body.
The SaaS Canvas explicitly passes `tier: 4` from the
CreateWorkspaceDialog `isSaaS ? 4 : 3` branch, so the Go default
never runs on a SaaS request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 15:34:22 -07:00
parent 19cd5c9f4b
commit 2baaa977c7
8 changed files with 39 additions and 16 deletions

View File

@ -89,7 +89,13 @@ export function CreateWorkspaceButton() {
],
[isSaaS],
);
const defaultTier = isSaaS ? 4 : 1;
// T3 ("Privileged") is the self-hosted default — gives agents the
// read_write workspace mount + Docker daemon access most templates
// expect to do real work. T1 sandboxed and T2 standard are kept as
// explicit opt-ins for low-trust agents. SaaS still defaults to T4
// because every SaaS workspace gets its own EC2 (sibling VMs, no
// shared blast radius — see isSaaSTenant() / tier picker hide logic).
const defaultTier = isSaaS ? 4 : 3;
const [tier, setTier] = useState(defaultTier);
// Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)

View File

@ -29,8 +29,9 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
parentID := "parent-ws-123"
mock.ExpectBegin()
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 1, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil)).
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").

View File

@ -279,9 +279,10 @@ func TestWorkspaceCreate(t *testing.T) {
// Expect transaction begin for atomic workspace+secrets creation
mock.ExpectBegin()
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace)
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace).
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 1, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
// Expect transaction commit (no secrets in this payload)

View File

@ -74,7 +74,9 @@ func generateDefaultConfig(name string, files map[string]string) string {
var cfg strings.Builder
cfg.WriteString(`name: "` + escaped + `"` + "\n")
cfg.WriteString("description: Imported agent\n")
cfg.WriteString("version: 1.0.0\ntier: 1\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")
cfg.WriteString("model: anthropic:claude-haiku-4-5-20251001\n")
cfg.WriteString("\nprompt_files:\n")
if len(promptFiles) > 0 {

View File

@ -61,8 +61,8 @@ func TestGenerateDefaultConfig_WithFiles(t *testing.T) {
if !strings.Contains(cfg, `name: "Test Agent"`) {
t.Errorf("config should contain quoted agent name, got:\n%s", cfg)
}
if !strings.Contains(cfg, "tier: 1") {
t.Error("config should default to tier 1")
if !strings.Contains(cfg, "tier: 3") {
t.Error("config should default to tier 3 (Privileged) — matches workspace.go create handler default")
}
// Should detect prompt files
if !strings.Contains(cfg, "system-prompt.md") {

View File

@ -92,7 +92,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
id := uuid.New().String()
awarenessNamespace := workspaceAwarenessNamespace(id)
if payload.Tier == 0 {
payload.Tier = 1
// Default to T3 ("Privileged"). T3 gives agents a read_write
// workspace mount + Docker daemon access — the level most
// templates need to do real work. Lower tiers (T1 sandboxed,
// T2 standard) stay available as explicit opt-ins for
// low-trust agents. Matches the Canvas CreateWorkspaceDialog
// default for self-hosted hosts (SaaS defaults to T4 via
// CreateWorkspaceDialog because each SaaS workspace runs on
// its own sibling EC2).
payload.Tier = 3
}
// Detect runtime + default model from template config.yaml when the

View File

@ -143,7 +143,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
1, // tier
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id

View File

@ -154,7 +154,7 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
// Transaction begins, workspace INSERT fails, transaction is rolled back.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 1, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnError(sql.ErrConnDone)
mock.ExpectRollback()
@ -184,9 +184,10 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
// Transaction wraps the workspace INSERT (no secrets in this request).
mock.ExpectBegin()
// Expect workspace INSERT with defaulted tier=1, runtime="langgraph"
// Expect workspace INSERT with defaulted tier=3 (Privileged — the
// handler default in workspace.go), runtime="langgraph"
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 1, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@ -237,7 +238,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 1, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
// Secret inserted inside the same transaction.
mock.ExpectExec("INSERT INTO workspace_secrets").
@ -1255,7 +1256,7 @@ runtime_config:
// and hand the completed values to the INSERT.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Hermes Agent", nil, 1, "hermes",
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes",
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@ -1306,9 +1307,13 @@ model: anthropic:claude-sonnet-4-5
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
mock.ExpectBegin()
// Default tier 3 (Privileged) — see workspace.go create-handler comment.
// Template declares tier: 1 but the handler's current semantics ignore
// that field and fall through to the default. If that's ever fixed,
// this assertion should flip back to 1.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Legacy Agent", nil, 1, "langgraph",
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph",
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@ -1361,7 +1366,7 @@ runtime_config:
// absence of a handler error to mean the model passthrough was honored.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Custom Hermes", nil, 1, "hermes",
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes",
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()