Compare commits

...

8 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) ce89cc17a1 fix(schedules): remove duplicate db and scheduler imports
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 11s
CI / Python Lint & Test (pull_request) Successful in 5s
Check migration collisions / Migration version collision check (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 6s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Failing after 43s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 46s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 11s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m17s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 6s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m24s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m31s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
gate-check-v3 / gate-check (pull_request) Failing after 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
qa-review / approved (pull_request) Failing after 14s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 11s
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m30s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 5m59s
CI / Canvas (Next.js) (pull_request) Successful in 17s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 13s
CI / Platform (Go) (pull_request) Failing after 1m53s
E2E Chat / E2E Chat (pull_request) Successful in 17s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
Harness Replays / Harness Replays (pull_request) Successful in 7s
CI / all-required (pull_request) Failing after 24m20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 1m47s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 2m51s
2026-05-27 04:53:18 +00:00
Molecule AI Dev Engineer A (Kimi) a18d13a351 fix(merge): remove duplicate imports from bad main-merge in #1676
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
audit-force-merge / audit (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 7s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
Check migration collisions / Migration version collision check (pull_request) Successful in 9s
CI / Python Lint & Test (pull_request) Successful in 6s
CI / Detect changes (pull_request) Successful in 10s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Chat / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 11s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Failing after 41s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 7s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 41s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m12s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 4s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m24s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m30s
qa-review / approved (pull_request) Failing after 11s
security-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m23s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m9s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m12s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 7m5s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Failing after 59s
E2E Chat / E2E Chat (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
CI / all-required (pull_request) Failing after 18m58s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 40s
Harness Replays / Harness Replays (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m5s
gate-check-v3 / gate-check (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 7s
sop-tier-check / tier-check (pull_request) Successful in 8s
The merge of main into test-1675-canvas-user-activity-log-regression
introduced duplicate internal/* import blocks in a2a_proxy.go and
a2a_proxy_helpers.go, causing compilation failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:01:20 +00:00
Molecule AI Dev Engineer A (Kimi) 432873d261 Merge branch 'main' into test-1675-canvas-user-activity-log-regression
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 10s
CI / Detect changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 37s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 13s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
gate-check-v3 / gate-check (pull_request) Successful in 4s
qa-review / approved (pull_request) Failing after 4s
security-review / approved (pull_request) Failing after 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Failing after 59s
E2E Chat / E2E Chat (pull_request) Successful in 9s
CI / all-required (pull_request) Failing after 4m5s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Failing after 38s
Harness Replays / Harness Replays (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 1m8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Successful in 5m20s
# Conflicts:
#	workspace-server/internal/handlers/a2a_proxy.go
#	workspace-server/internal/handlers/a2a_proxy_helpers.go
#	workspace-server/internal/handlers/schedules.go
2026-05-26 11:32:25 +00:00
Molecule AI Dev Engineer A (Kimi) 540222220a test(handlers): re-add ADMIN_TOKEN clears lost in rebase conflict resolution
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 8s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Detect changes (pull_request) Successful in 7s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 15s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 11s
Harness Replays / detect-changes (pull_request) Successful in 11s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 5s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 34s
gate-check-v3 / gate-check (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
sop-checklist / na-declarations (pull_request) N/A: (none)
qa-review / approved (pull_request) Failing after 16s
sop-checklist / review-refire (pull_request) Has been skipped
security-review / approved (pull_request) Failing after 9s
sop-checklist / all-items-acked (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m26s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
CI / all-required (pull_request) Failing after 40m2s
E2E Chat / E2E Chat (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m7s
CI / Platform (Go) (pull_request) Successful in 5m1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m21s
Rebase onto main resolved conflicts in test files by keeping HEAD,
but HEAD lacked the ADMIN_TOKEN hermeticity clears for:
  - admin_test_token_test.go (3 tests)
  - security_regression_685_686_687_688_test.go (2 tests)

Add the missing t.Setenv(\"ADMIN_TOKEN\", \"\") so these tests pass
in containers that set ADMIN_TOKEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:54:05 +00:00
Molecule AI Dev Engineer A (Kimi) 7f703b3feb refactor(workspace-server): extract requireCanCommunicate, flatten validateCallerToken
Simplify skill findings from the canvas-user A2A 403 regression fix:

- Extract the duplicated CanCommunicate+isSystemCaller+isCanvasUser gate
  into requireCanCommunicate() shared helper (used by a2a_proxy and
  ScheduleHealth). Eliminates copy-paste between the two call sites.

- Flatten nested conditionals in validateCallerToken: parse bearer token
  once at the top instead of twice (tokenless + tokened branches).
  Remove named return in favor of explicit bool/error returns.

- Remove now-unused registry import from a2a_proxy.go and schedules.go
  (moved to a2a_proxy_helpers.go where the shared helper lives).

All tests pass (41 packages, handlers 19.9s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:49:26 +00:00
Molecule AI Dev Engineer A (Kimi) ac61332098 test(handlers): make test suite hermetic in container env
The test container sets MOLECULE_ORG_ID and ADMIN_TOKEN, which caused
16 pre-existing test failures:

- MOLECULE_ORG_ID → saasMode() true → RFC-1918 private IPs allowed,
  breaking TestIsSafeURL_*, TestIsPrivateOrMetadataIP_*, and
  TestValidateAgentURL/blocked_RFC1918 subtests.
- MOLECULE_ORG_ID → saasMode() true → issueAndInjectToken returns early
  without injecting .auth_token, breaking TestIssueAndInjectToken_*.
- ADMIN_TOKEN → AdminAuth requires bearer token, breaking
  TestAdminTestToken_*, TestSecurity_GetTemplates_*, and
  TestSecurity_GetOrgTemplates_*.

Fix: add t.Setenv(\"MOLECULE_ORG_ID\", \"\") and/or t.Setenv(\"ADMIN_TOKEN\", \"\")
to each affected test so they run in a predictable strict-mode / no-admin
environment regardless of container configuration.

Files changed:
- admin_test_token_test.go
- mcp_test.go
- registry_test.go
- security_regression_685_686_687_688_test.go
- workspace_provision_test.go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:49:12 +00:00
Molecule AI Dev Engineer A (Kimi) f8c64fadb6 fix(workspace-server): #1674 canvas-user callerID 403 regression + unskip #1675 test
Cherry-picks the fix from fix/memory-list-rows-err onto the #1675 regression
branch and unskips TestProxyA2A_PollMode_CanvasUserCallerID_PropagatesToActivityLog.

validateCallerToken now detects canvas-user identity callers via:
- same-origin canvas requests (IsSameOriginCanvas)
- admin token bearer (ADMIN_TOKEN env)
- org token bearer (orgtoken.Validate)

Canvas-user callers bypass registry.CanCommunicate hierarchy checks,
restoring pre-RFC#637 behaviour where canvas chat messages were not
blocked by workspace hierarchy rules.

Files changed:
- a2a_proxy.go: propagate isCanvasUser through proxyA2ARequest
- a2a_proxy_helpers.go: detect canvas users in validateCallerToken
- a2a_proxy_test.go: unskip #1675 test, add HasAnyLiveToken + ADMIN_TOKEN mocks
- a2a_queue.go, delegation.go, webhooks.go: pass isCanvasUser=false
- schedules.go: handle canvas users in ScheduleHealth

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:48:13 +00:00
cp-be aaeb0411b2 test(workspace-server): #1675 regression test — canvas-user callerID propagation
Pin the contract that broke in molecule-core#1675: when canvas chat sends
a message to a poll-mode workspace, the resulting POST /workspaces/:id/a2a
MUST write an activity_logs row whose source_id equals the canvas user's
identity workspace UUID — so (a) the channel plugin's poll path can
surface the message to the bound Claude Code session, and (b) chat-history
re-renders the user's own message on canvas reopen.

Empirical root cause uncovered by running this test against current main:
`proxyA2ARequest` rejects canvas-user callers with 403 `access denied:
workspaces cannot communicate per hierarchy rules` BEFORE reaching the
poll-mode short-circuit (the `logA2AReceiveQueued` call site).

Pre-RFC#637 the guard at proxy_a2a.go:359 short-circuited because canvas
callerID was empty:
    if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) {
RFC#637 populated callerID with the canvas-user identity workspace UUID,
making the guard fall through into `registry.CanCommunicate(canvasUserWS,
targetWS)` — which returns false because canvas-user identity workspaces
have no parent/sibling relationship with arbitrary target workspaces
(they represent the *human user*, not a peer agent). Every canvas chat
send to a poll-mode workspace silently 403s before LogActivity can run,
the bound Claude Code session loses the message, and chat-history breaks.

Test is skipped (t.Skip) until the fix lands at proxy_a2a.go:359 — the
hierarchy bypass needs to extend to canvas-user identity callers,
analogous to isSystemCaller. Likely implementation: an
`isCanvasUserCaller(ctx, callerID)` helper that queries the workspaces
table for the canvas-user marker (the exact column / value combination
needs platform team input — `runtime`, `role`, or an `is_canvas_user`
bit). When the fix lands, the skip is removed and this test gates
regression.

Per CTO directive 2026-05-22 ("all bugs found should have test
coverage") — the test exists to PIN the contract before the fix lands
so the same regression class cannot silently recur after RFC#637-shaped
schema changes.

Empirical evidence in molecule-core#1675:
- Tenant 30ba7f0b had 3+ hours of silent canvas-message loss while
  peer-agent A2A (PM→CEO_Assistant) kept arriving correctly.
- Direct query of activity_logs confirms no rows for canvas sends
  after 02:43:50Z; bot polls + cursor advances correctly.
- The 403 from CanCommunicate is silent (only stderr log line), so
  the canvas FE sees the queued bubble and the failure is invisible.

Related:
- molecule-core#1675 — the bug
- internal#471 — logA2AReceiveQueued must be synchronous (this PR's
  failure mode means the synchronous write never reaches the table)
- RFC#637 — canvas-user identity capture (the schema change that
  unmasked this bug)
- feedback_no_dev_only_routes_in_e2e — once the fix lands, follow up
  with a true E2E that hits production /workspaces/:id/a2a through
  the canvas FE's actual auth path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:48:13 +00:00
8 changed files with 157 additions and 36 deletions
@@ -19,7 +19,6 @@ import (
"strconv"
"strings"
"time"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/envx"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
@@ -367,14 +366,8 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
// are trusted. Self-calls (callerID == workspaceID) are always allowed.
// Post-RFC#637: canvas-user identity workspaces also bypass CanCommunicate
// because human users sit outside the org hierarchy.
if callerID != "" && callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ProxyA2A: access denied %s → %s", callerID, workspaceID)
return 0, nil, &proxyA2AError{
Status: http.StatusForbidden,
Response: gin.H{"error": "access denied: workspaces cannot communicate per hierarchy rules"},
}
}
if proxyErr := requireCanCommunicate(callerID, workspaceID, isCanvasUser, "ProxyA2A"); proxyErr != nil {
return 0, nil, proxyErr
}
// Budget enforcement: reject A2A calls when the workspace has exceeded its
@@ -14,12 +14,12 @@ import (
"os"
"strconv"
"time"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/middleware"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/orgtoken"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/registry"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/wsauth"
"github.com/gin-gonic/gin"
)
@@ -31,6 +31,23 @@ type proxyDispatchBuildError struct{ err error }
func (e *proxyDispatchBuildError) Error() string { return e.err.Error() }
// requireCanCommunicate returns a proxyA2AError when callerID is not allowed to
// reach workspaceID. Self-calls, system callers, and canvas users are permitted
// without a registry check.
func requireCanCommunicate(callerID, workspaceID string, isCanvasUser bool, logPrefix string) *proxyA2AError {
if callerID == "" || callerID == workspaceID || isSystemCaller(callerID) || isCanvasUser {
return nil
}
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("%s: access denied %s → %s", logPrefix, callerID, workspaceID)
return &proxyA2AError{
Status: http.StatusForbidden,
Response: gin.H{"error": "access denied: workspaces cannot communicate per hierarchy rules"},
}
}
return nil
}
// handleA2ADispatchError translates a forward-call failure into a proxyA2AError,
// runs the reactive container-health check, and records the outcome. Busy
// targets that are successfully queued are logged as queued, not failed.
@@ -432,7 +449,7 @@ func nilIfEmpty(s string) *string {
//
// On auth failure this writes the 401 via c and returns an error so the
// handler aborts without running the proxy.
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (isCanvasUser bool, err error) {
func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (bool, error) {
hasLive, dbErr := wsauth.HasAnyLiveToken(ctx, db.DB, callerID)
if dbErr != nil {
// Fail-open here matches the heartbeat path — A2A caller auth is
@@ -442,25 +459,28 @@ func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) (
log.Printf("wsauth: caller HasAnyLiveToken(%s) failed: %v — allowing A2A", callerID, dbErr)
return false, nil
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if !hasLive {
// Tokenless workspace — could be legacy/pre-upgrade caller or
// canvas-user identity. Distinguish by request auth signals.
if middleware.IsSameOriginCanvas(c) {
return true, nil
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok != "" {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret != "" && subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
return true, nil
}
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
return true, nil
}
if tok == "" {
return false, nil // legacy / pre-upgrade caller
}
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret != "" && subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
return true, nil
}
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
return true, nil
}
return false, nil // legacy / pre-upgrade caller
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
if tok == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing caller auth token"})
return false, errInvalidCallerToken
@@ -2341,6 +2341,106 @@ func TestProxyA2A_PollMode_ShortCircuits_NoSSRF_NoDispatch(t *testing.T) {
}
}
// TestProxyA2A_PollMode_CanvasUserCallerID_PropagatesToActivityLog pins
// the specific contract that broke in molecule-core#1675 (2026-05-22):
// canvas chat messages from a user with an identity workspace (RFC#637
// canvas-user-identity rollout) MUST write an activity_logs row whose
// source_id matches the canvas user's workspace UUID, NOT NULL — so the
// channel plugin's poll path can deliver them as `<channel kind="canvas_user">`
// tags to the bound Claude Code session, AND the canvas chat-history can
// re-render the user's own message on reopen.
//
// The sibling test TestProxyA2A_PollMode_ShortCircuits_NoSSRF_NoDispatch
// covers the legacy "no callerID" path (anonymous canvas without RFC#637).
// THIS test covers the post-RFC#637 path with an explicit X-Workspace-ID
// header naming the canvas user's identity workspace.
//
// Empirical trigger: Hongming's tenant 30ba7f0b had 3+ hours of silent
// canvas-message loss while peer-agent A2A (PM→CEO_Assistant) kept
// arriving — the breakage was specific to canvas user → target workspace
// routing. The fix MUST ensure logA2AReceiveQueued runs synchronously
// before the queued 200, and source_id is populated from callerID.
func TestProxyA2A_PollMode_CanvasUserCallerID_PropagatesToActivityLog(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
const targetWS = "ws-canvas-target-1675"
const canvasUserWS = "344a2623-50bf-4ab9-9732-220779305c8f" // shape from #1675 evidence
// Post-fix (PR #1756): validateCallerToken checks whether the caller
// workspace has live tokens. Canvas-user identity workspaces are
// tokenless, so they fall through to the admin/org-token detection
// path. We set ADMIN_TOKEN + Authorization so the caller is identified
// as a canvas user and CanCommunicate is bypassed.
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(canvasUserWS).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
expectBudgetCheck(mock, targetWS)
t.Setenv("ADMIN_TOKEN", "test-admin-secret-1675")
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(targetWS).
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("poll"))
// CRITICAL: the activity_logs INSERT MUST happen, and its source_id
// argument MUST match the canvas user's workspace UUID. The previous
// behaviour (sqlmock.ExpectExec with no WithArgs) accepted any args —
// which is exactly how the regression in #1675 escaped CI: the INSERT
// fired, but with source_id=NULL because callerID propagation was
// bypassed somewhere upstream. Pin the source_id position explicitly.
mock.ExpectQuery("SELECT name FROM workspaces WHERE id").
WithArgs(targetWS).
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Canvas Target"))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(
targetWS, // workspace_id
"a2a_receive", // activity_type
canvasUserWS, // source_id (NOT NULL — the contract this test exists to pin)
targetWS, // target_id
"message/send", // method
sqlmock.AnyArg(), // summary
sqlmock.AnyArg(), // request_body
sqlmock.AnyArg(), // response_body (nil for queued)
sqlmock.AnyArg(), // tool_trace
sqlmock.AnyArg(), // duration_ms
"ok", // status
sqlmock.AnyArg(), // error_detail
).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: targetWS}}
// X-Workspace-ID is the canonical way canvas Next.js identifies the
// signed-in user's identity workspace to the platform (per RFC#637).
c.Request = httptest.NewRequest("POST", "/workspaces/"+targetWS+"/a2a",
bytes.NewBufferString(`{"jsonrpc":"2.0","id":"canvas-1","method":"message/send","params":{"message":{"role":"user","parts":[{"text":"hello from canvas"}]}}}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("X-Workspace-ID", canvasUserWS)
c.Request.Header.Set("Authorization", "Bearer test-admin-secret-1675")
handler.ProxyA2A(c)
time.Sleep(50 * time.Millisecond)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 queued, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp["status"] != "queued" {
t.Errorf("response.status = %v, want %q", resp["status"], "queued")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations — the activity INSERT may have been skipped OR fired with a different source_id (the #1675 regression shape): %v", err)
}
}
// TestProxyA2A_PushMode_NoShortCircuit verifies the symmetric contract:
// a push-mode workspace (default) is NOT affected by the new short-circuit.
// It still proceeds to resolveAgentURL + dispatch. Without this guard, a
@@ -1146,7 +1146,7 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://10.0.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
@@ -1155,7 +1155,7 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
func TestIsSafeURL_Blocks172Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://172.16.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
@@ -1164,7 +1164,7 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://192.168.1.100/agent")
if err == nil {
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
@@ -1189,7 +1189,7 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1200,7 +1200,7 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1211,7 +1211,7 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -713,7 +713,7 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
func TestValidateAgentURL(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
cases := []struct {
name string
url string
@@ -9,7 +9,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/registry"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/scheduler"
@@ -472,7 +471,7 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
// Skip for system callers and self-calls, same as the A2A proxy.
// Post-RFC#637: canvas users may read schedule health too.
isCanvasUser := false
if !isSystemCaller(callerID) && callerID != workspaceID {
if callerID != workspaceID && !isSystemCaller(callerID) {
var err error
isCanvasUser, err = validateCallerToken(ctx, c, callerID)
if err != nil {
@@ -482,12 +481,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
// CanCommunicate gate — only peers in the org hierarchy may read health.
// Canvas users (human operators) bypass this gate.
if callerID != workspaceID && !isSystemCaller(callerID) && !isCanvasUser {
if !registry.CanCommunicate(callerID, workspaceID) {
log.Printf("ScheduleHealth: access denied %s → %s", callerID, workspaceID)
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}
if proxyErr := requireCanCommunicate(callerID, workspaceID, isCanvasUser, "ScheduleHealth"); proxyErr != nil {
c.JSON(proxyErr.Status, proxyErr.Response)
return
}
rows, err := db.DB.QueryContext(ctx, `
@@ -96,6 +96,7 @@ func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -154,6 +155,7 @@ func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -840,6 +840,8 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
// into cfg.ConfigFiles[".auth_token"].
func TestIssueAndInjectToken_HappyPath(t *testing.T) {
mock := setupTestDB(t)
// Hermetic: container may have MOLECULE_ORG_ID set (SaaS mode skips injection).
t.Setenv("MOLECULE_ORG_ID", "")
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
@@ -879,6 +881,8 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
// issuing a fresh one so we never accumulate stale live tokens in the DB.
func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
mock := setupTestDB(t)
// Hermetic: container may have MOLECULE_ORG_ID set (SaaS mode skips injection).
t.Setenv("MOLECULE_ORG_ID", "")
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
@@ -924,6 +928,8 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
// live token that the old file might accidentally present.
func TestIssueAndInjectToken_RevokeFailSkipsInjection(t *testing.T) {
mock := setupTestDB(t)
// Hermetic: container may have MOLECULE_ORG_ID set (SaaS mode skips injection).
t.Setenv("MOLECULE_ORG_ID", "")
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -947,6 +953,8 @@ func TestIssueAndInjectToken_RevokeFailSkipsInjection(t *testing.T) {
// IssueToken also skips injection without panicking.
func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
mock := setupTestDB(t)
// Hermetic: container may have MOLECULE_ORG_ID set (SaaS mode skips injection).
t.Setenv("MOLECULE_ORG_ID", "")
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
@@ -975,6 +983,8 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
// ConfigFiles map is allocated before the token is written.
func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
mock := setupTestDB(t)
// Hermetic: container may have MOLECULE_ORG_ID set (SaaS mode skips injection).
t.Setenv("MOLECULE_ORG_ID", "")
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")