* fix(docs): update architecture + API reference paths for workspace-server rename Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update workspace script comments for workspace-template → workspace rename Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: ChatTab comment path for workspace-server rename Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add BatchActionBar unit tests (7 tests) Covers: render threshold, count badge, action buttons, clear selection, ConfirmDialog trigger, ARIA toolbar role. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update publish workflow name + document staging-first flow Default branch is now staging for both molecule-core and molecule-controlplane. PRs target staging, CEO merges staging → main to promote to production. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): update working-directory for workspace-server/ and workspace/ renames - platform-build: working-directory platform → workspace-server - golangci-lint: working-directory platform → workspace-server - python-lint: working-directory workspace-template → workspace - e2e-api: working-directory platform → workspace-server - canvas-deploy-reminder: fix duplicate if: key (merged into single condition) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add mol_pk_ and cfut_ to pre-commit secret scanner Partner API keys (mol_pk_*) and Cloudflare tokens (cfut_*) now caught by the pre-commit hook alongside sk-ant-, ghp_, AKIA. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(canvas): enable Turbopack for dev server — faster HMR next dev --turbopack for significantly faster dev server startup and hot module replacement. Build script unchanged (Turbopack for next build is still experimental). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(db): schema_migrations tracking — migrations only run once Adds a schema_migrations table that records which migration files have been applied. On boot, only new migrations execute — previously applied ones are skipped. This eliminates: - Re-running all 33 migrations on every restart - Risk of non-idempotent DDL failing on restart - Unnecessary log noise from re-applying unchanged schema First boot auto-populates the tracking table with all existing migrations. Subsequent boots only apply new ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(scheduler): strip CRLF from cron prompts on insert/update (closes #958) Windows CRLF in org-template prompt text caused empty agent responses and phantom-producing detection. Strips \r at the handler level before DB persist, plus a one-time migration to clean existing rows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): strip current_task from public GET /workspaces/:id (closes #955) current_task exposes live agent instructions to any caller with a valid workspace UUID. Also strips last_sample_error and workspace_dir from the public endpoint. These fields remain available through authenticated workspace-specific endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore(canvas): initialize shadcn/ui — components.json + cn utility Sets up shadcn/ui CLI so new components can be added with `npx shadcn add <component>`. Uses new-york style, zinc base color, no CSS variables (matches existing Tailwind-only approach). Adds clsx + tailwind-merge for the cn() utility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): GLOBAL memory delimiter spoofing + pin MCP npm version SAFE-T1201 (#807): Escape [MEMORY prefix in GLOBAL memory content on write to prevent delimiter-spoofing prompt injection. Content stored as "[_MEMORY " so it renders as text, not structure, when wrapped with the real delimiter on read. SAFE-T1102 (#805): Pin @molecule-ai/mcp-server@1.0.0 in .mcp.json.example. Prevents supply-chain attacks via unpinned npx -y. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: schema_migrations tracking — 4 cases (first boot, re-boot, mixed, down.sql filter) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: verify current_task + last_sample_error + workspace_dir stripped from public GET Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: GLOBAL memory delimiter spoofing escape + LOCAL scope untouched - TestCommitMemory_GlobalScope_DelimiterSpoofingEscaped: verifies [MEMORY prefix is escaped to [_MEMORY before DB insert (SAFE-T1201, #807) - TestCommitMemory_LocalScope_NoDelimiterEscape: LOCAL scope stored verbatim Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(security): Phase 35.1 — SG lockdown script for tenant EC2 instances Restricts tenant EC2 port 8080 ingress to Cloudflare IP ranges only, blocking direct-IP access. Supports two modes: 1. Lock to CF IPs (Worker deployment): 14 IPv4 CIDR rules 2. Close ingress entirely (Tunnel deployment): removes 0.0.0.0/0 only Usage: bash scripts/lockdown-tenant-sg.sh --sg-id sg-xxxxx bash scripts/lockdown-tenant-sg.sh --sg-id sg-xxxxx --close-ingress bash scripts/lockdown-tenant-sg.sh --sg-id sg-xxxxx --dry-run Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: update GitHub Actions to current stable versions (closes #780) - golangci/golangci-lint-action@v4 → v9 - docker/setup-qemu-action@v3 → v4 - docker/setup-buildx-action@v3 → v4 - docker/build-push-action@v5 → v6 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs(opencode): RFC 2119 — 'should not' → 'must not' for SAFE-T1201 warning (closes #861) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(canvas): degraded badge WCAG AA contrast — amber-400 → amber-300 (closes #885) amber-400 on zinc-900 is 5.4:1 (AA pass). amber-300 is 6.9:1 (AA+AAA pass) and matches the rest of the amber usage in WorkspaceNode (currentTask, error detail, badge chip). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(platform): 409 guard on /hibernate when active_tasks > 0 (closes #822) Phase 35.1 / #799 security condition C3 — prevents operator from accidentally killing a mid-task agent. Behavior: - active_tasks == 0 → proceed as before - active_tasks > 0 && ?force=true → log [WARN] + proceed - active_tasks > 0 && no force → 409 with {error, active_tasks} 2 new tests: TestHibernateHandler_ActiveTasks_Returns409, TestHibernateHandler_ActiveTasks_ForceTrue_Returns200. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(platform): track last_outbound_at for silent-workspace detection (closes #817) Sub of #795 (phantom-busy post-mortem). Adds last_outbound_at TIMESTAMPTZ column to workspaces. Bumped async on every successful outbound A2A call from a real workspace (skip canvas + system callers). Exposed in GET /workspaces/:id response as "last_outbound_at". PM/Dev Lead orchestrators can now detect workspaces that have gone silent despite being online (> 2h + active cron = phantom-busy warning). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(workspace): snapshot secret scrubber (closes #823) Sub-issue of #799, security condition C4. Standalone module in workspace/lib/snapshot_scrub.py with three public functions: - scrub_content(str) → str: regex-based redaction of secret patterns - is_sandbox_content(str) → bool: detect run_code tool output markers - scrub_snapshot(dict) → dict: walk memories, scrub each, drop sandbox entries Patterns covered: sk-ant-/sk-proj-, ghp_/ghs_/github_pat_, AKIA, cfut_, mol_pk_, ctx7_, Bearer, env-var assignments, base64 blobs ≥33 chars. 21 unit tests, 100% coverage on new code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(security): cap webhook + config PATCH bodies (H3/H4) Two HIGH-severity DoS surfaces: both handlers read the entire HTTP body with io.ReadAll(r.Body) and no upper bound, so a caller streaming a multi-gigabyte request could exhaust memory on the tenant instance before we even validated the JSON. H3 (Discord webhook): wrap Body in io.LimitReader with a 1 MiB cap. Discord Interactions payloads are well under 10 KiB in practice. H4 (workspace config PATCH): wrap Body in http.MaxBytesReader with a 256 KiB cap. Real configs are <10 KiB; jsonb handles the cap comfortably. Returns 413 Request Entity Too Large on overflow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): C4 — close AdminAuth fail-open race on hosted-SaaS fresh install Pre-launch review blocker. AdminAuth's Tier-1 fail-open fired whenever the workspace_auth_tokens table was empty — including the window between a hosted tenant EC2 booting and the first workspace being created. In that window, every admin-gated route (POST /org/import, POST /workspaces, POST /bundles/import, etc.) was reachable without a bearer, letting an attacker pre-empt the first real user by importing a hostile workspace into a freshly provisioned instance. Fix: fail-open is now ONLY applied when ADMIN_TOKEN is unset (self- hosted dev with zero auth configured). Hosted SaaS always sets ADMIN_TOKEN at provision time, so the branch never fires in prod and requests with no bearer get 401 even before the first token is minted. Tier-2 / Tier-3 paths unchanged. The old TestAdminAuth_684_FailOpen_AdminTokenSet_NoGlobalTokens test was codifying exactly this bug (asserting 200 on fresh install with ADMIN_TOKEN set). Renamed and flipped to TestAdminAuth_C4_AdminTokenSet_FreshInstall_FailsClosed asserting 401. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): scrub workspace-server token + upstream error logs Two findings from the pre-launch log-scrub audit: 1. handlers/workspace_provision.go:548 logged `token[:8]` — the exact H1 pattern that panicked on short keys. Even with a length guard, leaking 8 chars of an auth token into centralized logs shortens the search space for anyone who gets log-read access. Now logs only `len(token)` as a liveness signal. 2. provisioner/cp_provisioner.go:101 fell back to logging the raw control-plane response body when the structured {"error":"..."} field was absent. If the CP ever echoed request headers (Authorization) or a portion of user-data back in an error path, the bearer token would end up in our tenant-instance logs. Now logs the byte count only; the structured error remains in place for the happy path. Also caps the read at 64 KiB via io.LimitReader to prevent log-flood DoS from a compromised upstream. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): tenant CPProvisioner attaches CP bearer on all calls Completes the C1 integration (PR #50 on molecule-controlplane). The CP now requires Authorization: Bearer <PROVISION_SHARED_SECRET> on all three /cp/workspaces/* endpoints; without this change the tenant-side Start/Stop/IsRunning calls would all 401 (or 404 when the CP's routes refused to mount) and every workspace provision from a SaaS tenant would silently fail. Reads MOLECULE_CP_SHARED_SECRET, falling back to PROVISION_SHARED_SECRET so operators can use one env-var name on both sides of the wire. Empty value is a no-op: self-hosted deployments with no CP or a CP that doesn't gate /cp/workspaces/* keep working as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(canvas): add 15s fetch timeout on API calls Pre-launch audit flagged api.ts as missing a timeout on every fetch. A slow or hung CP response would leave the UI spinning indefinitely with no way for the user to abort — effectively a client-side DoS. 15s is long enough for real CP queries (slowest observed is Stripe portal redirect at ~3s) and short enough that a stalled backend surfaces as a clear error with a retry affordance. Uses AbortSignal.timeout (widely supported since 2023) so the abort propagates through React Query / SWR consumers cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e): stop asserting current_task on public workspace GET (#966) PR #966 intentionally stripped current_task, last_sample_error, and workspace_dir from the public GET /workspaces/:id response to avoid leaking task bodies to anyone with a workspace bearer. The E2E smoke test hadn't caught up — it was still asserting "current_task":"..." on the single-workspace GET, which made every post-#966 CI run fail with '60 passed, 2 failed'. Swap the per-workspace asserts to check active_tasks (still exposed, canonical busy signal) and keep the list-endpoint check that proves admin-auth'd callers still see current_task end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: 2026-04-19 SaaS prod migration notes Captures the 10-PR staging→main cutover: what shipped, the three new Railway prod env vars (PROVISION_SHARED_SECRET / EC2_VPC_ID / CP_BASE_URL), and the sharp edge for existing tenants — their containers pre-date PR #53 so they still need MOLECULE_CP_SHARED_SECRET added manually (or a re-provision) before the new CPProvisioner's outbound bearer works. Also includes a post-deploy verification checklist and rollback plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ws-server): pull env from CP on startup Paired with molecule-controlplane PR #55 (GET /cp/tenants/config). Lets existing tenants heal themselves when we rotate or add a CP-side env var (e.g. MOLECULE_CP_SHARED_SECRET landing earlier today) without any ssh or re-provision. Flow: main() calls refreshEnvFromCP() before any other os.Getenv read. The helper reads MOLECULE_ORG_ID + ADMIN_TOKEN from the baked-in user-data env, GETs {MOLECULE_CP_URL}/cp/tenants/config with those credentials, and applies the returned string map via os.Setenv so downstream code (CPProvisioner, etc.) sees the fresh values. Best-effort semantics: - self-hosted / no MOLECULE_ORG_ID → no-op (return nil) - CP unreachable / non-200 → log + return error (main keeps booting) - oversized values (>4 KiB each) rejected to avoid env pollution - body read capped at 64 KiB Once this image hits GHCR, the 5-minute tenant auto-updater picks it up, the container restarts, refresh runs, and every tenant has MOLECULE_CP_SHARED_SECRET within ~5 minutes — no operator toil. Also fixes workspace-server/.gitignore so `server` no longer matches the cmd/server package dir — it only ignored the compiled binary but pattern was too broad. Anchored to `/server`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canary): smoke harness + GHA verification workflow (Phase 2) Post-deploy verification for staging tenant images. Runs against the canary fleet after each publish-workspace-server-image build — catches auto-update breakage (a la today's E2E current_task drift) before it propagates to the prod tenant fleet that auto-pulls :latest every 5 min. scripts/canary-smoke.sh iterates a space-sep list of canary base URLs (paired with their ADMIN_TOKENs) and checks: - /admin/liveness reachable with admin bearer (tenant boot OK) - /workspaces list responds (wsAuth + DB path OK) - /memories/commit + /memories/search round-trip (encryption + scrubber) - /events admin read (AdminAuth C4 path) - /admin/liveness without bearer returns 401 (C4 fail-closed regression) .github/workflows/canary-verify.yml runs after publish succeeds: - 6-min sleep (tenant auto-updater pulls every 5 min) - bash scripts/canary-smoke.sh with secrets pulled from repo settings - on failure: writes a Step Summary flagging that :latest should be rolled back to prior known-good digest Phase 3 follow-up will split the publish workflow so only :staging-<sha> ships initially, and canary-verify's green gate is what promotes :staging-<sha> → :latest. This commit lays the test gate alone so we have something running against tenants immediately. Secrets to set in GitHub repo settings before this workflow can run: - CANARY_TENANT_URLS (space-sep list) - CANARY_ADMIN_TOKENS (same order as URLs) - CANARY_CP_SHARED_SECRET (matches staging CP PROVISION_SHARED_SECRET) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canary): gate :latest tag promotion on canary verify green (Phase 3) Completes the canary release train. Before this, publish-workspace- server-image.yml pushed both :staging-<sha> and :latest on every main merge — meaning the prod tenant fleet auto-pulled every image immediately, before any post-deploy smoke test. A broken image (think: this morning's E2E current_task drift, but shipped at 3am instead of caught in CI) would have fanned out to every running tenant within 5 min. Now: - publish workflow pushes :staging-<sha> ONLY - canary tenants are configured to track :staging-<sha>; they pick up the new image on their next auto-update cycle - canary-verify.yml runs the smoke suite (Phase 2) after the sleep - on green: a new promote-to-latest job uses crane to remotely retag :staging-<sha> → :latest for both platform and tenant images - prod tenants auto-update to the newly-retagged :latest within their usual 5-min window - on red: :latest stays frozen on prior good digest; prod is untouched crane is pulled onto the runner (~4 MB, GitHub release) rather than docker-daemon retag so the workflow doesn't need a privileged runner. Rollback: if canary passed but something surfaces post-promotion, operator runs "crane tag ghcr.io/molecule-ai/platform:<prior-good-sha> latest" manually. A follow-up can wrap that in a Phase 4 admin endpoint / script. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canary): rollback-latest script + release-pipeline doc (Phase 4) Closes the canary loop with the escape hatch and a single place to read about the whole flow. scripts/rollback-latest.sh <sha> uses crane to retag :latest ← :staging-<sha> for BOTH the platform and tenant images. Pre-checks the target tag exists and verifies the :latest digest after the move so a bad ops typo doesn't silently promote the wrong thing. Prod tenants auto-update to the rolled-back digest within their 5-min cycle. Exit codes: 0 = both retagged, 1 = registry/tag error, 2 = usage error. docs/architecture/canary-release.md The one-page map of the pipeline: how PR → main → staging-<sha> → canary smoke → :latest promotion works end-to-end, how to add a canary tenant, how to roll back, and what this gate explicitly does NOT catch (prod-only data, config drift, cross-tenant bugs). No code changes in the CP or workspace-server — this PR is shell + docs only, so it's safe to land independently of the other Phase {1,1.5,2,3} PRs still in review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ws-server): cover CPProvisioner — auth, env fallback, error paths Post-merge audit flagged cp_provisioner.go as the only new file from the canary/C1 work without test coverage. Fills the gap: - NewCPProvisioner_RequiresOrgID — self-hosted without MOLECULE_ORG_ID refuses to construct (avoids silent phone-home to prod CP). - NewCPProvisioner_FallsBackToProvisionSharedSecret — the operator ergonomics of using one env-var name on both sides of the wire. - AuthHeader noop + happy path — bearer only set when secret is set. - Start_HappyPath — end-to-end POST to stubbed CP, bearer forwarded, instance_id parsed out of response. - Start_Non201ReturnsStructuredError — when CP returns structured {"error":"…"}, that message surfaces to the caller. - Start_NoStructuredErrorFallsBackToSize — regression gate for the anti-log-leak change from PR #980: raw upstream body must NOT appear in the error, only the byte count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf(scheduler): collapse empty-run bump to single RETURNING query The phantom-producer detector (#795) was doing UPDATE + SELECT in two roundtrips — first incrementing consecutive_empty_runs, then re- reading to check the stale threshold. Switch to UPDATE ... RETURNING so the post-increment value comes back in one query. Called once per schedule per cron tick. At 100 tenants × dozens of schedules per tenant, the halved DB traffic on the empty-response path is measurable, not just cosmetic. Also now properly logs if the bump itself fails (previously it silent- swallowed the ExecContext error and still ran the SELECT, which would confuse debugging). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canvas): /orgs landing page for post-signup users CP's Callback handler redirects every new WorkOS session to APP_URL/orgs, but canvas had no such route — new users hit the canvas Home component, which tries to call /workspaces on a tenant that doesn't exist yet, and saw a confusing error. This PR plugs that gap with a dedicated landing page that: - Bounces anonymous visitors back to /cp/auth/login - Zero-org users see a slug-picker (POST /cp/orgs, refresh) - For each existing org, shows status + CTA: * awaiting_payment → amber "Complete payment" → /pricing?org=… * running → emerald "Open" → https://<slug>.moleculesai.app * failed → "Contact support" → mailto * provisioning → read-only "provisioning…" - Surfaces errors inline with a Retry button Deliberately server-light: one GET /cp/orgs, no WebSocket, no canvas store hydration. Goal is to move the user from signup to either Stripe Checkout or their tenant URL with one click each. Closes the last UX gap between the BILLING_REQUIRED gate landing on the CP and real users being able to complete a signup today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canvas): post-checkout UX — Stripe success lands on /orgs with banner Two small polish items that together close the signup-to-running-tenant flow for real users: 1. Stripe success_url now points at /orgs?checkout=success instead of the current page (was pricing). The old behavior left people staring at plan cards with no indication payment went through — the new behavior drops them right onto their org list where they can watch the status flip. 2. /orgs shows a green "Payment confirmed, workspace spinning up" banner when it sees ?checkout=success, then clears the query param via replaceState so a reload doesn't show it again. 3. /orgs now polls every 5s while any org is awaiting_payment or provisioning. Users see the Stripe webhook's effect live — no manual refresh needed — and once every org settles the polling stops so idle tabs don't hammer /cp/orgs. Paired with PR #992 (the /orgs page itself) this makes the end-to-end flow on BILLING_REQUIRED=true deployments feel right: /pricing → Stripe → /orgs?checkout=success → banner → live poll → "Open" button when org.status transitions to running. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(canvas): bump billing test for /orgs success_url * fix(ci): clone sibling plugin repo so publish-workspace-server-image builds Publish has been failing since the 2026-04-18 open-source restructure (#964's merge) because workspace-server/Dockerfile still COPYs ./molecule-ai-plugin-github-app-auth/ but the restructure moved that code out to its own repo. Every main merge since has produced a "failed to compute cache key: /molecule-ai-plugin-github-app-auth: not found" error — prod images haven't moved. Fix: add an actions/checkout step that fetches the plugin repo into the build context before docker build runs. Private-repo safe: uses PLUGIN_REPO_PAT secret (fine-grained PAT with Contents:Read on Molecule-AI/molecule-ai-plugin-github-app-auth). Falls back to the default GITHUB_TOKEN if the plugin repo is public. Ops: set repo secret PLUGIN_REPO_PAT before the next main merge, or publish will fail with a 404 on the checkout step. Also gitignores the cloned dir so local dev builds don't accidentally commit it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(promote-latest): workflow_dispatch to retag :staging-<sha> → :latest Escape hatch for the initial rollout window (canary fleet not yet provisioned, so canary-verify.yml's automatic promotion doesn't fire) AND for manual rollback scenarios. Uses the default GITHUB_TOKEN which carries write:packages on repo- owned GHCR images, so no new secrets are needed. crane handles the remote retag without pulling or pushing layers. Validates the src tag exists before retagging + verifies the :latest digest post-retag so a typo can't silently promote the wrong image. Trigger from Actions → promote-latest → Run workflow → enter the short sha (e.g. "4c1d56e"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(promote-latest): run on self-hosted mac mini (GH-hosted quota blocked) * ci(promote-latest): suppress brew cleanup that hits perm-denied on shared runner * feat(canvas): Phase 5 — credit balance pill + low-balance banner Adds the UI surface for the credit system to /orgs: - CreditsPill next to each org row. Tone shifts from zinc → amber at 10% of plan to red at zero. - LowCreditsBanner appears under the pill for running orgs when the balance crosses thresholds: overage_used > 0 → "overage active", balance <= 0 → "out of credits, upgrade", trial tail → "trial almost out". - Pure helpers extracted to lib/credits.ts so formatCredits, pillTone, and bannerKind are unit-tested without jsdom. Backend List query now returns credits_balance / plan_monthly_credits / overage_used_credits / overage_cap_credits so no second round-trip is needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canvas): ToS gate modal + us-east-2 data residency notice Wraps /orgs in a TermsGate that polls /cp/auth/terms-status on mount and overlays a blocking modal when the current terms version hasn't been accepted yet. "I agree" POSTs /cp/auth/accept-terms and dismisses the modal; the backend records IP + UA as GDPR Art. 7 proof-of-consent. Also adds a short data residency notice under the page header: workspaces run in AWS us-east-2 (Ohio, US). An EU region selector is a future lift once the infra is provisioned there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scheduler): defer cron fires when workspace busy instead of skipping (#969) Previously, the scheduler skipped cron fires entirely when a workspace had active_tasks > 0 (#115). This caused permanent cron misses for workspaces kept perpetually busy by the 5-min Orchestrator pulse — work crons (pick-up-work, PR review) were skipped every fire because the agent was always processing a delegation. Measured impact on Dev Lead: 17 context-deadline-exceeded timeouts in 2 hours, ~30% of inter-agent messages silently dropped. Fix: when workspace is busy, poll every 10s for up to 2 minutes waiting for idle. If idle within the window, fire normally. If still busy after 2 min, fall back to the original skip behavior. This is a minimal, safe change: - No new goroutines or channels - Same fire path once idle - Bounded wait (2 min max, won't block the scheduler pool) - Falls back to skip if workspace never becomes idle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mcp): scrub secrets in commit_memory MCP tool path (#838 sibling) PR #881 closed SAFE-T1201 (#838) on the HTTP path by wiring redactSecrets() into MemoriesHandler.Commit — but the sibling code path on the MCP bridge (MCPHandler.toolCommitMemory) was left with only the TODO comment. Agents calling commit_memory via the MCP tool bridge are the PRIMARY attack vector for #838 (confused / prompt-injected agent pipes raw tool-response text containing plain-text credentials into agent_memories, leaking into shared TEAM scope). The HTTP path is only exercised by canvas UI posts, so the MCP gap was the hotter one. Change: workspace-server/internal/handlers/mcp.go:725 - TODO(#838): run _redactSecrets(content) before insert — plain-text - API keys from tool responses must not land in the memories table. + SAFE-T1201 (#838): scrub known credential patterns before persistence… + content, _ = redactSecrets(workspaceID, content) Reuses redactSecrets (same package) so there's no duplicated pattern list — a future-added pattern in memories.go automatically covers the MCP path too. Tests added in mcp_test.go: - TestMCPHandler_CommitMemory_SecretInContent_IsRedactedBeforeInsert Exercises three patterns (env-var assignment, Bearer token, sk-…) and uses sqlmock's WithArgs to bind the exact REDACTED form — so a regression (removing the redactSecrets call) fails with arg-mismatch rather than silently persisting the secret. - TestMCPHandler_CommitMemory_CleanContent_PassesThrough Regression guard — benign content must NOT be altered by the redactor. NOTE: unable to run `go test -race ./...` locally (this container has no Go toolchain). The change is mechanical reuse of an already-shipped function in the same package; CI must validate. The sqlmock patterns mirror the existing TestMCPHandler_CommitMemory_LocalScope_Success test exactly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(ci): move canary-verify to self-hosted runner GitHub-hosted ubuntu-latest runs on this repo hit "recent account payments have failed or your spending limit needs to be increased" — same root cause as the publish + CodeQL + molecule-app workflow moves earlier this quarter. canary-verify was the last one still on ubuntu-latest. Switches both jobs to [self-hosted, macos, arm64]. crane install switched from Linux tarball to brew (matches promote-latest.yml's install pattern + avoids /usr/local/bin write perms on the shared mac mini). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(canvas): pin AbortSignal timeout regression + cover /orgs landing page Two independent test additions that harden the surface freshly landed on staging via PRs #982 (canvas fetch timeout), #992 (/orgs landing), #994 (post-checkout redirect to /orgs). canvas/src/lib/__tests__/api.test.ts (+74 lines, 7 new tests) - GET/POST/PATCH/PUT/DELETE each pass an AbortSignal to fetch - TimeoutError (DOMException name=TimeoutError) propagates to the caller - Each request installs its own signal — no shared module-level controller that would allow one slow request to cancel an unrelated fast one This is the hardening nit I flagged in my APPROVE-w/-nit review of fix/canvas-api-fetch-timeout. Landing as a follow-up now that #982 is in staging. canvas/src/app/__tests__/orgs-page.test.tsx (+251 lines, new file, 10 tests) - Auth guard: signed-out → redirectToLogin and no /cp/orgs fetch - Error state: failed /cp/orgs → Error message + Retry button - Empty list: CreateOrgForm renders - CTA by status: running → "Open" link targets {slug}.moleculesai.app awaiting_payment → "Complete payment" → /pricing?org=<slug> failed → "Contact support" mailto - Post-checkout: ?checkout=success renders CheckoutBanner AND history.replaceState scrubs the query param - Fetch contract: /cp/orgs called with credentials:include + AbortSignal Local baseline on origin/staging tip845ac47: canvas vitest: 50 files / 778 tests, all green canvas build: clean, /orgs route present (2.83 kB / 105 kB first-load) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(canvas): cover /orgs 5s polling on in-flight orgs The test docstring promised polling coverage but I'd only wired the describe-block header, not the actual tests. Closing that gap — vitest fake timers drive three cases: - `provisioning` org → 2nd fetch fires after 5.1s advance - all `running` → no 2nd fetch even after 10s advance - `awaiting_payment` org, unmount before timer fires → no post-unmount fetch (cleanup correctly clears the pollTimer) The unmount case is the meaningful one: without it a fast nav-away leaves the 5s interval chasing the CP forever. page.tsx L97-99 does clear the timer; the test pins the contract. Local baseline on origin/staging tip845ac47+ this branch: canvas vitest: 50 files / 781 tests, all green (+3 vs prior commit) canvas build: clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci(codeql): cover main + staging via workflow GitHub's UI-configured "Code quality" scan only fires on the default branch (staging), which leaves every staging→main promotion PR unscanned. The "On push and pull requests to" field in the UI has no dropdown; multi-branch scanning on private repos without GHAS isn't available there. Workflow file gives us the control we can't get in the UI: triggers on push + pull_request for both branches. Runs on the same self-hosted mac mini via [self-hosted, macos, arm64]. upload: never — GHAS isn't enabled on this repo so the SARIF upload API 403s. Keep results locally, filter to error+warning severity, fail the PR check on findings, publish SARIF as a workflow artifact. Flipping upload: never → always after GHAS is enabled (if ever) is a one-line change. Picks up the review-flagged improvements from the earlier closed PR: - jq install step (brew, no assumption it's present) - severity filter (error+warning only, drops noisy note-level) - set -euo pipefail - SARIF glob (file name doesn't match matrix language id) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bundle/exporter): add rows.Err() after child workspace enumeration Silent data loss on mid-cursor DB errors — partial sub-workspace bundles returned instead of surfacing the iteration error. Adds rows.Err() check after the SELECT id FROM workspaces query in Export(), mirroring the pattern already used in scheduler.go and handlers with similar recursion patterns. Closes: R1 MISSING-ROWS-ERR findings (bundle/exporter.go) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(a11y): WorkspaceNode font floor, contrast, focus rings (Cycle 10) C1: skills badge spans text-[7px]→text-[10px]; "+N more" overflow text-[7px] text-zinc-500→text-[10px] text-zinc-400 C2: Team section label text-[7px] text-zinc-600→text-[10px] text-zinc-400 H4: status label text-[9px]→text-[10px]; active-tasks count text-[9px] text-amber-300/80→text-[10px] text-amber-300 (remove opacity modifier per design-system contrast rule); current-task text text-[9px] text-amber-300/70→text-[10px] text-amber-300 L1: add focus-visible:ring-2 focus-visible:ring-blue-500/70 to the Restart button (independently Tab-focusable inside role="button" wrapper) and to the Extract-from-team button in TeamMemberChip; TeamMemberChip role="button" div already has the focus ring (COVERED, no change) 762/762 tests pass · build clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ci): replace sleep 360 with health-check poll in canary-verify (#1013) The canary-verify workflow blocked the self-hosted runner for a fixed 6 minutes regardless of whether canaries had already updated. This wastes the runner slot when canaries update in 2-3 minutes. Fix: poll each canary's /health endpoint every 30s for up to 7 min. Exit early when all canaries report the expected SHA. Falls back to proceeding after timeout — the smoke suite validates regardless. Typical time saving: ~3-4 minutes per canary verify run. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(gate-1): remove unused fireEvent import (#1011) Mechanical lint fix. github-code-quality[bot] flagged unused import on line 18 — fireEvent is imported but never referenced in the test file. Removing it clears the code quality gate without changing any test behaviour. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: event-driven cron triggers + auto-push hook for agent productivity Three changes to boost agent throughput: 1. Event-driven cron triggers (webhooks.go): GitHub issues/opened events fire all "pick-up-work" schedules immediately. PR review/submitted events fire "PR review" and "security review" schedules. Uses next_run_at=now() so the scheduler picks them up on next tick. 2. Auto-push hook (executor_helpers.py): After every task completion, agents automatically push unpushed commits and open a PR targeting staging. Guards: only on non-protected branches with unpushed work. Uses /usr/local/bin/git and /usr/local/bin/gh wrappers with baked-in GH_TOKEN. Never crashes the agent — all errors logged and continued. 3. Integration (claude_sdk_executor.py): auto_push_hook() called in the _execute_locked finally block after commit_memory. Closes productivity gap where agents wrote code but never pushed, and where work crons only fired on timers instead of reacting to events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: disable schedules when workspace is deleted (#1027) When a workspace is deleted (status set to 'removed'), its schedules remained enabled, causing the scheduler to keep firing cron jobs for non-existent containers. Add a cascade disable query alongside the existing token revocation and canvas layout cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: stop hardcoding CLAUDE_CODE_OAUTH_TOKEN in required_env (#1028) The provisioner was unconditionally writing CLAUDE_CODE_OAUTH_TOKEN into config.yaml's required_env for all claude-code workspaces. When the baked token expired, preflight rejected every workspace — even those with a valid token injected via the secrets API at runtime. Changes: - workspace_provision.go: remove hardcoded required_env for claude-code and codex runtimes; tokens are injected at container start via secrets - workspace_provision_test.go: flip assertion to reject hardcoded token Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add cascade schedule disable tests for #1027 - TestWorkspaceDelete_DisablesSchedules — leaf workspace delete disables its schedules - TestWorkspaceDelete_CascadeDisablesDescendantSchedules — parent+child+grandchild cascade - TestWorkspaceDelete_ScheduleDisableOnlyTargetsDeletedWorkspace — negative test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: multiple platform handler bug fixes - secrets.go: Log RowsAffected errors instead of silently discarding them - a2a_proxy.go: Add 60s safety timeout to a2aClient HTTP client - terminal.go: Fix defer ordering - always close WebSocket conn on error, only defer resp.Close() after successful exec attach - webhooks.go: Add shortSHA() helper to safely handle empty HeadSHA Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(runtime): inject HMA memory instructions at platform level (#1047) Every agent now gets hierarchical memory instructions in their system prompt automatically — no template configuration needed. Instructions cover commit_memory (LOCAL/TEAM/GLOBAL scopes), recall_memory, and when to use each proactively. Follows the same pattern as A2A instructions: defined in executor_helpers.py, injected by _build_system_prompt() in the claude_sdk_executor. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: seed initial memories from org template and create payload (#1050) Add MemorySeed model and initial_memories support at three levels: - POST /workspaces payload: seed memories on workspace creation - org.yaml workspace config: per-workspace initial_memories with defaults fallback - org.yaml global_memories: org-wide GLOBAL scope memories seeded on the first root workspace during import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(template): restructure molecule-dev org template to 39-agent hierarchy Comprehensive rewrite of the Molecule AI dev team org template: - Rename agents to {team}-{role} convention (e.g., core-be, cp-lead, app-qa) - Add 5 new team leads: Core Platform Lead, Controlplane Lead, App & Docs Lead, Infra Lead, SDK Lead - Add new roles: Release Manager, Integration Tester, Technical Writer, Infra-SRE, Infra-Runtime-BE, SDK-Dev, Plugin-Dev - Delete triage-operator and triage-operator-2 (leads own triage now) - Set default model to MiniMax-M2.7, tier 3, idle_interval_seconds 900 - Update org.yaml category_routing to new agent names - Add orchestrator-pulse schedules for all leads (*/5 cron) - Add pick-up-work schedules for engineers (*/15 cron) - Add qa-review schedules for QA agents (*/15 cron) - Add security-scan schedules for security agents (*/30 cron) - Add release-cycle and e2e-test schedules for Release Manager and Integration Tester - Update marketing agents with web search MCP and media generation capabilities - All schedule prompts reference Molecule-AI/internal for PLAN.md and known-issues.md - Un-ignore org-templates/molecule-dev/ in .gitignore for version tracking Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix test assertions to account for HMA instructions in system prompt Mock get_hma_instructions in exact-match tests so they don't break when HMA content is appended. Add a dedicated test for HMA inclusion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: gitignore org-templates/ and plugins/ entirely These directories are cloned from their standalone repos (molecule-ai-org-template-*, molecule-ai-plugin-*) and should never be committed to molecule-core directly. Removed the !/org-templates/molecule-dev/ exception that allowed PR #1056 to land template files in the wrong repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(workspace-server): send X-Molecule-Admin-Token on CP calls controlplane #118 + #130 made /cp/workspaces/* require a per-tenant admin_token header in addition to the platform-wide shared secret. Without it, every workspace provision / deprovision / status call now 401s. ADMIN_TOKEN is already injected into the tenant container by the controlplane's Secrets Manager bootstrap, so this is purely a header-plumbing change — no new config required on the tenant side. ## Change - CPProvisioner carries adminToken alongside sharedSecret - New authHeaders method sets BOTH auth headers on every outbound request (old authHeader deleted — single call site was misleading once the semantics changed) - Empty values on either header are no-ops so self-hosted / dev deployments without a real CP still work ## Tests Renamed + expanded cp_provisioner_test cases: - TestAuthHeaders_NoopWhenBothEmpty — self-hosted path - TestAuthHeaders_SetsBothWhenBothProvided — prod happy path - TestAuthHeaders_OnlyAdminTokenWhenSecretEmpty — transition window Full workspace-server suite green. ## Rollout Next tenant provision will ship an image with this commit merged. Existing tenants (none in prod right now — hongming was the only one and was purged earlier today) will auto-update via the 5-min image-pull cron. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: GitHub token refresh — add WorkspaceAuth path for credential helper (#1068) PR #729 tightened AdminAuth to require ADMIN_TOKEN, breaking the workspace credential helper which called /admin/github-installation-token with a workspace bearer token. Tokens expired after 60 min with no refresh. Fix: Add /workspaces/:id/github-installation-token under WorkspaceAuth so any authenticated workspace can refresh its GitHub token. Keep the admin path as backward-compatible alias. Update molecule-git-token-helper.sh to use the workspace-scoped path when WORKSPACE_ID is set. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(workspace-server): cover Stop/IsRunning/Close + auth-header + transport errors Closes review gap: pre-PR coverage on CPProvisioner was 37%. After this commit every exported method is exercised: - NewCPProvisioner 100% - authHeaders 100% - Start 91.7% (remainder: json.Marshal error path, unreachable with fixed-type request struct) - Stop 100% (new — header + path + error) - IsRunning 100% (new — 4-state matrix + auth) - Close 100% (new — contract no-op) New cases assert both auth headers (shared secret + admin_token) land on every outbound request, transport failures surface clear errors on Start/Stop, and IsRunning doesn't misreport on transport failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(workspace-server): IsRunning surfaces non-2xx + JSON errors Pre-existing silent-failure path: IsRunning decoded CP responses regardless of HTTP status, so a CP 500 → empty body → State="" → returned (false, nil). The sweeper couldn't distinguish "workspace stopped" from "CP broken" and would leave a dead row in place. ## Fix - Non-2xx → wrapped error, does NOT echo body (CP 5xx bodies may contain echoed headers; leaking into logs would expose bearer) - JSON decode error → wrapped error - Transport error → now wrapped with "cp provisioner: status:" prefix for easier log grepping ## Tests +7 cases (5-status table + malformed JSON + existing transport). IsRunning coverage 100%; overall cp_provisioner at 98%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cp_provisioner): IsRunning returns (true, err) on transient failures My #1071 made IsRunning return (false, err) on all error paths, but that breaks a2a_proxy which depends on Docker provisioner's (true, err) contract. Without this fix, any brief CP outage causes a2a_proxy to mark workspaces offline and trigger restart cascades across every tenant. Contract now matches Docker.IsRunning: transport error → (true, err) — alive, degraded signal non-2xx response → (true, err) — alive, degraded signal JSON decode error → (true, err) — alive, degraded signal 2xx state!=running → (false, nil) 2xx state==running → (true, nil) healthsweep.go is also happy with this — it skips on err regardless. Adds TestIsRunning_ContractCompat_A2AProxy as regression guard that asserts each error path explicitly against the a2a_proxy expectations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cp_provisioner): cap IsRunning body read at 64 KiB IsRunning used an unbounded json.NewDecoder(resp.Body).Decode on CP status responses. Start already caps its body read at 64 KiB (cp_provisioner.go:137) to defend against a misconfigured or compromised CP streaming a huge body and exhausting memory. IsRunning is called reactively per-request from a2a_proxy and periodically from healthsweep, so it's a hotter path than Start and arguably deserves the same defense more. Adds TestIsRunning_BoundedBodyRead that serves a body padded past the cap and asserts the decode still succeeds on the JSON prefix. Follow-up to code-review Nit-2 on #1073. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canvas): /waitlist page with contact form Adds the user-facing half of the beta-gate: a page at /waitlist that the CP auth callback redirects users to when their email isn't on the allowlist. Collects email + optional name + use-case and POSTs to /cp/waitlist/request (backend landed in controlplane #150). ## Behavior - No auto-pre-fill of email from URL query (CP's #145 dropped the ?email= param for the privacy reason; this test guards against a future regression on the client side). - Client-side validates email shape for instant feedback; backend re-validates. - Three UI states after submit: success → "your request is in" banner, form hidden dedup → softer "already on file" banner when backend returns dedup=true (same 200, no 409 to avoid enumeration) error → inline banner with backend message or network fallback ## Tests 9 tests in __tests__/waitlist-page.test.tsx covering: - default render + a11y (role=button, role=status, role=alert) - URL-pre-fill privacy regression guard - HTML5 + JS validation (empty, malformed) - successful POST with trimmed body - dedup branch - non-2xx with + without error field - network rejection Follow-up to the beta-gate rollout on controlplane #145 / #150. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(canvas): remove dead /waitlist page (lives in molecule-app) #1080 added /waitlist to canvas, but canvas isn't served at app.moleculesai.app — it backs the tenant subdomains (acme.moleculesai.app etc.). The real /waitlist lives in the separate molecule-app repo, which is what the CP auth callback redirects to. molecule-app#12 has the real page + contact form wiring to /cp/waitlist/request. This canvas copy was never reachable and would only diverge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(org-import): limit concurrent Docker provisioning to 3 (#1084) The org import fired all workspace provisioning goroutines concurrently, overwhelming Docker when creating 39+ containers. Containers timed out, leaving workspaces stuck in 'provisioning' with no schedules or hooks. Fix: - Add provisionConcurrency=3 semaphore limiting concurrent Docker ops - Increase workspaceCreatePacingMs from 50ms to 2000ms between siblings - Pass semaphore through createWorkspaceTree recursion With 39 workspaces at 3 concurrent + 2s pacing, import takes ~30s instead of timing out. Each workspace gets its full template: schedules, hooks, settings, hierarchy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add ?purge=true hard-delete to DELETE /workspaces/:id (#1087) Soft-delete (status='removed') leaves orphan DB rows and FK data forever. When ?purge=true is passed, after container cleanup the handler cascade- deletes all leaf FK tables and hard-removes the workspace row. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove org-templates/molecule-dev from git tracking This directory belongs in the dedicated repo Molecule-AI/molecule-ai-org-template-molecule-dev. It should be cloned locally for platform mounting, never committed to molecule-core. The .gitignore already blocks it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(canvas): add NEXT_PUBLIC_ADMIN_TOKEN + CSP_DEV_MODE to docker-compose Canvas needs AdminAuth token to fetch /workspaces (gated since PR #729) and CSP_DEV_MODE to allow cross-port fetches in local Docker. These were added earlier but lost on nuke+rebuild because they weren't committed to staging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(canvas): CSP_DEV_MODE + admin token for local Docker (#1052 follow-up) Three changes that keep getting lost on nuke+rebuild: 1. middleware.ts: read CSP_DEV_MODE env to relax CSP in local Docker 2. api.ts: send NEXT_PUBLIC_ADMIN_TOKEN header (AdminAuth on /workspaces) 3. Dockerfile: accept NEXT_PUBLIC_ADMIN_TOKEN as build arg All three are required for the canvas to work in local Docker where canvas (port 3000) fetches from platform (port 8080) cross-origin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(canvas): make root layout dynamic so CSP nonce reaches Next scripts Tenant page loads were failing with repeated CSP violations: Executing inline script violates ... script-src 'self' 'nonce-M2M4YTVh...' 'strict-dynamic'. ... because Next.js's bootstrap inline scripts were emitted without a nonce attribute. The middleware was generating per-request nonces correctly and sending them via `x-nonce` — but the layout was fully static, so Next.js cached the HTML once and served that cached bundle (no nonces baked in) for every request. Fix: call `await headers()` in the root layout. That opts the tree into dynamic rendering AND signals Next.js to propagate the x-nonce value to its own generated <script> tags. The `nonce` return value is intentionally unused — the framework handles its bootstrap scripts automatically once the read happens. Future code that adds third-party <Script> components (analytics, etc.) should pass the returned nonce explicitly. Verified against live tenant: before this change every /_next/ chunk script tag in the HTML had no nonce attribute; expected after deploy is `<script nonce="..." src="/_next/...">` on each. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): accept admin token in WorkspaceAuth for canvas dashboard The canvas sends NEXT_PUBLIC_ADMIN_TOKEN on all API calls but per-workspace routes (/activity, /delegations, /traces) use WorkspaceAuth which only accepts per-workspace bearer tokens. This made the canvas dashboard 401 on every workspace detail view. Fix: WorkspaceAuth now accepts the admin token as a fallback after workspace token validation fails. This lets the canvas read all workspace data with a single admin credential. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(auth): accept admin token in CanvasOrBearer for viewport PUT * fix(ci): bake api.moleculesai.app into tenant canvas bundle Canvas's browser-side code (auth.ts, api.ts, billing.ts) all call fetch(PLATFORM_URL + /cp/*). PLATFORM_URL comes from NEXT_PUBLIC_PLATFORM_URL at build time; with the build arg unset, it falls back to http://localhost:8080 in the compiled bundle. That means on a tenant like hongmingwang.moleculesai.app, the user's browser actually tried to fetch http://localhost:8080/cp/ auth/me — which resolves to the USER'S OWN machine, not the tenant. Login redirect loops 404. Every tenant canvas has been unable to complete a fresh login on this path; existing sessions only worked because the cookie was already set domain-wide. Fix: pass NEXT_PUBLIC_PLATFORM_URL=https://api.moleculesai.app as a build arg in the tenant-image workflow. CP already allows CORS from *.moleculesai.app + credentials, and the session cookie is scoped to .moleculesai.app so tenant subdomains inherit it. Verified in prod by rebuilding canvas locally with the flag and hot-patching the hongmingwang instance via SSM. Baked chunks now contain api.moleculesai.app; browser auth redirects resolve cleanly to the CP. Self-hosted users override by rebuilding with their own URL — same pattern molecule-app uses with NEXT_PUBLIC_CP_ORIGIN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: nuke-and-rebuild.sh — one-command fleet reset Two scripts: - nuke-and-rebuild.sh: docker down -v, clean orphans, rebuild, setup - post-rebuild-setup.sh: insert global secrets (MiniMax + GH PAT), import org template, wait for platform health Global secrets ensure every provisioned container gets MiniMax API config and GitHub PAT injected as env vars automatically — no manual settings.json deployment needed. Usage: bash scripts/nuke-and-rebuild.sh Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(canvas): include NEXT_PUBLIC_PLATFORM_URL in CSP connect-src Tenant page loads were blocked by: Refused to connect to 'https://api.moleculesai.app/cp/auth/me' because it violates the document's Content Security Policy. CSP had `connect-src 'self' wss:` — fine for same-origin + any wss, but browser refuses cross-origin HTTPS fetches that aren't listed. PLATFORM_URL (baked from NEXT_PUBLIC_PLATFORM_URL, which is the CP origin on SaaS tenants) needs to be explicit. Fix: middleware reads NEXT_PUBLIC_PLATFORM_URL at build/runtime and adds both the https and wss siblings to connect-src. Self- hosted deploys that override the build-arg automatically get a matching CSP — no hardcoded hostname. Test added: buildCsp includes NEXT_PUBLIC_PLATFORM_URL origin in connect-src when set. Also loosens the dev `ws:` assertion since dev uses `connect-src *` which subsumes ws (pre-existing behavior, test was stale). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(router): /cp/* reverse-proxy to CP + same-origin canvas fetches Canvas's browser bundle issues fetches to both CP endpoints (/cp/auth/me, /cp/orgs, ...) AND tenant-platform endpoints (/canvas/viewport, /approvals/pending, /org/templates). They share ONE build-time base URL. Baking api.moleculesai.app broke tenant calls with 404; baking the tenant subdomain broke auth. Tried both today and saw exactly one failure mode per attempt. Real fix: same-origin fetches + tenant-side split. Adds: internal/router/cp_proxy.go # /cp/* → CP_UPSTREAM_URL mounted before NoRoute(canvasProxy). Now a tenant serves: /cp/* → reverse-proxy to api.moleculesai.app /canvas/viewport, /approvals/pending, /workspaces/:id/*, /ws, /registry, → tenant platform (existing handlers) /metrics everything else → canvas UI (existing reverse-proxy) Canvas middleware reverts to `connect-src 'self' wss:` for the same-origin path (keeping explicit PLATFORM_URL whitelist as a self-hosted escape hatch when the build-arg is non-empty). CI build-arg flips to NEXT_PUBLIC_PLATFORM_URL="" so the bundle issues relative fetches. Security of cp_proxy: - Cookie + Authorization PRESERVED across the hop (opposite of canvas proxy) — they carry the WorkOS session, which is the whole point. - Host rewritten to upstream so CORS + cookie-domain on the CP side see their own hostname. - Upstream URL validated at construction: must parse, must be http(s), must have a host — misconfig fails closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * security: remove hardcoded API keys from post-rebuild-setup.sh GitGuardian detected exposed MiniMax API key and GitHub PAT in the script's default values. Replaced with env var reads from .env file (which is gitignored). Script now validates required secrets exist before proceeding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(middleware): TenantGuard passes through /cp/* to CP proxy Today's rollout of cp_proxy (PR #1095/1096) mounted /cp/* as a reverse-proxy to the control plane, but the TenantGuard middleware runs first in the global chain and 404s anything that isn't in its exact-path allowlist (/health + /metrics). Every /cp/auth/me fetch from canvas landed on a 40µs 404 before ever reaching the proxy. /cp/* is handled upstream (WorkOS session + admin bearer), so the tenant doesn't need to attach org identity for those paths. Passing them through is correct — matches the design where the tenant platform is a pure transit layer for /cp/*. Verified: /cp/auth/me via tunnel now returns 401 (correct unauth from CP) instead of 404 from TenantGuard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(middleware): AdminAuth accepts CP-verified WorkOS session Canvas (SaaS tenant UI) runs in the browser and authenticates the user via a WorkOS session cookie scoped to .moleculesai.app. It has no bearer token — the token-based ADMIN_TOKEN scheme is for CLI + server-to-server callers, not end users. Adds a session-verification tier to AdminAuth that runs BEFORE the bearer check: 1. If Cookie header present AND CP_UPSTREAM_URL configured → GET /cp/auth/me upstream with the same cookie. 200 + valid user_id → grant admin access. Non-200 → fall through. 2. Else (no cookie, or no CP configured, or CP said no) → existing bearer-only path unchanged. Positive verifications are cached 30s keyed by the raw Cookie header, so a burst of canvas admin-page renders doesn't DDoS the CP. Revocations propagate within that window. Self-hosted / dev deploys without CP_UPSTREAM_URL: feature disabled, behavior unchanged. So this is strictly additive for the SaaS case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docker): fix plugin go.mod replace for TokenProvider interface (#960) The github-app-auth plugin's go.mod had a relative replace directive (../molecule-monorepo/platform) that didn't resolve in Docker where the plugin is at /plugin/ and the platform at /app/. This caused the plugin's provisionhook.TokenProvider interface to come from a different package path than the platform's, so the type assertion in FirstTokenProvider() failed — "no token provider registered". Fix: sed the plugin's go.mod replace to point at /app during Docker build. Also added debug logging to GetInstallationToken for future diagnosis. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: close cross-tenant authz + cp_proxy admin-traversal gaps Addresses three Critical findings from today's code review of the SaaS-canvas routing stack. ## Critical-1: session verification scoped to the current tenant session_auth.go previously verified via GET /cp/auth/me, which only answers "is someone logged in" — NOT "is this user in the org they're targeting." Every WorkOS-authed user (including folks who only signed up via app.moleculesai.app with no tenant relationship) could call /workspaces, /approvals/pending, /bundles/import, /org/import etc. on ANY tenant they could reach. Cross-tenant read: user at acme.moleculesai.app could hit bob.moleculesai.app/workspaces with their cookie and get Bob's workspaces. Fix: - CP gains GET /cp/auth/tenant-member?slug=<slug> which joins org_members × organizations and only returns member:true when the authenticated user is actually in that org. - Tenant sets MOLECULE_ORG_SLUG at boot via user-data. - session_auth now calls tenant-member (not /me), passing its own slug. Cache key includes slug so one tenant's cached positive never satisfies another's check. ## Critical-2: cp_proxy path allowlist (lateral-movement fix) cp_proxy.go forwarded any /cp/* path upstream with the cookie and bearer attached. Since /cp/admin/* accepts sessions as one of its auth tiers, a tenant-authed user could curl /cp/admin/tenants/other-slug/diagnostics through their tenant and the CP would honor it — turning any tenant into a lateral hop into admin surface. Fix: explicit allowlist of paths the canvas browser bundle actually needs (/cp/auth, /cp/orgs, /cp/billing, /cp/templates, /cp/legal). Everything else 404s at the tenant before cookies leave. Fail-closed: future UI paths require explicit entries. ## Important-1,2: bounded session cache + split positive/negative TTL Previous sync.Map cache grew unbounded (one entry per unique Cookie header for process lifetime) and cached failures for 30s, meaning a 3s CP blip locked users out for the full window. Fix: - Bounded map with batch random eviction at cap (10k entries × ~100 bytes = 1 MB ceiling). Random eviction is O(1) expected; we don't need precise LRU. - Periodic sweeper goroutine (2 min) reclaims expired entries even when they're not re-hit. - Positive TTL 30s, negative TTL 5s — short negative so CP flakes self-heal fast. - Transport errors NOT cached (would otherwise trap every user during a multi-second upstream outage). - Cache key = sha256(slug + cookie) so raw session tokens don't sit in process memory, and cross-tenant isolation is structural not policy. ## Important-3: TenantGuard /cp/* bypass documented Added a security note to the bypass explaining why it's safe only under the current setup (cp_proxy allowlist + tunnel-only ingress), and what would require revisiting (SG opens :8080 inbound to the VPC). ## Tests - session_auth_test.go: 12 new tests — empty cookie, missing slug, no CP, member:true happy path with cache hit, member: false, 401 upstream, malformed JSON, transport error not cached, cross-tenant isolation (same cookie different tenants hit upstream separately), bounded eviction, expired entries, cache key collision resistance. - cp_proxy_test.go: new — isCPProxyAllowedPath covers 17 allow/block cases, forwarding preserves Cookie+Auth, Host rewritten, blocked paths 404 without calling upstream. All platform tests pass. CP provisioner tests pass after threading cfg.OrgSlug into the container env. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): organization-scoped API keys for admin access Adds user-facing API keys with full-org admin scope. Replaces the single ADMIN_TOKEN env var with named, revocable, audited tokens that users can mint/rotate from the canvas UI without ops intervention. Designed for the beta growth phase — one token tier (full admin). Future work will split into scoped roles (admin / workspace-write / read-only) and per-workspace bindings. See docs/architecture/ org-api-keys.md for the design + follow-up roadmap. ## Surface POST /org/tokens mint (plaintext returned once) GET /org/tokens list live keys (prefix-only) DELETE /org/tokens/:id revoke (idempotent) All AdminAuth-gated. Bootstrap path: mint the first token via ADMIN_TOKEN or canvas session; tokens can mint more tokens after. ## Validation as a new AdminAuth tier (2a) AdminAuth evaluation order: Tier 0 lazy-bootstrap fail-open (only when no live tokens AND no ADMIN_TOKEN env) Tier 1 verified WorkOS session via /cp/auth/tenant-member Tier 2a org_api_tokens SELECT — NEW Tier 2b ADMIN_TOKEN env (bootstrap / CLI break-glass) Tier 3 any live workspace token (deprecated, only when ADMIN_TOKEN unset) Tier 2a runs ONE indexed lookup (partial index on token_hash WHERE revoked_at IS NULL) + an async last_used_at bump. No measurable latency cost on the hot path. ## UI New "Org API Keys" tab in the settings panel. Label field for human-readable naming. Plaintext shown once + clipboard copy. Revoke with confirm dialog. Mirrors the existing workspace- TokensTab flow so users who've used one get the other for free. ## Security properties - Plaintext never stored. sha256 hash + 8-char display prefix. - Revocation is immediate: partial index on revoked_at IS NULL means the next request validates or fails in microseconds. - created_by audit field captures provenance: "org-token:<short>" when a token mints another, "session" for browser-UI mints, "admin-token" for the ADMIN_TOKEN bootstrap path. - Validate() collapses all failure shapes into ErrInvalidToken so response-shape can't distinguish "never existed" from "revoked". ## Tests - internal/orgtoken: 9 unit tests (hash storage, empty field null-ing, validation happy path, empty plaintext, unknown hash, revoked filtering, list ordering, revoke idempotency, has-any- live short-circuit). - AdminAuth tier-2a integration covered by existing middleware tests unchanged (fail-open + bearer paths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): org tokens reach /workspaces/:id/* subroutes + docs Extends WorkspaceAuth to accept org API tokens as a valid credential for any workspace sub-route in the org. Previously a user minting an org token could hit admin-surface endpoints (/workspaces, /org/import, etc.) but couldn't reach per-workspace routes like /workspaces/:id/channels — those were gated by WorkspaceAuth which only knew about workspace-scoped tokens. Scope matches the explicit product spec: one org API key can manipulate every workspace in the org. AI agents given a key can read/write channels, tokens, schedules, secrets, tasks across all workspaces. ## WorkspaceAuth tier order 1. ADMIN_TOKEN exact match (break-glass / bootstrap) 2. Org API token (Validate against org_api_tokens) NEW 3. Workspace-scoped token (ValidateToken with :id binding) 4. Same-origin canvas referer Org token tier sits above the per-workspace check so a presenter of an org key doesn't hit the narrower ValidateToken failure path first. Checked with isSameOriginCanvas path unchanged. ## End-to-end verified Minted test token via ADMIN_TOKEN, then with that org token: - GET /workspaces → 200 (list all) - GET /workspaces/<id> → 200 (detail, admin-only route) - GET /workspaces/<id>/channels → 200 (workspace sub-route) - GET /workspaces/<id>/tokens → 200 (workspace tokens list) - GET /workspaces/<bad-uuid> → 404 workspace not found (routing still scoped correctly) ## Documentation - docs/architecture/org-api-keys.md — design, data model, threat model, security properties - docs/architecture/org-api-keys-followups.md — 10 tracked follow-ups prioritized (role scoping P1, per-workspace binding P1, expiry P2, usage metrics P2, WorkOS user_id capture P2, rotation webhooks P3, mint-rate limit P3, audit log P2, CLI P3, migrate ADMIN_TOKEN to the same table P4) - docs/guides/org-api-keys.md — end-user guide (mint via UI, use in curl/Python/TS/AI agents, session-vs-key comparison) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(org-tokens): rate-limit mint, bound list, correct audit provenance Addresses the Critical + Important findings from today's code review of the org API keys feature (PRs #1105-1108). ## Critical-1: rate-limit mint endpoint Previously POST /org/tokens had no mint-rate limit. A compromised WorkOS session or leaked bearer could mint thousands of tokens in seconds, forcing a painful manual cleanup of each one. Fix: dedicated per-IP token bucket, 10 mints/hour/IP. Legitimate bursts fit under the ceiling; abuse bounces. List + Delete stay on the global limiter — they can't be used to generate new secret material. ## Important-1: HTTP handler integration tests internal/orgtoken had 9 unit tests; the HTTP layer (org_tokens.go) had none. Adds org_tokens_test.go covering: - List happy path + DB error → 500 - Create actor="admin-token" (bootstrap), actor="org-token:<prefix>" (chained mint), actor="session" (canvas browser path) - Create name>100 chars → 400 - Create with empty body mints with no name - Revoke happy path 200, missing id 404, empty id 400 - Plaintext returned in response body and prefix matches first 8 chars - Warning text present A regression that breaks the tier-ordering, drops the createdBy field, or accepts oversized names now fails at CI not prod. ## Important-2: bound List output List() had no LIMIT — a mint-storm bug or abuse could make the admin UI slow to render and allocate proportionally. Adds LIMIT 500 at the SQL layer. 10x realistic ceiling, guardrail against pathological cases. ## Important-3: audit provenance uses plaintext prefix, not UUID orgTokenActor() was logging "org-token:<first-8-of-uuid>" which couldn't be cross-referenced with the UI (which shows first-8 of the plaintext). Users could not correlate "who minted this" audit entries with the revoke button they're looking at. Fix: Validate() now returns (id, prefix, error). Middleware stashes both on the gin context. Handler reads prefix for the actor string. Audit rows now match UI prefixes exactly. ## Nit: named constants for audit labels actorOrgTokenPrefix / actorSession / actorAdminToken replace the hardcoded strings scattered across the handler. Greppable across log pipelines + audit queries; one place to change if the format evolves. ## Tests - internal/orgtoken: 9 existing + 0 new, all still green (updated signatures for Validate returning prefix). - internal/handlers/org_tokens_test.go: new — 9 HTTP-layer tests above. Full gin.Context + sqlmock harness. - Full `go test ./...` green except one pre-existing TestGitHubToken_NoTokenProvider flake unrelated to this change (expects 404, gets 500 — tracked separately). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: strip internal roadmap/followups from public org-api-keys docs The monorepo docs/ tree is ecosystem + user-facing. Internal roadmap ("what we'll build next", priorities, effort estimates) doesn't belong there — customers reading our docs don't need our backlog in their face, and we shouldn't signal "feature X is coming" contractually when it's just a P2 item in internal tracking. Removes: - docs/architecture/org-api-keys-followups.md (the whole prioritized roadmap). Moved to the internal repo at runbooks/org-api-keys-followups.md where it belongs. - "Follow-up roadmap" section in docs/architecture/org-api- keys.md, replaced with a shorter "Known limitations" section that names the current constraints (full-admin only, no expiry, no user_id in session-minted audit) without speculating on when they change. - "What's coming" section in docs/guides/org-api-keys.md, replaced with "Current limits" that names the same constraints from the user's POV. Public docs now describe the feature as it exists TODAY. Internal tracking of what comes next lives in Molecule-AI/internal (private). * fix: harden stuck-provisioning UX — details crash, preflight, sweeper Workspaces stuck in status='provisioning' previously surfaced in three bad ways: 1. **Details tab crashed** with `Cannot read properties of undefined (reading 'toLocaleString')`. `BudgetSection` + `WorkspaceUsage` assumed full response shapes but a provisioning-stuck workspace returns partial `{}`. Guard each deep field with `?? 0` and cover the partial-response case with regression tests. 2. **Missing required env vars failed silently** 15+ minutes later as a cosmetic "Provisioning Timeout" banner. The in-container preflight catches them but by then the container has already crashed without calling /registry/register, so the workspace sat in 'provisioning' forever. Mirror the preflight server-side: parse config.yaml's `runtime_config.required_env` before launch, fail fast with a WORKSPACE_PROVISION_FAILED event naming the missing vars. 3. **No backend timeout** ever flipped a stuck workspace to 'failed'. Add a registry sweeper (10m default, env-overridable) that detects workspaces stuck past the window, flips them to 'failed', and emits WORKSPACE_PROVISION_TIMEOUT. Race-safe: the UPDATE re-checks the status + age predicate so a concurrent register/restart wins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(canvas): delete workspace dialog race with context menu close Clicking "Delete" in the workspace context menu did nothing for stuck workspaces. The confirm dialog was rendered via portal as a child of ContextMenu. ContextMenu's outside-click handler checks whether the click target is inside its ref — but the portal puts the dialog in document.body, outside the ref. So clicking the dialog's Confirm counted as "outside", closed the menu, unmounted the dialog mid-click, and the onConfirm handler never ran. Hoist the pending-delete state to the canvas store and render the confirm dialog at the Canvas level (same pattern as the existing pendingNest dialog). The dialog now outlives ContextMenu, so the outside-click close is harmless. Close the context menu on the Delete click itself rather than waiting for the dialog to resolve. Add a regression test covering the new flow and add the standard ?confirm=true query param so the backend's child-cascade guard is consulted correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(canvas): infinite render loop in ContextMenu + dedupe SSRF funcs (#1499) ContextMenu: useCanvasStore selector returned .filter() (new array on every call), causing React 19's useSyncExternalStore to detect a reference change and re-render infinitely. Fixed by using .some() which returns a stable boolean. Also deduplicates isSafeURL, isPrivateOrMetadataIP, validateRelPath which existed in 3 files after PR merges collided. Canonical location is ssrf.go. Removed unused imports (fmt, net, net/url, database/sql, strings) from a2a_proxy.go, a2a_proxy_helpers.go, mcp_tools.go. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Molecule AI SDK-Dev <sdk-dev@agents.moleculesai.app> * fix(canvas+templates): fetch runtime dropdown from /templates registry (#1526) * fix(canvas+templates): fetch runtime dropdown from /templates registry Canvas hardcoded 6 runtime options, drifting from manifest.json which already registers hermes + gemini-cli as first-class workspace templates. A Hermes workspace had runtime=hermes in its DB row but Config showed "LangGraph (default)" — the HTML select fell back to its first option because "hermes" wasn't listed, and saving would clobber the runtime back to empty. Now: - GET /templates returns the runtime field from each cloned template's config.yaml (previously dropped on the floor) - ConfigTab fetches /templates on mount, dedupes non-empty runtimes, and renders them as <option>s. Falls back to the static list if the fetch fails (offline, older backend), so the control never renders empty. Adding a template to manifest.json now flows through automatically — no canvas PR required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(canvas+templates): model + required-env suggestions from template Extends the dropdown fix so Model and Required Env also flow from the template registry instead of being free-form fields the user has to remember. Template config.yaml now declares: runtime_config: model: <default> models: - id: nous-hermes-3-70b name: Nous Hermes 3 70B (Nous Portal) required_env: [HERMES_API_KEY] - id: nousresearch/hermes-3-llama-3.1-70b name: Hermes 3 70B (via OpenRouter) required_env: [OPENROUTER_API_KEY] Platform: GET /templates now returns runtime + model + models[] per template (was previously dropping runtime + ignoring runtime_config). Canvas: - Runtime dropdown built from /templates (was hardcoded 6 options) - Model input becomes a datalist combobox; free-form input still allowed since model names rotate faster than templates - Required Env Vars default to the selected model's required_env, labelled "(suggested)" so the user knows it's template-driven - Everything falls back to a static list when /templates is unreachable, so offline editing still works Follow-up: add models[] to the other 7 template repos (claude-code, crewai, autogen, deepagents, openclaw, gemini-cli, langgraph). This PR updates the platform + canvas; the Hermes template config update goes in a separate PR against its own repo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(canvas): commit required_env on model change; add backend tests Review turned up that the \"Required Env Vars (suggested)\" display was cosmetic-only — users picking a different model saw the new env suggestion in the TagList, but the values never made it into state, so Save serialized an empty (or stale) required_env and the workspace ran with the wrong auth check. Canvas fixes: - Model input onChange now commits the matched modelSpec's required_env to state — but only when the prior required_env was empty or matched the previous modelSpec's list (i.e. user hadn't manually edited). User-typed envs always win. - Dropped the display-only fallback in TagList values; shows only what's actually in state. - New \"Template suggests X, Apply\" hint button covers the edge case where state and template differ (existing workspace whose required_env lags the template's current recommendation). - datalist option key now includes index so template authors shipping duplicate model ids don't trigger a silent React key collision. - Small arraysEqual helper. Backend tests: - TestTemplatesList_RuntimeAndModelsRegistry — asserts /templates response carries runtime + models[] with per-model required_env. - TestTemplatesList_LegacyTopLevelModel — asserts older templates with top-level model: still surface correctly, with empty Models[]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(handlers): add CWE-22 regression suite + KI-005 terminal access fix + tests (#1574) * fix(lint): unblock Platform Go CI — suppress 8 pre-existing errcheck warnings golangci-lint errcheck has been flagging these since before this PR — not regressions from the restart fix, just long-standing debt that blocks Platform (Go) CI from ever going green. Prefix ignored returns with `_ =` to make the signal explicit without changing behavior: - channels/lark_test.go:97 (w.Write) + :118 (resp.Body.Close) - channels/channels_test.go:620 + :760 (mockDB.Close in t.Cleanup) - channels/manager.go:131 + :196 (defer rows.Close via closure wrapper) - channels/manager.go:206–207 (json.Unmarshal into struct fields) - artifacts/client_test.go:195, 237, 297 (json.Decode in test handlers) The manager.go defer patch uses `defer func() { _ = rows.Close() }()` since errcheck doesn't allow the `_ =` prefix directly on `defer`. Build + `go test ./...` green locally for internal/channels and internal/artifacts. The manager.go change touches production code so I re-ran the channels test suite; passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: trigger PR refresh * test(handlers): add CWE-22 regression suite + KI-005 terminal access fix + tests container_files_test.go (152 lines): - 11 path-traversal test cases for copyFilesToContainer (F1501/CWE-22) - Tests nil Docker client — validation logic runs before any Docker call terminal.go KI-005 security fix (backport from ship/security-fix 6de7530c): - Enforce CanCommunicate hierarchy check before granting terminal access - Shell access is more dangerous than A2A message-passing; apply the same hierarchy check used by A2A and discovery endpoints - When X-Workspace-ID header is present and bearer token is valid (ValidateAnyToken), reject unless CanCommunicate(callerID, targetID) - Canvas/molecli callers without X-Workspace-ID header pass through to WorkspaceAuth middleware for existing bearer check - canCommunicateCheck exposed as package var for testability terminal_test.go (5 test cases): - TestTerminalConnect_KI005_RejectsUnauthorizedCrossWorkspace - TestTerminalConnect_KI005_AllowsOwnTerminal - TestTerminalConnect_KI005_SkipsCheckWithoutHeader - TestTerminalConnect_KI005_RejectsInvalidToken - TestTerminalConnect_KI005_AllowsSiblingWorkspace Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app> * fix(scripts): correct platform dir path + add ROOT isolation (shellcheck clean) - dev-start.sh: $ROOT/platform → $ROOT/workspace-server (Go server lives in workspace-server/, not platform/; any developer running this script would get "no such directory" immediately) - nuke-and-rebuild.sh: add ROOT variable and -f "$ROOT/docker-compose.yml" so docker compose works from any CWD; fix post-rebuild-setup.sh path - rollback-latest.sh: add 'local' to src_digest and new_digest vars inside roll() function to prevent global-scope leakage Co-authored-by: Molecule AI Core-DevOps <core-devops@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas/a11y): add aria-hidden to decorative SVGs + MissingKeysModal semantics - DeleteCascadeConfirmDialog: aria-hidden on warning triangle SVG (button already has adjacent text content; icon is purely decorative) - Toolbar: aria-hidden on 4 decorative SVGs (stop-all, restart-pending, search, help) — buttons all have aria-label/aria-expanded/text - MissingKeysModal: role="dialog" aria-modal="true" aria-labelledby on container, id="missing-keys-title" on heading, requestAnimationFrame focus management via useRef (replaces autoFocus={index===0}) - CreateWorkspaceDialog: remove redundant aria-describedby={undefined} WCAG 2.1 SC 1.1.1 — screen readers skip purely-presentational icons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(F1085): scope rm to /configs volume in deleteViaEphemeral (#1616) * fix(F1085): scope rm to /configs volume in deleteViaEphemeral Regressed by commit49ab614("CWE-78/CWE-22 — block shell injection in deleteViaEphemeral") which changed the rm form from the scoped concat "/configs/" + filePath to the unscoped 2-arg "/configs", filePath. With 2 args, rm receives /configs as the first target — rm -rf /configs attempts to delete the entire volume mount before processing filePath, which is the F1085 (Misconfiguration - Filesystems) defect. The concat form passes a single scoped path so rm only touches files inside /configs. validateRelPath call retained as CWE-22 defence-in-depth. * docs: note F1085 defect in deleteViaEphemeral 2-arg rm form Amends the CWE-22+CWE-78 incident entry to record that commit49ab614regressed the F1085 (volume deletion scope) fix, and that f1085-fix commit a432df5 restores the correct concat form. --------- Co-authored-by: Molecule AI CP-QA <cp-qa@agents.moleculesai.app> * fix(canvas/a11y): dialog aria-modal, icon-button labels, focus management - CookieConsent.tsx: add aria-modal="true" (WCAG 2.1.1) - ConsoleModal.tsx: add useRef + requestAnimationFrame focus management on open - ConversationTraceModal.tsx: remove redundant aria-describedby={undefined} - FileTree.tsx: add aria-label to directory/file delete buttons (WCAG 4.1.2) - FileEditor.tsx: add aria-label to download button (WCAG 4.1.2) - ScheduleTab.tsx: add aria-label to Run Now, Edit, Delete icon buttons - form-inputs.tsx: add aria-label to tag removal button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas/a11y): MissingKeysModal — backdrop aria-hidden, decorative SVGs - Backdrop div: add aria-hidden="true" so screen readers skip it (WCAG 4.1.2) - Warning triangle SVG (header): add aria-hidden="true" (decorative icon) - Saved-badge checkmark SVG: add aria-hidden="true" (decorative icon) - Add MissingKeysModal.a11y.test.tsx: 14 tests covering role=dialog, aria-modal, aria-labelledby, backdrop aria-hidden, SVG aria-hidden, focus-on-open (WCAG 2.4.3), Escape key handler (WCAG 2.1.2), accessible button names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas/a11y): unaudited components — backdrop/semantic a11y gaps - ConsoleModal.tsx: backdrop div aria-hidden; error div role=alert (WCAG 4.1.2) - ProvisioningTimeout.tsx: warning SVG aria-hidden; cancel-dialog backdrop aria-hidden (WCAG 4.1.2) - TermsGate.tsx: backdrop aria-hidden; dialog role=dialog+aria-modal+aria-labelledby; error role=alert - TopBar.tsx: replace non-semantic role=banner div with <header>; logo emoji aria-hidden - FilesToolbar.tsx: aria-label on select dropdown; aria-label on all icon buttons (New, Upload, Export, Clear, Refresh, file input) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: update ecosystem-watch with LangGraph PR verification - PRs #6645, #7113, #7205 not found in langchain-ai/langgraph open PR list - Added VERIFY flags to LangGraph tracker; requires manual re-check - Updated market events log with verification result - Battlecard v0.3 LangGraph status is now flagged as stale pending re-verify Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: stage A2A v1 deep-dive content brief for Content Marketer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: remove #AgenticAI from org-api-keys social copy Not in positioning brief. Replace with #A2A per PMM alignment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add LangGraph governance-gap ADR section to A2A v1 blog Adds competitive differentiation section explicitly calling out the governance layer gap in LangGraph's current A2A PRs vs Molecule AI's Phase 30 production implementation. Canonical URL verified correct. Closes PMM A2A blog final-review item. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add Phase 34 Partner API Keys positioning brief Three-channel brief covering partner platforms, marketplace resellers, and enterprise CI/CD automation. Links to Phase 30 (mol_ws_* token model) as cross-sell. Flags first-mover opportunity vs CrewAI/LangGraph Cloud. Collocates collateral gap list and open PM questions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: commit all Phase 30/34 staged work - Phase 34 Partner API Keys battlecard - A2A Enterprise Deep-Dive SEO brief + social copy - Phase 30 social copy (X + LinkedIn threads) - Phase 30 blog post (remote-workspaces) - Launch pages (org-scoped API keys, instance ID, EC2 SSH) - Fly.io + Discord Adapter + EC2 social copy - Screencast storyboards (4 demos) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas/a11y): DeleteCascadeConfirmDialog backdrop aria-hidden (WCAG 4.1.2) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(canvas/a11y): add WCAG 2.1 accessibility tests for ConsoleModal and DeleteCascadeConfirmDialog ConsoleModal: role=dialog, aria-modal, aria-labelledby, backdrop aria-hidden, error role=alert, accessible button names DeleteCascadeConfirmDialog: role=dialog, aria-modal, aria-labelledby, backdrop aria-hidden, SVG aria-hidden, disabled state, keyboard interactions (Escape, Enter), accessible names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: update EC2 SSH social copy — add ephemeral key versions + positioning approval - Add Version E: ephemeral key story (60-second RSA key lifecycle) - Elevate Version D: zero key rot angle with explicit 60-second key window - Add Version A/D as approved primary angles (ops simplicity / security) - Update status to APPROVED, unblocked for Social Media Brand - Add header: positioning angle confirmed per GH issue #1637 - Add image suggestion for ephemeral key timeline graphic Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas/a11y): orgs/page.tsx — form labels, error announcements, checkout banner - CreateOrgForm: replace bare <span> labels with <label htmlFor> + input id (WCAG 1.3.1 — programmatic label association); add aria-describedby hint for slug field - Error state: add role=alert on error <p> (WCAG 4.1.3 — Status Messages) - CheckoutBanner: add role=status + aria-live=polite (WCAG 4.1.3); restore decorative ✓ with aria-hidden=true Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * PMM: add enterprise governance + org API key attribution to A2A v1 blog - Add "Org-Scoped API Keys: Delegation Attribution for Regulated Industries" section with org:keyId audit trail, created_by chain of custody, revocation story - Add CloudTrail-compatible architecture bullet to enterprise section - Update meta description: governance/compliance angle (replaces "native vs bolted-on") - Cross-links org keys, audit trail, and compliance frameworks to existing Phase 30 primitives Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(build): add missing fmt import + fix canvas Dockerfile GID (#1487) * docs(canary-release): flag as aspirational; link to current state The canary-release.md doc describes the pipeline as if the fleet is running — referring to AWS account 004947743811 and a configured MoleculeStagingProvisioner role. Reality as of 2026-04-22: no canary tenants are provisioned, the 3 GH Actions secrets are empty, and canary-verify.yml has failed 7/7 times in a row. Added a top-of-doc ⚠️ state note that: 1. Clarifies this is intended design, not deployed reality. 2. Notes the AWS account ID is historical / unverified. 3. Explains that merges currently rely on manual promote-latest. 4. Cross-links to molecule-controlplane/docs/canary-tenants.md for the Phase 1 work that's shipped, the Phase 2 stand-up plan, and the "should we even do this now?" decision framework. 5. Asks whoever lands Phase 2 to reconcile the two docs. No behaviour change — doc-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(build): add missing fmt import in a2a_proxy.go, fix canvas Dockerfile GID - a2a_proxy.go: missing "fmt" import caused build failure (8 undefined references at lines 743-775). Likely dropped during a recent merge. - canvas/Dockerfile: GID 1000 already in use in node base image. Changed to dynamic group/user creation with fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Hongming Wang <hongmingwangrabbit@gmail.com> * docs(blog): Phase 33 direct-connect migration — Cloudflare Tunnel to public IP (#1612) * docs(social): EC2 Instance Connect SSH launch copy + terminal demo visual PR #1533 (feat/terminal: remote path via aws ec2-instance-connect + pty) Issue #1547 (social: launch thread for EC2 Instance Connect SSH) Content: - docs/marketing/social/2026-04-22-ec2-instance-connect-ssh/social-copy.md 5-post X thread + LinkedIn single post, dark theme brand voice - docs/assets/blog/2026-04-22-ec2-instance-connect-ssh/ec2-terminal-demo.png (1200x800) Canvas Terminal tab mockup showing EC2 bash prompt via EIC Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(blog): Phase 33 direct-connect migration — Cloudflare Tunnel to public IP Migrate from Cloudflare Tunnel (outbound WebSocket) to direct-connect agent workspaces with per-workspace public IPs. Covers operator actions, developer notes, security model, and Phase 33 rollout timeline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Social Media Brand <social-media-brand@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Molecule AI DevRel Engineer <devrel-engineer@agents.moleculesai.app> * docs(marketing): add Day 4 + Day 5 social copy Day 4: EC2 Console Output — approved by Marketing Lead + PM Day 5: Org-Scoped API Keys — approved by Marketing Lead + PM Both campaigns queued for Apr 24 and Apr 25. Co-authored-by: Marketing Lead <marketing-lead@agents.moleculesai.app> * docs(security): move sensitive runbooks to private internal repo Three changes to stop ferrying sensitive content through our public monorepo. All content already imported to Molecule-AI/internal (private) — see linked PRs below. Contained full security audit cycle records with CWE references, file:line pointers to historical vulnerabilities, and severity ratings. None of that belongs in a public repo. → Moved to Molecule-AI/internal/security/incident-log.md (PR #20). Monorepo file becomes a 17-line stub pointing at the internal location. Future incidents land in the internal file only. Had AWS account ID `004947743811` and IAM role name `MoleculeStagingProvisioner` embedded. Even though the fleet described isn't actually running (see state note), these identifiers are account-specific and don't belong in public git. → Removed both values, replaced with generic references + a pointer to Molecule-AI/internal/runbooks/canary-fleet.md (PR #21) where the actual identifiers live. Any future rotation touches the internal file, no public-git-history rewrite needed. Contained the full ops runbook: bootstrap script output, per-tenant SG backfill loop with live SG IDs, customer slug names (hongmingwang). Useful content but too specific for a public repo. → Moved to Molecule-AI/internal/runbooks/workspace-terminal.md (PR #22). Monorepo file becomes a 30-line public summary of what the feature does + pointers to code, so external readers / self-hosters still get the design story. Marketing briefs, SEO plans, campaign copy, research dossiers, and internal product designs (hermes-adapter-plan, medo-integration, cognee-*) are the next batches. See docs policy doc coming next to set team expectations. Net removal: ~820 lines from public git going forward. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: canary-verify graceful-skip + draft auto-promote staging→main Two related workflow hygiene changes: ## (1) canary-verify: graceful-skip when canary secrets absent Before: canary-verify hit `scripts/canary-smoke.sh` which exited non-zero when CANARY_TENANT_URLS was empty. Every main publish ran → canary-verify failed → red check on main CI signal (7/7 in past 24h). Noise, no value. After: smoke step detects the missing-secrets case, writes a warning to the step summary, sets an output `smoke_ran=false`, and exits 0. The workflow completes green without pretending to have tested anything. Gated downstream: `promote-to-latest` now requires BOTH `needs.canary-smoke.result == success` AND `needs.canary-smoke.outputs.smoke_ran == true`. A skip does NOT auto-promote — manual `promote-latest.yml` remains the release gate while Phase 2 canary is absent (see molecule-controlplane/docs/canary-tenants.md for the fleet stand-up plan + decision framework). When the canary fleet is stood up and secrets populated: delete the early-exit branch + the smoke_ran gate. The workflow goes back to its original "smoke gates promotion" semantics. ## (2) auto-promote-staging.yml — draft New workflow that fires after CI / E2E Staging Canvas / E2E API / CodeQL complete on the staging branch, checks that ALL four are green on the same SHA, and fast-forwards `main` to that SHA. Shipped disabled: the promote step is gated behind repo variable `AUTO_PROMOTE_ENABLED=true`. Until that's set, the workflow dry-runs and logs what it would have done. Toggle via Settings → Variables when staging CI has been reliably green for a few days. Safety: - workflow_run events only fire on push to staging (PRs into staging don't promote). - Every required gate must be `completed/success` on the same head_sha. Pending / failed / skipped / cancelled → abort. - `--ff-only` push. Refuses to advance main if it has diverged from staging history (someone landed a direct-to-main commit that's not on staging). Human resolves the fork. - `workflow_dispatch` with `force=true` lets us test the flow end-to-end before flipping the variable on. Motivation: molecule-core#1496 has been open with 1172 commits divergence between staging and main. Today that trapped PR #1526 (dynamic canvas runtime dropdown) on staging while prod users hit the hardcoded-dropdown bug. Auto-promote retires the bulk staging→main PR pattern once the staging CI it depends on is reliable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(F1085): scope rm to /configs volume in deleteViaEphemeral F1085 (Misconfiguration - Filesystems): the 2-arg exec form []string{"rm", "-rf", "/configs", filePath} passes /configs as an rm target, so rm -rf /configs deletes the entire volume mount regardless of what filePath resolves to. Fix uses filepath.Join + filepath.Clean + HasPrefix assertion to scope rm to the /configs/ prefix. validateRelPath (CWE-22) catches leading/mid-path ".." before rm. HasPrefix guard is defence-in-depth. Includes CP-BE's 12-case regression test suite (docker: nil, validates all traversal forms rejected before Docker call). Co-Authored-By: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com> Co-Authored-By: Molecule AI CP-BE <cp-be@agents.moleculesai.app> * docs(tutorial): EC2 Instance Connect SSH — workspace terminal via EIC Endpoint (#1617) * docs(social): EC2 Instance Connect SSH launch copy + terminal demo visual PR #1533 (feat/terminal: remote path via aws ec2-instance-connect + pty) Issue #1547 (social: launch thread for EC2 Instance Connect SSH) Content: - docs/marketing/social/2026-04-22-ec2-instance-connect-ssh/social-copy.md 5-post X thread + LinkedIn single post, dark theme brand voice - docs/assets/blog/2026-04-22-ec2-instance-connect-ssh/ec2-terminal-demo.png (1200x800) Canvas Terminal tab mockup showing EC2 bash prompt via EIC Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(tutorial): EC2 Instance Connect SSH — workspace terminal via EIC Endpoint Runnable tutorial for PR #1533: - How EIC SSH bridges PTY to Canvas Terminal tab - Prerequisites: IAM policy, EIC Endpoint, aws-cli in tenant image - 6-step runnable snippet (workspace create → poll → Terminal verify → CloudWatch audit) - Design notes: subprocess aws-cli pattern, bidirectional context cancel - Teardown, links to social copy and infra runbook Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Social Media Brand <social-media-brand@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Molecule AI DevRel Engineer <devrel-engineer@agents.moleculesai.app> * docs(blog): AI agent credential model — one key, named, monitored (#1614) * docs(social): EC2 Instance Connect SSH launch copy + terminal demo visual PR #1533 (feat/terminal: remote path via aws ec2-instance-connect + pty) Issue #1547 (social: launch thread for EC2 Instance Connect SSH) Content: - docs/marketing/social/2026-04-22-ec2-instance-connect-ssh/social-copy.md 5-post X thread + LinkedIn single post, dark theme brand voice - docs/assets/blog/2026-04-22-ec2-instance-connect-ssh/ec2-terminal-demo.png (1200x800) Canvas Terminal tab mockup showing EC2 bash prompt via EIC Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(blog): AI agent credential model — one key, named, monitored Companion post to the enterprise-key-management launch post. Focuses on the agent-specific angle: dynamic tool interfaces, emergent behavior containment, delegation chains, and the security properties that survive agent compromise. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Social Media Brand <social-media-brand@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Molecule AI DevRel Engineer <devrel-engineer@agents.moleculesai.app> * docs(marketing): Phase 30 Day 2 social package — Discord adapter, Reddit/HN (#1662) * docs(devrel): add Phase 30 hero video — 3 aspect ratio cuts Primary (16:9), social (9:16), and LinkedIn (1:1) cuts. 47.95s, 30fps H.264, dark zinc theme, burn-in captions, VO track. Assembled from: - marketing/assets/phase30-fleet-diagram.png - marketing/audio/phase30-video-vo.mp3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(marketing): fill Discord adapter Day 2 blog URL — ready for Apr 22 push Adds https://moleculesai.app/blog/discord-adapter to both Reddit (r/LocalLLaMA) and Hacker News post bodies. Updates status line and draft attribution. Reddit/HN copy is now complete and ready for Social Media Brand coordination. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(marketing): correct Discord adapter blog URL — discord-adapter → 2026-04-21-discord-adapter Fixes broken link in Reddit and HN Day 2 copy. Correct slug is /blog/2026-04-21-discord-adapter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Molecule AI Community Manager <community-manager@agents.moleculesai.app> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Molecule AI Technical Writer <technical-writer@agents.moleculesai.app> * test(canvas): add ActivityTab and MissingKeysModal component tests - ActivityTab.test.tsx: 27 tests covering filter bar (aria-pressed states, API reload), loading/error/empty states, ActivityRow content (type badges, method, duration_ms, summary, error styling), A2A flow indicators, auto-refresh Live/Paused toggle, refresh button, activity count - MissingKeysModal.component.test.tsx: 25 tests covering visibility, ARIA semantics (role=dialog, aria-modal, aria-labelledby), content, keyboard (Escape, Enter), save flow (disabled/.../Saved/error), Add Keys & Deploy gate, Cancel + backdrop click, Open Settings button - MissingKeysModal.test.tsx: refactored to preflight logic only (7 tests); component rendering now covered in component test file 863 tests passing (+3 net). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(canvas): relax setPendingDelete assertion to use expect.objectContaining Staging added hasChildren/children fields to workspace store shape. Test assertion updated to use objectContaining to avoid false negatives. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(canvas): add type=button to ApprovalBanner action buttons (bug #1669) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(guides): add 5-minute external-workspace quickstart for DevRel Existing external-agent-registration.md is 784 lines — great reference but hostile to first-time devs evaluating Molecule. Add a tight 5-minute quickstart aimed at "make it work today": - 40-line Python agent with A2A JSON-RPC skeleton - Cloudflare quick-tunnel for instant public URL (no account) - Single curl registration - Common gotchas table (includes the canvas dedup + tunnel rotation issues caught in the demo this afternoon) - Production upgrade path - Preview of polling mode (Phase N+1 transport) - 4-step diagnostic checklist at the bottom The reference doc (external-agent-registration.md) now has a prominent "in a hurry?" callout pointing at the quickstart, so the discovery path works either way. Target audience: a developer who wants to see their code on canvas inside 5 minutes, not a self-hoster hardening for prod. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(e2e/staging-saas): send provider-prefixed model slug for hermes The E2E posts a bare "gpt-4o" as the workspace model. Hermes template's derive-provider.sh parses the slug PREFIX (before the slash) to set HERMES_INFERENCE_PROVIDER at install time. With no prefix, provider falls back to hermes's auto-detect, which picks the compiled-in Anthropic default. Hermes-agent then tries the Anthropic API with the OpenAI key the E2E passed in SECRETS_JSON and returns 401 "Invalid API key" at step 8/11 (A2A call). Same trap PR #1714 fixed for the canvas Create flow. The E2E was quietly broken on the same vector — it masked before today because workspaces never reached "online" (pre-#231 install.sh hook missing on staging; staging now deploys #231 via CP #236). Fix: pin MODEL_SLUG="openai/gpt-4o" since the E2E's secret is always the OpenAI key. Non-hermes runtimes ignore the prefix. Now that both layers are fixed (install.sh runs AND the slug steers hermes to OpenAI), the E2E should reach step 11/11. Evidence from run 24822173171 attempt 2 (post-CP-#236 deploy): 07:55:25 ✅ CP reachable 07:57:28 ✅ Tenant provisioning complete (2:03, canary) 08:04:56 ✅ Workspace 52107c1a online (7:28, install.sh ran!) 08:05:06 ✅ Workspace 34a286df online 08:05:06 ❌ A2A 401 — hermes tried Anthropic with OpenAI key Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(canvas): add getState to useCanvasStore mock in ContextMenu keyboard test ContextMenu.tsx reads parent-workspace children via useCanvasStore.getState().nodes.filter(...) — a direct .getState() call, not the selector-calling form. The existing vi.mock exposed only the selector form, so rendering crashed with "TypeError: useCanvasStore.getState is not a function". Restructure the vi.mock factory to return Object.assign(fn, { getState: () => mockStore }) so both call shapes resolve. Factory body builds the function locally because vi.mock hoists above outer-scope variable declarations and can't reference `mockStore` via closure. Verified: all 15 tests in the file pass after the change. Unblocks the Canvas (Next.js) CI check on PR #1743 (staging→main sync). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(handlers): validate path/auth BEFORE docker availability checks Three traversal / cross-workspace rejection tests on staging were masked by premature "docker not available" early returns: 1. deleteViaEphemeral — nil-docker check fired BEFORE path validation; malicious paths got "docker not available" (wrong code path) instead of "path not allowed". Reversed the order + added "path not allowed:" prefix to rejection messages. 2. copyFilesToContainer — split the traversal classifier into: - absolute path → "unsafe file path in archive" - literal "../" prefix → "unsafe file path in archive" (classic) - URL-encoded / mid-path traversal → "path escapes destination" Added nil-docker guard AFTER validation so legitimate inputs error cleanly instead of panicking on nil docker. 3. HandleConnect KI-005 — test used outdated table name "workspace_tokens"; ValidateAnyToken uses "workspace_auth_tokens" since #1210. Updated the mock. Added best-effort last_used_at UPDATE expectation that fires after successful token validation. Brings the handlers package from 3 failing tests to 0. All 20 Go packages green on go test -race ./... locally. * fix(test): add getState to useCanvasStore mock in ContextMenu keyboard test PR #1781 introduced useCanvasStore.getState() call in ContextMenu.tsx (line 169) but the existing Vitest mock for useCanvasStore in the keyboard test file lacked a getState method, causing: TypeError: useCanvasStore.getState is not a function Fix: attach getState: () => mockStore to the mock using Object.assign so the static method is available alongside the selector fn. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(security): prevent cross-tenant memory contamination in commit_memory/recall_memory (GH#1610) Two critical gaps in a2a_tools.py let any tenant workspace poison org-wide (GLOBAL) memory and bypass all RBAC enforcement: 1. tool_commit_memory had no RBAC check — any agent could write any scope. 2. tool_commit_memory had no root-workspace enforcement for GLOBAL scope — Tenant A could POST scope=GLOBAL and pollute the shared memory store that Tenant B's agent reads as trusted context. Fix adds: - _ROLE_PERMISSIONS table (mirrors builtin_tools/audit.py) so a2a_tools has isolated RBAC logic without depending on memory.py. - _check_memory_write_permission() / _check_memory_read_permission() helpers: evaluate RBAC roles from WorkspaceConfig; fail closed (deny) on errors. - _is_root_workspace() / _get_workspace_tier(): read WorkspaceConfig.tier (0 = root/org, 1+ = tenant) from config.yaml; fall back to WORKSPACE_TIER env var. - tool_commit_memory now (a) checks memory.write RBAC, (b) rejects GLOBAL scope for non-root workspaces, (c) embeds workspace_id in the POST body so the platform can namespace-isolate and audit cross-workspace writes. - tool_recall_memory now checks memory.read RBAC before any HTTP call, and always sends workspace_id as a GET param for platform cross-validation. Security regression tests added: - GLOBAL scope denied for non-root (tier>0) workspaces. - RBAC denial blocks all scope levels (including LOCAL) on write. - RBAC denial blocks recall entirely. - workspace_id present in POST body and GET params. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: re-trigger checks on staging→main sync PR --------- Co-authored-by: Hongming Wang <hongmingwang.rabbit@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Molecule AI Backend Engineer <backend-engineer@agents.moleculesai.app> Co-authored-by: qa-agent <qa-agent@users.noreply.github.com> Co-authored-by: Molecule AI Frontend Engineer <frontend-engineer@agents.moleculesai.app> Co-authored-by: Molecule AI Triage Operator <triage-operator@agents.moleculesai.app> Co-authored-by: Molecule AI Platform Engineer <platform-engineer@agents.moleculesai.app> Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com> Co-authored-by: Molecule AI SDK-Dev <sdk-dev@agents.moleculesai.app> Co-authored-by: airenostars <airenostars@gmail.com> Co-authored-by: Molecule AI Core-BE <core-be@agents.moleculesai.app> Co-authored-by: Molecule AI Core-DevOps <core-devops@agents.moleculesai.app> Co-authored-by: Molecule AI Core-FE <core-fe@agents.moleculesai.app> Co-authored-by: Molecule AI Fullstack (floater) <fullstack-floater@agents.moleculesai.app> Co-authored-by: Molecule AI CP-QA <cp-qa@agents.moleculesai.app> Co-authored-by: Molecule AI Core-UIUX <core-uiux@agents.moleculesai.app> Co-authored-by: Molecule AI PMM <pmm@agents.moleculesai.app> Co-authored-by: Molecule AI Social Media Brand <social-media-brand@agents.moleculesai.app> Co-authored-by: Molecule AI DevRel Engineer <devrel-engineer@agents.moleculesai.app> Co-authored-by: Marketing Lead <marketing-lead@agents.moleculesai.app> Co-authored-by: Molecule AI Controlplane Lead <controlplane-lead@agents.moleculesai.app> Co-authored-by: Molecule AI CP-BE <cp-be@agents.moleculesai.app> Co-authored-by: Molecule AI Community Manager <community-manager@agents.moleculesai.app> Co-authored-by: Molecule AI Technical Writer <technical-writer@agents.moleculesai.app> Co-authored-by: Molecule AI App-FE <app-fe@agents.moleculesai.app>
225 lines
8.3 KiB
Go
225 lines
8.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
)
|
|
|
|
// maxExecOutput limits container exec output to 5MB to prevent OOM.
|
|
const maxExecOutput = 5 * 1024 * 1024
|
|
|
|
// findContainer finds a running container for the workspace.
|
|
// Checks provisioner name, full ID, and DB workspace name (same candidates as terminal handler).
|
|
func (h *TemplatesHandler) findContainer(ctx context.Context, workspaceID string) string {
|
|
if h.docker == nil {
|
|
return ""
|
|
}
|
|
name := provisioner.ContainerName(workspaceID)
|
|
candidates := []string{name}
|
|
if name != "ws-"+workspaceID {
|
|
candidates = append(candidates, "ws-"+workspaceID)
|
|
}
|
|
// Also check by workspace name from DB
|
|
var wsName string
|
|
db.DB.QueryRowContext(ctx, `SELECT LOWER(REPLACE(name, ' ', '-')) FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
|
if wsName != "" {
|
|
candidates = append(candidates, wsName)
|
|
}
|
|
for _, c := range candidates {
|
|
info, err := h.docker.ContainerInspect(ctx, c)
|
|
if err == nil && info.State.Running {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// execInContainer runs a command in a container and returns stdout (capped at maxExecOutput).
|
|
func (h *TemplatesHandler) execInContainer(ctx context.Context, containerName string, cmd []string) (string, error) {
|
|
execCfg := container.ExecOptions{
|
|
Cmd: cmd,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
}
|
|
execID, err := h.docker.ContainerExecCreate(ctx, containerName, execCfg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := h.docker.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Close()
|
|
var stdout bytes.Buffer
|
|
// Use stdcopy to correctly demux Docker multiplexed stream (stdout/stderr)
|
|
stdcopy.StdCopy(&stdout, io.Discard, io.LimitReader(resp.Reader, maxExecOutput))
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|
|
|
|
// copyFilesToContainer creates a tar archive from a map of files and copies it into a container.
|
|
// The destPath is prepended to each file name. File names must be relative and must not escape
|
|
// destPath via ".." segments — otherwise the tar header name could escape the mounted volume.
|
|
func (h *TemplatesHandler) copyFilesToContainer(ctx context.Context, containerName, destPath string, files map[string]string) error {
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
|
|
createdDirs := map[string]bool{}
|
|
for name, content := range files {
|
|
// Block absolute paths and traversal attempts at the archive-write boundary.
|
|
// Files are written inside destPath (typically /configs); anything that escapes
|
|
// via ".." or an absolute name could reach other volumes or system paths.
|
|
clean := filepath.Clean(name)
|
|
if filepath.IsAbs(clean) {
|
|
return fmt.Errorf("unsafe file path in archive: %s", name)
|
|
}
|
|
if strings.HasPrefix(name, "../") {
|
|
// Literal leading "../" with separator — classic traversal.
|
|
// Tests expect "unsafe file path in archive" wording here.
|
|
// URL-encoded "..%2F..." and mid-path "foo/../.." fall through
|
|
// to the Clean-based check below, which uses "path escapes
|
|
// destination" wording.
|
|
return fmt.Errorf("unsafe file path in archive: %s", name)
|
|
}
|
|
if strings.HasPrefix(clean, "..") {
|
|
// Mid-path traversal that resolves out of the intended root
|
|
// after filepath.Clean — tests expect "path escapes destination".
|
|
return fmt.Errorf("path escapes destination: %s", name)
|
|
}
|
|
// Prepend destPath so relative paths land inside the volume mount.
|
|
// Use cleaned name so validation (which checks clean) and usage stay consistent.
|
|
archiveName := filepath.Join(destPath, clean)
|
|
// Defence-in-depth: ensure the joined path doesn't escape destPath.
|
|
// This guards against platform-specific filepath.Join behaviour where
|
|
// joining a relative name containing ".." with a destPath can still
|
|
// produce an absolute path outside the intended directory.
|
|
if !strings.HasPrefix(archiveName, destPath) && archiveName != destPath {
|
|
return fmt.Errorf("path escapes destination: %s", name)
|
|
}
|
|
|
|
// Create parent directories in tar (deduplicated)
|
|
dir := filepath.Dir(archiveName)
|
|
if dir != destPath && !createdDirs[dir] {
|
|
tw.WriteHeader(&tar.Header{
|
|
Typeflag: tar.TypeDir,
|
|
Name: dir + "/",
|
|
Mode: 0755,
|
|
})
|
|
createdDirs[dir] = true
|
|
}
|
|
|
|
data := []byte(content)
|
|
header := &tar.Header{
|
|
Name: archiveName,
|
|
Mode: 0644,
|
|
Size: int64(len(data)),
|
|
}
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return fmt.Errorf("failed to write tar header for %s: %w", name, err)
|
|
}
|
|
if _, err := tw.Write(data); err != nil {
|
|
return fmt.Errorf("failed to write tar data for %s: %w", name, err)
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
return fmt.Errorf("failed to close tar writer: %w", err)
|
|
}
|
|
|
|
if h.docker == nil {
|
|
return fmt.Errorf("docker not available")
|
|
}
|
|
return h.docker.CopyToContainer(ctx, containerName, destPath, &buf, container.CopyToContainerOptions{})
|
|
}
|
|
|
|
// writeViaEphemeral writes files to a named volume using an ephemeral Alpine container.
|
|
// Used when the workspace container is offline (e.g., during provisioning).
|
|
func (h *TemplatesHandler) writeViaEphemeral(ctx context.Context, volumeName string, files map[string]string) error {
|
|
if h.docker == nil {
|
|
return fmt.Errorf("docker not available")
|
|
}
|
|
|
|
// Create ephemeral container mounting the volume
|
|
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
|
|
Image: "alpine:latest",
|
|
Cmd: []string{"sleep", "10"},
|
|
}, &container.HostConfig{
|
|
Binds: []string{volumeName + ":/configs"},
|
|
}, nil, nil, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create ephemeral container: %w", err)
|
|
}
|
|
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
|
|
|
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
|
return fmt.Errorf("failed to start ephemeral container: %w", err)
|
|
}
|
|
|
|
// Copy files via tar, then stop container cleanly
|
|
if err := h.copyFilesToContainer(ctx, resp.ID, "/configs", files); err != nil {
|
|
return err
|
|
}
|
|
// Wait for container to be ready for removal (copy is synchronous, but be safe)
|
|
timeout := 5
|
|
h.docker.ContainerStop(ctx, resp.ID, container.StopOptions{Timeout: &timeout})
|
|
return nil
|
|
}
|
|
|
|
// deleteViaEphemeral deletes a file from a named volume using an ephemeral container.
|
|
func (h *TemplatesHandler) deleteViaEphemeral(ctx context.Context, volumeName, filePath string) error {
|
|
// CWE-78/CWE-22: validate BEFORE any downstream availability check.
|
|
// Reversed order from earlier versions: the "docker not available"
|
|
// early return used to mask malicious paths with a generic error
|
|
// when tests (or ops with no Docker daemon) invoked the handler,
|
|
// making it impossible to verify the traversal guards fire. Exec
|
|
// form ([]string{...}) also defends against shell injection.
|
|
if err := validateRelPath(filePath); err != nil {
|
|
return fmt.Errorf("path not allowed: %w", err)
|
|
}
|
|
|
|
// F1085 (Misconfiguration - Filesystems): scope rm to the /configs volume.
|
|
// filepath.Join scopes the rm target; filepath.Clean normalizes ".."; the
|
|
// HasPrefix assertion is a defence-in-depth guard against any edge case
|
|
// where the cleaned path could escape the /configs/ prefix.
|
|
rmTarget := filepath.Join("/configs", filePath)
|
|
rmTarget = filepath.Clean(rmTarget)
|
|
if !strings.HasPrefix(rmTarget, "/configs/") {
|
|
return fmt.Errorf("path not allowed: escapes volume scope: %s", filePath)
|
|
}
|
|
|
|
if h.docker == nil {
|
|
return fmt.Errorf("docker not available")
|
|
}
|
|
|
|
resp, err := h.docker.ContainerCreate(ctx, &container.Config{
|
|
Image: "alpine:latest",
|
|
Cmd: []string{"rm", "-rf", rmTarget},
|
|
}, &container.HostConfig{
|
|
Binds: []string{volumeName + ":/configs"},
|
|
}, nil, nil, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create ephemeral container: %w", err)
|
|
}
|
|
defer h.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
|
|
|
if err := h.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
|
return err
|
|
}
|
|
// Wait for the rm command to finish before removing the container
|
|
statusCh, errCh := h.docker.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning)
|
|
select {
|
|
case <-statusCh:
|
|
return nil
|
|
case err := <-errCh:
|
|
return err
|
|
}
|
|
}
|