Sweep on the workspace-creation dialog — same patterns shipped on every
other surface.
- 2× bg-accent-strong hover:bg-accent (FAB + Create) hovered LIGHTER
on white text → bg-accent hover:bg-accent-strong + focus-visible
rings.
- Cancel: bg-surface-card hover:bg-surface-card no-op → surface-
elevated + focus-visible ring.
- 4× placeholder-zinc-500/600 hardcoded → placeholder-ink-soft so
placeholders flip with theme.
- FAB shadow tinting (shadow-blue-600/20 + shadow-blue-500/30) was
hardcoded blue with no theme variant; switched to shadow-accent so
the glow tint matches the brand mint accent in both modes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Independent code review of #2555 caught two contrast regressions left
by the bulk perl pass:
1. text-white → text-ink mass-substitution silently broke destructive
and primary buttons. text-ink resolves to #15181c (warm-paper
near-black) in light mode — dark text on bg-red-600 / bg-amber-600
/ bg-emerald-600 / bg-blue-600 / bg-accent / bg-accent-strong /
bg-good / bg-bad fails WCAG contrast and looks broken. Per-line
pass flips text-ink → text-white only when a saturated bg utility
is present; tinted-state pills (bg-red-950/50 etc.) keep their
intentionally-retained text-* literals.
2. Original mapping table was missing bg-zinc-600 (most-used
hover-state literal for cancel buttons — caused them to JUMP from
warm cream resting state to dark zinc on hover in light mode) and
text-zinc-700/800/900 (separator dots and decorative dim text
invisible on warm-paper light bg). Extended mapping fills these
gaps with bg-surface-card / text-ink-soft.
Also: drop stale tailwind.config.ts reference from components.json
(file deleted by the v3→v4 migration); switch baseColor zinc →
neutral and enable cssVariables since v4 uses CSS-driven tokens.
Future shadcn-cli invocations would have failed or written malformed
components without this.
27 sites in 27 files affected by #1, ~20 sites in 20 files by #2.
1214/1214 unit tests still pass; build still clean.
Findings courtesy of multi-model review per code-review-and-quality
skill — different blind spots catch different bugs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the data-driven pattern PR #2454 set in ConfigTab: read
runtime_config.providers from /templates and filter the modal's
provider <select> to that subset. Same source of truth, three fewer
hardcoded copies of the provider list.
Behavior:
- Template declares providers → dropdown shows only those.
- Template ships no providers field → fall back to full HERMES_PROVIDERS
catalog (back-compat for older templates / self-hosted setups).
- Declared list has no overlap with our static metadata → fall back to
full catalog so the form can't lock the operator out.
- hermesProvider snaps back to the first available pick when its
current value falls out of the filtered list.
Tests: 3 new pinning the filter, no-providers-field fallback, and
the unknown-providers fallback. All 27 CreateWorkspaceDialog tests
pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Problem
Two issues the external-workspace path was silently dropping:
1. `knownRuntimes` was a hardcoded Go map that drifted from
manifest.json — e.g. `gemini-cli` was in manifest but missing
from the Go allowlist, so any workspace provisioning with
runtime=gemini-cli got silently coerced to langgraph.
2. No end-to-end "bring your own compute" story. The canvas UI
had no way to pick runtime=external; the partial backend code
required the operator to already have a URL ready (chicken-and-
egg with the agent that doesn't exist yet), and no workspace_auth
_token was minted so the external agent couldn't authenticate its
register call.
## Change
### Runtime registry driven by manifest.json
- New `runtime_registry.go` reads `manifest.json` at service init.
Each `workspace_templates[].name` becomes a runtime identifier
(with the `-default` suffix stripped so `claude-code-default`
and `claude-code` resolve to the same runtime).
- `external` is always injected (no template repo exists for it).
- Falls back to a static map on manifest load failure so tests /
dev containers keep working.
- 5 new tests including a real-manifest sanity check.
### First-class external workspace flow
When `POST /workspaces` is called with `runtime: "external"` AND
no URL supplied:
1. Workspace row inserted with `status='awaiting_agent'`
(distinct from `provisioning` so canvas doesn't trip its
provisioning-timeout UX).
2. A workspace_auth_token is minted via `wsauth.IssueToken`.
3. Response body includes a `connection` object with:
- `workspace_id`, `platform_url`, `auth_token`
- `registry_endpoint`, `heartbeat_endpoint`
- `curl_register_template` — zero-dep one-shot register snippet
- `python_snippet` — full SDK setup w/ heartbeat loop,
paired with molecule-sdk-python PR #13's A2AServer
4. The platform URL is resolved from `EXTERNAL_PLATFORM_URL` env
(ops-configurable per tenant) or falls back to request headers.
The legacy `payload.External` + `payload.URL` path is preserved —
org-import and other callers that already have a URL still work.
### Canvas UI
- New "External agent (bring your own compute)" checkbox in
CreateWorkspaceDialog.
- When checked, template/model/hermes-provider fields are hidden
and the POST body includes `runtime: "external"`.
- New `ExternalConnectModal` component: shown once after create,
renders Python / curl / raw-fields tabs with copy-to-clipboard
buttons. Stays mounted as a sibling of the create dialog so the
token survives the create dialog unmount.
- `auth_token` is interpolated into the snippet client-side so the
copied block is truly ready to run — operator only has to fill
in their agent's public URL.
## Tests
- Go: 5 new runtime_registry tests (happy path, -default strip,
external always injected, missing file, malformed JSON, real
manifest sanity). All existing handler tests still pass.
- TypeScript: no type errors on my files; pre-existing
canvas-batch-partial-failure type drift is on main already and
tracked on the #2061 branch.
## Follow-ups (filed separately)
- Cut molecule-sdk-python v0.y to PyPI so the snippet can use
`pip install molecule-ai-sdk` instead of `git+main`.
- Add a `runtime: string` field per template in manifest.json so
one template can declare its runtime explicitly (instead of
deriving it from name conventions). Unblocks N-templates-per-
runtime (e.g. hermes-minimax, hermes-anthropic both runtime=hermes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Brings the staging branch up to date with main's feature-fix stream so
every staging-targeted PR stops tripping on pre-existing rot. Before
this merge, staging had 30+ compile + test failures from fix PRs that
landed on main but never reached staging — primarily #1755's panic-
cascade + schema-drift alignments.
After this merge the handlers package goes from 30+ fails → 2 pre-
existing nil-docker test panics (TestCopyFilesToContainer_CWE22_
RejectsTraversal + TestDeleteViaEphemeral_F1085_RejectsTraversal),
both authored on staging and broken before this promotion. Tracked
separately; not a merge regression.
## Conflicts resolved
1. docs/marketing/campaigns/discord-adapter-announcement/announcement.md
— deleted on main (9d0d213: "move sensitive strategy + research to
internal repo"), modified on staging. Deletion wins: marketing
content moved out of the public monorepo per that commit's intent.
The content lives in the internal repo.
2. workspace-server/internal/handlers/container_files.go — staging's
rmTarget version kept. Main's version had `Cmd: []string{"rm",
"-rf", "/configs/" + filePath}` which concatenates raw filePath
AFTER the prefix-check on rmTarget, defeating the path-traversal
guard (a "../etc/passwd" input passes validation but the rm cmd
then traverses). Staging's `Cmd: []string{"rm", "-rf", rmTarget}`
uses the validated path. Keeping staging's more-secure variant.
## Includes build unblockers from #1769 / #1782
- terminal.go: malformed handleLocalConnect repaired
- terminal_test.go: missing braces in TestHandleConnect_RoutesToLocal
- workspace_crud.go: unused imports + duplicate strField block
- container_files_test.go: duplicate contains() removed (uses the one
in workspace_provision_test.go, same package)
## Verification
- go build ./... ✅ clean
- go vet ./... ✅ clean
- go test -race ./... — 18/20 packages green; 2 test panics in
internal/handlers are pre-existing on staging (documented above)
Root cause of the hermes 401 "Invalid API key" on SaaS workspaces:
1. CreateWorkspaceDialog never sent `model` in the /workspaces POST
2. Tenant/CP plumbed through a valid (provider, API key) but empty MODEL
3. Workspace install.sh ran with HERMES_DEFAULT_MODEL unset
4. derive-provider.sh saw no slug → PROVIDER="auto"
5. Hermes fell back to its compiled-in default (Anthropic via
OpenAI-compat adapter)
6. User's MINIMAX_API_KEY was present but irrelevant — hermes tried
Anthropic with it → 401
Fix:
- Extend HERMES_PROVIDERS with `defaultModel` + `models` (suggestion
list). Each provider ships with a known-good default so the trap
is physically impossible to hit with the new form.
- Add a required Model input to the Hermes panel, auto-populated
from the provider's defaultModel when the provider changes (only
if the user hasn't typed their own slug yet).
- Datalist surfaces additional model suggestions per provider so
users can pick a different size (e.g. M2.7-highspeed) without
typing the whole slug.
- handleCreate validates hermesModel is non-empty, sends as `model`
in the POST body alongside the secrets block.
- useEffect guard avoids clobbering a user-typed custom slug when
they toggle providers back and forth.
Existing 19 a11y tests still pass (non-SaaS path unchanged, four-tier
picker still renders, arrow-key nav still wraps).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Following feedback that T4 — not T3 — is the full-access tier:
- Non-SaaS picker now shows all four tiers: T1 Sandboxed, T2 Standard,
T3 Privileged, T4 Full Access. Four-column grid.
- SaaS picker stays single-option but now locks to T4 (was T3). Every
SaaS workspace gets a dedicated EC2 VM, which is unambiguously the
"full host" case — T3 (privileged container) was a category mismatch.
- Default tier on SaaS is 4 (was 3). CP provisioner already supports
tier 4 (t3.large / 80 GB). TIER_CONFIG already has T4's amber color.
Tests updated for the four-tier picker: wrap tests now go T4 ↔ T1, and
the selection/tabIndex tests cover the fourth button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On SaaS every workspace gets its own EC2 VM — the Docker-sandbox
distinction between T1 (sandboxed), T2 (standard Docker), and T3
(full host access) doesn't apply. A SaaS workspace is always a
dedicated VM, which is "full access" by construction. Showing T1/T2
in that UI is a category error: users pick a sandbox level that has
no effect on the actual EC2 machine they get.
Changes:
- tenant.ts: export isSaaSTenant() — returns true when canvas is
served at <slug>.moleculesai.app (SSR-safe: false on server)
- CreateWorkspaceDialog: when isSaaSTenant(), render only the T3
option, default tier=3, grid collapses to a single column. Label
gets a " — dedicated VM" hint so the user knows what they're
getting. On self-hosted the full T1/T2/T3 picker is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds useId() to the InputField helper in CreateWorkspaceDialog so every
<label> is wired to its <input> via htmlFor/id. Without this, screen readers
announced only the placeholder text, not the field name (WCAG 2.1 SC 1.3.1
Level A violation, build 4JIwTGVMjDGNLO8iMGJeC).
Affected fields: Name (required), Role, Budget limit (USD), Template.
The Hermes provider fields were already correctly wired.
Adds 6 new tests in CreateWorkspaceDialog.a11y.test.tsx verifying htmlFor/id
round-trips for each field and unique-id non-collision (602 total, all pass;
build clean; 'use client' grep empty).
Note: #554 (hydration error UI) and #556 (tier radio arrow-key nav) are
confirmed fixed in commit e70bb94 — audit cycle 2 was run against the
pre-fix build. #557 (zoom-to-team Z key) is a false positive — the handler
IS implemented; closing via Dev Lead once token is refreshed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Adds optional Budget limit (USD) numeric field to CreateWorkspaceDialog;
blank = null (unlimited), populated = parsed float sent as budget_limit in
POST /workspaces body
- Adds budget_limit field to DetailsTab edit form; saves via
PATCH /workspaces/:id; pre-fills from current WorkspaceNodeData
- Shows 'Budget limit exceeded' warning badge when budgetUsed > budgetLimit
(forward-compatible — badge hidden when budgetUsed is absent)
- Extends WorkspaceData, WorkspaceNodeData, and buildNodesAndEdges to carry
budgetLimit / budgetUsed fields ready for backend hydration (issue #541 BE PR)
- Ships 22 new tests across CreateWorkspaceDialog and BudgetLimit.DetailsTab
suites (575 total, all passing); npm run build clean; 'use client' grep empty
API shape confirmed from workspace.go and CreateWorkspacePayload struct:
field name: budget_limit | type: number | null | units: USD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- #554 CRITICAL: Add hydrationError state to Zustand store; catch handler now
calls setHydrationError instead of silent console.error; page renders a
full-screen zinc-950 error banner with a Retry button that reloads the page
- #556 MEDIUM: Add roving tabIndex + ArrowDown/Up/Left/Right keyboard handler
to the tier radio group in CreateWorkspaceDialog (WCAG 2.1 compliant)
- #557 MEDIUM: Add "Zoom to Team" menu item to ContextMenu (visible only when
node has children); dispatches molecule:zoom-to-team for keyboard accessibility
- Bonus: add missing 'use client' directive to RevealToggle.tsx
Co-authored-by: Molecule AI Frontend Engineer <frontend-engineer@agents.moleculesai.app>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user sets template="hermes", surface a provider dropdown
(15 providers, defaulting to anthropic) and a masked API key input.
On submit the chosen key is sent as `secrets: { [ENV_VAR]: key }` so
the backend can persist it encrypted before the container boots,
fixing the silent preflight failure reported in #493.
- Adds HERMES_PROVIDERS constant (exported for tests)
- Validates API key presence before POST when template is hermes
- Uses violet accent to visually distinguish the hermes section
- 11 new unit tests covering picker visibility, default, env-var
mapping, validation, and POST payload shape
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace raw Parent Workspace ID text input with a <select> populated
from GET /workspaces (T{tier} · {name} format, graceful fallback on
fetch error). Raise all interactive button text from text-[8px]/[9px]
to text-[11px] across SkillsTab, ScheduleTab, secrets-section,
ActivityTab, SidePanel, ChatTab; non-interactive labels/badges to
text-[10px]. Adds 7 CreateWorkspaceDialog unit tests (372/372 passing).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>