From 706df19b439170fe34aca827e704f304241b3285 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 03:34:48 +0000 Subject: [PATCH 01/10] [core-be-agent] fix(security#321): CWE-22 path traversal guards in loadWorkspaceEnv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two vulnerable call sites confirmed on origin/main: 1. org_helpers.go:loadWorkspaceEnv (line 101): filesDir from untrusted org YAML joined directly with orgBaseDir without traversal guard. A malicious filesDir like "../../../etc" escapes the org root and reads arbitrary files. 2. org_import.go:createWorkspaceTree (line 494): same pattern directly in the env-loading block — not covered by staging-targeted PR #345. Fix (both locations): call resolveInsideRoot(orgBaseDir, filesDir) before filepath.Join. On traversal detection, org_helpers.go returns an empty map (caller contract); org_import.go silently skips the workspace .env override (matches existing template-resolution pattern in the same function). Tests: org_helpers_test.go — 3 cases covering traversal rejection, workspace-override happy path, and empty filesDir edge case. Closes: molecule-core#362, molecule-core#321 Co-Authored-By: Claude Opus 4.7 --- .../internal/handlers/org_helpers.go | 13 ++- .../internal/handlers/org_helpers_test.go | 104 ++++++++++++++++++ .../internal/handlers/org_import.go | 7 +- 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 workspace-server/internal/handlers/org_helpers_test.go diff --git a/workspace-server/internal/handlers/org_helpers.go b/workspace-server/internal/handlers/org_helpers.go index 824fd2d7..24c973f8 100644 --- a/workspace-server/internal/handlers/org_helpers.go +++ b/workspace-server/internal/handlers/org_helpers.go @@ -91,6 +91,10 @@ func expandWithEnv(s string, env map[string]string) string { // loadWorkspaceEnv reads the org root .env and the workspace-specific .env // (workspace overrides org root). Used by both secret injection and channel // config expansion. +// +// SECURITY: filesDir is sourced from untrusted org YAML input (ws.FilesDir). +// resolveInsideRoot guard prevents path traversal (CWE-22) where a malicious +// filesDir like "../../../etc" could escape the org root. func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string { envVars := map[string]string{} if orgBaseDir == "" { @@ -98,7 +102,14 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string { } parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars) if filesDir != "" { - parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars) + safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir) + if err != nil { + // Reject traversal attempt silently — callers expect an empty map + // on any read failure. + log.Printf("loadWorkspaceEnv: rejecting filesDir %q: %v", filesDir, err) + return envVars + } + parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars) } return envVars } diff --git a/workspace-server/internal/handlers/org_helpers_test.go b/workspace-server/internal/handlers/org_helpers_test.go new file mode 100644 index 00000000..c42ca0cd --- /dev/null +++ b/workspace-server/internal/handlers/org_helpers_test.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLoadWorkspaceEnv_RejectsTraversal asserts that loadWorkspaceEnv refuses +// to read workspace-specific .env files when filesDir contains CWE-22 traversal +// patterns (../../../etc, absolute paths, etc.). This is the primary security +// control for the ws.FilesDir attack surface in POST /org/import. + +func TestLoadWorkspaceEnv_RejectsTraversal(t *testing.T) { + tmp := t.TempDir() + orgRoot := filepath.Join(tmp, "my-org") + if err := os.Mkdir(orgRoot, 0o755); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + filesDir string + }{ + {"traversal_parent", "../../../etc"}, + {"traversal_deep", "../../../../../../../../../etc"}, + {"traversal_sibling", "../sibling"}, + {"traversal_mixed", "foo/../../bar"}, + {"absolute_path", "/etc/passwd"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Write an org-level .env to confirm it loads even when the + // workspace .env is rejected. + orgEnv := filepath.Join(orgRoot, ".env") + if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-value\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := loadWorkspaceEnv(orgRoot, tc.filesDir) + + // Org-level .env must be loaded regardless of workspace rejection. + if got["ORG_KEY"] != "org-value" { + t.Errorf("org-level .env not loaded: got %v", got) + } + // Traversal path must NOT have been read. + if val, ok := got["TRAVERSAL_KEY"]; ok { + t.Errorf("traversal escaped: got TRAVERSAL_KEY=%q", val) + } + }) + } +} + +// TestLoadWorkspaceEnv_HappyPath verifies that legitimate filesDir values +// resolve correctly and workspace .env overrides org-level values. + +func TestLoadWorkspaceEnv_HappyPath(t *testing.T) { + tmp := t.TempDir() + orgRoot := filepath.Join(tmp, "my-org") + wsDir := filepath.Join(orgRoot, "workspaces", "dev-workspace") + if err := os.MkdirAll(wsDir, 0o755); err != nil { + t.Fatal(err) + } + + orgEnv := filepath.Join(orgRoot, ".env") + wsEnv := filepath.Join(wsDir, ".env") + if err := os.WriteFile(orgEnv, []byte("ORG_KEY=org-val\nSHARED=org-wins\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(wsEnv, []byte("WS_KEY=ws-val\nSHARED=ws-wins\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := loadWorkspaceEnv(orgRoot, filepath.Join("workspaces", "dev-workspace")) + + if got["ORG_KEY"] != "org-val" { + t.Errorf("org-level key missing: %v", got) + } + if got["WS_KEY"] != "ws-val" { + t.Errorf("workspace key missing: %v", got) + } + if got["SHARED"] != "ws-wins" { + t.Errorf("workspace should override org-level: got %v", got) + } +} + +// TestLoadWorkspaceEnv_EmptyFilesDirOnlyLoadsOrgLevel verifies that an empty +// filesDir only loads the org-level .env (no workspace override). + +func TestLoadWorkspaceEnv_EmptyFilesDir(t *testing.T) { + tmp := t.TempDir() + orgRoot := filepath.Join(tmp, "my-org") + if err := os.Mkdir(orgRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(orgRoot, ".env"), []byte("KEY=only-org\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := loadWorkspaceEnv(orgRoot, "") + if got["KEY"] != "only-org" { + t.Errorf("expected only-org, got %v", got) + } +} diff --git a/workspace-server/internal/handlers/org_import.go b/workspace-server/internal/handlers/org_import.go index 2e06479f..e521198e 100644 --- a/workspace-server/internal/handlers/org_import.go +++ b/workspace-server/internal/handlers/org_import.go @@ -490,8 +490,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX // 1. Org root .env (shared defaults) parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars) // 2. Workspace-specific .env (overrides) + // SECURITY: ws.FilesDir is untrusted YAML input — guard against CWE-22 + // traversal so a crafted filesDir like "../../../etc" cannot escape orgBaseDir. if ws.FilesDir != "" { - parseEnvFile(filepath.Join(orgBaseDir, ws.FilesDir, ".env"), envVars) + if safeFilesDir, err := resolveInsideRoot(orgBaseDir, ws.FilesDir); err == nil { + parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars) + } + // Traversal rejection: silently skip — callers expect partial env on failure. } } // Store as workspace secrets via DB (encrypted if key is set, raw otherwise) From fd40700c43bb2af008d2d3d6b89a75c486bf43bb Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 03:48:31 +0000 Subject: [PATCH 02/10] [ci skip false-positive] force re-run CI (runner stuck at infra#241) From d166d77abc13996b76af7d9035b98e33342a3bf6 Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Sun, 10 May 2026 20:48:38 -0700 Subject: [PATCH 03/10] =?UTF-8?q?ci:=20port=20.github/workflows/ci.yml=20t?= =?UTF-8?q?o=20.gitea/workflows/ci.yml=20(RFC=20internal#219=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of RFC internal#219 (CI/CD hard-gate hardening). molecule-core's branch protection on main currently requires only Secret scan + sop-tier-check/tier-check — there is no required gate that asserts the actual Go code builds. The .github/workflows/ci.yml has six jobs that would catch build/test/lint/coverage regressions, but Gitea Actions only reads .gitea/workflows/. So today every Go regression on molecule-core merges through (recurrence of feedback_phantom_required_check_after_gitea_migration). This PR ports the workflow to .gitea/workflows/ci.yml. Per RFC §1, the port lands with `continue-on-error: true` on every job so we surface broken jobs without blocking PRs while the team triages anything that falls out of "first contact with reality". A follow-up PR (Phase 4) will flip continue-on-error to false, add the `ci/all-required` aggregator sentinel (mirroring molecule-controlplane#89's pattern), and PATCH branch protection to require it. Four-surface migration audit performed (feedback_gitea_actions_migration_audit_pattern): 1. YAML: dropped merge_group trigger (no Gitea merge queue); no workflow_dispatch.inputs to worry about (feedback_gitea_workflow_dispatch_inputs_unsupported); no environment: blocks; runs-on: ubuntu-latest preserved. Set workflow-level env.GITHUB_SERVER_URL as belt-and-suspenders against runner-default regression (feedback_act_runner_github_server_url + feedback_act_runner_needs_config_file_env). 2. Cache + artifact: actions/upload-artifact pinned at v3.2.2 (original already had this — Gitea act_runner v0.6 doesn't speak the v4 artifact protocol). setup-python cache: pip preserved. 3. Token: workflow uses no custom dispatch tokens; auto-injected GITHUB_TOKEN (Gitea-scoped runner token) handles checkout against this same repo. 4. Docs: no github.com docs/scripts references to swap. The canvas-deploy-reminder step references ghcr.io/.../canvas — that's external documentation prose, not a build dependency, and is a separate ghcr→ECR sweep if in scope. actions/* (checkout, setup-go, setup-node, setup-python, upload-artifact) are verified mirrored on this Gitea instance (git.moleculesai.app/actions/*); app.ini has DEFAULT_ACTIONS_URL = self so the @SHA refs resolve locally. Scope guard (per RFC): - This PR ports ONLY ci.yml. The other 34 workflows in .github/workflows/ get swept in a follow-up per the runbooks/gitea-actions-migration-checklist.md. - This PR does NOT add the all-required aggregator sentinel (Phase 4). - This PR does NOT modify branch protection (Phase 4). - This PR does NOT delete .github/workflows/ci.yml (RFC §1 leaves it in place initially). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitea/workflows/ci.yml | 453 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..6936ae9d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,453 @@ +# Ported from .github/workflows/ci.yml on 2026-05-11 per RFC internal#219 §1. +# continue-on-error: true on every job; follow-up PR will flip required after +# surfaced bugs are fixed (per RFC §1 — "surface broken workflows without +# blocking"). The four-surface migration audit +# (feedback_gitea_actions_migration_audit_pattern) was performed against this +# port: +# +# 1. YAML — dropped `merge_group` trigger (no Gitea merge queue); no +# `workflow_dispatch.inputs` to drop (Gitea 1.22.6 rejects those — +# feedback_gitea_workflow_dispatch_inputs_unsupported); no `environment:` +# blocks; kept `runs-on: ubuntu-latest` (Gitea runner pool advertises +# this label per agent_labels in action_runner table). Workflow-level +# env.GITHUB_SERVER_URL set as belt-and-suspenders against runner +# defaults (feedback_act_runner_github_server_url). +# +# 2. Cache — `actions/upload-artifact@v3.2.2` was already pinned to v3 for +# Gitea act_runner v0.6 compatibility (a comment in the original called +# this out). v4+ is incompatible with Gitea 1.22.x. No `actions/cache` +# usage to audit. `actions/setup-python@v6` `cache: pip` is left in +# place — works against Gitea's built-in cache server when runner.cache +# is configured (currently is, /opt/molecule/runners/config.yaml). +# +# 3. Token — workflow uses no custom dispatch tokens. The auto-injected +# `GITHUB_TOKEN` (which Gitea aliases to a runner-scoped token) is +# sufficient for `actions/checkout` against this same repo. +# +# 4. Docs — no docs/scripts reference github.com URLs that need swapping. +# The canvas-deploy-reminder step writes a `ghcr.io/...` image +# reference into the step summary text — that's documentation prose +# pointing at the ECR-mirrored canvas image and stays unchanged for +# this port (a separate cleanup if ghcr→ECR sweep is in scope). +# +# Cross-links: +# - RFC: internal#219 (CI/CD hard-gate hardening) +# - Reference port style: molecule-controlplane/.gitea/workflows/ci.yml +# - Bugs that may surface immediately and are tracked separately: +# internal#214 (Go-side vanity-import / go.sum drift, if any) +# - Phase 4 (this PR's follow-up): flip `continue-on-error: false` once +# surfaced defects are fixed, then add `all-required` aggregator +# sentinel (RFC §2) and PATCH branch protection (Phase 4 scope). + +name: CI + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + # `merge_group` (GitHub merge-queue trigger) dropped — Gitea has no merge + # queue. The .github/ original retains it; this Gitea-side copy drops it. + +# Cancel in-progress CI runs when a new commit arrives on the same ref. +# Stale runs queue up otherwise. PR refs and main/staging refs each get +# their own group because github.ref differs. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + # Belt-and-suspenders against the runner-default trap + # (feedback_act_runner_github_server_url). Runners are configured with + # this env via /opt/molecule/runners/config.yaml runner.envs, but pinning + # at the workflow level protects against a runner regenerated without + # the config file (feedback_act_runner_needs_config_file_env). + GITHUB_SERVER_URL: https://git.moleculesai.app + +jobs: + # Detect which paths changed so downstream jobs can skip when only + # docs/markdown files were modified. + changes: + name: Detect changes + runs-on: ubuntu-latest + # Phase 3 (RFC #219 §1): surface broken workflows without blocking + # the PR. Follow-up PR flips this off after the surfaced defects + # (if any) are triaged. + continue-on-error: true + outputs: + platform: ${{ steps.check.outputs.platform }} + canvas: ${{ steps.check.outputs.canvas }} + python: ${{ steps.check.outputs.python }} + scripts: ${{ steps.check.outputs.scripts }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - id: check + run: | + # For PR events: diff against the base branch (not HEAD~1 of the branch, + # which may be unrelated after force-pushes). When a push updates a PR, + # both pull_request and push events fire — prefer the PR base so that + # the diff is always computed against the actual merge base, not the + # previous SHA on the branch which may be on a different history line. + BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}" + # GITHUB_BASE_REF is set for PR events (the base branch name). + # For pull_request events we use the stored base.sha; for push events + # (or when base.sha is unavailable) fall back to github.event.before. + if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + fi + # Fallback: if BASE is empty or all zeros (new branch), run everything + if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then + echo "platform=true" >> "$GITHUB_OUTPUT" + echo "canvas=true" >> "$GITHUB_OUTPUT" + echo "python=true" >> "$GITHUB_OUTPUT" + echo "scripts=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count + # as "this workflow changed" — either edit should force-run every + # downstream job. The Gitea port follows the same shape as the + # GitHub original so behavior matches when triggered on either + # platform. + DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml") + echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT" + + # Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run + # + per-step gating shape preserves the GitHub-side required-check name + # contract (so when this Gitea port becomes a required check in Phase 4, + # the name match works on PRs that don't touch workspace-server/). + platform-build: + name: Platform (Go) + needs: changes + runs-on: ubuntu-latest + continue-on-error: true + defaults: + run: + working-directory: workspace-server + steps: + - if: needs.changes.outputs.platform != 'true' + working-directory: . + run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.platform == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - if: needs.changes.outputs.platform == 'true' + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 'stable' + - if: needs.changes.outputs.platform == 'true' + run: go mod download + - if: needs.changes.outputs.platform == 'true' + run: go build ./cmd/server + # CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli + - if: needs.changes.outputs.platform == 'true' + run: go vet ./... || true + - if: needs.changes.outputs.platform == 'true' + name: Run golangci-lint + run: golangci-lint run --timeout 3m ./... || true + - if: needs.changes.outputs.platform == 'true' + name: Run tests with race detection and coverage + run: go test -race -coverprofile=coverage.out ./... + + - if: needs.changes.outputs.platform == 'true' + name: Per-file coverage report + # Advisory — lists every source file with its coverage so reviewers + # can see at-a-glance where gaps are. Sorted ascending so the worst + # offenders float to the top. Does NOT fail the build; the hard + # gate is the threshold check below. (#1823) + run: | + echo "=== Per-file coverage (worst first) ===" + go tool cover -func=coverage.out \ + | grep -v '^total:' \ + | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} + END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \ + | sort -n + + - if: needs.changes.outputs.platform == 'true' + name: Check coverage thresholds + # Enforces two gates from #1823 Layer 1: + # 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md). + # 2. Per-file floor — non-test .go files in security-critical + # paths with coverage <10% fail the build, UNLESS the file + # path is listed in .coverage-allowlist.txt (acknowledged + # historical debt with a tracking issue + expiry). + run: | + set -e + TOTAL_FLOOR=25 + # Security-critical paths where a 0%-coverage file is a real risk. + CRITICAL_PATHS=( + "internal/handlers/tokens" + "internal/handlers/workspace_provision" + "internal/handlers/a2a_proxy" + "internal/handlers/registry" + "internal/handlers/secrets" + "internal/middleware/wsauth" + "internal/crypto" + ) + + TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//') + echo "Total coverage: ${TOTAL}%" + if awk "BEGIN{exit !($TOTAL < $TOTAL_FLOOR)}"; then + echo "::error::Total coverage ${TOTAL}% is below the ${TOTAL_FLOOR}% floor. See COVERAGE_FLOOR.md for ratchet plan." + exit 1 + fi + + # Aggregate per-file coverage → /tmp/perfile.txt: " " + go tool cover -func=coverage.out \ + | grep -v '^total:' \ + | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} + END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' \ + > /tmp/perfile.txt + + # Build allowlist — paths relative to workspace-server, one per line. + # Lines starting with # are comments. + ALLOWLIST="" + if [ -f ../.coverage-allowlist.txt ]; then + ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true) + fi + + FAILED=0 + WARNED=0 + for path in "${CRITICAL_PATHS[@]}"; do + while read -r file pct; do + [[ "$file" == *_test.go ]] && continue + [[ "$file" == *"$path"* ]] || continue + awk "BEGIN{exit !($pct < 10)}" || continue + + # Strip the package-import prefix so we can match .coverage-allowlist.txt + # entries written as paths relative to workspace-server/. + # Handle both module paths: platform/workspace-server/... and platform/... + rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||') + + if echo "$ALLOWLIST" | grep -qxF "$rel"; then + echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry." + WARNED=$((WARNED+1)) + else + echo "::error file=workspace-server/$rel::Critical file at ${pct}% coverage — must be >=10% (target 80%). See #1823. To acknowledge as known debt, add this path to .coverage-allowlist.txt." + FAILED=$((FAILED+1)) + fi + done < /tmp/perfile.txt + done + + echo "" + echo "Critical-path check: $FAILED new failures, $WARNED allowlisted warnings." + + if [ "$FAILED" -gt 0 ]; then + echo "" + echo "$FAILED security-critical file(s) have <10% test coverage and are" + echo "NOT in the allowlist. These paths handle auth, tokens, secrets, or" + echo "workspace provisioning — a 0% file here is the exact gap that let" + echo "CWE-22, CWE-78, KI-005 slip through in past incidents. Either:" + echo " (a) add tests to raise coverage above 10%, or" + echo " (b) add the path to .coverage-allowlist.txt with an expiry date" + echo " and a tracking issue reference." + exit 1 + fi + + # Canvas (Next.js) — required check, always runs. Same always-run + + # per-step gating shape as platform-build. The two-job-sharing-name + # pattern attempted in PR #2321 doesn't satisfy branch protection + # (SKIPPED siblings count as not-passed regardless of SUCCESS + # siblings — verified empirically on PR #2314). + canvas-build: + name: Canvas (Next.js) + needs: changes + runs-on: ubuntu-latest + continue-on-error: true + defaults: + run: + working-directory: canvas + steps: + - if: needs.changes.outputs.canvas != 'true' + working-directory: . + run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.canvas == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - if: needs.changes.outputs.canvas == 'true' + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + - if: needs.changes.outputs.canvas == 'true' + run: rm -f package-lock.json && npm install + - if: needs.changes.outputs.canvas == 'true' + run: npm run build + - if: needs.changes.outputs.canvas == 'true' + name: Run tests with coverage + # Coverage instrumentation is configured in canvas/vitest.config.ts + # (provider: v8, reporters: text + html + json-summary). Step 2 of + # #1815 — wires coverage into CI so we get a baseline visible on + # every PR. No threshold gate yet; thresholds dial in (Step 3, also + # tracked in #1815) after the team sees what current coverage is. + run: npx vitest run --coverage + - name: Upload coverage summary as artifact + if: needs.changes.outputs.canvas == 'true' && always() + # Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses + # the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT + # implement, surfacing as `GHESNotSupportedError: @actions/artifact + # v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not + # currently supported on GHES`. Drop this pin when Gitea ships + # the v4 protocol (tracked: post-Gitea-1.23 followup). + uses: actions/upload-artifact@c6a366c94c3e0affe28c06c8df20a878f24da3cf # v3.2.2 + with: + name: canvas-coverage-${{ github.run_id }} + path: canvas/coverage/ + retention-days: 7 + if-no-files-found: warn + + # Shellcheck (E2E scripts) — required check, always runs. + shellcheck: + name: Shellcheck (E2E scripts) + needs: changes + runs-on: ubuntu-latest + continue-on-error: true + steps: + - if: needs.changes.outputs.scripts != 'true' + run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.scripts == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - if: needs.changes.outputs.scripts == 'true' + name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh + # shellcheck is pre-installed on ubuntu-latest runners (via apt). + # infra/scripts/ is included because setup.sh + nuke.sh gate the + # README quickstart — a shellcheck regression there silently breaks + # new-user onboarding. scripts/ is intentionally excluded until its + # pre-existing SC3040/SC3043 warnings are cleaned up. + run: | + find tests/e2e infra/scripts -type f -name '*.sh' -print0 \ + | xargs -0 shellcheck --severity=warning + + - if: needs.changes.outputs.scripts == 'true' + name: Lint cleanup-trap hygiene (RFC #2873) + run: bash tests/e2e/lint_cleanup_traps.sh + + - if: needs.changes.outputs.scripts == 'true' + name: Run E2E bash unit tests (no live infra) + run: | + bash tests/e2e/test_model_slug.sh + + canvas-deploy-reminder: + name: Canvas Deploy Reminder + runs-on: ubuntu-latest + continue-on-error: true + needs: [changes, canvas-build] + # Only fires on direct pushes to main (i.e. after staging→main promotion). + if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Write deploy reminder to step summary + env: + COMMIT_SHA: ${{ github.sha }} + # github.server_url resolves via the workflow-level env override + # to the Gitea instance, so the RUN_URL points at the Gitea run + # page (not github.com). See feedback_act_runner_github_server_url. + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + # Write body to a temp file — avoids backtick escaping in shell. + cat > /tmp/deploy-reminder.md << 'BODY' + ## Canvas build passed — deploy required + + The `publish-canvas-image` workflow is now building a fresh Docker image + (`ghcr.io/molecule-ai/canvas:latest`) in the background. + + Once it completes (~3–5 min), apply on the host machine with: + ```bash + cd + git pull origin main + docker compose pull canvas && docker compose up -d canvas + ``` + + If you need to rebuild from local source instead (e.g. testing unreleased + changes or a new `NEXT_PUBLIC_*` URL), use: + ```bash + docker compose build canvas && docker compose up -d canvas + ``` + BODY + printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \ + "$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md + + # Gitea has no commit-comments API; write to GITHUB_STEP_SUMMARY, + # which both GitHub Actions and Gitea Actions render as the + # workflow run's summary page. (#75 / PR-D) + cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY" + + # Python Lint & Test — required check, always runs. + python-lint: + name: Python Lint & Test + needs: changes + runs-on: ubuntu-latest + continue-on-error: true + env: + WORKSPACE_ID: test + defaults: + run: + working-directory: workspace + steps: + - if: needs.changes.outputs.python != 'true' + working-directory: . + run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection." + - if: needs.changes.outputs.python == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - if: needs.changes.outputs.python == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: workspace/requirements.txt + - if: needs.changes.outputs.python == 'true' + run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0 + # Coverage flags + fail-under floor moved into workspace/pytest.ini + # (issue #1817) so local `pytest` and CI use identical config. + - if: needs.changes.outputs.python == 'true' + run: python -m pytest --tb=short + + - if: needs.changes.outputs.python == 'true' + name: Per-file critical-path coverage (MCP / inbox / auth) + # MCP-critical Python files have a per-file floor on top of the + # 86% total floor in pytest.ini. See issue #2790 for full rationale. + run: | + set -e + PER_FILE_FLOOR=75 + CRITICAL_FILES=( + "a2a_mcp_server.py" + "mcp_cli.py" + "a2a_tools.py" + "a2a_tools_inbox.py" + "inbox.py" + "platform_auth.py" + ) + + # pytest already wrote .coverage; emit a JSON view scoped to + # the critical files so jq/python can read the per-file pct + # without parsing tabular text. + INCLUDES=$(printf '*%s,' "${CRITICAL_FILES[@]}") + INCLUDES="${INCLUDES%,}" + python -m coverage json -o /tmp/critical-cov.json --include="$INCLUDES" + + FAILED=0 + for f in "${CRITICAL_FILES[@]}"; do + pct=$(jq -r --arg f "$f" '.files | to_entries | map(select(.key == $f)) | .[0].value.summary.percent_covered // "MISSING"' /tmp/critical-cov.json) + if [ "$pct" = "MISSING" ]; then + echo "::error file=workspace/$f::No coverage data — file may have moved or test exclusion mis-set." + FAILED=$((FAILED+1)) + continue + fi + echo "$f: ${pct}%" + if awk "BEGIN{exit !($pct < $PER_FILE_FLOOR)}"; then + echo "::error file=workspace/$f::${pct}% < ${PER_FILE_FLOOR}% per-file floor (MCP critical path). See COVERAGE_FLOOR.md." + FAILED=$((FAILED+1)) + fi + done + + if [ "$FAILED" -gt 0 ]; then + echo "" + echo "$FAILED MCP critical-path file(s) below the ${PER_FILE_FLOOR}% per-file floor." + echo "These paths handle multi-tenant routing, auth tokens, and inbox dispatch." + echo "A coverage drop here is the same risk shape as Go-side tokens/secrets files" + echo "dropping below 10% (see COVERAGE_FLOOR.md). Either:" + echo " (a) add tests to raise coverage back above ${PER_FILE_FLOOR}%, or" + echo " (b) if this is unavoidable historical debt, file an issue and propose" + echo " adjusting the floor with rationale in COVERAGE_FLOOR.md." + exit 1 + fi From f82033a3ca3209a1d611be20c2c264ad94643932 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 03:52:40 +0000 Subject: [PATCH 04/10] [ci force] force fresh runner From 1f9042688eb7359e5f652cf9b3688f51c74d2e9a Mon Sep 17 00:00:00 2001 From: Molecule AI Core-DevOps Date: Mon, 11 May 2026 05:02:59 +0000 Subject: [PATCH 05/10] ci: install jq before sop-tier-check script runs Gitea Actions runners (ubuntu-latest) do not bundle jq. The sop-tier-check script uses jq for all JSON API parsing. Install jq before the script runs so sop-tier-check can pass. Uses direct binary download from GitHub releases (faster, more reliable than apt-get in containerized environments) with apt-get fallback and jq --version smoke test. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/sop-tier-check.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.gitea/workflows/sop-tier-check.yml b/.gitea/workflows/sop-tier-check.yml index d4b74ed3..76750d50 100644 --- a/.gitea/workflows/sop-tier-check.yml +++ b/.gitea/workflows/sop-tier-check.yml @@ -77,6 +77,23 @@ jobs: # works if we never check out PR HEAD. Same SHA the workflow # itself was loaded from. ref: ${{ github.event.pull_request.base.sha }} + - name: Install jq + # Gitea Actions runners (ubuntu-latest label) do not bundle jq. + # The sop-tier-check script uses jq for all JSON API parsing. + # Install jq before the script runs so sop-tier-check can pass. + # + # Method: download binary directly from GitHub releases (faster and + # more reliable than apt-get in containerized environments). Falls + # back to apt-get if the download fails. The smoke test confirms + # jq is on PATH before the main script runs. + run: | + set -e + timeout 60 curl -sSL \ + "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \ + -o /usr/local/bin/jq && chmod +x /usr/local/bin/jq \ + || apt-get update -qq && apt-get install -y -qq jq + jq --version + - name: Verify tier label + reviewer team membership env: # SOP_TIER_CHECK_TOKEN is the org-level secret for the From 93b7d9a88a6bfe6532fb194c9ee2f89b82d26a43 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 01:22:02 +0000 Subject: [PATCH 06/10] fix(a2a_tools): add comment + test coverage for string-form error handling in delegate_task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staging branch bea89ce4 introduced duplicate dead code after a `return` in the delegate_task error-handling block — the first occurrence was the correct fix (adding isinstance(err, str)), but the second occurrence (now unreachable) made the block fragile. Main already has the correct code; this branch adds an explanatory comment and regression tests. The non-tool delegate_task() in a2a_tools.py uses httpx.AsyncClient directly (not send_a2a_message) and must handle three A2A proxy error shapes: {"error": "plain string"} ← the bug fix: isinstance(err, str) {"error": {"message": "...", ...}} ← pre-existing path {"error": {"nested": "object"}} ← falls through to str(err) Adds TestDelegateTaskDirect: test_string_form_error_returns_error_message — regression for AttributeError test_dict_form_error_returns_error_message — pre-existing path still works test_success_returns_result_text — happy path still works Co-Authored-By: Claude Opus 4.7 --- workspace/builtin_tools/a2a_tools.py | 2 + workspace/tests/test_a2a_tools_impl.py | 99 ++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/workspace/builtin_tools/a2a_tools.py b/workspace/builtin_tools/a2a_tools.py index acdd15cb..d568ee40 100644 --- a/workspace/builtin_tools/a2a_tools.py +++ b/workspace/builtin_tools/a2a_tools.py @@ -77,6 +77,8 @@ async def delegate_task(workspace_id: str, task: str) -> str: return str(result) if isinstance(result, str) else "(no text)" elif "error" in data: 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", "") diff --git a/workspace/tests/test_a2a_tools_impl.py b/workspace/tests/test_a2a_tools_impl.py index 801eae80..690b3fc5 100644 --- a/workspace/tests/test_a2a_tools_impl.py +++ b/workspace/tests/test_a2a_tools_impl.py @@ -326,6 +326,105 @@ class TestToolDelegateTask: assert a2a_tools._peer_names.get("ws-nona000") is not None +# --------------------------------------------------------------------------- +# delegate_task (non-tool, direct httpx path — used by adapter templates) +# --------------------------------------------------------------------------- + +class TestDelegateTaskDirect: + + async def test_string_form_error_returns_error_message(self): + """The A2A proxy can return {"error": "plain string"}. Must not raise + AttributeError: 'str' object has no attribute 'get'.""" + import a2a_tools + + # Mock: discover succeeds, A2A POST returns a string-form error + mc = AsyncMock() + mc.__aenter__ = AsyncMock(return_value=mc) + mc.__aexit__ = AsyncMock(return_value=False) + + async def fake_post(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={"error": "peer workspace unreachable"}) + return r + + async def fake_get(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) + return r + + mc.post = fake_post + mc.get = fake_get + + with patch("a2a_tools.httpx.AsyncClient", return_value=mc): + result = await a2a_tools.delegate_task("ws-peer-123", "do a thing") + + assert "Error" in result + assert "peer workspace unreachable" in result + + async def test_dict_form_error_returns_error_message(self): + """{"error": {"message": "...", "code": ...}} — the pre-existing path.""" + import a2a_tools + + mc = AsyncMock() + mc.__aenter__ = AsyncMock(return_value=mc) + mc.__aexit__ = AsyncMock(return_value=False) + + async def fake_post(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={"error": {"message": "internal server error", "code": 500}}) + return r + + async def fake_get(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) + return r + + mc.post = fake_post + mc.get = fake_get + + with patch("a2a_tools.httpx.AsyncClient", return_value=mc): + result = await a2a_tools.delegate_task("ws-peer-456", "do a thing") + + assert "Error" in result + assert "internal server error" in result + + async def test_success_returns_result_text(self): + """Happy path: result with parts returns the first text part.""" + import a2a_tools + + mc = AsyncMock() + mc.__aenter__ = AsyncMock(return_value=mc) + mc.__aexit__ = AsyncMock(return_value=False) + + async def fake_post(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={ + "result": { + "parts": [{"kind": "text", "text": "Task done!"}] + } + }) + return r + + async def fake_get(url, **kwargs): + r = MagicMock() + r.status_code = 200 + r.json = MagicMock(return_value={"url": "http://peer.svc/a2a"}) + return r + + mc.post = fake_post + mc.get = fake_get + + with patch("a2a_tools.httpx.AsyncClient", return_value=mc): + result = await a2a_tools.delegate_task("ws-peer-789", "do a thing") + + assert result == "Task done!" + + # --------------------------------------------------------------------------- # tool_delegate_task_async # --------------------------------------------------------------------------- From f4e42c23b279f8ba5ad5e6176394bb638144657a Mon Sep 17 00:00:00 2001 From: "claude-ceo-assistant (Claude Opus 4.7 on Hongming's MacBook)" Date: Sun, 10 May 2026 23:00:10 -0700 Subject: [PATCH 07/10] Revert "ci: install jq before sop-tier-check script runs" This reverts commit 1f9042688eb7359e5f652cf9b3688f51c74d2e9a. --- .gitea/workflows/sop-tier-check.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.gitea/workflows/sop-tier-check.yml b/.gitea/workflows/sop-tier-check.yml index 76750d50..d4b74ed3 100644 --- a/.gitea/workflows/sop-tier-check.yml +++ b/.gitea/workflows/sop-tier-check.yml @@ -77,23 +77,6 @@ jobs: # works if we never check out PR HEAD. Same SHA the workflow # itself was loaded from. ref: ${{ github.event.pull_request.base.sha }} - - name: Install jq - # Gitea Actions runners (ubuntu-latest label) do not bundle jq. - # The sop-tier-check script uses jq for all JSON API parsing. - # Install jq before the script runs so sop-tier-check can pass. - # - # Method: download binary directly from GitHub releases (faster and - # more reliable than apt-get in containerized environments). Falls - # back to apt-get if the download fails. The smoke test confirms - # jq is on PATH before the main script runs. - run: | - set -e - timeout 60 curl -sSL \ - "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \ - -o /usr/local/bin/jq && chmod +x /usr/local/bin/jq \ - || apt-get update -qq && apt-get install -y -qq jq - jq --version - - name: Verify tier label + reviewer team membership env: # SOP_TIER_CHECK_TOKEN is the org-level secret for the From aa49dbc72832d042ac54fd59e613a8b08a288bd7 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 06:15:42 +0000 Subject: [PATCH 08/10] fix(handlers): add rows.Err() checks after rows.Next() loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add deferred error checks following rows.Next() iteration in: - ListDelegations (delegation.go): log on error, continue serving results - org import reconcile orphan query (org.go): log + append to reconcileErrs Fixes the rows.Err() gap identified in the delegated rows.Err() check PR (#302, closed; replaced by this PR). Two additional files already had the check (activity.go, memories.go) — pattern applied consistently here. Co-Authored-By: Claude Opus 4.7 --- workspace-server/internal/handlers/delegation.go | 3 +++ workspace-server/internal/handlers/org.go | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/workspace-server/internal/handlers/delegation.go b/workspace-server/internal/handlers/delegation.go index 6761ec7e..e0d06b8b 100644 --- a/workspace-server/internal/handlers/delegation.go +++ b/workspace-server/internal/handlers/delegation.go @@ -645,6 +645,9 @@ func (h *DelegationHandler) ListDelegations(c *gin.Context) { } delegations = append(delegations, entry) } + if err := rows.Err(); err != nil { + log.Printf("ListDelegations rows.Err: %v", err) + } if delegations == nil { delegations = []map[string]interface{}{} diff --git a/workspace-server/internal/handlers/org.go b/workspace-server/internal/handlers/org.go index 8b5c4585..b93671dd 100644 --- a/workspace-server/internal/handlers/org.go +++ b/workspace-server/internal/handlers/org.go @@ -800,6 +800,10 @@ func (h *OrgHandler) Import(c *gin.Context) { orphanIDs = append(orphanIDs, orphanID) } } + if err := rows.Err(); err != nil { + log.Printf("Org import reconcile: orphan query rows.Err: %v", err) + reconcileErrs = append(reconcileErrs, fmt.Sprintf("orphan query rows.Err: %v", err)) + } rows.Close() for _, oid := range orphanIDs { From 8d4a9a184fc28b2614d9a0c4ac227b257ae73c13 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 06:24:01 +0000 Subject: [PATCH 09/10] ci: re-trigger after runner stall Force a fresh sop-tier-check run to check if runners have recovered from infra#241 OOM cascade. Co-Authored-By: Claude Opus 4.7 From 150bf84b0b9b1cfc1864bd4c7b553080f404b181 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-BE Date: Mon, 11 May 2026 06:42:24 +0000 Subject: [PATCH 10/10] ci: re-trigger CI for fresh PR Co-Authored-By: Claude Opus 4.7