diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 6318d0ae..344f0e46 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -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) diff --git a/workspace-server/internal/handlers/handlers_additional_test.go b/workspace-server/internal/handlers/handlers_additional_test.go index 888527f5..0e2ecd82 100644 --- a/workspace-server/internal/handlers/handlers_additional_test.go +++ b/workspace-server/internal/handlers/handlers_additional_test.go @@ -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"). diff --git a/workspace-server/internal/handlers/handlers_test.go b/workspace-server/internal/handlers/handlers_test.go index 19ac59fb..962c15f5 100644 --- a/workspace-server/internal/handlers/handlers_test.go +++ b/workspace-server/internal/handlers/handlers_test.go @@ -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) diff --git a/workspace-server/internal/handlers/template_import.go b/workspace-server/internal/handlers/template_import.go index 5776db3c..7d4ab4d1 100644 --- a/workspace-server/internal/handlers/template_import.go +++ b/workspace-server/internal/handlers/template_import.go @@ -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 { diff --git a/workspace-server/internal/handlers/template_import_test.go b/workspace-server/internal/handlers/template_import_test.go index a583ebf3..42336844 100644 --- a/workspace-server/internal/handlers/template_import_test.go +++ b/workspace-server/internal/handlers/template_import_test.go @@ -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") { diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index c55f1543..b962c858 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -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 diff --git a/workspace-server/internal/handlers/workspace_budget_test.go b/workspace-server/internal/handlers/workspace_budget_test.go index 6baa9a40..01a96db3 100644 --- a/workspace-server/internal/handlers/workspace_budget_test.go +++ b/workspace-server/internal/handlers/workspace_budget_test.go @@ -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 diff --git a/workspace-server/internal/handlers/workspace_test.go b/workspace-server/internal/handlers/workspace_test.go index b98f42d3..878af611 100644 --- a/workspace-server/internal/handlers/workspace_test.go +++ b/workspace-server/internal/handlers/workspace_test.go @@ -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()