diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index 2452ef1a..d54f1709 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -48,16 +48,21 @@ export function EmptyState() { }); // "Create blank" bypasses templates entirely — no preflight, no - // modal, just POST /workspaces with a default name and tier. - // Deliberately NOT routed through useTemplateDeploy because it - // has no `template.id` to deploy against. + // modal, just POST /workspaces with a default name. Deliberately + // NOT routed through useTemplateDeploy because it has no + // `template.id` to deploy against. + // + // tier is omitted so the backend picks a SaaS-aware default + // (T4 on SaaS, T3 on self-hosted — see WorkspaceHandler.DefaultTier). + // The previous hardcoded `tier: 2` shipped every fresh-tenant agent + // at Standard regardless of host, which surprised SaaS users whose + // CreateWorkspaceDialog already defaults to T4. const createBlank = async () => { setBlankCreating(true); setBlankError(null); try { const ws = await api.post<{ id: string }>("/workspaces", { name: "My First Agent", - tier: 2, canvas: firstDeployCoords(), }); handleDeployed(ws.id); diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index 70151e09..94ca0b34 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -61,7 +61,17 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX tier = defaults.Tier } if tier == 0 { - tier = 2 + // 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 + } else { + tier = 2 + } } ctxLookup := context.Background() diff --git a/workspace-server/internal/handlers/workspace.go b/workspace-server/internal/handlers/workspace.go index 3b5b4c02..cf210342 100644 --- a/workspace-server/internal/handlers/workspace.go +++ b/workspace-server/internal/handlers/workspace.go @@ -148,15 +148,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) { id := uuid.New().String() awarenessNamespace := workspaceAwarenessNamespace(id) if payload.Tier == 0 { - // 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 + // SaaS-aware default. SaaS → T4 (full host access; each + // workspace runs on its own sibling EC2 so the tier boundary + // is a Docker resource limit on the only container present — + // no neighbour to protect from). Self-hosted → T3 (read-write + // workspace mount + Docker daemon access, most templates' + // baseline). Lower tiers (T1 sandboxed, T2 standard) remain + // explicit opt-ins for low-trust agents. Matches the canvas + // CreateWorkspaceDialog defaults so the API and the UI agree. + payload.Tier = h.DefaultTier() } // Detect runtime + default model from template config.yaml when the diff --git a/workspace-server/internal/handlers/workspace_dispatchers.go b/workspace-server/internal/handlers/workspace_dispatchers.go index 23237d00..18ede255 100644 --- a/workspace-server/internal/handlers/workspace_dispatchers.go +++ b/workspace-server/internal/handlers/workspace_dispatchers.go @@ -49,6 +49,32 @@ func (h *WorkspaceHandler) HasProvisioner() bool { return h.cpProv != nil || h.provisioner != nil } +// IsSaaS reports whether the CP (EC2) provisioner is wired. Each SaaS +// workspace runs on its own sibling EC2, so the per-workspace tier +// boundary is a Docker resource limit applied to the only container +// on that EC2 — there's no neighbour to protect from. Self-hosted +// runs many workspaces in one Docker daemon on a single host, so +// the tier-2-by-default safe-neighbour-share posture stays. +// +// Tier defaults across Create / OrgImport / canvas EmptyState branch +// on IsSaaS so SaaS users get T4 (full host access) by default and +// self-hosted users keep the lower-trust caps. +func (h *WorkspaceHandler) IsSaaS() bool { + return h.cpProv != nil +} + +// DefaultTier is the SaaS-aware default tier. T4 on SaaS (single +// container per EC2 — full host access matches the boundary), T3 on +// self-hosted (read-write workspace mount + Docker daemon access, +// most templates' baseline). Callers default to this when the user +// hasn't explicitly picked a tier. +func (h *WorkspaceHandler) DefaultTier() int { + if h.IsSaaS() { + return 4 + } + return 3 +} + // provisionWorkspaceAuto picks the backend (CP for SaaS, local Docker // for self-hosted) and starts provisioning in a goroutine. Returns true // when a backend was kicked off, false when neither is wired.