Compare commits

...

16 Commits

Author SHA1 Message Date
fullstack-engineer 67b2e48824 fix(a2a): handle string-form errors in delegate_task
sop-tier-check / tier-check (pull_request) Failing after 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 13s
audit-force-merge / audit (pull_request) Has been skipped
The A2A proxy can return three error shapes:
  {"error": "plain string"}
  {"error": {"message": "...", "code": ...}}
  {"error": {"message": {"nested": "object"}}}   ← value at .message is a string

builtin_tools/a2a_tools.py:72 called data["error"].get("message")
without guarding against error being a string, which raised:
  AttributeError: 'str' object has no attribute 'get'

This broke every delegation attempt through the legacy a2a_tools path
(the LangChain-wrapped version used by adapter templates). The
SSOT parser a2a_response.py already handled string errors; the
legacy inline sniffer in a2a_tools.py did not.

Fix: branch on isinstance(err, dict/str/other) before calling .get().

Also update both publish-workflow files to remove the dead
`staging` branch trigger — trunk-based migration (PR #109,
2026-05-08) removed the staging branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 10:13:24 +00:00
integration-tester cd73b38c7b chore: restore manifest.json after trigger test 2026-05-10 10:13:24 +00:00
integration-tester 1746b96765 chore: trigger publish workflow [Integration Tester 2026-05-10T08:45Z] 2026-05-10 10:13:24 +00:00
integration-tester 1a28110539 chore: staging trigger commit from Integration Tester 2026-05-10 10:13:24 +00: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 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
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
9 changed files with 193 additions and 13 deletions
@@ -23,7 +23,7 @@ name: publish-workspace-server-image
on:
push:
branches: [staging, main]
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
@@ -32,11 +32,9 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
@@ -32,7 +32,7 @@ name: publish-workspace-server-image
on:
push:
branches: [staging, main]
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
+1
View File
@@ -0,0 +1 @@
staging trigger
+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)
+1
View File
@@ -44,3 +44,4 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+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)
}
}
+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.
+16 -3
View File
@@ -66,10 +66,23 @@ 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)")
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"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
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}"