PR #357 deleted the grace-period tests that used hasLiveTokenQuery and
workspaceExistsQuery, but the constants themselves (and the stale comment
describing the old HasAnyLiveToken-based dispatch) were not removed.
Remove both dead const declarations and update the header comment to
reflect the strict-enforcement contract introduced by #357.
Closes#358.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Severity HIGH. #318 closed the fake-UUID fail-open for WorkspaceAuth
but left the grace period intact for *real* workspaces with no live
tokens. Zombie test-artifact workspaces from prior DAST runs still
exist in the DB with empty configs and no tokens, so they pass
WorkspaceExists=true but HasAnyLiveToken=false — and fell through the
grace period, leaking every global-secret key name to any
unauthenticated caller on the Docker network.
Phase 30.1 shipped months ago; every production workspace has gone
through multiple boot cycles and acquired a token since. The
"legacy workspaces grandfathered" window no longer serves legitimate
traffic. Removing it entirely is the cleanest fix — and does NOT
affect registration (which is on /registry/register, outside this
middleware's scope).
New contract (strict):
every /workspaces/:id/* request MUST carry
Authorization: Bearer <token-for-this-workspace>
Any missing/mismatched/revoked/wrong-workspace bearer → 401. No
existence check, no fallback. The wsauth.WorkspaceExists helper is
kept in the package for any future caller but no longer used here.
Tests:
- TestWorkspaceAuth_351_NoBearer_Returns401_NoDBCalls — new, covers
fake UUID / zombie / pre-token in one sub-table. Asserts zero DB
calls on missing bearer.
- Existing C4/C8 + #170 tests updated to drop the stale
HasAnyLiveToken sqlmock expectations.
- Renamed TestWorkspaceAuth_Issue170_SecretDelete_FailOpen_NoTokens
to _NoTokensStillRejected and flipped the assertion from 200 to 401.
- Dropped TestWorkspaceAuth_318_ExistsQueryError_Returns500 — the
code path it covered no longer exists.
Full platform test sweep green.
Closes#351
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two new entries to docs/ecosystem-watch.md:
- **AgentScope** (modelscope/agentscope, ~23.8k ⭐, Apache 2.0,
v1.0.18 March 26 2026): Alibaba/ModelScope multi-agent framework
with MCP support, MsgHub typed routing, and OpenTelemetry
observability. No canvas or workspace lifecycle — framework-layer
complement, not a platform competitor.
- **Plannotator** (backnotprop/plannotator, ~4.3k ⭐, Apache 2.0+MIT,
v0.17.10 April 13 2026): Browser-based agent plan annotation tool
with structured feedback types (delete/insert/replace/comment).
Directly informs our hitl.py feedback schema. Filed #349 to add
structured feedback types to resume_task.
HEAD at survey time: 0897f9e
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two recent platform-level security changes (#319 channel_config
encryption, #337 constant-time webhook_secret compare) were not
reflected in the Security Auditor's system prompt or the schedule cron
prompt. That meant the auditor wouldn't proactively look for the
*next* instance of either class — a new credential field added to
channel_config without being added to sensitiveFields, or a new
secret comparison using raw `!=`, would slip through until a human
happened to notice.
Updated two files:
1. org-templates/molecule-dev/security-auditor/system-prompt.md
Added two bullets to "What You Check":
- Secret comparisons must use subtle.ConstantTimeCompare /
crypto.timingSafeEqual (cites #337 as the repo's recent instance)
- Secret storage at rest: any new channel_config credential field
must be added to sensitiveFields and exercised in both the
Encrypt (write) and Decrypt (read) boundary helpers, and the
ec1: prefix must never leak into API responses (cites #319)
2. org-templates/molecule-dev/org.yaml
Same two checks added to the Security Auditor's 12-hour cron
prompt's "MANUAL REVIEW of every changed file" section. Wording
is concrete enough to paste into a grep: "flag any `!=` / `==` /
bytes.Equal against a user-supplied value that gates auth".
Pure config / prompt — no code changes, no tests to write. YAML parse
verified, TestPlugins_UnionWithDefaults still passes.
Closes#342
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Severity LOW. The /webhooks/:type handler compared the Telegram
X-Telegram-Bot-Api-Secret-Token header against the decrypted
webhook_secret using Go's `!=` operator, which short-circuits on the
first mismatched byte. Under low-latency Docker-network conditions an
attacker could time response latency byte-by-byte and converge on the
real secret, then inject Telegram-formatted messages into any channel.
Fix: switch to crypto/subtle.ConstantTimeCompare, which runs in time
proportional to the length of the shorter input regardless of content
match. Same posture as the cdp-proxy token compare in host-bridge
(which already used timingSafeEqual).
Risk profile over the public internet is low (Telegram webhooks have
natural jitter that masks the signal), but the defensive pattern
matters for consistency across all secret comparisons.
Closes#337
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CI 5/6 pass (E2E cancel = run-supersession pattern). Dev Lead review 04:21: ✅ Approved. Fixes cross-tenant token exposure: PausePollersForToken now scoped to requesting workspace_id via SQL WHERE clause. Closes#329.
CI fully green. Dev Lead review: ✅ Approved. Docs-only: adds Gemini CLI and open-multi-agent entries to ecosystem-watch.md; files issues #332 (gemini-cli adapter) and #333 (PM goal-decomp skill).
#273 tried to fix the macOS Keychain -25308 error by pointing
DOCKER_CONFIG at a per-run temp dir with `{"auths": {}}`. That was
necessary but not sufficient: Docker on macOS inherits `osxkeychain` as
the default credsStore even when config.json doesn't declare one
(comes from Docker Desktop's bundled binding), so the login-action
still tried to call /usr/local/bin/docker-credential-osxkeychain which
fails with -25308 from the non-interactive launchd session.
Evidence: after #273, publish-platform-image still failed on every
main merge with:
error saving credentials: error storing credentials - err: exit
status 1, out: `User interaction is not allowed. (-25308)`
Fix: write a config.json that explicitly sets `credsStore: ""` and
clears `credHelpers`, forcing Docker to store creds in the inline
`auths` map of this disposable config.json instead of reaching for
the keychain. Also print config.json at diagnostic time so a future
regression surfaces in the log instead of at login.
No runtime / test impact — this only changes what the runner writes
to the workflow's temp DOCKER_CONFIG directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Severity HIGH. The /transcript route in main.py used `if expected:`
around the bearer-token compare, so `get_token()` returning None (no
/configs/.auth_token on disk — bootstrap window, deleted file, OSError)
silently skipped the entire auth check. Any container on
molecule-monorepo-net could GET /transcript during the provisioning
window and walk away with the full session log (user messages, Claude
tool calls, assistant replies).
The platform's TranscriptHandler always has a valid token (it acquired
one at workspace registration), so tightening this gate has no
legitimate-caller impact. Only unauthenticated sniffers lose access,
which was never the intended contract of #287.
Fix:
1. Extracted the auth gate into `workspace-template/transcript_auth.py`
— a 20-line module with no heavy imports so the security-critical
code is unit-testable without standing up the full uvicorn/a2a/httpx
stack (the former inline guard could only be tested end-to-end,
which explains why the regression shipped in #287).
2. `transcript_authorized(expected, auth_header)` returns False when
`expected` is None or empty — the #328 fix — and otherwise does
strict equality against "Bearer <expected>".
3. main.py's inline handler calls the extracted function:
if not _transcript_authorized(get_token(), auth_header):
return 401
4. New tests/test_transcript_auth.py covers: None token, empty token,
valid bearer, wrong bearer, missing header, case-sensitive prefix,
whitespace fuzzing. All 7 pass.
Closes#328
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#246Closes#247
Critical security findings and CI build-break alerts are now pushed via Telegram instead of waiting for someone to manually check memory/logs.
Backend Engineer and Frontend Engineer were missing molecule-skill-llm-judge
while Dev Lead, QA Engineer, and Security Auditor already have it.
llm-judge lets engineers self-gate their PR against the issue body before
requesting review, catching 'shipped the wrong thing' before Dev Lead sees it.
No new plugins needed — already installed org-wide.
Closes#310
Closes#287
Any container on molecule-monorepo-net could previously read the full Claude session log without authentication. Guard uses get_token() from platform_auth — skipped only before workspace registration (dev-mode).
Closes#306. The cron expression was "5,20,35,50 * * * *" (every 15
min = 96 ticks/day) despite the schedule being named "Hourly UI/UX
audit". Each tick launches Chromium, takes 8 screenshots, runs them
through Claude vision, and delegates to PM — 768 vision calls/day
from one workspace with no meaningful delta between ticks (canvas UI
only changes on deploys).
Changed to "5 * * * *" (hourly, at :05 past the hour). 6x reduction
in cost + noise.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#303. Surfaces CVE/secret scanning at dev time instead of
waiting for the Security Auditor's 12h cron. Backend Engineer's
plugin list: [molecule-hitl, molecule-skill-code-review,
molecule-security-scan].
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds auth_headers to recall_memory and commit_memory in a2a_tools.py. Fixes the #215-class auth regression for A2A memory tools. Test mocks updated to accept headers kwarg.
One-liner oversight from #295: the macOS install path wrote the plist
with the default umask (~0644), leaving CDP_PROXY_TOKEN world-readable
to any local user account. The Linux path already writes to a chmod
600 env-file — this brings macOS to parity.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
HIGH finding from security-auditor on PR #291 (merged tick-37). The
cdp-proxy bound to 0.0.0.0:9223 with no authentication, exposing
Chrome DevTools Protocol — full remote control of any tab, including
cookie/localStorage exfiltration — to anyone on the same WiFi/LAN.
Root cause: Docker Desktop on macOS routes host.docker.internal
through the VM network interface, not loopback. Binding to 127.0.0.1
would break the primary use case (containers reaching the host
Chrome). The design trade was "bind wide for reachability, accept LAN
exposure" — #293 makes that trade unacceptable.
Fix: bearer token auth on every HTTP + WebSocket request. The proxy
REFUSES TO START without a token — no unauth mode.
Three-file change:
1. cdp-proxy.cjs
- Read token from CDP_PROXY_TOKEN env OR ~/.molecule-cdp-proxy-token
- Fail loudly if neither is set (exit 1 with install-host-bridge.sh
pointer)
- Validate X-CDP-Proxy-Token header via crypto.timingSafeEqual on
every HTTP request AND every WS upgrade
- Strip the header before forwarding to Chrome (defense in depth —
token never leaks into Chrome's request log)
2. install-host-bridge.sh
- New ensure_token() function generates a 64-char hex token via
openssl rand -hex 32 (fallback to /dev/urandom). Written to
~/.molecule-cdp-proxy-token with chmod 600.
- macOS: token injected into launchd plist EnvironmentVariables
- Linux: written to ~/.molecule-cdp-proxy.env (chmod 600) and
referenced via systemd EnvironmentFile — avoids embedding the
token in the often world-readable unit file
- Install reuses existing token if present (16+ chars); uninstall
preserves token file so a reinstall keeps the same token
- Verify command now includes the token header
- Documents container-side bind-mount pattern
(-v ~/.molecule-cdp-proxy-token:/run/secrets/cdp-proxy-token:ro)
3. lib/connect.js
- New loadProxyToken() with precedence: env var >
/run/secrets/cdp-proxy-token > ~/.molecule-cdp-proxy-token
- Attaches X-CDP-Proxy-Token header on both /json/version probe +
final puppeteer.connect() call via headers: {} option
(puppeteer-core v21+ supports this natively)
- Host-direct fallback (CDP port 9222 on loopback) unchanged —
Chrome's own port is loopback-only so it doesn't need the token
Attack surface now:
- LAN attacker must also steal the token file from the user's home
directory (requires shell access) OR the env var (requires
launchd/systemd process inspection as the same user) — reduces to
local-privilege-escalation territory
- Containers on the same Docker network still have access (they
mount the token by design) — intentional, any workspace-template
install already runs inside the platform's trust boundary
Not fixing in this PR:
- Rate limiting on /json/version (low priority — probe-and-mine is
expensive even without)
- IP allowlist on top of token auth (diminishing returns)
- Rotating the token periodically (user can rm ~/.molecule-cdp-proxy-token
and reinstall)
Closes#293.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Issue surfaced in SEO Builder Run 10 (2026-04-15):
- Marketing Leader found 2 code-level metadata blockers
(white-rock page.tsx override + en.json description >160c)
- Telegram report listed them under "⚠️ ACTION ITEMS (human)"
- User: "it should automatically report to dev team instead of
just asking CEO to do it"
Fix: when seo-builder finds a code-level blocker it can't fix via
DB, it delegates to the Dev Leader sibling workspace via A2A instead
of flagging for human. Only genuine human actions (Yelp email
verification, Google account-linked operations) stay in the human
bucket.
Also clarify marketing-leader/CLAUDE.md so the "DO NOT DELEGATE"
rule doesn't accidentally block this pattern — it's now explicit
that sibling handoff for scope mismatches is allowed (as opposed
to delegating down the hierarchy to spawn sub-agents, which stays
forbidden).
`TranscriptHandler.Get` previously proxied `agent_card->>'url'` directly
to the outbound HTTP client with no validation. Since `agent_card` is
attacker-writable via /registry/register, a workspace-token holder
could point it at cloud metadata (169.254.169.254), link-local ranges,
or non-http schemes and pivot the platform container against internal
services (IMDS, Redis, Postgres, other containers on the Docker net).
Four required fixes per reviewer:
1. `validateWorkspaceURL(u *url.URL)` — runs before `httpClient.Do`:
- scheme must be http/https (rejects file://, gopher://, ftp://)
- cloud metadata hostname blocklist (GCP + Azure + plain "metadata")
- IMDS IP blocklist (169.254.169.254)
- IPv4/IPv6 link-local blocklist (169.254/16, fe80::/10, multicast)
- IPv6 unique-local fd00::/8 blocklist
- loopback + docker.internal still allowed for local dev
2. Query-param allowlist — `target.RawQuery = c.Request.URL.RawQuery`
forwarded everything verbatim, letting a caller smuggle params the
upstream transcript endpoint didn't intend to expose. Replaced with
an allowlist of `since` and `limit`.
3. Sanitized error string — `fmt.Sprintf("workspace unreachable: %v", err)`
leaked the actual internal host/IP via `net.OpError`. Now logs the
real error server-side and returns a plain "workspace unreachable"
to the caller.
4. 10 new regression test cases:
- `TestTranscript_Rejects{CloudMetadataIP,NonHTTPScheme,MetadataHostname,LinkLocalIPv6}`
exercise the handler end-to-end with each attack URL and assert
400 before the HTTP client fires.
- `TestValidateWorkspaceURL` table-drives the validator across
localhost/public/docker-internal (allowed) + IMDS/GCP/Azure/file/
gopher/link-local/multicast (rejected).
- `TestTranscript_ProxyPropagatesAllowlistedQueryParams` asserts
`secret=leak&cmd=rm` is stripped while `since=42&limit=7` pass
through.
Also fixed a pre-existing test bug: `seedWorkspace` was issuing a real
SQL Exec against sqlmock with no expectation set, so the prior test
helpers silently failed in CI. Replaced with `expectWorkspaceURLLookup`
which programs the mock correctly. All 11 tests now pass.
Closes#272
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a new `social-publish` skill under the Marketing Leader template
containing verbatim copies of 7 puppeteer-core helper scripts that reliably
publish video posts to Facebook, Instagram, X, LinkedIn, TikTok, YouTube,
and Google Business Profile. Each helper encapsulates hours of debugging
from the 2026-04-15 incident (Lexical editor mirror selection, FB Reel
Next-button disambiguation, post-publish upsell dismissal, TikTok
beforeunload race, GBP iframe scoping, etc).
Rewrite the existing social-media-poster / monitor / engage skills to
delegate publishing to these helpers instead of freestyling puppeteer
per run. Mirror the same delegation note into the social-media-specialist
skill copies so both the Marketing Leader and its specialist agent follow
the same rule.
Not implemented as a platform plugin: the helpers are dom-specific to
Reno Stars Chrome sessions (profile path, account IDs, hardcoded URLs)
and belong in org-template content rather than a generic platform
capability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The plugin now ships everything a user needs to wire Chrome on their
host to workspaces inside Docker:
- host-bridge/cdp-proxy.cjs — rewrites the Host header so Chrome accepts
DevTools Protocol connections from container-originated traffic, and
forwards both HTTP (tab list, screenshots) and WebSocket upgrades.
- host-bridge/install-host-bridge.sh — one-command install on macOS
(launchd user agent) or Linux (systemd --user unit). `uninstall`
subcommand cleans up. No root required.
- skills/browser-automation/lib/connect.js — the mandatory helper
consumers already use; re-exported here so the plugin is self-contained.
- SKILL.md — documents the one-time host setup and the existing
defaultViewport:null + disconnect-not-close rules. The 2026-04-15
social-media-poster incident (3h debug chasing phantom "sessions
expired" errors on an 800x600 viewport) is captured inline.
Smoke-tested on macOS: install script registered the agent, proxy
listens on 0.0.0.0:9223, and a live workspace container
(ws-bee4d521-3d3) successfully reached Chrome via
host.docker.internal:9223.
This replaces ad-hoc per-user CDP proxies and makes the plugin
usable by any Molecule operator, not just the Reno Stars org.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The #215-class fix in memory.py (859a60e) adds headers=_headers to the
direct-httpx commit_memory + search_memory paths, but 9 existing tests
in test_memory.py had FakeAsyncClient.post/get signatures like
`async def post(self, url, json):` with no headers kwarg. Python
raised TypeError: unexpected keyword argument 'headers' on every call,
commit_memory caught it and returned {success: False}, tests failed.
Fixes applied:
1. Add `headers=None` to every FakeAsyncClient.post + .get signature
across test_memory.py. Uses replace_all so all 9+ fakes match.
2. For tests that capture a single captured["url"]:
- test_commit_memory_uses_awareness_client_when_configured
- test_commit_memory_uses_platform_fallback_without_awareness
- test_commit_memory_httpx_201_success
filter to only capture /memories URLs. Without the filter, the
subsequent _record_memory_activity fire-and-forget post to /activity
overwrites captured["url"] and the assertion fails.
3. For test_commit_memory_promoted_packet_logs_skill_promotion: bump
expected captured["calls"] from 3 to 4. Pre-fix, the memory_write
/activity call (from _record_memory_activity #125) was silently
dropped because the fake rejected headers=; post-fix it succeeds
and lands in the captured list alongside the skill_promotion
/activity and /registry/heartbeat calls. Also extend that test's
fake to accept /registry/heartbeat (was raising AssertionError).
Total: 36/36 memory tests pass. Full workspace-template suite 1189/1189.
This is strictly test-infrastructure work — zero production code
changed. CI never caught the break because the Mac mini runner has
been stuck for ~4 hours (tick-33/34/35/36 reports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Context: platform now gates `GET /workspaces/:id/memories` and
`POST /workspaces/:id/memories` behind workspace auth (post-#166 /
#167 AdminAuth wave). The `builtin_tools.memory` tool had three HTTP
call sites:
1. commit_memory POST fallback (line 121) ← NO auth_headers
2. search_memory GET fallback (line 269) ← NO auth_headers
3. activity-log helper POST (line 371) ← HAS auth_headers
Path 3 was already fixed. Paths 1 + 2 silently 401 every call, but the
tool's error-handling path returns `{"success": False}` without surfacing
the auth failure to the agent. Result: the agent sees an empty memory
backlog on every call and assumes there's nothing to do.
## Discovered today
Technical Researcher is the first workspace opted in to the idle-loop
pilot from #216 (reflection-on-completion pattern). The pilot fires
every 10 min, the agent calls `search_memory "research-backlog:..."` as
the first step, gets back an empty result, writes "tr-idle clean" to
memory, and stops. Clean-idle outcome every tick, 9 consecutive ticks.
Looking at TR's activity_logs response bodies:
"Memory auth has failed on every tick this session — skipping the call"
"tr-idle — step 2 done. Memory unavailable (auth token missing..."
"tr-idle 04:15 — clean (memory auth still down, 3rd consecutive tick)"
The AGENT knew the memory calls were failing. The platform 401 error
was surfacing in the tool response, but our instrumentation wasn't
counting it as a defect — we saw "tr-idle clean" writes and assumed
the pilot was working as designed. It was actually silently broken.
## Fix
Import `platform_auth.auth_headers` lazily (same pattern as the
activity-log path already uses), attach `headers=_auth()` to both
httpx call sites. Matches the #225 fix for the register call.
## Not in this PR
- awareness_client.py also makes HTTP calls to a separate AWARENESS_URL
service (not the platform), which may or may not need the same fix
depending on that service's auth posture. Out of scope for this PR.
- TR's specific token problem: TR's `/configs/.auth_token` file is
empty because it was re-provisioned via `apply_template: true`
(recovery path from the failed-volume incident) and Phase 30.1
only mints a token on FIRST register per workspace. This fix
doesn't help TR until TR gets a fresh token — tracked separately.
## Test plan
- [x] Python syntax check on memory.py passes
- [ ] CI: all memory-related tests should still pass (the new code
paths only add header passing, no shape change)
- [ ] Real-world verification: after TR gets a fresh token, idle-loop
pilot should produce a dispatch within 10 min (seeded backlog
already in place from this session)
## Related
- #215 / #225 — register call auth_headers fix (same pattern)
- #216 — TR idle-loop pilot (couldn't measure until this lands)
- #166 / #167 — platform AdminAuth wave that surfaced this gap
Now that the Molecule-AI org has two self-hosted Apple-silicon runners
(`hongming-m1-mini` + `hongming-m1-mini-2`) servicing the same label set,
two CI runs could execute the e2e-api job concurrently. Each run starts
fixed-name docker containers (`molecule-ci-postgres`, `molecule-ci-redis`)
bound to host ports 15432/16379 — a collision means the second run fails
with "container name already in use" or "port already in use".
Adds a workflow-level `concurrency: e2e-api` group to the job so GitHub
Actions serializes e2e-api executions globally regardless of which runner
picks them up. `cancel-in-progress: false` ensures later runs queue
rather than cancelling the in-flight one (we want every PR's e2e check
to actually execute, not get skipped by a newer push).
Tradeoff: e2e-api is now effectively single-threaded across the whole
org. Measured duration is ~1-2 min per run, so the added serialization
latency is small relative to total CI wall time. All other jobs still
parallelize across both runners.
Closes#280. Self-review rubric now runs on the same workspaces that
raise PRs, not just on the reviewers. Dev Lead uses the same
16-criteria rubric in review, so catching issues pre-PR cuts the
review loop.
- Frontend Engineer: new plugins: [molecule-skill-code-review]
- Backend Engineer: plugins extended from [molecule-hitl] to
[molecule-hitl, molecule-skill-code-review]
- DevOps Engineer: plugins extended from [molecule-hitl] to
[molecule-hitl, molecule-skill-code-review]
The issue didn't explicitly call out DevOps Engineer but the reasoning
applies — DevOps Engineer writes Dockerfiles + CI workflows + infra
scripts that Dev Lead reviews with the same rubric. Including here
for consistency.
Verified all 5 reviewer/engineer roles' plugin lists via
walk-script:
Dev Lead: [code-review, llm-judge]
Frontend Eng: [code-review] ← NEW
Backend Eng: [hitl, code-review] ← NEW
DevOps Eng: [hitl, code-review] ← NEW
Security Aud: [code-review, cross-vendor, llm-judge,
security-scan, hitl]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>