Security:
- Replace hardcoded Cloudflare account/zone/KV IDs in wrangler.toml
with placeholders; add wrangler.toml to .gitignore, ship .example
- Replace real EC2 IPs in docs with <EC2_IP> placeholders
- Redact partial CF API token prefix in retrospective
- Parameterize Langfuse dev credentials in docker-compose.infra.yml
- Replace Neon project ID in runbook with <neon-project-id>
Community:
- Add CONTRIBUTING.md (build, test, branch conventions, CI info)
- Add CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
Cleanup:
- Replace personal runner username/machine name in CI + PLAN.md
- Replace personal tenant URL in MCP setup guide
- Replace personal author field in bundle-system doc
- Replace personal login in webhook test fixture
- Rewrite cryptominer incident reference as generic security remediation
- Remove private repo commit hashes from PLAN.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add supply_chain.go with VerifyManifestIntegrity (SHA256 content check)
- Add pinned-ref enforcement to GithubResolver.Fetch (rejects bare org/repo)
- Fix duplicate TestSlackAdapter_Type across channels_test.go and slack_test.go
- Fix sync.Once lock copy in audit_test.go resetAuditKeyCache
- Fix slack_test.go horizontal rule expectations to match implementation
- Existing tests updated with PLUGIN_ALLOW_UNPINNED=true for bare-ref specs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Second layer of the permanent CRLF fix. The Go provisioner now strips
\r\n → \n from shell, Python, and markdown files during the tar
copy into containers.
Three-layer CRLF defense:
1. Provisioner (this) — strips during template copy
2. Entrypoint.sh — strips at boot (safety net)
3. Runtime plugin installer (builtins.py) — strips during plugin install
Any one layer is sufficient. All three together make CRLF impossible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the final step (3/3) of the durable Temporal resume path:
Platform (Go):
- `Latest` handler: GET /workspaces/:id/checkpoints/latest returns the
most recently completed step across all workflows for the workspace,
ordered by completed_at DESC. Returns 404 when no checkpoints exist.
- Router: registers the new route BEFORE the wildcard :wfid route to
avoid shadowing; callerMismatch guard enforces workspace isolation.
- 4 new unit tests: 200, 500, 404 (ErrNoRows), and 403 (caller mismatch).
Workspace runtime (Python):
- `_fetch_latest_checkpoint()`: non-fatal async helper that GETs the
new endpoint and returns the parsed dict, or None on 404 / any error.
- `TemporalWorkflowWrapper.run()`: on startup, fetches the latest
checkpoint and prepends a synthetic [system, ...] entry to the
serialised AgentTaskInput.history so the agent is aware of its prior
crash state before receiving the current task.
- 4 new pytest tests: 404→None, 200→dict, exception→None (non-fatal
contract), and end-to-end injection into AgentTaskInput.history.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BroadcastToWorkspaceChannels now filters channel_type='slack'.
Telegram is CEO-only — explicit escalations via agent's outbound call,
never auto-posted from cron output. PM's routine pulses and agent
errors were spamming the CEO's Telegram.
PM's Telegram channel stays enabled for POLLING (inbound CEO messages)
but BroadcastToWorkspaceChannels skips it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The replace directive `=> /plugin` breaks CI builds where go build runs
natively (no /plugin directory). Move the replace to Dockerfile RUN so
it only applies during Docker builds where the plugin is COPYed.
Fixes: "replacement directory /plugin does not exist" on CI runner.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds callback_query to AllowedUpdates in Telegram polling. When CEO
clicks Yes/No inline keyboard buttons:
1. Acknowledges press (removes loading spinner)
2. Updates message with 'CEO approved/rejected'
3. Routes 'CEO_DECISION: approve:xyz' as inbound to the agent
Only one workspace polls per bot token (Triage Operator) — other
workspaces with Telegram use outbound-only via direct API.
Fixed: duplicate pollers causing 'terminated by other getUpdates'
errors — removed PM/DevLead/ResearchLead Telegram channel rows
(they send outbound via direct Telegram API calls, not channel manager).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Dockerfile COPY for molecule-ai-plugin-github-app-auth was lost
during a rebase earlier this session. Without it, the platform binary
compiled without the TokenProvider interface implementation, causing
/admin/github-installation-token to return 'no token provider registered'.
This forced hourly rolling restarts to refresh GH_TOKEN (the env var
from provision time expires after ~60 min). Each restart also required
re-applying 6 manual patches and caused ~2 min of A2A downtime where
agents reported peers as 'unresponsive'.
With this fix, the gh-wrapper in each container auto-refreshes tokens
via the platform endpoint on every gh call. Zero restarts needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Security Auditor pre-merge conditions for PR#840:
C5: toolCommitMemory passes content directly to DB insert without secret
redaction. Gap is tracked to #838 (platform-wide _redactSecrets pass).
Adds inline TODO(#838) comment at the insert site so the gap is visible
in-code, not only in the issue tracker.
C6: toolDelegateTask sets X-Workspace-ID but no bearer token on the
outbound A2A call. The /workspaces/:id/a2a route is intentionally outside
WorkspaceAuth (by design in router.go). CanCommunicate is enforced before
the request is constructed, and callerID was authenticated by WorkspaceAuth
on the MCP bridge entry point. Documents this trust assumption at the call
site.
Tables: Slack has no table syntax. Converter now detects markdown tables
and renders them as monospace code blocks with aligned columns.
Dividers: replaced unicode em-dash (caused encoding artifacts) with
plain ASCII dashes.
Strikethrough: ~~text~~ converts to ~text~ (Slack native).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The function was defined on a feature branch, referenced by manager.go
and slack_test.go, but never made it to main after the rebase. This
caused go build to fail with 'undefined: FetchChannelHistory', which
Docker masked by using a cached binary from the last successful build.
That cached binary had neither the mrkdwn blocks nor the Level 3
context injection — explaining why Slack messages showed raw markdown
despite the source having the converter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slack's chat.postMessage renders the text field as plain text when
username override is used. Switching to blocks with type=mrkdwn
forces rich formatting (bold, links, code, dividers).
Also restores FetchWorkspaceChannelContext that was lost in rebase.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both were lost during the PR #844 rebase — the converter was in the
source but the binary couldn't compile because FetchWorkspaceChannelContext
was missing from manager.go (interface mismatch). Previous deploys
silently used the cached old binary without the converter.
Also removed unused 'log' import that blocked compilation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Agents output standard Markdown (Claude Code default) but Slack uses
its own mrkdwn format. Without conversion:
**bold** shows as literal **bold**
### heading shows as literal ###
[text](url) shows as raw markdown link
Converter handles:
**bold** → *bold* (Slack bold is single asterisk)
### heading → *heading* (bold text, no headings in Slack)
[text](url) → <url|text> (Slack link format)
--- → ——— (visual separator)
`code` and ```blocks``` pass through unchanged
6 new tests: bold, heading, link, hr, code block, mixed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a cron fires, the scheduler now fetches the last 10 messages from
the workspace's Slack channel via conversations.history and prepends them
to the cron prompt as '[Slack channel context — recent team messages]'.
This gives each agent ambient awareness of what peers are doing:
- Backend sees Frontend posted 'PR #840 ready for review' → can check
- Security Auditor sees Backend posted 'new endpoint added' → plans review
- PM sees all engineering activity → better synthesis in rollup
Implementation:
- slack.go: FetchChannelHistory() calls conversations.history, filters
bot's own messages, returns last N as SlackHistoryMessage structs
- manager.go: FetchWorkspaceChannelContext() looks up the workspace's
Slack config, fetches history, formats as readable context block
- scheduler.go: ChannelBroadcaster interface extended with
FetchWorkspaceChannelContext; fireSchedule injects context before
the cron prompt (prepended, not appended, so the agent sees team
context BEFORE its task instructions)
Best-effort: if Slack API fails or workspace has no channels, the
prompt is unchanged. Truncated to 200 chars per message, 10 messages
max to keep prompt overhead bounded.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code review findings addressed:
Critical:
1. Bot echo loop: add bot_id + subtype='bot_message' check in ParseWebhook
to prevent outbound auto-posts from triggering inbound → infinite loop
2. Connection leak: close resp.Body immediately after reading instead of
defer inside loop (was holding N connections open for N chunks)
3. Cancelled context: auto-post goroutine now uses context.Background()
with 30s timeout instead of inheriting fireCtx (which gets cancelled
by deferred cancel() when fireSchedule returns)
4. Slug validation: regex ^[a-zA-Z0-9 _-]+$ rejects path traversal and
special chars in [slug] routing
Improvements:
5. Shared HTTP client (slackHTTPClient) for connection pooling instead of
per-request &http.Client{}
6. Rune-safe truncation in BroadcastToWorkspaceChannels for CJK/emoji
7. Log async HandleInbound errors instead of silently discarding
8. url_verification challenge properly returned (c.JSON with challenge)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Humans type [backend] what's #800? in a shared #mol-engineering channel
and the message routes specifically to Backend Engineer's workspace.
Matching logic (case-insensitive):
[pm] → PM
[backend] → Backend Engineer
[dev-lead] → Dev Lead
[security] → Security Auditor (prefix match on 'security-auditor')
Unknown slugs return the available agent list for that channel so the
user knows what slugs are valid.
Messages without a [slug] prefix route to the first matching workspace
(backward compat with Level 2).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Level 1 — Auto-post cron output to Slack:
- scheduler.go: captures A2A response body, extracts agent text via
extractResponseSummary(), broadcasts to workspace's configured Slack
channels on successful non-empty cron completions
- manager.go: adds BroadcastToWorkspaceChannels() — fans out to all
enabled channels for a workspace (engineering+firehose for eng agents,
research+firehose for research agents, etc.)
- main.go: wires scheduler → channel manager via SetChannels()
- Truncates output to 500 chars for Slack readability
Level 2 — Inbound Slack messages route to workspaces:
Already implemented by the existing webhook handler (POST /webhooks/slack)
+ the ParseWebhook method in slack.go which handles both Events API JSON
payloads and slash command form-encoded payloads. Needs Slack App Events
API URL configured to: https://<platform-host>/webhooks/slack
Also in this commit:
- slack.go: dual-mode adapter (bot_token + webhook fallback)
- 031 migration: pgvector guard wraps entire DO block
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slack adapter: adds chat.postMessage mode alongside legacy webhooks.
When bot_token is configured, uses chat:write.customize for per-agent
display name + emoji on every message. Each of the 15 active agents
posts with a distinct identity (PM 💼, Backend ⚙️, etc.).
5 channels configured:
#mol-engineering — PM, Dev Lead, Frontend, Backend, QA, Security, UIUX, Docs
#mol-research — Research Lead, Market Analyst, Tech Researcher, Competitive Intel
#mol-ops — DevOps, Triage, Offensive Security
#mol-ceo-feed — PM synthesized rollup (CEO-facing)
#mol-firehose — all agents (raw feed)
Tested live: 5 test messages across 4 channels, all ok=true.
pgvector migration: moved ALTER TABLE + CREATE INDEX inside the DO
block so the entire migration is skipped when pgvector extension is
unavailable (was crashing platform on restart — the guard caught
CREATE EXTENSION but execution continued to ALTER TABLE which used
the non-existent vector type).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the racy SELECT-then-Stop two-step in HibernateWorkspace with a
three-step atomic pattern that eliminates the TOCTOU window (SAFE-819):
1. Atomic claim: single UPDATE WHERE id=$1
AND status IN ('online','degraded')
AND active_tasks = 0
— rowsAffected=0 means another caller already claimed it or tasks
arrived; we abort immediately without calling Stop.
2. provisioner.Stop: safe because status='hibernating' blocks new task
routing between step 1 and step 2 (no new task can be dispatched).
3. Final UPDATE to 'hibernated': records the completed hibernation.
Also adds stopFnOverride func(ctx, id) to WorkspaceHandler (always nil in
production) so tests can count Stop calls without a running Docker daemon.
Tests added/updated (13 total across 2 files):
- TestHibernateWorkspace_ActiveTasksNotHibernated
- TestHibernateWorkspace_AlreadyHibernatingNotHibernated
- TestHibernateWorkspace_SuccessPath
- TestHibernateWorkspace_ConcurrentOnlyOneStop
- TestHibernateWorkspace_DBErrorOnClaim
- Updated 3 existing HibernateWorkspace tests + 1 HTTP handler test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The migration SQL is read as raw SQL (not through Go fmt.Sprintf),
so %% is two parameters, not an escaped percent. Postgres RAISE
uses single % for parameter substitution.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The ALTER TABLE and CREATE INDEX referenced vector(1536) outside the
exception-handling DO block, so when pgvector wasn't installed they
crashed the migration runner — blocking ALL E2E runs on main.
Fix: move all DDL inside the single DO block so the EXCEPTION handler
catches any pgvector-related failure and skips the entire migration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#790. Depends on feat/issue-583-1-checkpoint-persistence (PR #788).
Platform (Go) — checkpoints_integration_test.go (5 new tests):
1. ThreeStepPersistence: POST task_receive/llm_call/task_complete → GET returns
all 3 in step_index DESC order with correct names and payloads.
2. CrashResume_HighestStepIsResumptionPoint: POST steps 0+1 only (crash before
step 2) → GET shows step_index=1 as the resume point; task_complete absent.
3. UpsertIdempotency_LatestPayloadWins: POST same (wf_id, step_name) twice with
different payloads → List returns only the second payload (ON CONFLICT DO UPDATE).
4. PostCascadeDelete_Returns404: simulate post ON-DELETE-CASCADE state (empty
rows) → List returns 404 as expected after workspace deletion.
5. AuthGate_NoToken_Returns401: router-level test with WorkspaceAuth middleware;
POST/GET/DELETE all return 401 without a bearer token (no DB calls made).
workspace-template — _save_checkpoint + 4 Python tests:
- Add async _save_checkpoint() to temporal_workflow.py: POST to the platform
checkpoint endpoint after each activity stage; fully non-fatal (try/except
inside the function, plus defence-in-depth try/except at every call site).
- 4 new pytest cases (test_temporal_workflow.py):
- nonfatal_on_http_error: _save_checkpoint raises HTTPStatusError (500) →
task_receive_activity still returns {"status":"received"}.
- nonfatal_on_network_error: _save_checkpoint raises ConnectError →
llm_call_activity still returns success LLMResult.
- success_path: _save_checkpoint no-op → activity returns correctly;
checkpoint called with correct args.
- standalone_http_error_is_swallowed: real _save_checkpoint function swallows
HTTP 500 from a mocked httpx.AsyncClient; returns None.
All 36 temporal workflow Python tests pass.
Go tests: Go binary not in this container; test file verified for syntax and
against the sqlmock patterns used throughout the handlers package.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds step-level checkpoint storage so workflows can resume from the
last completed step after a crash or restart without replaying prior work.
- Migration: `workflow_checkpoints` table — workspace_id (FK + CASCADE),
workflow_id, step_name, step_index, completed_at, payload JSONB.
UNIQUE(workspace_id, workflow_id, step_name) + covering index on
(workspace_id, workflow_id, completed_at DESC).
- Handlers (platform/internal/handlers/checkpoints.go):
POST /workspaces/:id/checkpoints — upsert via ON CONFLICT DO UPDATE
GET /workspaces/:id/checkpoints/:wfid — list steps ordered step_index DESC
DELETE /workspaces/:id/checkpoints/:wfid — clear on clean shutdown (404 if none)
- Router: all three routes on the wsAuth group (WorkspaceAuth middleware);
workspace A's token cannot reach workspace B's checkpoints.
- Tests (11 cases, sqlmock + race-safe): upsert-insert, upsert-update,
payload forwarding, list-ordered, list-not-found, rows.Err() → 500,
delete-success, delete-not-found, callerMismatch 403 on all 3 endpoints.
Closes#788. Parent: #583-1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Post-mortem fix: UIUX Designer ran 22 cron fires over 23 hours with
every single response being empty or '(no response generated)'. The
scheduler reported status=ok because the HTTP call succeeded — nobody
caught it until the CEO asked.
Changes:
- Migration 032: adds consecutive_empty_runs INT to workspace_schedules
- scheduler.go: captures response body from ProxyA2ARequest (was _),
checks for empty/sentinel markers via isEmptyResponse(), increments
consecutive_empty_runs on empty ok responses, resets on non-empty.
When consecutive_empty_runs >= 3, sets last_status='stale' with a
descriptive error message.
The 'stale' status is surfaced via:
- GET /admin/schedules/health (merged in #671)
- PM's silence detector (companion fix in org-template PR)
- Maintenance loop response-body sampling (operator-side fix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rebase of feat/issue-576-pgvector-semantic-memory onto current main,
preserving the #767 security layer (globalMemoryDelimiter + GLOBAL audit
log) that predates this branch.
Changes layered on top of main:
- Migration 031: embedding vector(1536) column + ivfflat cosine-ops index
(renumbered from 029 — 029/030 were taken by workspace-hibernation and
audit-events)
- Commit: embed-on-write after INSERT, non-fatal on embedding failure
- Search: semantic cosine-distance path when EmbeddingFunc is wired up;
falls back to FTS/ILIKE; GLOBAL delimiter wrapping applies on both paths
- EmbeddingFunc injection pattern; WithEmbedding chainable builder
All security invariants preserved:
- globalMemoryDelimiter wrapping on GLOBAL scope in both semantic + FTS
- GLOBAL write audit log (SHA-256 forensic trail) in Commit
- TestRecallMemory_GlobalScope_HasDelimiter passes
- TestMemoriesCommit_Global_AsRoot passes
- 3 new pgvector tests pass
Co-authored-by: molecule-ai[bot] <276602405+molecule-ai[bot]@users.noreply.github.com>
Adds platform/internal/plugins/supply_chain_test.go with 8 tests (7 from
the spec + 1 end-to-end combo) specifying both security controls.
Control 1 — SHA256 content integrity (tests 1-3 + end-to-end):
Tests call VerifyManifestIntegrity(stagedDir string) error, which does
NOT exist yet → 5 compile errors / build failure until supply_chain.go
is written. Once stubbed to nil, SHA256Mismatch test fails at runtime.
VerifyManifestIntegrity contract:
- manifest.json absent → nil (backward compat)
- manifest.json present, no sha256 field → nil (backward compat)
- sha256 matches computed stagedDirDigest → nil
- sha256 mismatch → error mentioning "sha256"
stagedDirDigest algorithm (canonical, test + impl must agree):
Walk all files except manifest.json, sorted by rel path,
format each as "<rel>\x00<content>", concatenate, SHA256, hex.
Control 2 — Pinned-ref enforcement (tests 4-7):
Tests call GithubResolver.Fetch with/without "#ref" fragment.
Currently returns nil for bare refs → TestPluginInstall_UnpinnedRef_Rejected
fails (GitRunner IS called; no "pinned ref" in error message).
PLUGIN_ALLOW_UNPINNED=true escape hatch tested by test 7.
RED state summary (current):
go test ./internal/plugins/... -v -run TestPluginInstall
→ build failed: 5× undefined: VerifyManifestIntegrity
→ (with no-op stub) 2 runtime failures:
FAIL TestPluginInstall_SHA256Mismatch_AbortsInstall
FAIL TestPluginInstall_UnpinnedRef_Rejected
Backend Engineer implementation checklist:
[ ] Add supply_chain.go in package plugins with VerifyManifestIntegrity
[ ] Add pinned-ref gate to GithubResolver.Fetch in github.go
[ ] PLUGIN_ALLOW_UNPINNED=true check skips the gate
[ ] All 8 tests GREEN before merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add two defenses against malicious plugins from uncontrolled sources:
1. **Pinned-ref enforcement** (resolveAndStage): github:// install/download
specs without a #<tag/sha> suffix are now rejected with HTTP 422. A
mutable default-branch tip could change between audit and install,
silently swapping in untrusted code. Override via PLUGIN_ALLOW_UNPINNED=true.
2. **SHA-256 content integrity** (installRequest.sha256): callers may
supply the expected hex SHA-256 of the fetched plugin.yaml. When present,
resolveAndStage verifies the digest after staging; a mismatch aborts the
install with HTTP 422 and cleans up the staging dir.
Updated TestPluginDownload_GithubSchemeStreamsTarball to use a pinned ref
(#v1.0.0) so it reflects the new security requirement.
Tests: 4 new (TestPluginInstall_SHA256Mismatch_AbortsInstall,
TestPluginInstall_SHA256Match_Succeeds, TestPluginInstall_UnpinnedRef_Rejected,
TestPluginInstall_PinnedRef_Accepted). All 15 packages green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR #724 (workspace hibernation) claimed migration number 029.
Renaming to 030 to resolve the sequence collision before merging #651.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two defenses against GLOBAL-scope agent memory injection attacks:
1. Recall delimiter: Search() wraps every GLOBAL-scope memory value
with a non-instructable prefix before returning it to MCP clients:
[MEMORY id=<uuid> scope=GLOBAL from=<workspace_id>]: <value>
This prevents stored content (e.g. "IGNORE ALL PREVIOUS INSTRUCTIONS")
from being parsed as instructions in the agent's context window.
Raw DB content is unchanged — the wrapper is applied on read only.
2. Write audit log: Commit() writes an activity_log entry with
activity_type='memory_write_global' whenever a GLOBAL memory is
stored. The entry records a SHA-256 hash of the content (never
plaintext) alongside memory_id and namespace for forensic replay.
Audit failure is non-fatal — a logging error must not roll back
a successful write.
Tests:
- TestRecallMemory_GlobalScope_HasDelimiter — verifies exact delimiter
format [MEMORY id=... scope=GLOBAL from=...]: <value>
- TestCommitMemory_GlobalScope_AuditLogEntry — verifies activity_logs
INSERT fires on every GLOBAL write (via mock.ExpectationsWereMet)
- TestMemoriesCommit_Global_AsRoot — updated to expect the audit INSERT
All 16 Go test packages pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added an early guard in ProxyA2A() that rejects HTTP requests whose
X-Workspace-ID header passes isSystemCaller() with 403 Forbidden.
Legitimate system callers (webhooks, scheduler, restart_context) call
proxyA2ARequest() directly via ProxyA2ARequest() and never send HTTP
headers with system-caller prefixes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>