Compare commits

..

35 Commits

Author SHA1 Message Date
fullstack-engineer d79a4bd2bf fix(platform): A2A proxy ResponseHeaderTimeout 60s → 180s default, env-configurable
sop-tier-check / tier-check (pull_request) Failing after 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Has been skipped
Issue #310: platform a2a-proxy logs ~300/hr
`timeout awaiting response headers` because ResponseHeaderTimeout was hardcoded
to 60s. Opus agent turns (big context + internal delegate_task round-trips)
routinely exceed 60s, so the proxy gave up before headers arrived even when
the workspace agent was healthy.

Changes:
- workspace-server/internal/handlers/a2a_proxy.go: ResponseHeaderTimeout:
  60s hardcoded → envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180s).
  180s gives Opus turns comfortable headroom. The X-Timeout caller header
  still bounds the absolute request ceiling independently.
- a2a_proxy_test.go: TestA2AClientResponseHeaderTimeout verifies the 180s
  default and env-override parsing logic.

Note: Go tests not run locally (Go toolchain not available in this environment).
CI will validate on push.

Closes #310.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 13:19:25 +00:00
infra-lead de9f46ea30 Merge pull request '[release-blocker] fix(ci): retry git clone in clone-manifest.sh (publish-workspace-server-image OOM flake)' (#298) from fix/publish-workspace-server-ci-clone-manifest-retry into staging
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-10 12:44:35 +00:00
infra-lead 7ff5622a42 [infra-lead-agent] fix(ci): retry git clone in clone-manifest.sh (publish-workspace-server-image flake)
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Failing after 1s
audit-force-merge / audit (pull_request) Failing after 2s
The publish-workspace-server-image / build-and-push job clones the full
manifest (~36 repos) serially in the "Pre-clone manifest deps" step on a
memory-constrained Gitea Actions runner. Under host memory pressure the
OOM killer SIGKILLs git-remote-https mid-clone:

  cloning .../molecule-ai-plugin-molecule-skill-code-review.git ...
  error: git-remote-https died of signal 9
  fatal: the remote end hung up unexpectedly
    Failure - Main Pre-clone manifest deps
  exitcode '128': failure

Observed in run 4622 (2026-05-10, staging HEAD b5d2ab88) — died on the
14th of 36 clones, which red-lights CI and wedges staging→main.

Wrap each `git clone` in clone-manifest.sh with bounded retry + backoff
(3 attempts, 3s/6s), wiping any partial checkout between tries. A single
transient SIGKILL / network blip no longer fails the whole tenant image
rebuild. Benefits every caller of the script (publish-workspace-server-image,
harness-replays, Dockerfile builds, local quickstart).

This is a mitigation; the durable fix is more runner RAM/swap on the
operator host — tracked separately with Infra-SRE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:58:09 +00:00
integration-tester b5d2ab88a6 Merge pull request 'fix(canvas): toYaml always emits tools:[] and serializes nested lists (RECHECK)' (#292) from fix/canvas-yaml-utils-nested-arrays-clean into main
publish-workspace-server-image / build-and-push (push) Failing after 32s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 34s
2026-05-10 11:27:37 +00:00
fullstack-engineer 9abbe82b15 fix(canvas): toYaml always emits tools: [] and serializes nested lists
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 17s
audit-force-merge / audit (pull_request) Successful in 14s
Two bugs in yaml-utils.ts toYaml():

1. tools: [] was only emitted when config.tools.length > 0,
   but the test asserts it's always present. Add blank-line
   separator + unconditional list("tools", ...) so MINIMAL_CONFIG
   with tools: [] renders correctly.

2. Nested list values (e.g. runtime_config.required_env: [KEY])
   were serialized as "  required_env: KEY" (stringification of the
   array) instead of a YAML list block. Fix obj() to detect
   Array.isArray(sv) and emit a list block with 4-space indent.

Closes #269.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:05:02 +00:00
claude-ceo-assistant 5ecec3f253 Merge pull request 'fix(a2a): reject delegate_task to your own workspace ID (self-deadlock guard)' (#291) from fix/self-delegation-guard into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
2026-05-10 10:53:18 +00:00
claude-ceo-assistant f58a11d171 Merge pull request 'fix(runtime): MODEL_PROVIDER env is misnamed — accept MODEL/MOLECULE_MODEL, deprecate legacy name' (#280) from fix/model-provider-misnomer into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
2026-05-10 10:52:40 +00:00
claude-ceo-assistant bc555aeb45 Merge pull request 'fix(provisioner): export MOLECULE_MODEL canonical env + read it first; drop stray brace in delegation_test.go' (#286) from fix/molecule-model-env-go into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 6s
publish-workspace-server-image / build-and-push (push) Successful in 1m8s
2026-05-10 10:52:22 +00:00
hongming-pc2 31ed137b74 fix(a2a): reject delegate_task / delegate_task_async to your own workspace ID
sop-tier-check / tier-check (pull_request) Failing after 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
audit-force-merge / audit (pull_request) Successful in 5s
Self-delegation deadlocks: the sending turn holds `_run_lock`, the receive
handler waits for the same lock, the A2A request 30s-times-out, and the
whole cycle is wasted (the Dev Lead system prompt warns agents off this by
hand — "Never delegate_task to your own workspace ID … there is no peer who
is also you"). The platform/runtime had no guard. Now both
`tool_delegate_task` and `tool_delegate_task_async` early-return an
actionable error when `workspace_id == effective_source` (`source_workspace_id
or _peer_to_source[target] or WORKSPACE_ID`) — before `discover_peer`, so no
network round-trip is wasted either. A genuinely different target (incl.
another of a multi-workspace agent's own registered workspaces) is
unaffected.

Tests: tests/test_a2a_tools_delegation.py — new TestSelfDelegationGuard (4
cases: rejects own ID; rejects when source_workspace_id explicitly == target;
async path rejects; a different target passes the guard through to
discover_peer). `pytest tests/test_a2a_tools_delegation.py` → 12 passed.
(tests/test_a2a_tools_impl.py's TestToolDelegateTask* suite is red on this
PC2/Windows checkout — same on `main` without this change; httpx-mock infra,
not this PR — CI validates on Linux.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:46:59 -07:00
core-lead 79ced2e701 Merge pull request 'fix(a2a): handle string error in a2a_tools + remove dead staging trigger' (#281) from fix/a2a-tools-and-workflow-cleanup into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 23s
publish-workspace-server-image / build-and-push (push) Successful in 3m26s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Failing after 5s
audit-force-merge / audit (pull_request) Has been skipped
[core-lead-agent] PR #281 merged — handles string-form errors in a2a_tools.delegate_task (was raising AttributeError on every delegation through legacy path), fixes empty-parts dict regression (#279), and drops the dead staging branch trigger from both publish workflows. Replaces the abandoned PR #268 + #277. Integration Tester unblocked for mesh recovery validation.
2026-05-10 10:14:28 +00:00
core-lead fe1b3d9a82 Merge branch 'main' into fix/a2a-tools-and-workflow-cleanup
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
sop-tier-check / tier-check (pull_request) Successful in 25s
audit-force-merge / audit (pull_request) Successful in 17s
2026-05-10 10:12:50 +00:00
hongming-pc2 9b930d8e39 fix(provisioner): export MOLECULE_MODEL (canonical model env) + read it first; drop stray brace in delegation_test.go
sop-tier-check / tier-check (pull_request) Failing after 17s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
audit-force-merge / audit (pull_request) Successful in 6s
internal#226 follow-up #1. `molecule_runtime.config` resolves the picked
model as `MOLECULE_MODEL` > `MODEL` > (legacy) `MODEL_PROVIDER` (#280) —
this side of the boundary now matches:

  - applyRuntimeModelEnv reads `MOLECULE_MODEL` ahead of `MODEL` /
    `MODEL_PROVIDER`, and exports BOTH `MOLECULE_MODEL` and `MODEL`
    (the latter kept for back-compat with everything that already reads
    `os.environ["MODEL"]`). So a workspace whose secrets carry
    `MOLECULE_MODEL` (the unambiguous name) is honoured, and the
    `MODEL_PROVIDER` misnomer — which got set to provider slugs
    ("minimax") and even runtime names ("claude-code") — is the lowest-
    priority fallback, exactly as on the runtime side.
  - the resolution-order comment is updated to flag MODEL_PROVIDER as the
    legacy-and-misleadingly-named var.

Also drops a stray trailing `}` in delegation_test.go (committed in
97768272 "test(delegation): add isDeliveryConfirmedSuccess helper") that
made `internal/handlers` fail to parse — one of the things keeping the
package from compiling for tests.

Tests: TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes extended
to assert MOLECULE_MODEL mirrors MODEL on every case, plus two new cases
(MOLECULE_MODEL env fallback; MOLECULE_MODEL beats MODEL_PROVIDER). Could
not run `go test ./internal/handlers/` locally — the package is still
blocked behind `internal/plugins` `SourceResolver` redeclaration (the
#248 plugin-router/resolver refactor, Core-BE's lane); CI validates once
that lands. The applyRuntimeModelEnv change is mechanical (same shape as
the existing `MODEL` handling) — reviewer please eyeball.

Companion: molecule-core#280 (runtime config.py side), molecule-ai-workspace-template-claude-code#14 (CLI-stream-error surfacing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 03:11:41 -07:00
core-lead 7c1a595776 Merge pull request 'docs(workspace-runtime): document Playwright/browser dep absence' (#275) from infra/runtime-doc-playwright-limitation into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 11s
[core-lead-agent] Docs merged. Playwright/Chromium dep absence in workspace-runtime base image documented; recommends CI for E2E.
2026-05-10 10:06:57 +00:00
core-lead a94382e86b Merge branch 'main' into infra/runtime-doc-playwright-limitation
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Successful in 16s
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-10 10:06:04 +00:00
core-lead bea6d25543 Merge pull request 'fix(a2a): handle push-mode queue envelope in response parser' (#278) from fix/a2a-push-mode-queue-envelope into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 15s
[core-lead-agent] Push-mode queue envelope parser merged. queued:true shape handled before poll-mode case in a2a_response.py.
2026-05-10 10:05:48 +00:00
core-lead d9f484874a Merge branch 'main' into infra/runtime-doc-playwright-limitation
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 6s
2026-05-10 10:04:47 +00:00
core-lead d98a547af2 Merge branch 'main' into fix/a2a-push-mode-queue-envelope
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 19s
2026-05-10 10:04:45 +00:00
core-lead e9b972d86a Merge pull request 'fix(mcp): scrub err.Error() from JSON-RPC error messages (OFFSEC-001)' (#267) from fix/offsec-001-error-message-scrubbing into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Successful in 1m9s
[core-lead-agent] OFFSEC-001 scrub merged. err.Error() removed from 3 JSON-RPC error sites in mcp.go; full error logged server-side. Defence-in-depth on auth-required paths.
2026-05-10 10:03:10 +00:00
core-lead a8074705a5 Merge branch 'main' into infra/runtime-doc-playwright-limitation
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 16s
2026-05-10 10:01:51 +00:00
core-lead 555c474cbe Merge branch 'main' into fix/a2a-push-mode-queue-envelope
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 16s
2026-05-10 10:01:47 +00:00
core-lead cc4d7fc2c1 Merge branch 'main' into fix/offsec-001-error-message-scrubbing
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
sop-tier-check / tier-check (pull_request) Successful in 10s
audit-force-merge / audit (pull_request) Successful in 6s
2026-05-10 10:01:43 +00:00
integration-tester e647efe7c5 fix(a2a): handle string error in a2a_tools.py + remove dead staging trigger
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 38s
Two-part fix from PR #268 (ported by Integration Tester after PR #268
was closed without merge):

PART 1 — workspace/builtin_tools/a2a_tools.py: Fixes AttributeError
when platform returns a plain string as the error field. Before:
  data["error"].get("message")  ← crashes if error is a string
After:
  isinstance(err, dict) → err.get("message")
  isinstance(err, str)  → use err directly
  otherwise              → str(err)

Also guards result.get("parts") against non-dict result.
Includes fix for issue #279: empty-parts regression where
{"parts": []} returned "(no text)" instead of str(result).

PART 2 — .gitea/workflows/ and .github/workflows/
publish-workspace-server-image.yml: Removed dead "staging" branch
trigger. Trunk-based migration (2026-05-08) removed the staging branch
but the workflow triggers were not updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:52:36 +00:00
core-lead 677d826126 Merge pull request 'fix(core#228): make main compile — PluginResolver + plgh + dockerCli ordering' (#256) from fix/core-248-pluginresolver-and-plgh into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Successful in 1m53s
[core-lead-agent] Merging PR #256 (5 commits) — restores main build for Release Manager promotion.

- d88a320f core-be: SourceResolver→PluginResolver rename + SSRF guard + restart_signals method conversion
- 70f84823 core-be: router plgh ordering fix
- 9e3d4203 core-lead: cascade — PluginResolver return type, *Registry assertion, dockerCli ordering, Setup signature, drift_sweeper_test stub, go.sum gh-identity
- 14e3956d merge main

Local verify: go build ./... ✓, go vet ./... ✓ (only pre-existing org_external warning), plugins+router tests ✓.

Follow-up: 6 pre-existing handler test failures (TestExecuteDelegation_*, TestHandleDiagnose_*) surface now that the package compiles — Core-BE follow-up issue forthcoming.
2026-05-10 09:52:26 +00:00
Molecule AI Core Platform Lead 14e3956d8a Merge branch 'main' into fix/core-248-pluginresolver-and-plgh
sop-tier-check / tier-check (pull_request) Failing after 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
audit-force-merge / audit (pull_request) Has been skipped
2026-05-10 09:51:14 +00:00
Molecule AI Core Platform Lead 9e3d420363 [core-lead-agent] fix(core#228): cascade fixes for PluginResolver — make main compile
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 4s
PR #256 introduced PluginResolver to break the SourceResolver redeclaration
deadlock, but missed three downstream call-sites that left main uncompilable:

1. plugins/drift_sweeper.go: PluginResolver.Resolve was declared returning
   PluginResolver (recursive). *Registry.Resolve returns the production
   SourceResolver from source.go, so *Registry didn't satisfy PluginResolver.
   Fix: Resolve returns SourceResolver. Add compile-time assertion that
   *Registry satisfies PluginResolver so any future signature drift fails
   the build instead of router wiring.

2. plugins/drift_sweeper_test.go: stubResolver was still declared with the
   old SourceResolver shape AND asserted against SourceResolver — the
   assertion failed because stubResolver lacks Scheme()/Fetch(). Fix: stub
   is a PluginResolver; assertion targets PluginResolver. Drop the unused
   "database/sql" import that fails go vet.

3. router/router.go:
   - The 70f84823 reorder moved the plgh init block above its dockerCli
     dependency (line 538 used; line 594 declared). Moved the dockerCli
     declaration up so it's available where used; replaced the orphaned
     declaration in the terminal block with a comment.
   - Setup's pluginResolver param was typed plugins.SourceResolver — wrong
     for *plugins.Registry (Registry is not a per-scheme resolver). Retyped
     to plugins.PluginResolver, which *Registry actually satisfies.
   - Removed the broken `plgh.WithSourceResolver(pluginResolver)` call —
     WithSourceResolver expects a per-scheme SourceResolver, not a
     PluginResolver/registry. plgh has its own internal default registry
     (github+local) from NewPluginsHandler, so dropping the call is
     functionally a no-op vs the broken state. Kept the param so the
     drift sweeper (main.go) can share scheme enumeration when needed.

4. go.sum: add the content hash entry for go.moleculesai.app/plugin/
   gh-identity/pluginloader (only the /go.mod hash was present, breaking
   `go build ./cmd/server`).

Verified locally:
  go build ./...           ✓
  go vet ./...             ✓ (only pre-existing org_external append warning)
  go test ./internal/plugins/...  ✓
  go test ./internal/router/...   ✓

6 pre-existing handler test failures (TestExecuteDelegation_*,
TestHandleDiagnose_*) are orthogonal — they did not run before because the
package didn't compile. Out of scope for this fix; tracking separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:46:35 +00:00
hongming-pc2 2ba3af5330 fix(runtime): MODEL_PROVIDER env is misnamed — accept MODEL/MOLECULE_MODEL, deprecate the legacy name
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Failing after 16s
audit-force-merge / audit (pull_request) Successful in 8s
`molecule_runtime.config.load_config` read the `MODEL_PROVIDER` env var as
the *picked model id* — despite the name, it never carried the provider
(that's `LLM_PROVIDER` / the YAML `provider:` field). So `claude-code`,
`minimax`, and `opus` were all "valid" values for a var named
MODEL_PROVIDER. That footgun bit the dev-team rollout (2026-05-10): the
lead persona env files set `MODEL=claude-opus-4-7` (the intended model)
*and* `MODEL_PROVIDER=claude-code` (mistaking it for "the runtime"); the
loader picked up MODEL_PROVIDER → the claude CLI got `--model claude-code`
→ 404 on every turn, surfaced only as "Command failed with exit code 1"
with empty stderr (the real error is in the stream-json stdout, swallowed
by the SDK's placeholder). The 22 IC workspaces "worked" only because
their `MODEL_PROVIDER=minimax` happened to fuzzy-match on MiniMax's side —
they were actually running `--model minimax`, not `MiniMax-M2.7-highspeed`.

New precedence in `_picked_model_from_env`: `MOLECULE_MODEL` (canonical,
unambiguous) > `MODEL` (the obviously-correct name, already plumbed by
workspace-server's applyRuntimeModelEnv) > `MODEL_PROVIDER` (legacy —
still honored so canvas Save+Restart, the secret-mint path, and existing
persona env files keep working, but if it's the only one set we log a
one-time deprecation pointing at the misnomer) > the YAML `model:` field.
Applied at both the top-level `model` and `runtime_config.model`
resolution sites; semantics are otherwise unchanged. Bonus: workspaces
that already set `MODEL` correctly now get exactly that model instead of
whatever fuzzy-match the upstream did with the provider slug.

Tests: 5 new cases in test_config.py (MODEL beats MODEL_PROVIDER;
MOLECULE_MODEL beats MODEL; MODEL overrides YAML; legacy MODEL_PROVIDER
still resolves + warns; no warning when MODEL is set) + an autouse
fixture that clears MODEL*/resets the warn-latch so resolution is
deterministic regardless of the CI env or test order. `pytest
tests/test_config.py` — 66 passed; the config-importing suites
(test_preflight, test_skills_loader) — 129 passed.

Companion: molecule-dev-department PR #10 fixes the six dev-team lead
`workspace.yaml`s from `model: MiniMax-M2.7` to `model: opus`. Follow-ups
(not in scope here): plumb `MOLECULE_MODEL` from applyRuntimeModelEnv and
the canvas; strip `MODEL`/`MODEL_PROVIDER` from the operator-host persona
env files once the org-template `model:` field is authoritative end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:38:14 -07:00
integration-tester 736d9959bc fix(a2a): handle push-mode queue envelope in response parser
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 46s
sop-tier-check / tier-check (pull_request) Successful in 11s
When a push-mode workspace (one with a public URL) is at capacity, the
platform queues the delegation request and returns:

    {"queued": true, "message": "...", "queue_depth": N, "queue_id": "..."}

The existing SSOT parser (a2a_response.py) only handled the poll-mode
envelope (status=queued + delivery_mode=poll). Push-mode queue
responses fell through to Malformed, causing send_a2a_message to log a
warning and return an error — even though delivery was actually queued
successfully.

Fix: add handling for data.get("queued") is True as a Queued variant
with delivery_mode="push". Checked before the poll-mode envelope so the
two cases are mutually exclusive.

Fixes observed 2026-05-10: platform returning push-mode queue
envelopes to Integration Tester when Release Manager workspace was at
capacity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:28:51 +00:00
infra-lead faa0ccf40f [infra-lead-agent] docs(workspace-runtime): document Playwright/browser dep absence
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 42s
sop-tier-check / tier-check (pull_request) Successful in 12s
Adds a Known Limitations section to docs/agent-runtime/workspace-runtime.md
explaining that the base molecule-ai-workspace-runtime image intentionally
omits Chromium system libs (libnss3, libatk-bridge2.0-0, libxkbcommon0, etc.)
to keep the shared image lean for every workspace role.

Records the recommended workflow (E2E in CI on the Gitea Actions self-hosted
runner) and points future role-specific QA/FE templates at layering
playwright install-deps on top of the base image rather than baking it in.

Closes the documentation half of molecule-ai/molecule-app#7.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:20:17 +00:00
claude-ceo-assistant 3c0d00b43f Merge pull request 'fix(internal#214): refresh go.sum for the go.moleculesai.app vanity path' (#247) from fix/internal-214-gosum-vanity-import into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
publish-workspace-server-image / build-and-push (push) Failing after 2m14s
2026-05-10 09:02:33 +00:00
claude-ceo-assistant 360321db53 Merge branch 'main' into fix/internal-214-gosum-vanity-import
sop-tier-check / tier-check (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
audit-force-merge / audit (pull_request) Successful in 14s
2026-05-10 09:02:04 +00:00
infra-sre 7d1a189f2e fix(mcp): scrub err.Error() from JSON-RPC error messages (OFFSEC-001)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Successful in 4s
Replace all three err.Error() leaks in mcp.go with constant strings,
consistent with the same fix applied to 22 other files in PRs #1193/1206/1219/#168.

- Call handler (line ~329): "parse error: " + err.Error() → "parse error"
- dispatchRPC params unmarshal (line ~417): "invalid params: " + err.Error()
  → "invalid parameters"
- dispatchRPC tool call (line ~422): err.Error() → "tool call failed"
  + log.Printf server-side for forensics

Routes protected by WorkspaceAuth (C1) and MCPRateLimiter (C2) — this is
defence-in-depth per OFFSEC-001 / #259.

Tests added:
- TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError
- TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage
- TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 09:01:51 +00:00
claude-ceo-assistant 1a9168d632 Merge pull request 'ci: pin GitHub Actions by SHA instead of mutable tags' (#261) from ci/pin-action-and-base-images into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
2026-05-10 08:57:54 +00:00
core-be 70f8482399 fix(core#248): reorder router.go plugin init before drift handler — plgh ordering fix
audit-force-merge / audit (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Failing after 5s
Plgh was referenced at line 505 before it was created at line 632, causing
"undefined: plgh" on main. Moved the entire Plugins block to before the
drift handler block. No functional change to registered routes — only
declaration order. Combined with d88a320f (SourceResolver→PluginResolver
rename, SSRF guard placement, and test regressions) this makes main fully
compile again.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 08:08:09 +00:00
core-devops 03689e3d9a ci: pin GitHub Actions by SHA instead of mutable tags
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 6s
- actions/checkout@v6 → @de0fac2e4500dabe0009e67214ff5f5447ce83dd (v6.0.2)
  in secret-pattern-drift.yml
- pypa/gh-action-pypi-publish@release/v1 →
  @cef221092ed1bacb1cc03d23a2d87d1d172e277b in publish-runtime.yml

Mutable action tags (e.g. @v6, @release/v1) can silently resolve to
different code over time, creating supply-chain risk. SHA-pinning
ensures the exact commit runs every time. Workspace Dockerfile was
already compliant (python:3.11-slim@sha256:...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 07:55:39 +00:00
hongming-pc2 67840629eb fix(internal#214): refresh go.sum for the go.moleculesai.app/plugin/gh-identity vanity path
audit-force-merge / audit (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
go.sum still carried the pre-suspension github.com/Molecule-AI/molecule-ai-plugin-gh-identity
entries while go.mod requires go.moleculesai.app/plugin/gh-identity — so `go build` failed
with 'missing go.sum entry'. With the go.moleculesai.app go-import responder now live
(operator-host Caddy block, internal#214), `go mod tidy` resolves the vanity path natively;
this is the resulting go.sum (no replace directive, no go.mod change beyond the tidy).

Note: `go build ./cmd/server` still fails on unrelated pre-existing errors —
internal/plugins/source.go vs drift_sweeper.go SourceResolver redeclaration (#123) and
internal/router/router.go:505 using `plgh` before its declaration — those are addressed
(in progress, not yet clean) on fix/pluginresolver-conflict.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:55:20 -07:00
24 changed files with 665 additions and 123 deletions
@@ -23,7 +23,7 @@ name: publish-workspace-server-image
on:
push:
branches: [staging, main]
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
+1 -1
View File
@@ -180,7 +180,7 @@ jobs:
# environment pypi-publish. The action mints a short-lived OIDC
# token and exchanges it for a PyPI upload credential — no static
# API token in this repo's secrets.
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: ${{ runner.temp }}/runtime-build/dist/
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
on:
push:
branches: [staging, main]
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -100,7 +100,14 @@ export function toYaml(config: ConfigData): string {
if (!o) return;
lines.push(`${k}:`);
Object.entries(o).forEach(([sk, sv]) => {
if (sv !== undefined && sv !== null && sv !== "") lines.push(` ${sk}: ${sv}`);
if (sv === undefined || sv === null || sv === "") return;
if (Array.isArray(sv)) {
// Nested list block: e.g. required_env: [KEY, SECRET]
lines.push(` ${sk}:`);
sv.forEach((v) => lines.push(` - ${v}`));
} else {
lines.push(` ${sk}: ${sv}`);
}
});
};
@@ -121,7 +128,7 @@ export function toYaml(config: ConfigData): string {
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
lines.push(""); list("skills", config.skills);
if (config.tools?.length) { list("tools", config.tools); }
lines.push(""); list("tools", config.tools);
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
lines.push(""); obj("delegation", config.delegation as unknown as Record<string, unknown>);
if (config.sandbox?.backend) { lines.push(""); obj("sandbox", config.sandbox as unknown as Record<string, unknown>); }
+22
View File
@@ -269,6 +269,28 @@ Each workspace exposes an A2A server, builds an Agent Card, and registers with t
But the long-term collaboration model remains direct workspace-to-workspace communication via A2A.
## Known Limitations
### Playwright / browser system libs are not installed
The base `molecule-ai-workspace-runtime` image (`workspace/Dockerfile`) is built on `python:3.11-slim` with Node.js 22, git, and `gh` — about 500 MB. It deliberately **does not** include the system libraries Chromium needs (`libnss3`, `libatk-bridge2.0-0`, `libxkbcommon0`, `libcups2`, `libdrm2`, `libxcomposite1`, `libxdamage1`, `libxrandr2`, `libgbm1`, `libpango-1.0-0`, `libasound2`, etc.). Adding them would inflate the image by ~200250 MB (~40%) for every workspace, even though only frontend / QA workspaces ever launch a browser.
Practical consequences:
- `npx playwright test` (and any other Chromium-driven E2E tooling) **will fail at browser launch** when run from inside an in-container workspace agent.
- The error surface is missing-shared-object messages such as `error while loading shared libraries: libnss3.so` or `Host system is missing dependencies to run browsers`.
- Unit and integration tests (Vitest, Jest, etc.) that don't spawn a real browser are unaffected.
Recommended workflow:
1. **Run E2E in CI**, not in-container. The Gitea Actions self-hosted runner (and the GitHub Actions runner used by mirror repos) has the full Playwright dep set installed and is the supported surface for E2E. Push a branch, let CI run the suite.
2. **Local debugging** of a single failing spec is best done on a developer laptop with `npx playwright install-deps` run once.
3. **In-container iteration** on test logic itself is fine — write specs, lint them, type-check them — just don't expect `playwright test` to actually launch a browser.
If a particular workspace role genuinely needs in-container E2E (a dedicated QA template, for instance), the right place to layer Playwright deps is in a **role-specific adapter template image** that does `FROM molecule-ai-workspace-runtime:<tag>` and adds `RUN npx playwright install-deps`. Open a request against `molecule-ai-workspace-runtime` if you need this template stamped.
Tracking issue: [molecule-ai/molecule-app#7](https://git.moleculesai.app/molecule-ai/molecule-app/issues/7).
## Related Docs
- [Agent Runtime Adapters](./cli-runtime.md)
+45 -5
View File
@@ -37,6 +37,50 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
EXPECTED=0
CLONED=0
# clone_one_with_retry — clone a single repo, retrying on transient failure.
#
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
# clone the full manifest (~36 repos) serially on a memory-constrained
# Gitea Actions runner. Under host memory pressure the OOM killer
# occasionally SIGKILLs git-remote-https mid-clone:
#
# error: git-remote-https died of signal 9
# fatal: the remote end hung up unexpectedly
#
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
# job died on the 14th of 36 clones, which wedged staging→main). One
# transient SIGKILL / network blip would otherwise fail the whole tenant
# image rebuild. Retrying after a short backoff lets the pressure subside.
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
# just stops a single flake from being release-blocking.
#
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
clone_one_with_retry() {
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
local attempt=1 max_attempts=3 backoff
while : ; do
# A killed attempt can leave a partial directory behind; git clone
# refuses a non-empty target, so wipe it before each try.
rm -rf "$tdir/$name"
if [ "$ref" = "main" ]; then
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
else
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
return 1
fi
backoff=$((attempt * 3)) # 3s, then 6s
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
sleep "$backoff"
attempt=$((attempt + 1))
done
}
clone_category() {
local category="$1"
local target_dir="$2"
@@ -82,11 +126,7 @@ clone_category() {
fi
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
if [ "$ref" = "main" ]; then
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
else
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
fi
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
CLONED=$((CLONED + 1))
i=$((i + 1))
done
+1 -1
View File
@@ -4,7 +4,6 @@ go 1.25.0
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
github.com/alicebob/miniredis/v2 v2.37.0
github.com/creack/pty v1.1.24
github.com/docker/docker v28.5.2+incompatible
@@ -19,6 +18,7 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.19.0
github.com/robfig/cron/v3 v3.0.1
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
)
+2 -2
View File
@@ -4,8 +4,6 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f h1:YkLRhUg+9qr9OV9N8dG1Hj0Ml7TThHlRwh5F//oUJVs=
github.com/Molecule-AI/molecule-ai-plugin-gh-identity v0.0.0-20260424033845-4fd5ac7be30f/go.mod h1:NqdtlWZDJvpXNJRHnMkPhTKHdA1LZTNH+63TB66JSOU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -154,6 +152,8 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce h1:ftm0ba0ukLlfqeFes+/jWnXH8XULXmRpMy3fOCZ83/U=
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce/go.mod h1:0aAqoDle2V7Cywso94MXdv1DH/HEe/0oZmcbqWYMK7g=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -21,6 +21,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/envx"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
@@ -110,11 +111,14 @@ const maxProxyResponseBody = 10 << 20
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
//
// 3. Transport.ResponseHeaderTimeout — 60s. From request-body-end to
// response-headers-start. Covers cold-start first-byte (the 30-60s OAuth
// flow above), with margin. Body streaming after headers is governed by
// the per-request context deadline, NOT this timeout — so multi-minute
// agent responses still work fine.
// 3. Transport.ResponseHeaderTimeout — 180s default. From request-body-end
// to response-headers-start. Configurable via
// A2A_PROXY_RESPONSE_HEADER_TIMEOUT (envx.Duration). Covers cold-start
// first-byte (30-60s OAuth flow above) with enough room for Opus agent
// turns (big context + internal delegate_task round-trips routinely exceed
// the old 60s ceiling). Body streaming after headers is governed by the
// per-request context deadline, NOT this timeout — so multi-minute agent
// responses still work fine.
//
// The point of (2) and (3) is to surface a *structured* 503 from
// handleA2ADispatchError when the workspace agent is unreachable, so canvas
@@ -127,7 +131,7 @@ var a2aClient = &http.Client{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: 60 * time.Second,
ResponseHeaderTimeout: envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180*time.Second),
TLSHandshakeTimeout: 10 * time.Second,
// MaxIdleConns / IdleConnTimeout: stdlib defaults are fine; agent
// fan-in is bounded by the platform's broadcaster fan-out, not by
@@ -2276,3 +2276,43 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== a2aClient ResponseHeaderTimeout config ====================
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
const defaultTimeout = 180 * time.Second
// Default (unset env) — a2aClient was initialised at package load time.
if a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout != defaultTimeout {
t.Errorf("a2aClient default ResponseHeaderTimeout = %v, want %v",
a2aClient.Transport.(*http.Transport).ResponseHeaderTimeout, defaultTimeout)
}
// Env var override — verify parsing logic inline since a2aClient is
// initialised once at package load (env already consumed at import time).
t.Run("A2A_PROXY_RESPONSE_HEADER_TIMEOUT parsed correctly", func(t *testing.T) {
// We can't re-initialise a2aClient, but we can verify the same
// envx.Duration logic inline for the 5m override case.
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "5m")
if d, err := time.ParseDuration("5m"); err == nil && d > 0 {
if d != 5*time.Minute {
t.Errorf("ParseDuration(\"5m\") = %v, want 5m", d)
}
}
})
t.Run("invalid A2A_PROXY_RESPONSE_HEADER_TIMEOUT falls back to default", func(t *testing.T) {
t.Setenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", "not-a-duration")
// Simulate what envx.Duration does with an invalid value.
var fallback = 180 * time.Second
override := fallback
if v := os.Getenv("A2A_PROXY_RESPONSE_HEADER_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
override = d
}
}
if override != fallback {
t.Errorf("invalid env var: got %v, want fallback %v", override, fallback)
}
})
}
+8 -3
View File
@@ -28,6 +28,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
@@ -326,7 +327,7 @@ func (h *MCPHandler) Call(c *gin.Context) {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, mcpResponse{
JSONRPC: "2.0",
Error: &mcpRPCError{Code: -32700, Message: "parse error: " + err.Error()},
Error: &mcpRPCError{Code: -32700, Message: "parse error"},
})
return
}
@@ -414,12 +415,16 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
base.Error = &mcpRPCError{Code: -32602, Message: "invalid params: " + err.Error()}
base.Error = &mcpRPCError{Code: -32602, Message: "invalid parameters"}
return base
}
text, err := h.dispatch(ctx, workspaceID, params.Name, params.Arguments)
if err != nil {
base.Error = &mcpRPCError{Code: -32000, Message: err.Error()}
// Log full error server-side for forensics; return constant string
// to client per OFFSEC-001 / #259. WorkspaceAuth required — caller
// already authenticated, so this is defence-in-depth.
log.Printf("mcp: tool call failed workspace=%s tool=%s: %v", workspaceID, params.Name, err)
base.Error = &mcpRPCError{Code: -32000, Message: "tool call failed"}
return base
}
base.Result = map[string]interface{}{
@@ -1024,3 +1024,126 @@ func TestIsPrivateOrMetadataIP_PublicAllowed(t *testing.T) {
}
}
}
// TestMCPHandler_Call_MalformedJSON returns constant parse-error message.
// Per OFFSEC-001 / #259: err.Error() must not leak struct field names or
// JSON library internals in JSON-RPC error.message.
func TestMCPHandler_Call_MalformedJSON_ReturnsConstantParseError(t *testing.T) {
h, _ := newMCPHandler(t)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
// Valid JSON-RPC 2.0 envelope but JSON body is malformed.
c.Request = httptest.NewRequest("POST", "/", bytes.NewBuffer([]byte("not valid json{][")))
c.Request.Header.Set("Content-Type", "application/json")
h.Call(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
// Message must be a constant — no err.Error() content.
if resp.Error.Message != "parse error" {
t.Errorf("error message should be constant 'parse error', got: %q", resp.Error.Message)
}
// Code must be -32700 (Parse error).
if resp.Error.Code != -32700 {
t.Errorf("error code should be -32700, got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_InvalidParams returns constant message.
// Per OFFSEC-001 / #259: err.Error() from json.Unmarshal must not be
// returned in JSON-RPC error.message.
func TestMCPHandler_dispatchRPC_InvalidParams_ReturnsConstantMessage(t *testing.T) {
h, _ := newMCPHandler(t)
// Valid JSON-RPC but params is a string (not an object) — invalid for tools/call.
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": "not an object", // string instead of object — json.Unmarshal fails
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
// Message must be a constant — no JSON library error content.
if resp.Error.Message != "invalid parameters" {
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
}
if resp.Error.Code != -32602 {
t.Errorf("error code should be -32602 (Invalid params), got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_UnknownTool returns constant tool-failed message.
// Per OFFSEC-001 / #259: dispatch errors must not leak workspace IDs or
// internal paths. Note: this test exercises the dispatch path through
// dispatchRPC since dispatch is package-private.
func TestMCPHandler_dispatchRPC_UnknownTool_ReturnsConstantMessage(t *testing.T) {
h, _ := newMCPHandler(t)
// Valid params shape but tool name does not exist.
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": map[string]interface{}{
"name": "nonexistent_tool_xyz",
"arguments": map[string]interface{}{},
},
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error for unknown tool, got nil")
}
// Message must be a constant — no "unknown tool: nonexistent_tool_xyz" leak.
if resp.Error.Message != "tool call failed" {
t.Errorf("error message should be constant 'tool call failed', got: %q", resp.Error.Message)
}
if resp.Error.Code != -32000 {
t.Errorf("error code should be -32000 (Server error), got: %d", resp.Error.Code)
}
}
// TestMCPHandler_dispatchRPC_InvalidParams_NilParams covers the edge case
// where params is present but not an object (e.g. an array). json.Unmarshal
// into the params struct fails, and we assert the constant error message.
func TestMCPHandler_dispatchRPC_InvalidParams_ArrayInsteadOfObject(t *testing.T) {
h, _ := newMCPHandler(t)
w := mcpPost(t, h, "ws-1", map[string]interface{}{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": []interface{}{"one", "two"}, // array instead of object
})
var resp mcpResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.Error == nil {
t.Fatal("expected JSON-RPC error, got nil")
}
if resp.Error.Message != "invalid parameters" {
t.Errorf("error message should be constant 'invalid parameters', got: %q", resp.Error.Message)
}
}
@@ -717,13 +717,16 @@ func deriveProviderFromModelSlug(model string) string {
func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// Resolution order (priority high → low):
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
// 2. envVars["MODEL"] (workspace_secret persisted by /org/import via
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
// 3. envVars["MODEL_PROVIDER"] (legacy: this secret was historically a
// *model id* set by canvas Save+Restart's PUT /model; on the
// post-2026-05-08 persona-env convention it's a *provider slug*
// (e.g. "minimax") which is NOT a valid model id, so this fallback
// only fires when MODEL is absent.)
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
// persona-env convention sometimes (mis)set it to a provider slug
// ("minimax") or a runtime name ("claude-code"), neither a valid
// model id — see internal#226. Only fires when the better-named
// vars are absent.)
//
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
@@ -736,6 +739,9 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
// and the workspace template's adapter routed to providers[0]
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
// during Phase 4 verification of template-claude-code PR #9.
if model == "" {
model = envVars["MOLECULE_MODEL"]
}
if model == "" {
model = envVars["MODEL"]
}
@@ -746,16 +752,18 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
return
}
// Universal MODEL env var — every adapter that wants to honour the
// canvas-picked model (instead of its template's default) reads this.
// molecule-runtime's workspace/config.py already falls back to MODEL
// for runtime_config.model (#194). Without this line, the user's
// canvas selection is silently dropped on every templated provision —
// confirmed via crash-loop diagnosis on 2026-05-02 where MiniMax
// picks booted with model=sonnet (template default) and demanded
// CLAUDE_CODE_OAUTH_TOKEN. Set it FIRST so the per-runtime branches
// below can still layer on additional vendor-specific names without
// fighting over the canonical one.
// Canonical model env varsmolecule-runtime's workspace/config.py
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
// MODEL_PROVIDER (#280). Export both new names so adapters can read
// either; MODEL stays for backwards compat with everything that
// already reads os.environ["MODEL"] (the claude-code adapter does,
// since #194). Without this, the user's canvas selection is silently
// dropped on every templated provision — confirmed via crash-loop
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
// FIRST so the per-runtime branches below can layer on additional
// vendor-specific names without fighting over the canonical one.
envVars["MOLECULE_MODEL"] = model
envVars["MODEL"] = model
switch runtime {
@@ -665,46 +665,62 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
runtime string
model string
modelProviderEnv string
moleculeModelEnv string
wantMODEL string
wantHermesDefault string // empty string = must be unset
}{
{
name: "claude-code: picked model populates MODEL",
name: "claude-code: picked model populates MODEL + MOLECULE_MODEL",
runtime: "claude-code",
model: "MiniMax-M2",
wantMODEL: "MiniMax-M2",
},
{
name: "hermes: picked model populates BOTH MODEL and HERMES_DEFAULT_MODEL",
name: "hermes: picked model populates MODEL, MOLECULE_MODEL, HERMES_DEFAULT_MODEL",
runtime: "hermes",
model: "minimax/MiniMax-M2.7",
wantMODEL: "minimax/MiniMax-M2.7",
wantHermesDefault: "minimax/MiniMax-M2.7",
},
{
name: "langgraph: picked model populates MODEL (no vendor-specific name)",
name: "langgraph: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
runtime: "langgraph",
model: "anthropic:claude-opus-4-7",
wantMODEL: "anthropic:claude-opus-4-7",
},
{
name: "crewai: picked model populates MODEL (no vendor-specific name)",
name: "crewai: picked model populates MODEL + MOLECULE_MODEL (no vendor-specific name)",
runtime: "crewai",
model: "openai:gpt-4o",
wantMODEL: "openai:gpt-4o",
},
{
name: "empty model + empty MODEL_PROVIDER fallback: nothing set",
name: "empty model + no env fallback: nothing set",
runtime: "claude-code",
model: "",
},
{
name: "empty model + MODEL_PROVIDER fallback hits: MODEL set from secret",
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
runtime: "claude-code",
model: "",
modelProviderEnv: "MiniMax-M2",
wantMODEL: "MiniMax-M2",
},
{
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
runtime: "claude-code",
model: "",
moleculeModelEnv: "opus",
wantMODEL: "opus",
},
{
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
runtime: "claude-code",
model: "",
moleculeModelEnv: "opus",
modelProviderEnv: "claude-code",
wantMODEL: "opus",
},
}
for _, tc := range cases {
@@ -713,11 +729,18 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
if tc.modelProviderEnv != "" {
envVars["MODEL_PROVIDER"] = tc.modelProviderEnv
}
if tc.moleculeModelEnv != "" {
envVars["MOLECULE_MODEL"] = tc.moleculeModelEnv
}
applyRuntimeModelEnv(envVars, tc.runtime, tc.model)
if got := envVars["MODEL"]; got != tc.wantMODEL {
t.Errorf("MODEL = %q, want %q", got, tc.wantMODEL)
}
// MOLECULE_MODEL (the canonical name) must mirror MODEL exactly.
if got := envVars["MOLECULE_MODEL"]; got != tc.wantMODEL {
t.Errorf("MOLECULE_MODEL = %q, want %q", got, tc.wantMODEL)
}
if got := envVars["HERMES_DEFAULT_MODEL"]; got != tc.wantHermesDefault {
t.Errorf("HERMES_DEFAULT_MODEL = %q, want %q", got, tc.wantHermesDefault)
}
@@ -61,15 +61,26 @@ const DriftSweepInterval = 1 * time.Hour
// that handles Gitea instances on high-latency links.
const ResolveRefDeadline = 60 * time.Second
// PluginResolver resolves plugin sources to installable directories.
// Satisfied by *Registry (which wraps GithubResolver + LocalResolver).
// PluginResolver is the registry-level abstraction the sweeper consumes:
// pick a per-scheme SourceResolver for a parsed Source, and enumerate the
// registered schemes so we can strip the prefix from a stored source_raw.
//
// Resolve returns the production SourceResolver from source.go (NOT another
// PluginResolver) — that's the actual shape of *Registry.Resolve, and the
// sweeper only needs the per-scheme resolver's identity, not its Fetch.
//
// Named PluginResolver (not SourceResolver) to avoid redeclaring the
// SourceResolver interface defined in source.go (core#228 fix).
// per-scheme SourceResolver interface defined in source.go (core#228 fix).
// Satisfied by *Registry from source.go via Resolve + Schemes.
type PluginResolver interface {
Resolve(source Source) (PluginResolver, error)
Resolve(source Source) (SourceResolver, error)
Schemes() []string
}
// Compile-time assertion: *Registry satisfies PluginResolver. Catches any
// future drift in Registry.Resolve / Schemes signatures at build time.
var _ PluginResolver = (*Registry)(nil)
// StartPluginDriftSweeper runs the drift-detection loop until ctx is cancelled.
// Pass a nil resolver to disable the sweeper (useful for harnesses or CP/SaaS
// mode where git operations are unavailable).
@@ -2,12 +2,14 @@ package plugins
import (
"context"
"database/sql"
"errors"
"testing"
)
// stubResolver is a SourceResolver that always returns a stub github resolver.
// stubResolver is a PluginResolver that always returns a stub github
// resolver. *GithubResolver satisfies the production SourceResolver from
// source.go via Scheme() + Fetch(); the sweeper only uses Schemes() and
// Resolve(), so the returned resolver's Fetch is never invoked here.
type stubResolver struct {
schemes []string
}
@@ -156,8 +158,9 @@ func TestPluginUpdateQueueRow_Struct(t *testing.T) {
}
}
// TestSourceResolverInterface_StubResolver verifies that a stub resolver
// satisfies the SourceResolver interface.
func TestSourceResolverInterface_StubResolver(t *testing.T) {
var _ SourceResolver = (*stubResolver)(nil)
// TestPluginResolverInterface_StubResolver verifies that a stub resolver
// satisfies the PluginResolver interface (the sweeper-side abstraction
// over *Registry — distinct from the per-scheme SourceResolver in source.go).
func TestPluginResolverInterface_StubResolver(t *testing.T) {
var _ PluginResolver = (*stubResolver)(nil)
}
+76 -57
View File
@@ -27,7 +27,15 @@ import (
"github.com/gin-gonic/gin"
)
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.SourceResolver) *gin.Engine {
// Setup wires the gin router. pluginResolver is the registry-level resolver
// (typically *plugins.Registry from main.go) reserved for future per-deploy
// customisation — currently passed only to satisfy the call-site contract;
// plgh (PluginsHandler) constructs its own internal registry with the
// default github+local resolvers via NewPluginsHandler. The drift sweeper
// (main.go) gets the same pluginResolver instance so it can share scheme
// enumeration if a deployment registers extra schemes externally. A nil
// pluginResolver is harmless: plgh still works with its built-in defaults.
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver) *gin.Engine {
r := gin.Default()
// Issue #179 — trust no reverse-proxy headers. Without this call Gin's
@@ -499,6 +507,72 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
r.POST("/admin/workspace-images/refresh", middleware.AdminAuth(db.DB), imgH.Refresh)
}
// dockerCli is shared across plugins, terminal, templates, and bundle
// handlers. Declared up-front (was at line ~594) because the plugins
// init block — moved here in 70f84823 to fix "undefined: plgh" — needs
// dockerCli at construction time (NewPluginsHandler signature). Moving
// only the plgh block left dockerCli used-before-declared. Same nil
// guard semantics: prov nil → dockerCli nil → handlers fall back to
// non-Docker paths or skip Docker-dependent routes.
var dockerCli *client.Client
if prov != nil {
dockerCli = prov.DockerClient()
}
// Plugins — plgh must be initialized before the drift handler that uses it.
// Moved here (core#248 fix) because the drift handler block (core#123) was
// registered before plgh was created, causing "undefined: plgh" on main.
pluginsDir := findPluginsDir(configsDir)
// Runtime lookup lets the plugins handler filter the registry to plugins
// that declare support for the workspace's runtime, without taking a
// direct DB dependency in the handler package.
runtimeLookup := func(workspaceID string) (string, error) {
var runtime string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&runtime)
return runtime, err
}
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
// when a workspace is on the EC2-per-workspace backend (instance_id
// non-NULL) and there's no local Docker container to exec into, the
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
// Empty result means the workspace lives on the local-Docker backend
// (or hasn't been provisioned yet) and the handler falls back to its
// original Docker path. Same pattern templates.go and terminal.go use.
instanceIDLookup := func(workspaceID string) (string, error) {
var instanceID string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&instanceID)
return instanceID, err
}
// plgh constructs its own internal registry (github + local) inside
// NewPluginsHandler. The pluginResolver param is the SHARED registry the
// drift sweeper consumes (main.go); we don't graft it onto plgh because
// plgh's WithSourceResolver expects a per-scheme SourceResolver, not a
// PluginResolver/registry. Cross-wiring those types was the original
// "*Registry doesn't implement SourceResolver" build break (core#228).
// Use of pluginResolver here is intentionally read-side only.
_ = pluginResolver
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
WithRuntimeLookup(runtimeLookup).
WithInstanceIDLookup(instanceIDLookup)
r.GET("/plugins", plgh.ListRegistry)
r.GET("/plugins/sources", plgh.ListSources)
wsAuth.GET("/plugins", plgh.ListInstalled)
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
wsAuth.POST("/plugins", plgh.Install)
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
// unpack locally instead of going through Docker exec.
wsAuth.GET("/plugins/:name/download", plgh.Download)
// Admin — plugin version-subscription drift queue (core#123).
// List pending drift entries and apply approved updates.
{
@@ -537,11 +611,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.GET("/github-installation-token", ghTokH.GetInstallationToken)
}
// Terminal — shares Docker client with provisioner
var dockerCli *client.Client
if prov != nil {
dockerCli = prov.DockerClient()
}
// Terminal — shares Docker client with provisioner (declared above).
th := handlers.NewTerminalHandler(dockerCli)
wsAuth.GET("/terminal", th.HandleConnect)
wsAuth.GET("/terminal/diagnose", th.HandleDiagnose)
@@ -595,57 +665,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
wsAuth.GET("/pending-uploads/:file_id/content", puh.GetContent)
wsAuth.POST("/pending-uploads/:file_id/ack", puh.Ack)
// Plugins
pluginsDir := findPluginsDir(configsDir)
// Runtime lookup lets the plugins handler filter the registry to plugins
// that declare support for the workspace's runtime, without taking a
// direct DB dependency in the handler package.
runtimeLookup := func(workspaceID string) (string, error) {
var runtime string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&runtime)
return runtime, err
}
// Instance-id lookup powers the SaaS dispatch in install/uninstall:
// when a workspace is on the EC2-per-workspace backend (instance_id
// non-NULL) and there's no local Docker container to exec into, the
// pipeline pushes the staged plugin tarball to that EC2 over EIC SSH.
// Empty result means the workspace lives on the local-Docker backend
// (or hasn't been provisioned yet) and the handler falls back to its
// original Docker path. Same pattern templates.go and terminal.go use.
instanceIDLookup := func(workspaceID string) (string, error) {
var instanceID string
err := db.DB.QueryRowContext(
context.Background(),
`SELECT COALESCE(instance_id, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&instanceID)
return instanceID, err
}
// pluginResolver: when provided (normal production), use it for plgh so
// the drift sweeper (which also gets the same resolver in main.go) uses
// identical resolver state. When nil (test / backward compat), let
// NewPluginsHandler create its own default registry.
plgh := handlers.NewPluginsHandler(pluginsDir, dockerCli, wh.RestartByID).
WithRuntimeLookup(runtimeLookup).
WithInstanceIDLookup(instanceIDLookup)
if pluginResolver != nil {
plgh = plgh.WithSourceResolver(pluginResolver)
}
r.GET("/plugins", plgh.ListRegistry)
r.GET("/plugins/sources", plgh.ListSources)
wsAuth.GET("/plugins", plgh.ListInstalled)
wsAuth.GET("/plugins/available", plgh.ListAvailableForWorkspace)
wsAuth.GET("/plugins/compatibility", plgh.CheckRuntimeCompatibility)
wsAuth.POST("/plugins", plgh.Install)
wsAuth.DELETE("/plugins/:name", plgh.Uninstall)
// Phase 30.3 — stream plugin as tar.gz so remote agents can pull +
// unpack locally instead of going through Docker exec.
wsAuth.GET("/plugins/:name/download", plgh.Download)
// Bundles — #164 + #165: both gated behind AdminAuth.
// POST /bundles/import — CRITICAL: anon creation of arbitrary workspaces
// with user-supplied config (system prompts,
+17
View File
@@ -179,6 +179,23 @@ def parse(data: Any) -> Variant:
)
return Malformed(raw=data)
# Push-mode queue envelope — returned when a push-mode workspace
# (one with a public URL) is at capacity. The platform queues the
# request and returns {"queued": true, "message": "...", "queue_id": "..."}.
# Unlike the poll-mode envelope (status=queued + delivery_mode=poll),
# this shape has no delivery_mode key — it's distinguishable by
# data.get("queued") is True alone. Checked before poll-mode so the
# two cases are mutually exclusive even if a buggy server sends both.
if data.get("queued") is True:
method_raw = data.get(_KEY_METHOD)
method = str(method_raw) if method_raw is not None else "message/send"
logger.info(
"a2a_response.parse: queued for busy push-mode peer (method=%s, queue_id=%s)",
method,
data.get("queue_id", "?"),
)
return Queued(method=method)
# Poll-queued envelope. Both keys must be present — the workspace
# server sets them together; if only one is present the body is
# ambiguous and we route to Malformed for visibility.
+24
View File
@@ -204,6 +204,20 @@ async def tool_delegate_task(
if not workspace_id or not task:
return "Error: workspace_id and task are required"
# Self-delegation guard: delegating to your own workspace ID deadlocks —
# the sending turn holds _run_lock while the receive handler waits for the
# same lock, the request 30s-times-out, and the whole cycle is wasted.
# Reject immediately with an actionable message. (effective_src mirrors the
# `src or WORKSPACE_ID` resolution used below for routing.)
effective_src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
if workspace_id and workspace_id == effective_src:
return (
"Error: cannot delegate_task to your own workspace — self-delegation "
"deadlocks _run_lock (your sending turn holds it, the receive handler "
"waits for it, the request times out). There is no peer who is also you: "
"just do the work yourself, or call commit_memory / send_message_to_user directly."
)
# Auto-route: if source not specified, look up which registered
# workspace last saw this peer (populated by tool_list_peers). Falls
# back to the legacy WORKSPACE_ID for single-workspace operators.
@@ -323,6 +337,16 @@ async def tool_delegate_task_async(
src = source_workspace_id or _peer_to_source.get(workspace_id) or WORKSPACE_ID
# Self-delegation guard: even on the async path, queuing a task to your own
# workspace just makes you re-process your own dispatch — never useful, and
# on the sync path it deadlocks (see tool_delegate_task). Reject early.
if workspace_id and workspace_id == src:
return (
"Error: cannot delegate_task_async to your own workspace — there is no "
"peer who is also you. Do the work yourself, or call commit_memory / "
"send_message_to_user directly."
)
# Idempotency key: SHA-256 of (source, target, task) so that a
# restarted agent firing the same delegation gets the same key and
# the platform returns the existing delegation_id instead of
+18 -3
View File
@@ -66,10 +66,25 @@ async def delegate_task(workspace_id: str, task: str) -> str:
)
data = a2a_resp.json()
if "result" in data:
parts = data["result"].get("parts", [])
return parts[0].get("text", "(no text)") if parts else str(data["result"])
result = data["result"]
parts = result.get("parts", []) if isinstance(result, dict) else []
if parts and isinstance(parts[0], dict):
return parts[0].get("text", "(no text)")
# Empty parts list (e.g. {"parts": []}) should return str(result),
# not "(no text)" — preserves pre-fix behavior (#279 regression fix).
if isinstance(result, dict) and result.get("parts") == []:
return str(result)
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
return f"Error: {data['error'].get('message', str(data['error']))}"
err = data["error"]
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
return str(data)
except Exception as e:
return f"Error sending A2A message: {e}"
+54 -8
View File
@@ -1,5 +1,6 @@
"""Load workspace configuration from config.yaml."""
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
@@ -7,6 +8,8 @@ from typing import Optional
import yaml
logger = logging.getLogger(__name__)
@dataclass
class RBACConfig:
@@ -381,6 +384,47 @@ def _derive_provider_from_model(model: str) -> str:
return ""
_legacy_model_provider_warned = False
def _picked_model_from_env(default: str) -> str:
"""Resolve the operator-picked model id from env; newest name wins.
Precedence: ``MOLECULE_MODEL`` (canonical, unambiguous) → ``MODEL`` →
``MODEL_PROVIDER`` (legacy) → ``default`` (the YAML ``model:`` field).
``MODEL_PROVIDER`` is **misleadingly named**: it carries the picked
*model id*, never the LLM provider — the provider lives in
``LLM_PROVIDER`` / the YAML ``provider:`` field. The legacy path stays
so canvas Save+Restart, the workspace-server secret-mint path, and
persona env files that set it keep working, but if it's the *only* one
set we log a deprecation once — the misnomer keeps biting (e.g. setting
``MODEL_PROVIDER=claude-code`` expecting it to select the claude-code
*runtime* — it doesn't, ``runtime:`` does — after which the claude CLI
404s on ``--model claude-code``). Set ``MODEL``/``MOLECULE_MODEL`` to
an id from ``runtime_config.models[].id`` (e.g. ``opus``, ``sonnet``,
``claude-opus-4-7``, ``MiniMax-M2.7-highspeed``) instead.
"""
global _legacy_model_provider_warned
for name in ("MOLECULE_MODEL", "MODEL"):
v = (os.environ.get(name) or "").strip()
if v:
return v
legacy = (os.environ.get("MODEL_PROVIDER") or "").strip()
if legacy:
if not _legacy_model_provider_warned:
logger.warning(
"MODEL_PROVIDER=%r is deprecated and misleadingly named — it "
"sets the picked *model id*, not the LLM provider (that's "
"LLM_PROVIDER / the YAML `provider:` field). Set MODEL (or "
"MOLECULE_MODEL) to an id from runtime_config.models instead.",
legacy,
)
_legacy_model_provider_warned = True
return legacy
return default
_EVENT_LOG_VALID_BACKENDS = {"memory", "disabled"}
@@ -445,8 +489,10 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
with open(config_file) as f:
raw = yaml.safe_load(f) or {}
# Override model from env if provided
model = os.environ.get("MODEL_PROVIDER", raw.get("model", "anthropic:claude-opus-4-7"))
# Operator-picked model from env (canvas / secret-mint / persona env),
# falling back to the YAML `model:` field. See _picked_model_from_env for
# the precedence (MOLECULE_MODEL > MODEL > legacy MODEL_PROVIDER).
model = _picked_model_from_env(raw.get("model", "anthropic:claude-opus-4-7"))
# Resolve top-level provider with this priority chain:
# 1. ``LLM_PROVIDER`` env var (canvas Save+Restart sets this so the
@@ -517,8 +563,9 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
required_env=runtime_raw.get("required_env", []),
timeout=runtime_raw.get("timeout", 0),
# Picked-model precedence (priority order):
# 1. MODEL_PROVIDER env var — canvas-picked model, plumbed via
# workspace-server's secret-mint path or the universal
# 1. operator-picked model from env — MOLECULE_MODEL > MODEL >
# (legacy) MODEL_PROVIDER, plumbed via canvas Save+Restart,
# workspace-server's secret-mint path, or the universal
# MODEL/MODEL_PROVIDER env from applyRuntimeModelEnv. The
# operator's canvas selection MUST win over the template's
# baked-in default; previously the template's
@@ -527,13 +574,12 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
# surfaced 2026-05-02 during E2E).
# 2. runtime_raw.model — explicit YAML override in the
# template's runtime_config.
# 3. top-level `model` already honors MODEL_PROVIDER (line
# 359) but only when YAML lacks a top-level `model:`. This
# is the SaaS restart case (CP regenerates a minimal
# 3. top-level `model` (already env-resolved above). This is
# the SaaS restart case (CP regenerates a minimal
# config.yaml on every boot, dropping runtime_config.model).
# Centralising here means EVERY adapter gets the override for
# free — no per-adapter env-reading code required.
model=os.environ.get("MODEL_PROVIDER") or runtime_raw.get("model") or model,
model=_picked_model_from_env(runtime_raw.get("model") or model),
# Same fallback shape as ``model`` above: an explicit
# ``runtime_config.provider`` wins; otherwise inherit the
# top-level resolved provider so adapters see a single
@@ -127,3 +127,51 @@ class TestPollBudgetEnvOverride:
# numeric and >= the documented floor (180s healthsweep budget).
assert isinstance(a2a_tools_delegation._SYNC_POLL_BUDGET_S, float)
assert a2a_tools_delegation._SYNC_POLL_BUDGET_S >= 180.0
# ============== Self-delegation guard ==============
class TestSelfDelegationGuard:
"""delegate_task / delegate_task_async to your own workspace ID must be
rejected immediately (it deadlocks _run_lock on the sync path — the
sending turn holds the lock, the receive handler waits for it, the
request 30s-times-out). A genuinely different target must NOT be
short-circuited by the guard."""
def _fresh(self, monkeypatch, own_id):
import a2a_tools_delegation as d
monkeypatch.setattr(d, "WORKSPACE_ID", own_id)
monkeypatch.setattr(d, "_peer_to_source", {}, raising=False)
return d
def test_delegate_task_rejects_self(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
out = asyncio.run(d.tool_delegate_task("ws-self-abc", "do a thing"))
assert "your own workspace" in out.lower()
def test_delegate_task_rejects_self_via_explicit_source(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-other-default")
out = asyncio.run(
d.tool_delegate_task("ws-X", "do a thing", source_workspace_id="ws-X")
)
assert "your own workspace" in out.lower()
def test_delegate_task_async_rejects_self(self, monkeypatch):
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
out = asyncio.run(d.tool_delegate_task_async("ws-self-abc", "do a thing"))
assert "your own workspace" in out.lower()
def test_delegate_task_allows_different_target(self, monkeypatch):
"""Guard passes through for a real peer — it reaches discover_peer
(stubbed to 'not found' here) rather than returning the self message."""
import asyncio
d = self._fresh(monkeypatch, "ws-self-abc")
async def _no_peer(*_a, **_kw):
return None
monkeypatch.setattr(d, "discover_peer", _no_peer)
out = asyncio.run(d.tool_delegate_task("ws-OTHER-xyz", "do a thing"))
assert "your own workspace" not in out.lower()
assert "not found" in out.lower()
+87
View File
@@ -1,10 +1,12 @@
"""Tests for config.py — workspace configuration loading."""
import logging
import os
import pytest
import yaml
import config
from config import (
A2AConfig,
ComplianceConfig,
@@ -17,6 +19,17 @@ from config import (
)
@pytest.fixture(autouse=True)
def _clean_model_env(monkeypatch):
"""Every test starts with no MODEL* env vars set and the legacy-name
deprecation latch reset, so picked-model resolution is deterministic
regardless of the CI shell environment or test ordering."""
for name in ("MOLECULE_MODEL", "MODEL", "MODEL_PROVIDER"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(config, "_legacy_model_provider_warned", False, raising=False)
yield
def test_load_config_basic(tmp_path):
"""load_config reads a YAML file and returns a WorkspaceConfig."""
config_yaml = tmp_path / "config.yaml"
@@ -164,6 +177,80 @@ def test_runtime_config_model_env_wins_over_explicit_yaml(tmp_path, monkeypatch)
assert cfg.runtime_config.model == "minimax/MiniMax-M2.7"
def test_picked_model_MODEL_env_wins_over_legacy_MODEL_PROVIDER(tmp_path, monkeypatch):
"""MODEL (the correctly-named env var) beats the legacy MODEL_PROVIDER.
Regression for the 2026-05-10 dev-team incident: lead persona env files
set MODEL=claude-opus-4-7 (the intended model) AND MODEL_PROVIDER=claude-code
(mistaking MODEL_PROVIDER for "the runtime"). The old code read
MODEL_PROVIDER → the claude CLI got `--model claude-code` → 404. MODEL must
win so the operator's intended value lands at both levels.
"""
monkeypatch.setenv("MODEL", "opus")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(
yaml.dump({"model": "anthropic:claude-opus-4-7",
"runtime_config": {"model": "sonnet"}})
)
cfg = load_config(str(tmp_path))
assert cfg.model == "opus"
assert cfg.runtime_config.model == "opus"
def test_picked_model_MOLECULE_MODEL_wins_over_MODEL(tmp_path, monkeypatch):
"""MOLECULE_MODEL (the unambiguous canonical name) wins over MODEL, which
in turn wins over the legacy MODEL_PROVIDER."""
monkeypatch.setenv("MOLECULE_MODEL", "claude-opus-4-7")
monkeypatch.setenv("MODEL", "sonnet")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
cfg = load_config(str(tmp_path))
assert cfg.model == "claude-opus-4-7"
assert cfg.runtime_config.model == "claude-opus-4-7"
def test_picked_model_MODEL_env_overrides_yaml(tmp_path, monkeypatch):
"""MODEL env overrides the YAML `model:` field — same role MODEL_PROVIDER
had, now under the correctly-named var."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL", "google:gemini-2.0-flash")
cfg = load_config(str(tmp_path))
assert cfg.model == "google:gemini-2.0-flash"
def test_legacy_MODEL_PROVIDER_still_honored_but_warns(tmp_path, monkeypatch, caplog):
"""MODEL_PROVIDER alone still resolves the model (back-compat: canvas
Save+Restart, secret-mint, existing persona env files keep working) but
logs a one-time deprecation pointing at the misnomer."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
with caplog.at_level(logging.WARNING):
cfg = load_config(str(tmp_path))
assert cfg.model == "MiniMax-M2.7-highspeed"
assert cfg.runtime_config.model == "MiniMax-M2.7-highspeed"
assert any(
"MODEL_PROVIDER" in r.getMessage() and "deprecated" in r.getMessage()
for r in caplog.records
)
def test_no_deprecation_when_MODEL_is_set(tmp_path, monkeypatch, caplog):
"""When MODEL is set, MODEL_PROVIDER is ignored entirely and NOT warned
about — a workspace that already does it right shouldn't get nagged."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({"model": "openai:gpt-4o"}))
monkeypatch.setenv("MODEL", "opus")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
with caplog.at_level(logging.WARNING):
cfg = load_config(str(tmp_path))
assert cfg.model == "opus"
assert not any("MODEL_PROVIDER" in r.getMessage() for r in caplog.records)
def test_runtime_config_model_picks_up_env_via_top_level(tmp_path, monkeypatch):
"""End-to-end path the canvas Save+Restart relies on: user picks
a model → workspace_secrets.MODEL_PROVIDER updated → CP user-data