Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer 91ea13cd43 fix(workspace-server): inject <PROVIDER>_BASE_URL fallback when API key is set but URL missing (internal#417)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Waiting to run
CI / Canvas Deploy Reminder (pull_request) Blocked by required conditions
CI / Python Lint & Test (pull_request) Blocked by required conditions
CI / all-required (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Successful in 32s
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m11s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m58s
CI / Canvas (Next.js) (pull_request) Successful in 16m48s
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
When the operator saves only a vendor API key (e.g. MINIMAX_API_KEY)
into global_secrets without the matching <PROVIDER>_BASE_URL, the
in-workspace adapter falls back to its hardcoded registry default —
which for MiniMax is api.minimaxi.com (China region), while every
operator-host key in the post-2026-05-06 stack is issued against
api.minimax.io (global region). The result is silent 401 on every
LLM call from the workspace.

Fix: in workspace-server/internal/handlers/workspace_provision.go's
loadWorkspaceSecrets, after both DB queries, run a fallback pass that
injects the canonical <PROVIDER>_BASE_URL for every vendor whose API
key is set but whose URL is absent. Operator-saved URLs always win
(no overwrite); empty keys are treated as unset (no injection).

The canonical URL registry lives in
workspace-server/internal/handlers/provider_defaults.go and mirrors
the third-party provider lists in workspace-configs-templates/
openclaw + claude-code-default + hermes adapters. Six providers
covered (openai, groq, openrouter, qianfan, minimax, moonshot).

Also seed MINIMAX_API_KEY + MINIMAX_BASE_URL into the dev-local
post-rebuild-setup.sh global_secrets block so dev parity matches
prod-style provisioning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:28:06 -07:00
96 changed files with 1631 additions and 5060 deletions
-1
View File
@@ -1 +0,0 @@
refire:1778784369
+4 -11
View File
@@ -203,17 +203,12 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
def ci_job_names(ci_doc: dict) -> set[str]:
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
whose `if:` gates on `github.event_name` or `github.ref` (those are
event-scoped and can legitimately be `skipped` for a given trigger;
if we required them under the sentinel `needs:`, every PR-only job
whose `if:` gates on `github.event_name` (those are event-scoped
and can legitimately be `skipped` for a given trigger; if we
required them under the sentinel `needs:`, every PR-only job
would be `skipped` on push and the sentinel would interpret
`skipped != success` as failure). RFC §4 spec.
`github.ref` is the companion gate for jobs that run only on direct
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
These never execute in a PR context, so flagging them as missing
from `all-required.needs:` is a false positive (mc#958 / mc#959).
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
(typos in needs) — see `ci_jobs_all` for that."""
jobs = ci_doc.get("jobs")
@@ -226,9 +221,7 @@ def ci_job_names(ci_doc: dict) -> set[str]:
continue
if isinstance(v, dict):
gate = v.get("if")
if isinstance(gate, str) and (
"github.event_name" in gate or "github.ref" in gate
):
if isinstance(gate, str) and "github.event_name" in gate:
continue
names.add(k)
return names
+1 -15
View File
@@ -417,21 +417,7 @@ def main() -> int:
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_require_runtime_env()
try:
return process_once(dry_run=args.dry_run)
except ApiError as exc:
# API errors (401/403/404/500) are transient for a queue tick —
# log and exit 0 so the workflow is not marked failed and the next
# tick can retry. Returning non-zero would permanently fail the
# workflow run, blocking future ticks.
sys.stderr.write(f"::error::queue API error: {exc}\n")
return 0
except urllib.error.URLError as exc:
sys.stderr.write(f"::error::queue network error: {exc}\n")
return 0
except TimeoutError as exc:
sys.stderr.write(f"::error::queue timeout: {exc}\n")
return 0
return process_once(dry_run=args.dry_run)
if __name__ == "__main__":
+25 -168
View File
@@ -68,7 +68,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
from typing import Any
# ---------------------------------------------------------------------------
@@ -110,7 +110,7 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke|sop-n/a)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
@@ -118,21 +118,19 @@ _DIRECTIVE_RE = re.compile(
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
) -> tuple[list[tuple[str, str, str]], list]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns (directives, na_directives) where each is a list of
(kind, canonical_slug, note) tuples:
kind is "sop-ack", "sop-revoke", or "sop-n/a"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
The two lists are kept separate so call sites can unpack them
directly (e.g. directives, na_directives = parse_directives(...)).
Returns (directives, na_directives) where:
directives is a list of (kind, canonical_slug, note) tuples
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
na_directives is reserved for future N/A handling (always [] for now)
"""
directives: list[tuple[str, str, str]] = []
na_directives: list[tuple[str, str, str]] = []
out: list[tuple[str, str, str]] = []
if not comment_body:
return directives, na_directives
return out, []
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
@@ -162,12 +160,8 @@ def parse_directives(
note_from_group = (m.group(3) or "").strip()
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
entry = (kind, canonical, note_from_group)
if kind == "sop-n/a":
na_directives.append(entry)
else:
directives.append(entry)
return directives, na_directives
out.append((kind, canonical, note_from_group))
return out, []
# ---------------------------------------------------------------------------
@@ -180,8 +174,8 @@ def section_marker_present(body: str, marker: str) -> bool:
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next non-blank line — this prevents
trivially-empty checklists like:
same line OR within the next line — this prevents trivially-empty
checklists like:
## SOP-Checklist
- [ ] **Comprehensive testing performed**:
@@ -190,18 +184,9 @@ def section_marker_present(body: str, marker: str) -> bool:
from auto-passing the section-present check. The peer-ack is still
required, but answering with empty content is captured as a soft
finding via the section-present test alone.
NOTE: we scan forward through blank lines (the markdown-header pattern
is ## Header\\n\\ncontent) so that a header + blank-line + content
structure still satisfies the check. The backward checkbox fallback
catches inline markers without a preceding checkbox (mc#1099).
"""
if not body or not marker:
return False
# Strip trailing whitespace so the blank-line scan below can find
# content that appears on the very last line of the body (without
# being misled by a trailing \n or spaces).
body = body.rstrip()
body_lower = body.lower()
marker_lower = marker.lower()
idx = body_lower.find(marker_lower)
@@ -217,44 +202,13 @@ def section_marker_present(body: str, marker: str) -> bool:
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
# Fall through: scan forward, skipping blank-only lines, until we find
# non-empty content or run out of body. Handles:
# ## Header ← marker line (empty after marker)
# ← blank line (skipped)
# - actual content ← found
pos = line_end
while True:
# Skip the current newline and any additional newlines (blank lines).
while pos < len(body) and body[pos] == "\n":
pos += 1
if pos >= len(body):
break
line_end = body.find("\n", pos)
if line_end < 0:
line_end = len(body)
line = body[pos:line_end]
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
pos = line_end
# Last resort: the marker may appear mid-sentence (e.g.
# **Memory/saved-feedback consulted**: No applicable...).
# Search backward within the CURRENT LINE only (not preceding lines)
# to find a checkbox on the same line before the marker text.
# mc#1099 follow-up: memory-consulted detection was failing because
# the checkbox was on the same line before the inline marker.
_CHECKBOX_RE = re.compile(r"- \[[ x\]]|<input", re.IGNORECASE)
line_start = body.rfind("\n", 0, idx) + 1 # 0 if no newline before idx
before = body[line_start:idx]
m = _CHECKBOX_RE.search(before)
if not m:
return False
# Require meaningful content between the checkbox and the marker text
# (markdown formatting like ** or * must also be stripped).
# If only whitespace/markdown chars remain, the checkbox line is empty.
between = before[m.end() :]
stripped_between = re.sub(r"[\s\*:#\[\]_\-]+", "", between)
return bool(stripped_between)
# Fall through: check the NEXT line (multi-line answers).
next_line_end = body.find("\n", line_end + 1)
if next_line_end < 0:
next_line_end = len(body)
next_line = body[line_end + 1:next_line_end]
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
return bool(stripped_next)
# ---------------------------------------------------------------------------
@@ -297,7 +251,8 @@ def compute_ack_state(
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
directives, _na = parse_directives(body, numeric_aliases)
for kind, slug, _note in directives:
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
@@ -349,63 +304,6 @@ def compute_ack_state(
}
# ---------------------------------------------------------------------------
# N/A-gate evaluation
# ---------------------------------------------------------------------------
def compute_na_state(
comments: list[dict[str, Any]],
author: str,
na_gates: dict[str, Any],
probe: Callable[[str, list[str]], list[str]],
) -> dict[str, dict[str, Any]]:
"""Evaluate which N/A gates have a valid declaration from a team member.
Returns dict[gate_name, dict] where each dict has:
declared: bool — at least one valid non-author team-member declared N/A
decl_ackers: list[str] — usernames who declared this gate N/A
rejected: dict with keys:
not_in_team: list[str] — users who tried but aren't in required teams
"""
# Build per-user latest N/A directive (most-recent wins per RFC#324).
latest_na: dict[str, tuple[str, str]] = {} # user → (gate, note)
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, gate, note in parse_directives(body, {})[1]:
# [1] = na_directives only
if gate in na_gates:
latest_na[user] = (gate, note)
result: dict[str, dict[str, Any]] = {}
for gate, gate_cfg in na_gates.items():
result[gate] = {
"declared": False,
"decl_ackers": [],
"rejected": {"not_in_team": []},
}
decl_ackers: list[str] = []
not_in_team: list[str] = []
for user, (g, _note) in latest_na.items():
if g != gate:
continue
if user == author:
continue # authors cannot self-declare N/A
approved = probe(gate, [user])
if approved:
decl_ackers.append(user)
else:
not_in_team.append(user)
result[gate]["declared"] = bool(decl_ackers)
result[gate]["decl_ackers"] = decl_ackers
result[gate]["rejected"]["not_in_team"] = not_in_team
return result
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
@@ -800,7 +698,6 @@ def main(argv: list[str] | None = None) -> int:
cfg = load_config(args.config)
items: list[dict[str, Any]] = cfg["items"]
items_by_slug = {it["slug"]: it for it in items}
na_gates: dict[str, Any] = cfg.get("n/a_gates", {})
numeric_aliases = {
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
}
@@ -921,46 +818,6 @@ def main(argv: list[str] | None = None) -> int:
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context}{state}")
# --- N/A gate status (RFC#324 §N/A follow-up) ---
# Post a separate status so review-check.sh can discover N/A declarations
# and waive the Gitea-approve requirement for that gate.
na_state: dict[str, dict[str, Any]] = {}
if na_gates:
na_state = compute_na_state(comments, author, na_gates, probe)
na_descs: list[str] = []
for gate, s in na_state.items():
if s["declared"]:
na_descs.append(gate)
decl = s["decl_ackers"]
rej = s["rejected"]["not_in_team"]
if decl:
print(f"::notice:: [N/A OK] {gate} — declared by {','.join(decl)}")
if rej:
print(
f"::notice:: [N/A REJ] {gate} — not-in-team: {','.join(rej)}",
file=sys.stderr,
)
na_desc = ", ".join(sorted(na_descs)) if na_descs else "(none)"
na_status_state = "success" if na_descs else "pending"
# review-check.sh reads the description to discover which gates are N/A.
# Include the gate names so it can grep for them.
na_description = f"N/A: {na_desc}" if na_descs else "N/A: (none)"
if not args.dry_run:
client.post_status(
args.owner, args.repo, head_sha,
state=na_status_state,
context="sop-checklist / na-declarations (pull_request)",
description=na_description,
target_url=target_url,
)
print(
f"::notice::na-declarations status → {na_status_state}: {na_description}"
)
# By default exit 0 — the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
@@ -551,55 +551,3 @@ class TestEndToEndAckFlow(unittest.TestCase):
if __name__ == "__main__":
unittest.main(verbosity=2)
# ---------------------------------------------------------------------------
# compute_na_state
# ---------------------------------------------------------------------------
class TestComputeNaState(unittest.TestCase):
"""Tests for /sop-n/a directive evaluation."""
def test_no_na_declarations(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = []
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda *_: [])
self.assertFalse(na_state["qa-review"]["declared"])
self.assertFalse(na_state["security-review"]["declared"])
def test_na_declared_by_authorized_user(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("bob", "/sop-n/a qa-review N/A: pure tooling change")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
self.assertTrue(na_state["qa-review"]["declared"])
self.assertEqual(na_state["qa-review"]["decl_ackers"], ["bob"])
def test_na_declared_by_unauthorized_user_rejected(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("mallory", "/sop-n/a qa-review N/A: not real team")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: [])
self.assertFalse(na_state["qa-review"]["declared"])
self.assertEqual(na_state["qa-review"]["rejected"]["not_in_team"], ["mallory"])
def test_author_cannot_self_declare_na(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("alice", "/sop-n/a qa-review N/A: I am the author")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
self.assertFalse(na_state["qa-review"]["declared"])
def test_parse_directives_separates_na_from_ack(self):
directives, na_directives = sop.parse_directives(
"/sop-ack comprehensive-testing\n/sop-n/a qa-review N/A: no surface",
{},
)
self.assertEqual(len(directives), 1)
self.assertEqual(directives[0][0], "sop-ack")
self.assertEqual(len(na_directives), 1)
self.assertEqual(na_directives[0][0], "sop-n/a")
self.assertEqual(na_directives[0][1], "qa-review")
self.assertIn("no surface", na_directives[0][2])
+93 -109
View File
@@ -348,15 +348,16 @@ jobs:
# Shellcheck (E2E scripts) — required check, always runs.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: false
- 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: always()
- if: needs.changes.outputs.scripts == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- 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
@@ -367,16 +368,16 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
@@ -386,7 +387,7 @@ jobs:
run: |
bash scripts/test-promote-tenant-image.sh
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
@@ -406,8 +407,8 @@ jobs:
# ci_job_names() detects this as github.ref-gated and skips it from F1.
# The step-level exit 0 handles the "not main push" case; the job-level
# `if:` makes the gating explicit so the drift script sees it.
# Runs on both main and staging pushes; step exits 0 when not applicable.
if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' }}
# continue-on-error removed (was mc#774 mask): step exits 0 when not applicable.
if: ${{ github.ref == 'refs/heads/staging' }}
needs: [changes, canvas-build]
steps:
- name: Write deploy reminder to step summary
@@ -458,6 +459,7 @@ jobs:
# Python Lint & Test — required check, always runs.
python-lint:
name: Python Lint & Test
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
@@ -467,25 +469,25 @@ jobs:
run:
working-directory: workspace
steps:
- if: false
- 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: always()
- if: needs.changes.outputs.python == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- 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: always()
- 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: always()
- if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short
- if: always()
- 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.
@@ -550,104 +552,86 @@ jobs:
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
# jobs settle, leaving branch protection with a permanent pending
# `CI / all-required` context. Instead, this independent sentinel polls the
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
# Three properties of this job each close a failure mode:
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
# it in this dependency list lets a skipped reminder skip the required
# sentinel before the `always()` guard can emit a branch-protection status.
# 1. `if: always()` — runs even when an upstream fails. Without it the
# sentinel is `skipped` and protection treats that as missing → merge
# ungated.
#
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
# entry that couldn't run) must NOT silently pass through.
# `skipped`-as-green is exactly the failure mode this gate closes.
#
# 3. `needs:` is the canonical list of "what counts as required."
# status_check_contexts will reference only `ci/all-required` (Step 5
# follow-up — branch-protection PATCH is Owners-tier per
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
# added simply by listing it in `needs:` here.
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# canvas-deploy-reminder is intentionally excluded from all-required.needs:
# it needs canvas-build, which is skipped on CI-only PRs (canvas=false).
# Including it in all-required.needs causes all-required to hang on
# every CI-only PR. Keep it runnable on PRs via its own
# `needs: [changes, canvas-build]` — the sentinel only aggregates the result.
#
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
# (Gitea suppresses status reporting for CoE jobs). This sentinel
# runs with continue-on-error: false so it always reports its
# result to the API — without this, the required-status entry
# (CI / all-required (pull_request)) is never created, which
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
# continue-on-error: false; this sentinel can then be flipped to
# continue-on-error: true if a Phase-4 regression requires it.
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 1
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
- canvas-deploy-reminder
if: ${{ always() }}
steps:
- name: Wait for required CI contexts
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API_ROOT: ${{ github.server_url }}/api/v1
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
- name: Assert every required dependency succeeded
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import sys
import time
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
api_root = os.environ["API_ROOT"].rstrip("/")
repo = os.environ["REPOSITORY"]
sha = os.environ["COMMIT_SHA"]
event = os.environ["EVENT_NAME"]
required = [
f"CI / Detect changes ({event})",
f"CI / Platform (Go) ({event})",
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
]
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
last_summary = None
def fetch_statuses():
statuses = []
for page in range(1, 6):
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=10) as resp:
chunk = json.load(resp)
if not chunk:
break
statuses.extend(chunk)
latest = {}
for item in statuses:
ctx = item.get("context")
if not ctx:
continue
prev = latest.get(ctx)
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
latest[ctx] = item
return latest
while True:
try:
latest = fetch_statuses()
except (TimeoutError, OSError, urllib.error.URLError) as exc:
if time.time() >= deadline:
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
sys.exit(1)
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
time.sleep(15)
continue
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
if summary != last_summary:
print(summary, flush=True)
last_summary = summary
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
if bad:
print("FAIL: required CI context failed:", file=sys.stderr)
for ctx, state in bad.items():
desc = (latest.get(ctx) or {}).get("description") or ""
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
sys.exit(1)
if all(state == "success" for state in states.values()):
print(f"OK: all {len(required)} required CI contexts succeeded")
sys.exit(0)
if time.time() >= deadline:
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
for ctx, state in states.items():
print(f" - {ctx}: {state}", file=sys.stderr)
sys.exit(1)
time.sleep(15)
PY
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
# Null results are skipped: they come from Phase 3 (continue-on-error: true
# suppresses status) or from jobs still in-flight. The sentinel succeeds
# rather than blocking PRs on Phase 3 noise.
results='${{ toJSON(needs) }}'
echo "$results"
echo "$results" | python3 -c '
import json, sys
ns = json.load(sys.stdin)
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
# Remove when mc#774 handler test failures are resolved.
PHASE3_MASKED = {"platform-build"}
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
bad = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
if bad:
print(f"FAIL: jobs not green:", file=sys.stderr)
for k, r in bad:
print(f" - {k}: {r}", file=sys.stderr)
sys.exit(1)
pending = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") is None]
cancelled = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") == "cancelled"]
if pending:
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
", ".join(k for k, _ in pending), file=sys.stderr)
if cancelled:
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
", ".join(k for k, _ in cancelled), file=sys.stderr)
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
'
-37
View File
@@ -69,13 +69,6 @@ name: E2E API Smoke Test
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
# they DO come up. Timeouts are not the bottleneck; not bumped.
#
# Item #1046 (fixed 2026-05-14): Stale platform-server from cancelled runs
# lingers on :8080 after "Stop platform" step is skipped (workflow cancelled
# before reaching line 335). Added a pre-start "Kill stale platform-server"
# step (line 286) that scans /proc for zombie platform-server processes
# and kills them before the port probe or bind. Makes the ephemeral port
# probe + start sequence deterministic.
#
# Item explicitly NOT fixed here: failing test `Status back online`
# fails because the platform's langgraph workspace template image
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
@@ -290,35 +283,6 @@ jobs:
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Kill stale platform-server before start (issue #1046)
if: needs.detect-changes.outputs.api == 'true'
run: |
# Concurrent runs on the same host-network act_runner can leave a
# zombie platform-server from a cancelled/timeout run. Cancelled
# runs never reach the "Stop platform" step (line 335), so the
# old process lingers. Kill it before the ephemeral port probe
# or start so the port is definitively free.
#
# /proc scan — works on any Linux without pkill/lsof/ss.
# comm field is truncated to 15 chars: "platform-serve" matches
# "platform-server". Verify with cmdline to avoid false positives.
killed=0
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
kpid="${pid%/comm}"
kpid="${kpid##*/}"
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
if echo "$cmdline" | grep -q "platform-server"; then
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
kill "$kpid" 2>/dev/null || true
killed=$((killed + 1))
fi
done
if [ "$killed" -gt 0 ]; then
sleep 2
echo "Killed $killed stale process(es); port(s) released."
else
echo "No stale platform-server found."
fi
- name: Start platform (background)
if: needs.detect-changes.outputs.api == 'true'
working-directory: workspace-server
@@ -382,4 +346,3 @@ jobs:
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
-288
View File
@@ -1,288 +0,0 @@
name: E2E Chat
# Comprehensive Playwright E2E for the unified chat stack (desktop
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
# workspace-server, or this workflow file.
#
# Architecture:
# 1. Ephemeral Postgres + Redis (docker, unique container names)
# 2. workspace-server built from source, started with
# MOLECULE_ENV=development (fail-open auth)
# 3. canvas dev server (npm run dev) on :3000
# 4. Playwright tests create workspaces via API, point them at an
# in-process echo runtime, and exercise the full send/receive
# round-trip through the browser.
#
# Parallel-safety: same pattern as e2e-api.yml — per-run container names
# and ephemeral host ports so concurrent jobs on the host-network runner
# don't collide.
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
concurrency:
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
detect-changes:
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
outputs:
chat: ${{ steps.decide.outputs.chat }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: decide
run: |
BASE="${GITHUB_BASE_REF:-${{ 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
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
else
echo "chat=false" >> "$GITHUB_OUTPUT"
fi
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
e2e-chat:
needs: detect-changes
name: E2E Chat
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 15
env:
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- name: No-op pass (paths filter excluded this commit)
if: needs.detect-changes.outputs.chat != 'true'
run: |
echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests."
echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 0:5432 postgres:16 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
exit 1
fi
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
echo "Postgres ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Postgres did not become ready in 30s"
exit 1
- name: Start Redis (docker)
if: needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
exit 1
fi
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Redis did not become ready in 15s"
exit 1
- name: Build platform
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Pick platform port
if: needs.detect-changes.outputs.chat == 'true'
run: |
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Pick canvas port
if: needs.detect-changes.outputs.chat == 'true'
run: |
CANVAS_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "CANVAS_PORT=${CANVAS_PORT}" >> "$GITHUB_ENV"
echo "Canvas host port: ${CANVAS_PORT}"
- name: Start platform (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: |
export MOLECULE_ENV=development
export DATABASE_URL="${DATABASE_URL}"
export REDIS_URL="${REDIS_URL}"
export PORT="${PLATFORM_PORT}"
export CORS_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:${CANVAS_PORT},http://127.0.0.1:${CANVAS_PORT}"
./platform-server > platform.log 2>&1 &
echo $! > platform.pid
- name: Wait for /health
if: needs.detect-changes.outputs.chat == 'true'
run: |
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
echo "Platform up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Platform did not become healthy in 30s"
cat workspace-server/platform.log || true
exit 1
- name: Install canvas dependencies
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npm ci
- name: Install Playwright browsers
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npx playwright install --with-deps chromium
- name: Start canvas dev server (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
npx next dev --turbopack -p "${CANVAS_PORT}" > canvas.log 2>&1 &
echo $! > canvas.pid
for i in $(seq 1 30); do
if curl -sf "http://localhost:${CANVAS_PORT}" > /dev/null 2>&1; then
echo "Canvas up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Canvas did not start in 30s"
cat canvas.log || true
exit 1
- name: Run Playwright E2E tests
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export E2E_DATABASE_URL="${DATABASE_URL}"
export PLAYWRIGHT_BASE_URL="http://localhost:${CANVAS_PORT}"
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts
- name: Dump platform log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat workspace-server/platform.log || true
- name: Dump canvas log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat canvas/canvas.log || true
- name: Upload Playwright report
if: failure() && needs.detect-changes.outputs.chat == 'true'
uses: actions/upload-artifact@v3.2.2
with:
name: playwright-report-chat
path: canvas/playwright-report/
- name: Stop canvas
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
if [ -f canvas/canvas.pid ]; then
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
fi
- name: Stop platform
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
-225
View File
@@ -1,225 +0,0 @@
name: E2E Peer Visibility (literal MCP list_peers)
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
# --------------------------------------------------------------
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
# were reported "fleet-verified / cascade-complete" because the *proxy*
# signals were green (registry registration + heartbeat for Hermes; model
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
# canvas "can you see your peers" actually FAILS:
# - Hermes: 401 on the molecule MCP `list_peers` call
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
#
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
# claude-code) in ONE org and assert each sees the others. The
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
# a multi-runtime matrix into it would conflate concerns and bloat its
# already-45-min run.
# - It needs its own concurrency group so it doesn't fight full-saas /
# canvas for the staging org-creation quota.
# - It needs an independent, non-required status-context name so it can
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
# have not landed) WITHOUT wedging unrelated merges — and flipped to
# REQUIRED in one branch-protection edit once it goes green
# (flip-to-required checklist: molecule-core#1296).
#
# THE ASSERTION IS NOT A PROXY. The driving script
# tests/e2e/test_peer_visibility_mcp_staging.sh issues the byte-for-byte
# JSON-RPC `tools/call name=list_peers` envelope to `POST
# /workspaces/:id/mcp` using each workspace's OWN bearer token, through
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
# read a registry row, /health, the heartbeat table, or
# GET /registry/:id/peers.
#
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
# fake-green mask would defeat the entire purpose. This workflow goes red
# on today's broken behavior and green only when the root-cause fixes
# actually land. It is intentionally NOT in branch_protections — see PR
# body for the required-vs-not decision + flip tracking issue.
#
# Gitea 1.22.6 / act_runner notes honored:
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned
# (feedback_act_runner_github_server_url).
# - pr-validate posts a status under the same check name so a
# workflow-only PR is not silently statusless and the context is
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
# and cannot run per-PR-update).
on:
push:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
pull_request:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
workflow_dispatch:
schedule:
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
# so the two don't collide on the staging org-creation quota.
- cron: '30 7 * * *'
concurrency:
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
# would let a queued staging/main push behind a PR run get cancelled,
# leaving any gate that reads "completed run at SHA" stuck.
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# PR path: post a real status under the required-ready check name so a
# workflow-only PR is never silently statusless. The actual EC2 E2E is
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
# the real assertion — it validates the driving script's bash syntax
# and inline-python so a broken test script fails at PR time.
pr-validate:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate driving script
run: |
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
echo "Real fresh-provision MCP list_peers E2E runs on push to"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
# peer set, then scoped teardown. push(main)/dispatch/cron only.
peer-visibility:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
timeout-minutes: 60
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
# LLM provider key so each runtime can authenticate at boot.
# Priority MiniMax → direct-Anthropic → OpenAI matches
# test_staging_full_saas.sh's secrets-injection chain.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
PV_RUNTIMES: "hermes openclaw claude-code"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
exit 2
fi
echo "Admin token present"
- name: Verify an LLM key present
run: |
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
exit 2
fi
echo "LLM key present"
- name: CP staging health preflight
run: |
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
if [ "$code" != "200" ]; then
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
exit 1
fi
echo "Staging CP healthy"
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
# TERM trap, but if the runner itself is cancelled the trap may not
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
# run created — never a cluster-wide sweep
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
# admin DELETE is idempotent so double-invoking is safe;
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
- name: Teardown safety net (runs on cancel/failure)
if: always()
env:
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
run: |
set +e
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
| python3 -c "
import json, sys, os, datetime
run_id = os.environ.get('GITHUB_RUN_ID', '')
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
# Sweep today AND yesterday's UTC date so a midnight-crossing run
# still matches its own slug (same bug class as the saas/canvas
# safety nets).
today = datetime.date.today()
yest = today - datetime.timedelta(days=1)
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
if run_id:
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
else:
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
orgs = d if isinstance(d, list) else d.get('orgs', [])
cands = [o['slug'] for o in orgs
if any(o.get('slug','').startswith(p) for p in prefixes)
and o.get('instance_status') not in ('purged',)]
print('\n'.join(cands))
" 2>/dev/null)
for slug in $orgs; do
echo "Safety-net teardown: $slug"
set +e
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
set -e
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
fi
done
exit 0
+11 -27
View File
@@ -83,41 +83,25 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each. This scheduled
# refresher is advisory; a transient Gitea list timeout must not turn
# main red. PR-specific gate-check runs still use normal failure
# semantics.
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
pr_numbers=$(python3 <<'PY'
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
socket.setdefaulttimeout(30)
socket.setdefaulttimeout(15)
token = os.environ["GITEA_TOKEN"]
repo = os.environ["REPO"]
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
last_error = None
for attempt in range(1, 4):
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
prs = json.loads(r.read())
break
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
last_error = exc
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
if attempt < 3:
time.sleep(2 * attempt)
else:
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
raise SystemExit(0)
req = urllib.request.Request(
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req) as r:
prs = json.loads(r.read())
for pr in prs:
print(pr["number"])
PY
@@ -86,11 +86,7 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# A full-history checkout can exceed the runner's quiet/startup
# window before the path filter emits logs. Fetch the common push
# case cheaply; the script below fetches the exact BASE SHA if it is
# not present in the shallow checkout.
fetch-depth: 2
fetch-depth: 0
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
@@ -93,7 +93,7 @@ jobs:
lint:
name: lint-continue-on-error-tracking
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 10
# Phase 3 (RFC #219 §1): surface masked defects without blocking
# PRs. Pre-existing continue-on-error: true directives on main
# all violate this lint at first — intentional. Flip to false
+7 -11
View File
@@ -49,17 +49,13 @@ jobs:
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
build-and-push:
name: Build & push canvas image
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
# path (on: push:main, canvas/**) — reserved capacity so a merged
# canvas fix's image build never FIFO-queues behind PR required-CI.
# The `publish` label resolves ONLY to the molecule-runner-publish-*
# sub-pool (config.publish.yaml). HARD DEPENDENCY: this MUST land
# AFTER the publish-lane runners are registered/advertising `publish`
# — the earlier #599 `docker` label attempt queued indefinitely with
# zero eligible runners precisely because the label was targeted
# before any runner advertised it (see #576). The lane is registered
# in this rollout (internal#462) so the precondition holds.
runs-on: publish
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
+2 -8
View File
@@ -66,10 +66,7 @@ concurrency:
jobs:
publish:
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
# path (on: push tag runtime-v*) — reserved capacity, never FIFO
# behind PR-CI. `publish` resolves only to molecule-runner-publish-*.
runs-on: publish
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
@@ -162,7 +159,6 @@ jobs:
exit 1
fi
python -m twine upload \
--verbose \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
@@ -170,9 +166,7 @@ jobs:
cascade:
needs: publish
# Publish/release lane (internal#462) — downstream of the runtime
# publish ship job; keep it on the reserved lane too.
runs-on: publish
runs-on: ubuntu-latest
steps:
- name: Wait for PyPI to propagate the new version
env:
@@ -54,14 +54,7 @@ env:
jobs:
build-and-push:
# Dedicated publish/release lane (internal#462 / #394 / #399). This
# is a post-merge ship job (on: push:main) — it must NOT FIFO-compete
# with PR required-CI on the shared pool (PR#1350's prod image build
# was delayed ~25min this way). The `publish` label resolves ONLY to
# the reserved molecule-runner-publish-* sub-pool (config.publish.yaml,
# OUTSIDE the managed 1..20 range) so a merged fix's image build
# starts immediately while PR-CI keeps the general pool.
runs-on: publish
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -188,9 +181,7 @@ jobs:
name: Production auto-deploy
needs: build-and-push
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Publish/release lane (internal#462) — production deploy of a merged
# fix; reserved capacity, never queued behind PR-CI.
runs-on: publish
runs-on: ubuntu-latest
timeout-minutes: 75
env:
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
@@ -68,10 +68,7 @@ jobs:
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
redeploy:
if: ${{ github.event_name == 'workflow_dispatch' }}
# Dedicated publish/release lane (internal#462 / #394 / #399).
# Production tenant redeploy — a deploy action, reserved capacity so
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
runs-on: publish
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -75,10 +75,7 @@ env:
jobs:
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
redeploy:
# Dedicated publish/release lane (internal#462 / #394 / #399).
# Post-merge staging redeploy — a deploy action, reserved capacity.
# `publish` -> molecule-runner-publish-* sub-pool.
runs-on: publish
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
@@ -18,10 +18,6 @@ permissions:
pull-requests: read
statuses: write
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
+1 -1
View File
@@ -70,7 +70,7 @@ name: sop-checklist
# Cancel any in-progress runs for the same PR to prevent
# stale runs from overwriting newer status contexts.
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
group: ${{ github.repository }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
-4
View File
@@ -61,10 +61,6 @@ on:
pull_request_review:
types: [submitted, dismissed, edited]
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tier-check:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1 +1 @@
staging trigger 2026-05-14T17:35:02Z
staging trigger
-1
View File
@@ -1 +0,0 @@
trigger
-173
View File
@@ -1,173 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("Desktop ChatTab", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
// Click the workspace node by its exact name label.
await page.getByText(workspaceName, { exact: true }).first().click();
// Wait for the side panel chat tab to be clickable, then click it.
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("What is the weather?");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Persistence test");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 });
});
test("file attachment round-trip", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Please read this file");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("secret content abc123"),
});
await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 });
});
test("activity log appears during send", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Trigger activity");
await page.getByRole("button", { name: /Send/ }).first().click();
// Activity log container should appear during the send flow.
await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => {
// Activity log may not be present in all layouts.
});
});
});
test.describe("Desktop ChatTab — Markdown rendering", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
const skipGuide2 = page.getByText("Skip guide");
if (await skipGuide2.isVisible().catch(() => false)) {
await skipGuide2.click();
}
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("code block renders <pre>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("```js\nconst x = 1;\n```");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
const pre = page.locator("pre").first();
await expect(pre).toBeVisible({ timeout: 5_000 });
await expect(pre).toContainText("const x = 1;");
});
test("table renders <table>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
const table = page.locator("table").first();
await expect(table).toBeVisible({ timeout: 5_000 });
await expect(table).toContainText("A");
await expect(table).toContainText("1");
});
});
-97
View File
@@ -1,97 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("MobileChat", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
// Navigate directly to the mobile chat view.
await page.goto(`/?m=chat&a=${workspaceId}`);
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile test message");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile persistence");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
});
test("composer auto-grows with multi-line text", async ({ page }) => {
const textarea = page.locator("textarea").first();
const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
await page.waitForTimeout(300);
const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
expect(grownHeight).toBeGreaterThan(initialHeight);
});
test("file attachment in mobile chat", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile file test");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "mobile.txt",
mimeType: "text/plain",
buffer: Buffer.from("mobile secret"),
});
await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
});
});
-187
View File
@@ -1,187 +0,0 @@
/**
* E2E seed fixture for chat tests.
*
* Creates an external workspace via the workspace-server API, extracts the
* auto-minted auth token, then overrides the DB row so it appears "online"
* with an echo-runtime URL. External runtime is used because the health
* sweep skips Docker checks for external workspaces; we keep the workspace
* alive with periodic heartbeats.
*/
import { randomUUID } from "node:crypto";
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
export interface SeededWorkspace {
id: string;
name: string;
agentURL: string;
authToken: string;
}
/**
* Create an external workspace and wire it to the echo runtime.
*/
export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
// 1. Create external workspace (no URL — platform will mint an auth token).
const runId = Math.random().toString(36).slice(2, 8);
const wsName = `Chat E2E Agent ${runId}`;
const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
}
const ws = (await createRes.json()) as {
id: string;
name: string;
connection?: { auth_token?: string };
};
const authToken = ws.connection?.auth_token;
if (!authToken) {
throw new Error("Workspace created but no auth_token returned");
}
// 2. Direct DB update: mark online + point url at echo runtime.
// The platform blocks loopback URLs at the API layer (SSRF guard),
// so we bypass via psql for local E2E.
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) {
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
}
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) {
throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
}
const [, user, pass, host, port, db] = m;
// Pre-seed a platform_inbound_secret so chat file uploads don't trigger
// the lazy-heal 503 "retry in 30 s" path on first use.
const inboundSecret = Array.from({ length: 43 }, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
Math.floor(Math.random() * 64)
],
).join("");
const psql = [
`PGPASSWORD=${pass} psql`,
`-h ${host} -p ${port} -U ${user} -d ${db}`,
`-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
].join(" ");
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch (err) {
throw new Error(`DB update failed: ${err}`);
}
return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
}
/**
* Start a heartbeat interval that keeps an external workspace alive.
* Returns a stop function.
*/
export function startHeartbeat(
workspaceId: string,
authToken: string,
intervalMs = 30_000,
): () => void {
const send = () => {
fetch(`${PLATFORM_URL}/registry/heartbeat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
workspace_id: workspaceId,
error_rate: 0,
sample_error: "",
active_tasks: 0,
current_task: "",
uptime_seconds: 0,
}),
}).catch(() => {});
};
// Send immediately so the first heartbeat lands before the stale sweep.
send();
const timer = setInterval(send, intervalMs);
return () => clearInterval(timer);
}
/**
* Seed chat-history rows for a workspace.
*/
export async function seedChatHistory(
workspaceId: string,
messages: Array<{ role: "user" | "agent"; content: string }>,
): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const values = messages
.map(
(msg, i) =>
`('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
)
.join(",");
const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
const { execSync } = await import("node:child_process");
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
execSync(psql, { stdio: "pipe", timeout: 10_000 });
}
/**
* Delete a seeded workspace row directly from the DB.
* Uses psql (same credentials as seedWorkspace) so we bypass any
* workspace-server side-effects (container stop, cascade cleanup, etc.)
* that can race or 500 on external workspaces.
*/
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch {
// Best-effort cleanup; don't fail the test suite if the row is already gone.
}
}
/**
* Mint a workspace auth token so the canvas can make authenticated API
* calls (WorkspaceAuth middleware).
*/
export async function mintTestToken(workspaceId: string): Promise<string> {
const res = await fetch(
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
);
if (!res.ok) {
throw new Error(`Failed to mint test token: ${res.status}`);
}
const data = (await res.json()) as { auth_token: string };
return data.auth_token;
}
-180
View File
@@ -1,180 +0,0 @@
/**
* Minimal A2A echo runtime for E2E tests.
*
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
* requests, and returns a response with the original text echoed back.
* Also implements the workspace-side chat upload ingest endpoint so
* file-attachment E2E can exercise the full upload → send → echo
* round-trip.
*
* Usage (inside test fixture):
* const echo = await startEchoRuntime();
* // ... seed workspace with agent_url pointing to echo.baseURL ...
* echo.stop();
*/
import { createServer, type Server } from "node:http";
export interface EchoRuntime {
baseURL: string;
stop: () => Promise<void>;
lastRequest: { method: string; text: string; files: unknown[] } | null;
}
/** Parse a minimal multipart body and extract the first file's name + content. */
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
// Find the boundary line (first line starting with "--").
const str = body.toString("binary");
const firstDash = str.indexOf("--");
if (firstDash === -1) return null;
const eol = str.indexOf("\r\n", firstDash);
if (eol === -1) return null;
const boundary = str.slice(firstDash + 2, eol);
const boundaryMarker = "\r\n--" + boundary;
// Find the first part that has a filename in Content-Disposition.
let pos = eol + 2;
while (pos < str.length) {
const nextBoundary = str.indexOf(boundaryMarker, pos);
if (nextBoundary === -1) break;
const part = str.slice(pos, nextBoundary);
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
if (cdMatch) {
const name = cdMatch[1];
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
// Body starts after the first double-CRLF in the part.
const bodyStart = part.indexOf("\r\n\r\n");
if (bodyStart !== -1) {
// Extract the raw bytes (not the string) so binary is safe.
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
const content = body.subarray(partStartInBody, partEndInBody);
return { name, mimeType, content };
}
}
pos = nextBoundary + boundaryMarker.length;
// Skip trailing "--" (end marker) or CRLF.
if (str.slice(pos, pos + 2) === "--") break;
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
}
return null;
}
export async function startEchoRuntime(): Promise<EchoRuntime> {
let lastRequest: EchoRuntime["lastRequest"] = null;
const server = createServer((req, res) => {
// CORS: allow the canvas origin (localhost:3000) to call us.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = req.url ?? "/";
// Workspace-side chat upload ingest (RFC #2312).
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks);
const file = parseMultipart(body);
if (!file) {
res.writeHead(400);
res.end(JSON.stringify({ error: "no files field" }));
return;
}
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
const prefix = Array.from({ length: 32 }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
const response = {
files: [
{
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
name: sanitized,
mimeType: file.mimeType,
size: file.content.length,
},
],
};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(JSON.stringify(response));
});
return;
}
// Default: A2A JSON-RPC handler.
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
body += chunk;
});
req.on("end", () => {
res.setHeader("Content-Type", "application/json");
try {
const rpc = JSON.parse(body);
const msg = rpc.params?.message;
const textParts =
msg?.parts
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
.map((p: { text?: string }) => p.text)
.filter(Boolean) ?? [];
const fileParts =
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
const text = textParts.join("\n");
lastRequest = {
method: rpc.method ?? "unknown",
text,
files: fileParts,
};
const replyText = text
? `Echo: ${text}`
: fileParts.length > 0
? "Echo: received your file(s)."
: "Echo: hello";
const response = {
jsonrpc: "2.0",
id: rpc.id ?? null,
result: {
parts: [{ kind: "text", text: replyText }],
},
};
res.writeHead(200);
res.end(JSON.stringify(response));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "invalid json" }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
const baseURL = `http://127.0.0.1:${port}`;
return {
baseURL,
stop: () =>
new Promise((resolve) => {
server.close(() => resolve(undefined));
}),
get lastRequest() {
return lastRequest;
},
};
}
+1 -2
View File
@@ -5,10 +5,9 @@ export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
baseURL: "http://localhost:3000",
headless: true,
screenshot: "only-on-failure",
},
@@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
})),
}));
// Wrap cleanup in act() so any pending React state updates (e.g. from
// keyDown handlers that call setTheme) flush before DOM unmount. Without
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => {
act(() => { cleanup(); });
cleanup();
vi.clearAllMocks();
});
@@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
@@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
@@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
+202 -318
View File
@@ -6,21 +6,21 @@
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
import {
useChatHistory,
useChatSend,
useChatSocket,
} from "@/components/tabs/chat/hooks";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
@@ -29,171 +29,30 @@ const formatStoredTimestamp = (iso: string): string => {
type SubTab = "my" | "a2a";
function MarkdownBubble({
children,
dark,
accent,
}: {
children: string;
dark: boolean;
accent: string;
}) {
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
const linkColor = accent;
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: linkColor, textDecoration: "underline" }}
>
{children}
</a>
),
pre: ({ children }) => (
<pre
style={{
background: codeBlockBg,
padding: "8px 10px",
borderRadius: 8,
overflow: "auto",
fontSize: 12,
lineHeight: 1.5,
fontFamily: MOBILE_FONT_MONO,
margin: "4px 0",
}}
>
{children}
</pre>
),
code: ({ children, className }) => {
const isBlock = className != null && String(className).length > 0;
if (isBlock) {
return (
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
{children}
</code>
);
}
return (
<code
style={{
background: codeBg,
padding: "1px 4px",
borderRadius: 4,
fontSize: 13,
fontFamily: MOBILE_FONT_MONO,
}}
>
{children}
</code>
);
},
ul: ({ children }) => (
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
{children}
</ol>
),
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
h1: ({ children }) => (
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h2: ({ children }) => (
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h3: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h4: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h5: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h6: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: `2px solid ${quoteBorder}`,
margin: "4px 0",
paddingLeft: 8,
opacity: 0.85,
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: `0.5px solid ${quoteBorder}`,
margin: "6px 0",
}}
/>
),
table: ({ children }) => (
<table
style={{
borderCollapse: "collapse",
fontSize: 13,
margin: "4px 0",
width: "100%",
}}
>
{children}
</table>
),
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
th: ({ children }) => (
<th
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
textAlign: "left",
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
}}
>
{children}
</td>
),
}}
>
{children}
</ReactMarkdown>
);
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
interface ApiChatMessage {
id: string;
role: string; // "user" | "agent" | "system"
content: string;
timestamp: string;
attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
}
interface ChatHistoryResponse {
messages: ApiChatMessage[];
reached_end: boolean;
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
@@ -204,40 +63,36 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
// Start empty — history is loaded via useEffect below.
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); // history is loading on mount
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const {
messages,
loading: historyLoading,
loadError: historyError,
loadInitial,
appendMessageDeduped,
} = useChatHistory(agentId);
const {
sending,
uploading,
sendMessage,
error: sendError,
clearError,
releaseSendGuards,
} = useChatSend(agentId, {
getHistoryMessages: () => messages,
onUserMessage: appendMessageDeduped,
onAgentMessage: appendMessageDeduped,
});
useChatSocket(agentId, {
onAgentMessage: appendMessageDeduped,
onSendComplete: releaseSendGuards,
});
// Guard: don't treat the initial store population as a live push.
// Set to false after the first render completes.
const initDoneRef = useRef(false);
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -250,26 +105,81 @@ export function MobileChat({
el.style.height = `${next}px`;
}, [draft]);
// Fetch chat history on mount; keep merging live agentMessages while the
// panel is open. InitDoneRef prevents the initial store snapshot from
// triggering the live-merge path (the store buffer is populated by
// ChatTab on desktop, not on mobile — this effect loads history as the
// mobile-native path).
useEffect(() => {
let cancelled = false;
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
});
const syncLive = () => {
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
if (live.length > 0) {
setMessages((prev) => {
const existingIds = new Set(prev.map((m) => m.id));
const newOnes = live
.filter((m) => !existingIds.has(m.id))
.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
});
}
};
const bootstrap = async (): Promise<(() => void) | undefined> => {
setLoading(true);
setHistoryError(null);
try {
const res = await api.get<ChatHistoryResponse>(
`/workspaces/${agentId}/chat-history?limit=50`,
);
if (cancelled) return;
const initial = (res.messages ?? []).map(mapApiMessage);
setMessages(initial);
// Mark init done BEFORE marking loading=false so any store push
// that arrives in the same tick is treated as live, not init.
initDoneRef.current = true;
setLoading(false);
// Subscribe to live pushes after init is complete.
syncLive();
const unsubscribe = useCanvasStore.subscribe(syncLive);
return unsubscribe; // returned for cleanup
} catch (e) {
if (cancelled) return;
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
setLoading(false);
initDoneRef.current = true;
return undefined;
}
};
let maybeUnsubscribe: (() => void) | undefined;
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
return () => {
cancelled = true;
if (maybeUnsubscribe) maybeUnsubscribe();
};
}, [agentId]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
// Consume any agent messages that arrived while history was loading.
const initialConsumeDoneRef = useRef(false);
useEffect(() => {
if (historyLoading || initialConsumeDoneRef.current) return;
initialConsumeDoneRef.current = true;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
for (const m of msgs) {
appendMessageDeduped(
createMessage("agent", m.content, m.attachments),
);
}
}, [historyLoading, agentId, appendMessageDeduped]);
if (!node) {
return (
<div
@@ -291,32 +201,58 @@ export function MobileChat({
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
const send = async () => {
const text = draft.trim();
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
clearError();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
const files = pendingFiles;
setPendingFiles([]);
await sendMessage(text, files);
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
<div
data-testid="chat-panel"
style={{
height: "100%",
display: "flex",
@@ -457,12 +393,13 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && historyLoading && (
{tab === "my" && loading && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading chat history
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}></div>
<div>Loading chat history</div>
</div>
)}
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
{tab === "my" && !loading && historyError && (
<div
role="alert"
style={{
@@ -476,7 +413,25 @@ export function MobileChat({
<button
type="button"
onClick={() => {
loadInitial();
setLoading(true);
setHistoryError(null);
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
(res: unknown) => {
const r = res as ChatHistoryResponse;
setMessages((r.messages ?? []).map((m) => ({
id: m.id,
role: m.role === "user" ? "user" : "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})));
setLoading(false);
initDoneRef.current = true;
},
).catch((e: unknown) => {
setHistoryError(e instanceof Error ? e.message : "Failed to load");
setLoading(false);
initDoneRef.current = true;
});
}}
style={{
padding: "6px 14px",
@@ -492,7 +447,7 @@ export function MobileChat({
</button>
</div>
)}
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
{tab === "my" && !loading && !historyError && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
@@ -521,9 +476,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
<MarkdownBubble dark={dark} accent={p.accent}>
{m.content}
</MarkdownBubble>
{m.text}
<div
style={{
fontSize: 10,
@@ -532,13 +485,13 @@ export function MobileChat({
fontFamily: MOBILE_FONT_MONO,
}}
>
{formatStoredTimestamp(m.timestamp)}
{m.ts}
</div>
</div>
</div>
);
})}
{sendError && (
{error && (
<div
role="alert"
style={{
@@ -550,7 +503,7 @@ export function MobileChat({
fontSize: 12,
}}
>
{sendError}
{error}
</div>
)}
</div>
@@ -581,60 +534,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
{pendingFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
paddingLeft: 2,
}}
>
{pendingFiles.map((f, i) => (
<div
key={`${f.name}:${f.size}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 10,
background: dark ? "#2a2823" : "#ece9e0",
fontSize: 12,
color: p.text2,
maxWidth: "100%",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.name}
</span>
<button
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
style={{
border: "none",
background: "transparent",
color: p.text3,
cursor: "pointer",
fontSize: 12,
padding: 0,
lineHeight: 1,
}}
>
</button>
</div>
))}
</div>
)}
<div
style={{
display: "flex",
@@ -646,32 +545,21 @@ export function MobileChat({
padding: "6px 6px 6px 12px",
}}
>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => onFilesPicked(e.target.files)}
aria-hidden="true"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -717,32 +605,28 @@ export function MobileChat({
<button
type="button"
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{uploading ? (
<span style={{ fontSize: 10, fontWeight: 600 }}></span>
) : (
Icons.send({ size: 16 })
)}
{Icons.send({ size: 16 })}
</button>
</div>
</div>
@@ -214,7 +214,6 @@ export function MobileDetail({
<button
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
style={{
width: "100%",
height: 52,
+4 -6
View File
@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { type Template } from "@/lib/deploy-preflight";
import { isSaaSTenant } from "@/lib/tenant";
import { tierCode } from "./palette";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
@@ -27,7 +26,6 @@ const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
const p = usePalette(dark);
const isSaaS = isSaaSTenant();
const [templates, setTemplates] = useState<Template[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(true);
const [tplId, setTplId] = useState<string | null>(null);
@@ -45,7 +43,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTemplates(list);
if (list.length > 0) {
setTplId(list[0].id);
setTier(isSaaS ? "T4" : tierCode(list[0].tier));
setTier(tierCode(list[0].tier));
}
})
.catch(() => {
@@ -57,7 +55,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
return () => {
cancelled = true;
};
}, [isSaaS]);
}, []);
const handleSpawn = async () => {
if (busy || !tplId) return;
@@ -69,7 +67,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
await api.post<{ id: string }>("/workspaces", {
name: (name.trim() || chosen.name),
template: chosen.id,
tier: isSaaS ? 4 : Number(tier.slice(1)),
tier: Number(tier.slice(1)),
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
@@ -205,7 +203,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
>
{templates.map((t) => {
const on = tplId === t.id;
const tCode = isSaaS ? "T4" : tierCode(t.tier);
const tCode = tierCode(t.tier);
return (
<button
key={t.id}
@@ -36,7 +36,6 @@ const mockStoreState = {
height?: number;
}>,
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
consumeAgentMessages: () => [],
};
vi.mock("@/store/canvas", () => ({
@@ -358,7 +357,7 @@ describe("MobileChat — chat history", () => {
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
`/workspaces/${mockAgentId}/chat-history?limit=50`,
);
});
@@ -288,7 +288,6 @@ export function AgentCard({
return (
<button
type="button"
data-testid="workspace-card"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
style={{
+696 -97
View File
@@ -5,19 +5,16 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types";
import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { uploadChatFiles, downloadChatFile, isPlatformAttachment } from "./chat/uploads";
import { PendingAttachmentPill } from "./chat/AttachmentViews";
import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useChatHistory } from "./chat/hooks/useChatHistory";
import { useChatSend } from "./chat/hooks/useChatSend";
import { useChatSocket } from "./chat/hooks/useChatSocket";
export { extractReplyText } from "./chat/hooks/useChatSend";
interface Props {
workspaceId: string;
@@ -26,6 +23,147 @@ interface Props {
type ChatSubTab = "my-chat" | "agent-comms";
// A2A response shape (subset). The full schema is in @a2a-js/sdk but we only
// need parts/artifacts text + file extraction for the synchronous fallback.
interface A2AFileRef {
name?: string;
mimeType?: string;
uri?: string;
bytes?: string;
size?: number;
}
// Outbound shape matches a2a-sdk's JSON-RPC `SendMessageRequest`
// Pydantic union (TextPart | FilePart | DataPart). The flat
// protobuf shape `{url, filename, mediaType}` is rejected at the
// request boundary with `Field required` errors — keep this
// outbound shape unless a2a-sdk migrates the JSON-RPC schema.
interface A2APart {
kind: string;
text?: string;
file?: A2AFileRef;
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
// Internal-self-message filtering moved server-side in RFC #2945
// PR-C/D — the platform's /chat-history endpoint applies the
// IsInternalSelfMessage predicate before returning rows, so the
// client no longer needs the local backstop on the history path.
// The proper fix is still X-Workspace-ID header (source_id=workspace_id);
// the platform-side prefix filter handles the residual cases.
// extractReplyText pulls the agent's text reply out of an A2A response.
// Concatenates ALL text parts (joined with "\n") rather than returning
// just the first. Claude Code and other runtimes commonly emit multi-
// part text replies for long content (markdown tables, code blocks),
// and the prior "first part wins" implementation silently truncated
// the rest — observed on a 15k-char Wave 1 brief that rendered only
// the table header. Mirrors extractTextsFromParts in message-parser.ts.
//
// Server-side counterpart in workspace-server/internal/channels/
// manager.go has the same single-part bug; fix that too if/when a
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
// Walk artifacts even if parts had text — some producers (Hermes
// tool calls) emit a summary in parts AND details in artifacts.
// Returning early on parts dropped the artifact body silently.
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
// Agent-returned files live on the same response shape as text —
// delegated to extractFilesFromTask in message-parser.ts, which also
// walks status.message.parts (that ChatTab's legacy text extractor
// doesn't). Single source of truth for file-part parsing across
// live chat, activity log replay, and any future consumers.
/** Initial chat history page size. The newest N messages are rendered
* on first paint; older history is fetched on demand via loadOlder()
* when the user scrolls the top sentinel into view. */
const INITIAL_HISTORY_LIMIT = 10;
/** Subsequent older-history batch size. Larger than INITIAL so a long
* scroll-back doesn't fan out into many round-trips. */
const OLDER_HISTORY_BATCH = 20;
/**
* Load chat history from the platform's typed /chat-history endpoint.
*
* Server-side rendering of activity_logs rows into ChatMessage shape
* lives in workspace-server/internal/messagestore/postgres_store.go
* (RFC #2945 PR-C/D). The server already applies the canvas-source
* filter, the internal-self-message predicate, the role decision
* (status=error vs agent-error prefix → system), and the v0/v1
* file-shape extraction. Canvas just renders what it receives.
*
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
*
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
* 200 → {"messages": ChatMessage[], "reached_end": boolean}
*
* Pagination:
* - Pass `limit` to bound the page size (newest-first from server).
* - Pass `beforeTs` (RFC3339) to fetch rows STRICTLY OLDER than that
* timestamp. Combined with limit, this yields the next-older page
* when scrolling backward through history.
*
* `reachedEnd` is propagated from the server. The server computes it
* by comparing rowCount vs limit so a partial last page is correctly
* detected even when the row→bubble fan-out is non-1:1 (each row
* produces 1-2 bubbles).
*/
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
// Server emits oldest-first within the page (RFC #2945 PR-C-2
// post-fix: server reverses row-aware before returning so the
// wire is display-ready). Canvas appends/prepends without
// reordering — this avoids the pair-flip bug a naive flat
// reverse causes when each row produces a (user, agent) pair
// with the same timestamp.
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
/**
* ChatTab container — renders sub-tab bar + My Chat or Agent Comms panel.
*/
@@ -33,7 +171,7 @@ export function ChatTab({ workspaceId, data }: Props) {
const [subTab, setSubTab] = useState<ChatSubTab>("my-chat");
return (
<div data-testid="chat-panel" className="flex flex-col h-full">
<div className="flex flex-col h-full">
{/* Sub-tab bar — role="tablist" so screen readers expose tab context */}
<div
role="tablist"
@@ -109,68 +247,268 @@ export function ChatTab({ workspaceId, data }: Props) {
* MyChatPanel — user↔agent conversation (extracted from original ChatTab).
*/
function MyChatPanel({ workspaceId, data }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [activityLog, setActivityLog] = useState<string[]>([]);
// `sending` is strictly the "this tab kicked off a send and hasn't
// seen the reply yet" signal. Previously this was initialized from
// data.currentTask to pick up in-flight agent work on mount, but
// that conflated agent-busy (workspace heartbeat) with user-
// in-flight (local send): when the WS dropped a TASK_COMPLETE event,
// currentTask lingered, the component re-mounted with sending=true,
// and the Send button stayed disabled forever even though nothing
// local was in flight. For the "agent is busy, show spinner" UX,
// use data.currentTask directly in the render path.
const [sending, setSending] = useState(false);
const [thinkingElapsed, setThinkingElapsed] = useState(0);
const [activityLog, setActivityLog] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const currentTaskRef = useRef(data.currentTask);
const sendingFromAPIRef = useRef(false);
const [agentReachable, setAgentReachable] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmRestart, setConfirmRestart] = useState(false);
const [dragOver, setDragOver] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// First-mount scroll-to-bottom needs `behavior: "instant"` — long
// conversations smooth-animate for ~300ms which any concurrent
// re-render can interrupt, leaving the user stuck mid-conversation
// when the chat tab opens. Subsequent appends (new agent messages)
// keep `smooth` for the visual "landing" feel. Flipped the first
// time messages.length goes positive, so a workspace switch (which
// remounts ChatTab) gets a fresh instant jump too.
const hasInitialScrollRef = useRef(false);
// Lazy-load older history on scroll-up.
// - containerRef = the scrollable messages viewport
// - topRef = sentinel above the messages list; IO observes it
// and triggers loadOlder() when it enters view
// - hasMore = false once a fetch returns < limit rows; stops IO
// - loadingOlder = drives the "Loading older messages…" UI label
// - inflightRef = synchronous guard against double-entry of loadOlder
// when the IO callback fires twice in the same
// microtask (state-based guard would be stale until
// the next React commit)
// - scrollAnchorRef = saves distance-from-bottom before a prepend
// so the useLayoutEffect below can restore the
// user's exact viewport position. Without this,
// prepending older messages would jump the scroll
// position by the height of the new content.
// - oldestMessageRef / hasMoreRef = let the loadOlder closure read
// the latest values without taking them as deps —
// every live agent push mutates `messages`, and
// having loadOlder depend on `messages` would tear
// down + re-arm the IntersectionObserver on every
// push. Refs decouple the observer lifecycle from
// message-list updates.
const containerRef = useRef<HTMLDivElement>(null);
const topRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const hasInitialScrollRef = useRef(false);
const [hasMore, setHasMore] = useState(true);
const [loadingOlder, setLoadingOlder] = useState(false);
const inflightRef = useRef(false);
// The scroll anchor includes the first-message id as it was BEFORE
// the prepend — see useLayoutEffect below for why. Without this tag,
// a live agent push that appends WHILE loadOlder is in flight would
// run useLayoutEffect against the append (anchor still set), the
// "restore" math would scroll the user to a stale offset, AND the
// append's normal scroll-to-bottom would be swallowed.
const scrollAnchorRef = useRef<
{ savedDistanceFromBottom: number; expectFirstIdNotEqual: string | null } | null
>(null);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
// Monotonic token bumped on workspace switch + on every loadOlder
// entry. Each fetch's .then() captures its own token; if the token
// has moved, the resolved messages belong to a stale workspace or a
// superseded fetch and we silently drop them. Without this guard, a
// workspace switch mid-fetch would have the in-flight promise
// resolve into the new workspace's setMessages — the user sees
// someone else's history briefly.
const fetchTokenRef = useRef(0);
// Files the user has picked but not yet sent. Cleared on send
// (upload success) or by the × on each pill.
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pasteCounterRef = useRef(0);
// Guard against a double-click during the upload phase: React
// state updates from the click that started the upload haven't
// flushed yet, so the disabled-button logic sees `uploading=false`
// from the closure and lets a second `sendMessage` enter. A ref
// observes the latest value synchronously.
const sendInFlightRef = useRef(false);
// Monotonic token bumped on every sendMessage entry. Each .then()/
// .catch() captures its own token in closure and bails if a newer
// send has superseded it — prevents a late HTTP response for an
// earlier message from clobbering the flags / appending text that
// belong to a newer in-flight send. Race scenario the token closes:
// (1) send msg #1 (2) WS push for msg #1 arrives, releases guards
// (3) user sends msg #2 (4) HTTP for msg #1 finally lands — without
// the token check, .then() sees sendingFromAPIRef=true (set by
// msg #2's send), enters the main body, and processes msg #1's body
// as if it were msg #2's reply.
const sendTokenRef = useRef(0);
const history = useChatHistory(workspaceId, containerRef);
const chatSend = useChatSend(workspaceId, {
getHistoryMessages: () => history.messages,
onUserMessage: (msg) => history.setMessages((prev) => [...prev, msg]),
onAgentMessage: (msg) => history.setMessages((prev) => appendMessageDeduped(prev, msg)),
});
const { sending, uploading, sendMessage, error: sendError, clearError: clearSendError, releaseSendGuards, sendingFromAPIRef } = chatSend;
// Release every in-flight send guard at once. Used by every site
// that ends a send: pendingAgentMsgs WS push, ACTIVITY_LOGGED
// a2a_receive ok/error WS event, HTTP .then() success, and HTTP
// .catch() success. Keep these in lockstep — a future contributor
// adding a new "I saw the reply" path that only clears `sending` +
// `sendingFromAPIRef` (the natural pair) silently re-introduces
// the post-WS Send-button freeze, because the disabled-button
// logic can't see `sendInFlightRef` and so the visible state diverges
// from the synchronous re-entry guard at line 464.
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const displayError = error || sendError;
// Initial-load fetch — used by the mount effect and the "Retry"
// button below. Single source of truth so the two paths can't drift
// (e.g. INITIAL_HISTORY_LIMIT bumped in the effect but not the
// retry, leading to inconsistent first-paint sizes).
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
// Bump the token; any in-flight fetch from the previous workspace
// (or a previous retry) will see token != myToken in its .then()
// and silently bail — the late response can't clobber the new
// workspace's state.
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useChatSocket(workspaceId, {
onAgentMessage: (msg) => {
history.setMessages((prev) => appendMessageDeduped(prev, msg));
if (sendingFromAPIRef.current) {
releaseSendGuards();
// Load chat history on mount / workspace switch.
// Initial load is bounded to INITIAL_HISTORY_LIMIT (newest 10) — the
// rest streams in as the user scrolls up via loadOlder() below. Pre-
// 2026-05-05 this fetched the newest 50 in one shot; on a long-running
// workspace that meant 50× message-bubble paint + DOM cost on every
// tab-open even when the user only wanted to read the last few.
useEffect(() => {
loadInitial();
}, [loadInitial]);
// Mirror the latest oldest-message + hasMore into refs so loadOlder
// can read them without taking `messages` as a dep. Every live push
// through agentMessages would otherwise recreate loadOlder and tear
// down the IO observer.
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
// Fetch the next-older batch and prepend. Stable identity (deps =
// [workspaceId]) so the IntersectionObserver effect below doesn't
// re-arm on every messages update.
const loadOlder = useCallback(async () => {
// inflightRef is the load-bearing guard — synchronous, set BEFORE
// any await, so two IO callbacks dispatched in the same microtask
// can't both pass. The state checks are defensive secondary
// gates for the slow-scroll case.
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef.current;
if (!container) return;
inflightRef.current = true;
// Capture the user's distance-from-bottom BEFORE we prepend so the
// useLayoutEffect can restore it after the new DOM lands. The
// expectFirstIdNotEqual tag is what the layout effect checks
// against `messages[0].id` to disambiguate prepend (id changed) vs
// append (id unchanged → live message landed mid-fetch). Without
// it, an agent push during loadOlder runs the "restore" against a
// stale anchor — user gets yanked + the append's bottom-pin is
// swallowed.
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
// Workspace switched (or another loadOlder bumped the token)
// mid-fetch — drop these results, they belong to a stale tab.
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
},
onActivityLog: (entry) => {
if (!sending) return;
setActivityLog((prev) => appendActivityLine(prev, entry));
},
onSendComplete: () => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
// Nothing came back — clear the anchor so the next paint doesn't
// try to "restore" against a no-op prepend.
scrollAnchorRef.current = null;
}
},
onSendError: (err) => {
if (sendingFromAPIRef.current) {
releaseSendGuards();
setError(err);
}
},
});
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
//
// Dependencies:
// - loadOlder — stable per workspaceId (refs decouple it from
// message updates), so this dep is here for the
// workspace-switch case only
// - hasMore — re-run when older history runs out so we
// disconnect cleanly
// - hasMessages — load-bearing: the sentinel JSX is gated on
// `messages.length > 0`, so topRef.current is null
// on the empty-messages render. We re-arm exactly
// once when messages first land. NOT depending on
// `messages.length` (or `messages`) directly so
// each subsequent message append doesn't tear down
// + re-arm the observer.
const hasMessages = messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!hasMore) return; // stop observing when no older history exists
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [loadOlder, hasMore, hasMessages]);
// Agent reachability
useEffect(() => {
const reachable = data.status === "online" || data.status === "degraded";
setAgentReachable(reachable);
if (reachable) {
setError(null);
clearSendError();
} else {
setError(`Agent is ${data.status}`);
}
}, [data.status, clearSendError]);
setError(reachable ? null : `Agent is ${data.status}`);
}, [data.status]);
useEffect(() => {
currentTaskRef.current = data.currentTask;
}, [data.currentTask]);
// Scroll behavior across messages updates:
// - Prepend (loadOlder landed) → restore the user's saved
@@ -180,24 +518,71 @@ function MyChatPanel({ workspaceId, data }: Props) {
// paint — otherwise the user sees the page jump for one frame.
useLayoutEffect(() => {
const container = containerRef.current;
const anchor = history.scrollAnchorRef.current;
const anchor = scrollAnchorRef.current;
// Only honor the anchor when this messages-update is the prepend
// we expected. messages[0].id is the test:
// - prepend → messages[0] is one of the older rows → id !== expectFirstIdNotEqual
// - append → messages[0] unchanged → id === expectFirstIdNotEqual → fall through
// Without this check, an agent push that lands mid-loadOlder would
// run the restore against the append's update, yank the user's
// scroll, AND swallow the append's bottom-pin.
if (
anchor &&
container &&
history.messages.length > 0 &&
history.messages[0].id !== anchor.expectFirstIdNotEqual
messages.length > 0 &&
messages[0].id !== anchor.expectFirstIdNotEqual
) {
container.scrollTop = container.scrollHeight - anchor.savedDistanceFromBottom;
history.scrollAnchorRef.current = null;
scrollAnchorRef.current = null;
return;
}
if (!hasInitialScrollRef.current && history.messages.length > 0) {
// Instant on first arrival of messages — smooth-scroll on a long
// conversation gets interrupted by concurrent renders and leaves
// the user stuck in the middle. After the first jump, subsequent
// appends animate as before.
if (!hasInitialScrollRef.current && messages.length > 0) {
hasInitialScrollRef.current = true;
bottomRef.current?.scrollIntoView({ behavior: "instant" as ScrollBehavior });
return;
}
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [history.messages, history.scrollAnchorRef]);
}, [messages]);
// Consume agent push messages (send_message_to_user) from global store.
// Runtimes like Claude Code SDK deliver their reply via a WS push rather
// than the /a2a HTTP response — when that happens, the push is the
// authoritative "reply arrived" signal for the UI, so clear `sending`
// here too. The HTTP .then() coordinates through sendingFromAPIRef so
// whichever path clears first wins.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
// Dedupe in case the agent proactively pushed the same text the
// HTTP /a2a response already delivered (observed with the Hermes
// runtime, which emits both a reply body and a send_message_to_user
// push for the same content). Attachments ride along with the
// message so files returned by the A2A_RESPONSE WS path render
// their download chips.
setMessages((prev) => appendMessageDeduped(prev, createMessage("agent", m.content, m.attachments)));
}
if (sendingFromAPIRef.current && msgs.length > 0) {
// Reply arrived via WS push (e.g. claude-code SDK). Release all
// three guards together — without sendInFlightRef the next
// sendMessage() silently no-ops at the synchronous re-entry
// check.
releaseSendGuards();
}
}, [pendingAgentMsgs, workspaceId]);
// Resolve workspace ID → name for activity display
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
// Elapsed timer while sending
useEffect(() => {
@@ -224,43 +609,211 @@ function MyChatPanel({ workspaceId, data }: Props) {
setActivityLog([`Processing with ${runtimeDisplayName(data.runtime)}...`]);
}, [sending, data.runtime]);
// IntersectionObserver on the top sentinel. Fires loadOlder() the
// moment the user scrolls within 200px of the top. AbortController
// unwires cleanly on workspace switch / unmount; root is the
// scrollable container so we observe only what's visible inside it.
const hasMessages = history.messages.length > 0;
useEffect(() => {
const top = topRef.current;
const container = containerRef.current;
if (!top || !container) return;
if (!history.hasMore) return;
const ac = new AbortController();
const io = new IntersectionObserver(
(entries) => {
if (ac.signal.aborted) return;
if (entries[0]?.isIntersecting) history.loadOlder();
},
{ root: container, rootMargin: "200px 0px 0px 0px", threshold: 0 },
);
io.observe(top);
ac.signal.addEventListener("abort", () => io.disconnect());
return () => ac.abort();
}, [history.loadOlder, history.hasMore, hasMessages]);
// Subscribe to global WS via the singleton ReconnectingSocket (no
// per-component WebSocket — the previous pattern dropped events
// silently on any reconnect because each panel's raw socket had no
// onclose handler).
useSocketEvent((msg) => {
if (!sending) return;
try {
if (msg.event === "ACTIVITY_LOGGED") {
// Filter to events for THIS workspace. The platform's
// BroadcastOnly fires to every connected client, and
// without this guard a sibling workspace's a2a_send would
// surface as "→ Delegating to X..." inside the wrong
// chat panel. (workspace_id on the WS envelope is the
// workspace whose activity_log row we just wrote.)
if (msg.workspace_id !== workspaceId) return;
const handleSend = async () => {
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
// The platform logs a successful a2a_receive once the workspace
// has fully produced its reply. That's the authoritative "done"
// signal for the spinner — clear it even if the reply hasn't
// surfaced through the store yet (it may be delivered shortly
// via pendingAgentMsgs or the HTTP .then()).
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
}
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own && sendingFromAPIRef.current) {
releaseSendGuards();
setError("Agent error (Exception) — see workspace logs for details.");
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
// Per-tool-use telemetry from claude_sdk_executor's
// _report_tool_use. The summary already carries an icon
// + human-readable args (📄 Read /path, ⚡ Bash: …)
// so we render it verbatim. No icon prefix here — the
// emoji at the start of summary is the visual marker.
if (summary) line = summary;
}
if (line) {
setActivityLog((prev) => appendActivityLine(prev, line));
}
} else if (msg.event === "TASK_UPDATED" && msg.workspace_id === workspaceId) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
setActivityLog((prev) => appendActivityLine(prev, `${task}`));
}
}
// A2A_RESPONSE is already consumed by the store and its text is
// appended to messages via the pendingAgentMsgs effect above; we
// don't need to duplicate it here.
} catch { /* ignore */ }
});
const sendMessage = async () => {
const text = input.trim();
const files = pendingFiles;
if ((!text && files.length === 0) || !agentReachable || sending || uploading) return;
const filesToSend = pendingFiles;
// Allow sending if EITHER text OR attachments are present — a user
// can drop a file with no text and the agent still receives it.
if ((!text && filesToSend.length === 0) || !agentReachable || sending || uploading) return;
// Synchronous re-entry guard — see sendInFlightRef comment.
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
// Upload attachments first so we can include URIs in the A2A
// message parts. Sequential-before-send: a message with references
// to files not yet staged would fail agent-side; staging happens
// synchronously via /chat/uploads before message/send dispatch.
let uploaded: ChatAttachment[] = [];
if (filesToSend.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, filesToSend);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed");
return;
}
setUploading(false);
}
setInput("");
setPendingFiles([]);
clearSendError();
setMessages((prev) => [...prev, createMessage("user", text, uploaded)]);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
await sendMessage(text, files);
// Capture this send's token so the .then()/.catch() callbacks can
// detect a newer send that may have superseded them. See the
// sendTokenRef declaration for the race scenario this closes.
const myToken = ++sendTokenRef.current;
// Build conversation history from prior messages (last 20)
const history = messages
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
// A2A parts: text part (if any) + file parts (per attachment). The
// agent sees both in a single turn, matching the A2A spec shape.
// Wire shape is v0 — see A2APart definition above.
const parts: A2APart[] = [];
if (text) parts.push({ kind: "text", text });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
// A2A calls can legitimately take minutes — LLM latency +
// multi-turn tool use is common on slower providers (Hermes+minimax,
// Claude Code invoking bash/file tools, etc.). The 15s default
// would silently abort the fetch here, leaving the server to
// complete the reply and the user staring at
// "agent may be unreachable". Match the upload timeout (60s × 2)
// for the happy-path ceiling; anything longer is genuinely stuck.
api.post<A2AResponse>(`/workspaces/${workspaceId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
}, { timeoutMs: 120_000 })
.then((resp) => {
// Bail without touching any flags if a newer sendMessage has
// already run — its myToken bumped sendTokenRef, so this is
// a stale callback for an earlier message. The newer send
// owns the in-flight guards now.
if (sendTokenRef.current !== myToken) return;
// Skip if the WS A2A_RESPONSE event already handled this response.
// Both paths (WS + HTTP) check sendingFromAPIRef — whichever clears
// it first wins, the other becomes a no-op (no duplicate messages).
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask((resp?.result ?? {}) as Record<string, unknown>);
if (replyText || replyFiles.length > 0) {
setMessages((prev) =>
appendMessageDeduped(prev, createMessage("agent", replyText, replyFiles)),
);
}
releaseSendGuards();
})
.catch(() => {
// Stale-callback guard — same rationale as .then().
if (sendTokenRef.current !== myToken) return;
// Same dedup guard as .then(): if a WS path (pendingAgentMsgs
// or ACTIVITY_LOGGED a2a_receive ok) already delivered the
// reply, sendingFromAPIRef is already false and there's
// nothing to roll back. Surfacing "Failed to send" here would
// contradict the agent reply the user is currently reading —
// exactly the false-positive observed when the HTTP request
// hung up (proxy idle / 502) after WS already won.
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
};
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
// Deduplicate against current pending set by name+size — user
// picking the same file twice shouldn't append it.
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
@@ -271,7 +824,35 @@ function MyChatPanel({ workspaceId, data }: Props) {
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
// Monotonic counter so two paste events within the same wall-clock
// second still produce distinct filenames. Without this, on
// Firefox (where pasted images have an empty `file.name`), two
// pastes ~100ms apart could yield identical synthetic names AND
// identical sizes, collapsing into one attachment via the
// `name:size` dedup in onFilesPicked.
const pasteCounterRef = useRef(0);
/** Paste-from-clipboard image attachment.
*
* Browser clipboard image items arrive as `File`s whose `name` is
* often a generic "image.png" (Chrome) or empty (Firefox/Safari),
* so two consecutive screenshot pastes collide on the name+size
* dedup the file-picker uses. Re-tag each pasted image with a
* per-paste unique name so dedup keeps them apart and the upload
* pipeline (which expects a non-empty filename) is happy.
*
* Falls through to onFilesPicked via direct File[] (NOT through
* the DataTransfer constructor — that throws on Safari < 14.1
* and old Edge, silently aborting the paste).
*
* Only intercepts the paste when the clipboard has at least one
* image; text-only pastes fall through to the textarea's default
* behaviour. */
const mimeToExt = (mime: string): string => {
// Avoid raw `mime.split("/")[1]` — that yields `"svg+xml"`,
// `"jpeg"`, `"webp"` etc. which produce ugly filenames and may
// trip server-side extension allowlists. Map known types
// explicitly; unknown falls back to a safe default.
if (mime === "image/svg+xml") return "svg";
if (mime === "image/jpeg") return "jpg";
if (mime === "image/png") return "png";
@@ -292,16 +873,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
const file = item.getAsFile();
if (!file) continue;
const ext = mimeToExt(file.type);
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const seq = pasteCounterRef.current++;
const fname = `pasted-${stamp}-${seq}-${i}.${ext}`;
imageFiles.push(new File([file], fname, { type: file.type }));
}
if (imageFiles.length === 0) return;
e.preventDefault();
// Reuse the picker path so file-size guards, dedup, and pending-
// list state all run through the same code. Build a synthetic
// FileList-like object to avoid the DataTransfer constructor —
// that's missing on Safari < 14.1 / old Edge and would silently
// throw, leaving the paste a no-op.
addPastedFiles(imageFiles);
};
// Variant of onFilesPicked that accepts a File[] directly, sidestepping
// the DataTransfer-FileList round-trip. Same dedup + state shape.
const addPastedFiles = (files: File[]) => {
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
@@ -309,6 +900,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
});
};
// Drag-and-drop staging. dragDepthRef counts enter vs leave events so
// the overlay doesn't flicker when the cursor crosses nested children
// (textarea, buttons) — dragenter/dragleave fire for every boundary.
const [dragOver, setDragOver] = useState(false);
const dragDepthRef = useRef(0);
const dropEnabled = agentReachable && !sending && !uploading;
const isFileDrag = (e: React.DragEvent) =>
Array.from(e.dataTransfer.types || []).includes("Files");
@@ -338,6 +934,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
};
const downloadAttachment = (att: ChatAttachment) => {
// Errors here are rare but user-visible (401 on a revoked token,
// 404 if the agent deleted the file). Surface via the inline
// error banner — the message list itself stays untouched.
downloadChatFile(workspaceId, att).catch((e) => {
setError(e instanceof Error ? `Download failed: ${e.message}` : "Download failed");
});
@@ -391,26 +990,26 @@ function MyChatPanel({ workspaceId, data }: Props) {
)}
{/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{history.loading && (
{loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div>
)}
{!history.loading && history.loadError !== null && history.messages.length === 0 && (
{!loading && loadError !== null && messages.length === 0 && (
<div
role="alert"
className="mx-2 mt-2 rounded-lg border border-red-800/50 bg-red-950/30 px-3 py-2.5"
>
<p className="text-[11px] text-bad mb-1.5">
Failed to load chat history: {history.loadError}
Failed to load chat history: {loadError}
</p>
<button
onClick={history.loadInitial}
onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{!history.loading && history.loadError === null && history.messages.length === 0 && (
{!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">
No messages yet. Send a message to start chatting with this agent.
</div>
@@ -428,12 +1027,12 @@ function MyChatPanel({ workspaceId, data }: Props) {
instead of showing a "no more messages" footer — the user's
scroll resting against the top of the conversation IS the
signal. */}
{history.hasMore && history.messages.length > 0 && (
{hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1">
{history.loadingOlder ? "Loading older messages…" : " "}
{loadingOlder ? "Loading older messages…" : " "}
</div>
)}
{history.messages.map((msg) => (
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
@@ -593,10 +1192,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div>
{/* Error banner */}
{displayError && (
{error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{displayError}</span>
<span className="text-[10px] text-red-300">{error}</span>
{!isOnline && (
<button
onClick={() => setConfirmRestart(true)}
@@ -664,7 +1263,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
e.keyCode !== 229
) {
e.preventDefault();
handleSend();
sendMessage();
}
}}
onPaste={onPasteIntoComposer}
@@ -674,7 +1273,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
/>
<button
onClick={handleSend}
onClick={sendMessage}
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
>
+1 -1
View File
@@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
// exactly the point of the platform adaptor. The deep `~/.hermes/
// config.yaml` on the container is a separate runtime-internal file,
// not this one.
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
@@ -1,3 +0,0 @@
export { useChatHistory } from "./useChatHistory";
export { useChatSend } from "./useChatSend";
export { useChatSocket } from "./useChatSocket";
@@ -1,11 +0,0 @@
"use client";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
/** Resolve a workspace ID to its human-readable name.
* Falls back to the first 8 chars of the ID. */
export function resolveWorkspaceName(id: string): string {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20;
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
export interface ScrollAnchor {
savedDistanceFromBottom: number;
expectFirstIdNotEqual: string | null;
}
export function useChatHistory(
workspaceId: string,
containerRef?: React.RefObject<HTMLDivElement | null>,
) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchTokenRef = useRef(0);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
const inflightRef = useRef(false);
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadOlder = useCallback(async () => {
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef?.current;
if (!container) return;
inflightRef.current = true;
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
scrollAnchorRef.current = null;
}
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId, containerRef]);
return {
messages,
loading,
loadError,
loadingOlder,
hasMore,
loadInitial,
loadOlder,
appendMessageDeduped: (msg: ChatMessage) =>
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
setMessages,
scrollAnchorRef,
};
}
@@ -1,182 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import { uploadChatFiles } from "../uploads";
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
import { extractFilesFromTask } from "../message-parser";
interface A2APart {
kind: string;
text?: string;
file?: {
name?: string;
mimeType?: string;
uri?: string;
size?: number;
};
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
export interface UseChatSendOptions {
getHistoryMessages: () => ChatMessage[];
onUserMessage?: (msg: ChatMessage) => void;
onAgentMessage?: (msg: ChatMessage) => void;
}
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendInFlightRef = useRef(false);
const sendingFromAPIRef = useRef(false);
const sendTokenRef = useRef(0);
const optionsRef = useRef(options);
optionsRef.current = options;
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const clearError = useCallback(() => setError(null), []);
const sendMessage = useCallback(
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || sending || uploading) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
let uploaded: ChatAttachment[] = [];
if (files.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, files);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
);
return;
}
setUploading(false);
}
const userMsg = createMessage("user", trimmed, uploaded);
optionsRef.current.onUserMessage?.(userMsg);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
const myToken = ++sendTokenRef.current;
const history = optionsRef.current
.getHistoryMessages()
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
const parts: A2APart[] = [];
if (trimmed) parts.push({ kind: "text", text: trimmed });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
api
.post<A2AResponse>(
`/workspaces/${workspaceId}/a2a`,
{
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
},
{ timeoutMs: 120_000 },
)
.then((resp) => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask(
(resp?.result ?? {}) as Record<string, unknown>,
);
if (replyText || replyFiles.length > 0) {
optionsRef.current.onAgentMessage?.(
createMessage("agent", replyText, replyFiles),
);
}
releaseSendGuards();
})
.catch(() => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
},
[workspaceId, sending, uploading],
);
return {
sending,
uploading,
sendMessage,
error,
clearError,
releaseSendGuards,
sendingFromAPIRef,
};
}
@@ -1,100 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { createMessage, type ChatMessage } from "../types";
export interface UseChatSocketCallbacks {
onAgentMessage?: (msg: ChatMessage) => void;
onActivityLog?: (entry: string) => void;
onSendComplete?: () => void;
onSendError?: (error: string) => void;
}
export function useChatSocket(
workspaceId: string,
callbacks: UseChatSocketCallbacks,
): void {
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
// Agent push messages from global store
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
callbacksRef.current.onAgentMessage?.(
createMessage("agent", m.content, m.attachments),
);
}
if (msgs.length > 0) {
callbacksRef.current.onSendComplete?.();
}
}, [pendingAgentMsgs, workspaceId]);
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
useSocketEvent((msg) => {
try {
if (msg.event === "ACTIVITY_LOGGED") {
if (msg.workspace_id !== workspaceId) return;
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) callbacksRef.current.onSendComplete?.();
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) {
callbacksRef.current.onSendComplete?.();
callbacksRef.current.onSendError?.(
"Agent error (Exception) — see workspace logs for details.",
);
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
if (summary) line = summary;
}
if (line) {
callbacksRef.current.onActivityLog?.(line);
}
} else if (
msg.event === "TASK_UPDATED" &&
msg.workspace_id === workspaceId
) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
callbacksRef.current.onActivityLog?.(`${task}`);
}
}
} catch {
/* ignore */
}
});
}
-3
View File
@@ -1,5 +1,2 @@
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
export { useChatHistory } from "./hooks/useChatHistory";
export { useChatSend } from "./hooks/useChatSend";
export { useChatSocket } from "./hooks/useChatSocket";
+1 -2
View File
@@ -8,7 +8,6 @@ import {
type PreflightResult,
type Template,
} from "@/lib/deploy-preflight";
import { isSaaSTenant } from "@/lib/tenant";
import { MissingKeysModal } from "@/components/MissingKeysModal";
/**
@@ -106,7 +105,7 @@ export function useTemplateDeploy(
const ws = await api.post<{ id: string }>("/workspaces", {
name: template.name,
template: template.id,
tier: isSaaSTenant() ? 4 : template.tier,
tier: template.tier,
canvas: coords,
...(model ? { model } : {}),
});
+8 -12
View File
@@ -8,18 +8,14 @@ import { getTenantSlug } from "./tenant";
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
// 35s is long enough for the slowest server-side path (EIC SSH
// tunnel for tenant EC2 file operations, bounded server-side by
// `eicFileOpTimeout = 30 * time.Second` in
// workspace-server/internal/handlers/template_files_eic.go) so the
// canvas surfaces the server's real error instead of aborting first
// with a generic timeout. Shorter values caused "Save & Restart" to
// time out at the client before the backend returned its 5xx. The
// abort still propagates through AbortController so React components
// can render a retry affordance. Callers that know an endpoint is
// intentionally slow (org import walks a tree of workspaces with
// server-side pacing) can pass `timeoutMs` to override.
const DEFAULT_TIMEOUT_MS = 35_000;
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance. Callers that know the
// endpoint is intentionally slow (org import walks a tree of
// workspaces with server-side pacing) can pass `timeoutMs` to
// override.
const DEFAULT_TIMEOUT_MS = 15_000;
export interface RequestOptions {
timeoutMs?: number;
+4 -1
View File
@@ -30,7 +30,10 @@
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
{"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"},
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"},
{"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"},
{"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+11 -2
View File
@@ -41,6 +41,13 @@ until curl -s --max-time 5 "$PLATFORM_URL/health" >/dev/null 2>&1; do
done
echo " platform up"
# MINIMAX_BASE_URL defaults to the global endpoint (api.minimax.io) —
# matches the operator-host MINIMAX_API_KEY scope. Override by setting
# MINIMAX_BASE_URL before running this script (e.g. for the China
# endpoint api.minimaxi.com). See RFC internal#417 for why the global
# URL is the safe default.
: "${MINIMAX_BASE_URL:=https://api.minimax.io}"
echo "=== Inserting global secrets ==="
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "
INSERT INTO global_secrets (key, encrypted_value, encryption_version) VALUES
@@ -50,10 +57,12 @@ INSERT INTO global_secrets (key, encrypted_value, encryption_version) VALUES
('ANTHROPIC_SMALL_FAST_MODEL', 'MiniMax-M2.7', 0),
('API_TIMEOUT_MS', '3000000', 0),
('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', '1', 0),
('GITHUB_TOKEN', '$GITHUB_PAT', 0)
('GITHUB_TOKEN', '$GITHUB_PAT', 0),
('MINIMAX_API_KEY', '$MINIMAX_API_KEY', 0),
('MINIMAX_BASE_URL', '$MINIMAX_BASE_URL', 0)
ON CONFLICT (key) DO UPDATE SET encrypted_value = EXCLUDED.encrypted_value;
"
echo " 7 global secrets set"
echo " 9 global secrets set"
echo "=== Importing org template ==="
curl -s --max-time 600 -X POST "$PLATFORM_URL/org/import" \
@@ -1,376 +0,0 @@
#!/usr/bin/env bash
# Staging E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
#
# WHY THIS EXISTS
# ---------------
# Hermes and OpenClaw were repeatedly reported "fleet-verified / cascade-
# complete" because the *proxy* signals were green:
# - registry-registration + heartbeat (Hermes), and
# - model round-trip 200 (OpenClaw).
# But a freshly-provisioned workspace, asked on canvas "can you see your
# peers", actually FAILS:
# - Hermes: 401 on the molecule MCP `list_peers` call,
# - OpenClaw: falls back to native `sessions_list`, sees no platform peers.
# Tasks #142/#159 were even marked "completed" under this same proxy flaw.
#
# This script codifies the LITERAL user-facing path so it can never silently
# regress: it provisions a brand-new throwaway org + sibling workspaces via
# the real control-plane provisioning path, then for each runtime that should
# have platform peer-visibility it drives the EXACT MCP call the canvas agent
# makes — `POST /workspaces/:id/mcp` JSON-RPC tools/call name=list_peers,
# authenticated by that workspace's own bearer token through the real
# WorkspaceAuth + MCPRateLimiter middleware chain. It then asserts:
# (1) HTTP 200,
# (2) JSON-RPC `result` present (NOT an `error` object — a -32000
# "tool call failed" or a 401 from WorkspaceAuth fails here),
# (3) the returned peer set CONTAINS the other provisioned sibling
# workspace IDs — not an empty list, not a native-sessions fallback.
#
# This is NOT a proxy. It does not look at a registry row, /health, the
# heartbeat table, or `GET /registry/:id/peers`. It drives the byte-for-byte
# JSON-RPC envelope that mcp_molecule_list_peers issues from a real agent.
#
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
# green only when the in-flight root-cause fixes (Hermes-401, OpenClaw MCP
# wiring) actually land. That is the point: it is the objective proof gate.
#
# AUTH MODEL (mirrors tests/e2e/test_staging_full_saas.sh)
# --------------------------------------------------------
# Single MOLECULE_ADMIN_TOKEN (= CP_ADMIN_API_TOKEN on Railway staging)
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token (returned by
# POST /workspaces) drives its MCP call.
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
# Optional env:
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
# E2E_RUN_ID slug suffix; CI passes ${GITHUB_RUN_ID}
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
# LLM provider key injected so the runtime can boot
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
#
# Exit codes:
# 0 every runtime saw its peers via the literal MCP call
# 1 generic failure
# 2 missing required env
# 3 provisioning timed out
# 4 teardown left orphan resources
# 10 peer-visibility regression reproduced (the gate firing as designed)
set -uo pipefail
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-1800}"
# Slug MUST start with 'e2e-' so the sweep-stale-e2e-orgs safety net
# (EPHEMERAL_PREFIXES) catches any leak this run fails to tear down.
SLUG="e2e-pv-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
ORG_ID=""
TENANT_URL=""
TENANT_TOKEN=""
log() { echo "[$(date +%H:%M:%S)] $*"; }
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
admin_call() {
local method="$1" path="$2"; shift 2
curl -sS -X "$method" "$CP_URL$path" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" "$@"
}
tenant_call() {
local method="$1" path="$2"; shift 2
curl -sS -X "$method" "$TENANT_URL$path" \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" "$@"
}
# ─── Scoped teardown ───────────────────────────────────────────────────
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
# sweep — honors feedback_cleanup_after_each_test and
# feedback_never_run_cluster_cleanup_tests_on_live_platform. The
# workflow's always() step + sweep-stale-e2e-orgs are the outer nets.
teardown() {
local rc=$?
set +e
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
echo ""
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
exit $rc
fi
echo ""
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
admin_call DELETE "/cp/admin/tenants/$SLUG" --max-time 120 \
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
for j in $(seq 1 24); do
LIST=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null)
LEAK=$(echo "$LIST" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(1); sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status') not in ('purged',) and o.get('status') != 'purged'))
" 2>/dev/null || echo 1)
if [ "$LEAK" = "0" ]; then
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
exit $rc
fi
sleep 5
done
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
[ $rc -eq 0 ] && rc=4
exit $rc
}
trap teardown EXIT INT TERM
# ─── 1. Provision the throwaway org ────────────────────────────────────
log "1/6 POST /cp/admin/orgs — slug=$SLUG"
CREATE=$(admin_call POST /cp/admin/orgs \
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E peer-visibility $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$ORG_ID" ] || fail "org creation failed: $(echo "$CREATE" | head -c 300)"
log " ORG_ID=$ORG_ID"
# ─── 2. Wait for tenant EC2 + DNS ──────────────────────────────────────
log "2/6 waiting for tenant instance_status=running (cold EC2 + cloudflared)..."
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
while true; do
[ "$(date +%s)" -gt "$DEADLINE" ] && fail "tenant never came up within ${PROVISION_TIMEOUT_SECS}s"
STATUS=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
for o in orgs:
if o.get('slug') == '$SLUG':
print(o.get('instance_status') or o.get('status') or 'unknown'); break
" 2>/dev/null)
case "$STATUS" in running|online|ready) break ;; esac
sleep 10
done
log " tenant status=$STATUS"
# ─── 3. Per-tenant admin token + tenant URL ────────────────────────────
log "3/6 fetching per-tenant admin token..."
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
[ -n "$TENANT_TOKEN" ] || fail "tenant token fetch failed: $(echo "$TT_RESP" | head -c 200)"
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
case "$CP_HOST" in
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
*) DERIVED_DOMAIN="$CP_HOST" ;;
esac
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
log " tenant url: $TENANT_URL"
log "3b. waiting for tenant /health (TLS/DNS, up to 10min)..."
for i in $(seq 1 120); do
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
sleep 5
done
# ─── 4. Provision the parent + one sibling per runtime under test ──────
# Inject the LLM provider key so each runtime can authenticate at boot.
# Priority: MiniMax → direct-Anthropic → OpenAI (mirrors
# test_staging_full_saas.sh's secrets-injection chain).
SECRETS_JSON='{}'
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))")
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))")
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
SECRETS_JSON=$(python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))")
fi
log "4/6 provisioning parent (claude-code) + one sibling per runtime under test..."
P_RESP=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$SECRETS_JSON}")
PARENT_ID=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$PARENT_ID" ] || fail "parent create failed: $(echo "$P_RESP" | head -c 300)"
log " PARENT_ID=$PARENT_ID"
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
declare -A WS_IDS WS_TOKENS
ALL_WS_IDS="$PARENT_ID"
for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# auth_token is top-level for container runtimes; external-like nest it
# under connection.auth_token (verified vs staging response shape).
WTOK=$(echo "$R" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
WS_IDS[$rt]="$WID"
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
# ─── 5. Wait for every sibling online ──────────────────────────────────
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
for rt in $PV_RUNTIMES; do
wid="${WS_IDS[$rt]}"
LAST=""
while true; do
[ "$(date +%s)" -gt "$WS_DEADLINE" ] && fail "$rt ($wid) never reached online (last=$LAST)"
S=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
print(w.get('status') or '')
" 2>/dev/null)
[ "$S" != "$LAST" ] && { log " $rt$S"; LAST="$S"; }
case "$S" in
online) break ;;
failed) sleep 10 ;; # transient: bootstrap-watcher 5-min deadline, heartbeat recovers
*) sleep 10 ;;
esac
done
ok " $rt online"
done
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
# name=list_peers, authenticated by the workspace's OWN bearer token
# through WorkspaceAuth + MCPRateLimiter.
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
echo ""
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
REGRESSED=0
declare -A VERDICT
for rt in $PV_RUNTIMES; do
wid="${WS_IDS[$rt]}"
wtok="${WS_TOKENS[$rt]}"
# The expected peer set = every OTHER provisioned workspace (parent +
# the sibling runtimes), excluding the caller itself.
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
set +e
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
-H "Authorization: Bearer $wtok" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d "$RPC_BODY" \
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
set -e
HTTP_CODE="$RESP"
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
echo "--- $rt (ws=$wid) ---"
echo " HTTP $HTTP_CODE"
echo " body: $(echo "$BODY" | head -c 600)"
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
if [ "$HTTP_CODE" != "200" ]; then
echo "$rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
REGRESSED=1
continue
fi
# (2) JSON-RPC result present, not an error object.
PARSE=$(echo "$BODY" | python3 -c "
import sys, json
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
try:
d = json.load(sys.stdin)
except Exception as e:
print('PARSE_ERROR:' + str(e)); sys.exit(0)
if isinstance(d, dict) and d.get('error') is not None:
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
res = d.get('result') if isinstance(d, dict) else None
if res is None:
print('NO_RESULT'); sys.exit(0)
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
text = ''
if isinstance(res, dict):
for c in res.get('content', []):
if c.get('type') == 'text':
text += c.get('text', '')
text_l = text.lower()
# Native-sessions fallback signature (the OpenClaw symptom): the agent
# answered from its own runtime session list, not the platform peer set.
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
# The expected sibling IDs must literally appear in the returned peer text.
found = sorted(i for i in expect if i in text)
missing = sorted(expect - set(found))
if not expect:
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
if missing:
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
sys.exit(0)
print('OK:found=%d/%d' % (len(found), len(expect)))
" 2>/dev/null)
case "$PARSE" in
OK:*)
echo "$rt: list_peers returned 200 and contains all expected peers ($PARSE)"
VERDICT[$rt]="OK"
;;
NATIVE_FALLBACK:*)
echo "$rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
VERDICT[$rt]="FAIL(native-fallback)"
REGRESSED=1
;;
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
echo "$rt: list_peers MCP call did not return a usable result ($PARSE)"
VERDICT[$rt]="FAIL(rpc=$PARSE)"
REGRESSED=1
;;
MISSING_PEERS:*)
echo "$rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
VERDICT[$rt]="FAIL(peers=$PARSE)"
REGRESSED=1
;;
*)
echo "$rt: unexpected verdict '$PARSE'"
VERDICT[$rt]="FAIL(unknown)"
REGRESSED=1
;;
esac
echo ""
done
echo "=== SUMMARY — fresh-provision peer-visibility (literal MCP list_peers) ==="
for rt in $PV_RUNTIMES; do
printf ' %-14s %s\n' "$rt" "${VERDICT[$rt]:-NO_RUN}"
done
echo ""
if [ "$REGRESSED" -ne 0 ]; then
echo "✗ GATE FAILED — at least one runtime cannot see its peers via the"
echo " literal mcp_molecule_list_peers call. This is the real user-facing"
echo " failure the proxy signals (registry row / heartbeat / model 200)"
echo " were hiding. Expected RED until the Hermes-401 + OpenClaw-MCP-wiring"
echo " root-cause fixes land; goes green only when they actually do."
exit 10
fi
ok "GATE PASSED — every runtime under test sees its platform peers via the literal MCP call."
exit 0
@@ -1,160 +0,0 @@
package handlers
// Regression coverage for the POLL-mode arm of the canvas user-message
// data-loss bug (internal#470 sibling — tracked on internal#471).
//
// Bug (reported 2026-05-16 by CTO Hongming): "in canvas i sometimes lose
// my own message when i exit chat". The push-mode arm was fixed by
// #1347 (persistUserMessageAtIngest — a SYNCHRONOUS, before-dispatch,
// context.WithoutCancel INSERT). #1347's framing asserted "poll-mode
// workspaces were never affected — logA2AReceiveQueued already persists
// at ingest". That assertion is OVERSTATED.
//
// Hongming's tenant (slug `hongming`, org 2c940477-...) has 4 workspaces,
// ALL runtime=external with empty URL → ALL delivery_mode=poll (proven
// empirically: a benign A2A probe returns the synthetic
// {"delivery_mode":"poll","status":"queued"} envelope for every one).
// So his reported loss is the POLL path, NOT the push path #1347 fixes.
//
// Root cause (poll arm): the poll-mode short-circuit (a2a_proxy.go ~402)
// calls logA2AReceiveQueued and then IMMEDIATELY returns the synthetic
// 200 {status:"queued"} to the canvas. But logA2AReceiveQueued's durable
// INSERT runs inside h.goAsync(...) — a DETACHED goroutine with NO
// happens-before barrier against the HTTP response. The canvas sees 200
// ("message accepted") while the activity_logs row may not yet be — and,
// on a workspace-server restart / deploy / OOM / EC2 hibernation between
// the 200 and the goroutine's commit, NEVER will be — durable. There is
// also no fallback (unlike push-mode's legacy-INSERT fallback): a
// swallowed LogActivity error loses the message with only a log line.
// Chat-history reads activity_logs (postgres_store.go:165-187); a missing
// row = message gone on reopen. That is exactly Hongming's symptom.
//
// Fix (parity with push-mode): the poll-mode ingest persist of the
// canvas user message must be SYNCHRONOUS — committed before the queued
// 200 is returned — on a context.WithoutCancel derived context, so a
// client disconnect on chat-exit and a post-response restart cannot lose
// it. Behavior is never worse than today (best-effort; a persist error
// still returns queued).
//
// TEST DESIGN NOTE: sqlmock.ExpectationsWereMet() hangs indefinitely if
// the expected query never fires. We use a select+default+time.After
// pattern so the test FAILS fast (not hangs) when the production code
// regresses to async (the INSERT never fires before handler returns),
// while still returning promptly when all expectations are met. The
// insertDelay is kept small (50ms) to minimise suite-level timing
// impact under -race detection, where mock delays are amplified by
// the instrumenter's goroutine overhead.
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse
// is the defining contract: for a poll-mode workspace, the canvas user
// message MUST be durably INSERTed into activity_logs BEFORE the synthetic
// queued 200 is returned to the client — with NO reliance on a detached
// async goroutine completing later.
//
// The test proves the ordering by making the INSERT block briefly and
// asserting the handler does NOT return until the INSERT has completed.
// Pre-fix (INSERT in h.goAsync, response returned immediately) the
// handler returns ~instantly while the INSERT is still pending in the
// goroutine → the elapsed time is far below the injected INSERT delay and
// ExpectationsWereMet() is racy/unmet at return. Post-fix (synchronous
// persist before the queued response) the handler return is gated on the
// INSERT, so elapsed >= the injected delay and the expectation is met
// deterministically at return WITHOUT any waitAsyncForTest()/sleep.
func TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
const wsID = "ws-poll-sync-persist"
// Keep delay small: -race detection amplifies mock delays significantly.
// A 50ms delay is sufficient to prove synchronous blocking (~50× the
// normal INSERT latency) without bloating the full ./... suite runtime.
const insertDelay = 50 * time.Millisecond
expectBudgetCheck(mock, wsID)
// lookupDeliveryMode → poll, triggering the short-circuit.
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("poll"))
// workspace-name lookup inside logA2AReceiveQueued.
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Poll WS"))
// The durable user-message write. We delay it so a synchronous
// persist visibly gates the handler return; a detached-goroutine
// persist (pre-fix) does not. The fix must keep using
// context.WithoutCancel so this write survives a chat-exit cancel.
mock.ExpectExec("INSERT INTO activity_logs").
WillDelayFor(insertDelay).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
// callerID == "" (no X-Workspace-ID) → this is a canvas_user message,
// exactly Hongming's case.
body := `{"jsonrpc":"2.0","id":"poll-canvas-1","method":"message/send","params":{"message":{"role":"user","parts":[{"text":"my own message"}]}}}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/a2a", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
start := time.Now()
handler.ProxyA2A(c)
elapsed := time.Since(start)
// Defining assertion #1: the handler must not have returned the
// queued response before the durable INSERT committed. Pre-fix this
// fails (elapsed ≈ 0, INSERT still racing in goAsync).
if elapsed < insertDelay {
t.Fatalf("poll-mode queued response returned in %v, before the %v user-message INSERT — "+
"the message is not durable when the client/process goes away (DATA LOSS). "+
"Persist must be synchronous before the queued 200.", elapsed, insertDelay)
}
// Defining assertion #2: the durable write actually happened by the
// time the handler returned. ExpectionsWereMet() hangs indefinitely if
// the mock never fires (e.g. production code regressed to async),
// so we check it in a goroutine with a hard 2s timeout — fails fast
// (no CI hang) on regression while returning promptly on success.
expectDone := make(chan error, 1)
go func() { expectDone <- mock.ExpectationsWereMet() }()
select {
case err := <-expectDone:
if err != nil {
t.Fatalf("user-message INSERT was not durable at handler return (unmet sqlmock expectations): %v", err)
}
case <-time.After(2 * time.Second):
t.Fatalf("ExpectationsWereMet() hung for >2s — INSERT mock never fired. " +
"Likely cause: production code regressed logA2AReceiveQueued to goAsync " +
"(INSERT fires after handler returns, not before).")
}
// Sanity: still the correct poll-mode envelope + status.
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (queued), got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp["status"] != "queued" || resp["delivery_mode"] != "poll" {
t.Errorf("poll envelope changed: got status=%v delivery_mode=%v, want queued/poll",
resp["status"], resp["delivery_mode"])
}
}
+20 -20
View File
@@ -97,28 +97,28 @@ const maxProxyResponseBody = 10 << 20
//
// Timeout model — three independent budgets, none of which gets in each other's way:
//
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
// the entire request including streamed body reads, and would pre-empt
// legitimate slow cold-start flows (Claude Code first-token over OAuth
// can take 30-60s on boot; long-running agent synthesis can stream
// tokens for minutes). Total-request budget is enforced per-request
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
// 1. Client.Timeout — DELIBERATELY UNSET. Client.Timeout is a hard wall on
// the entire request including streamed body reads, and would pre-empt
// legitimate slow cold-start flows (Claude Code first-token over OAuth
// can take 30-60s on boot; long-running agent synthesis can stream
// tokens for minutes). Total-request budget is enforced per-request
// via context deadline (canvas = idle-only, agent-to-agent = 30 min ceiling).
//
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
// black-holes TCP connects (instance terminated mid-flight, security group
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
// enough that Cloudflare's ~100s edge timeout can fire first and surface
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
// 2. Transport.DialContext — 10s connect timeout. When a workspace's EC2
// black-holes TCP connects (instance terminated mid-flight, security group
// flipped, NACL bug), the OS default is 75s on Linux / 21s on macOS — long
// enough that Cloudflare's ~100s edge timeout can fire first and surface
// a generic 502 page to canvas. 10s is well above realistic intra-region
// latencies and well below CF's edge timeout.
//
// 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.
// 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
@@ -194,7 +194,7 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
h.goAsync(func() { h.RestartByID(workspaceID) })
go h.RestartByID(workspaceID)
return true
}
@@ -241,7 +241,7 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
}
db.ClearWorkspaceKeys(ctx, workspaceID)
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
h.goAsync(func() { h.RestartByID(workspaceID) })
go h.RestartByID(workspaceID)
return &proxyA2AError{
Status: http.StatusServiceUnavailable,
Response: gin.H{
@@ -262,8 +262,8 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
errWsName = workspaceID
}
summary := "A2A request to " + errWsName + " failed: " + errMsg
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
@@ -277,7 +277,7 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
Status: "error",
ErrorDetail: &errMsg,
})
})
}(ctx)
}
// logA2ASuccess records a successful A2A round-trip and (for canvas-initiated
@@ -298,19 +298,19 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
// silent workspaces. Only update when callerID is a real workspace (not
// canvas, not a system caller) and the target returned 2xx/3xx.
if callerID != "" && !isSystemCaller(callerID) && statusCode < 400 {
h.goAsync(func() {
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.DB.ExecContext(bgCtx,
`UPDATE workspaces SET last_outbound_at = NOW() WHERE id = $1`, callerID); err != nil {
log.Printf("last_outbound_at update failed for %s: %v", callerID, err)
}
})
}()
}
summary := a2aMethod + " → " + wsNameForLog
toolTrace := extractToolTrace(respBody)
h.goAsync(func() {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
@@ -325,7 +325,7 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
DurationMs: &durationMs,
Status: logStatus,
})
})
}(ctx)
if callerID == "" && statusCode < 400 {
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventA2AResponse), map[string]interface{}{
@@ -504,50 +504,26 @@ func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
// reads in PR 3 — that's how a poll-mode workspace receives inbound A2A
// without a public URL.
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
// DATA-LOSS FIX (internal#471 — poll-mode sibling of #1347/internal#470):
// this is the ONLY durable write of a poll-mode inbound message,
// including a canvas_user message (callerID == "") typed in the canvas
// chat. It MUST be SYNCHRONOUS and complete BEFORE the caller returns
// the synthetic {status:"queued"} 200 — otherwise the canvas sees the
// send acknowledged while the activity_logs row is still racing in a
// detached goroutine, and a workspace-server restart / deploy / OOM /
// EC2 hibernation between the 200 and the goroutine's commit loses the
// user's message permanently (chat-history reads activity_logs, so a
// missing row = message gone on reopen). Hongming's tenant is entirely
// poll-mode (4 external workspaces, no URL — verified empirically), so
// his reported loss is THIS path; #1347 (push-mode, persists AFTER the
// poll short-circuit) structurally cannot cover it.
//
// Mirrors persistUserMessageAtIngest's discipline:
// - context.WithoutCancel: a client disconnect on chat-exit (which
// cancels the inbound request ctx) MUST NOT abort this write.
// - SYNCHRONOUS (no goAsync): the row must be durable before the
// queued 200 is returned to the caller.
// - Best-effort: LogActivity already logs+swallows INSERT errors, so
// a hiccup never blocks or fails the user's send (behavior for
// that one request is never worse than the pre-fix async path).
// The post-commit broadcast still fires inside LogActivity; a missed
// WebSocket event is not data loss (the durable row is the truth the
// canvas re-reads on reopen).
insCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
var wsName string
db.DB.QueryRowContext(insCtx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
if wsName == "" {
wsName = workspaceID
}
summary := a2aMethod + " → " + wsName + " (queued for poll)"
LogActivity(insCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
SourceID: nilIfEmpty(callerID),
TargetID: &workspaceID,
Method: &a2aMethod,
Summary: &summary,
RequestBody: json.RawMessage(body),
Status: "ok",
})
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
LogActivity(logCtx, h.broadcaster, ActivityParams{
WorkspaceID: workspaceID,
ActivityType: "a2a_receive",
SourceID: nilIfEmpty(callerID),
TargetID: &workspaceID,
Method: &a2aMethod,
Summary: &summary,
RequestBody: json.RawMessage(body),
Status: "ok",
})
}(ctx)
}
// readUsageMap extracts input_tokens / output_tokens from the "usage" key of m.
@@ -54,7 +54,6 @@ func TestPreflight_ContainerRunning_ReturnsNil(t *testing.T) {
_ = setupTestDB(t)
stub := &preflightLocalProv{running: true, err: nil}
h := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
h.provisioner = stub
if err := h.preflightContainerHealth(context.Background(), "ws-running-123"); err != nil {
@@ -187,8 +186,8 @@ func TestProxyA2A_Preflight_RoutesThroughProvisionerSSOT(t *testing.T) {
}
var (
callsIsRunning bool
callsContainerInspectRaw bool
callsIsRunning bool
callsContainerInspectRaw bool
callsRunningContainerNameDirect bool
)
ast.Inspect(fn.Body, func(n ast.Node) bool {
@@ -262,7 +262,6 @@ func TestProxyA2A_Upstream502_TriggersContainerDeadCheck(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: false}
handler.SetCPProvisioner(cp)
@@ -325,7 +324,6 @@ func TestProxyA2A_Upstream502_AliveAgent_PropagatesAsIs(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: true}
handler.SetCPProvisioner(cp)
@@ -515,7 +513,6 @@ func TestProxyA2A_AllowedSelf_SkipsAccessCheck(t *testing.T) {
allowLoopbackForTest(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -664,18 +661,18 @@ func TestProxyA2A_CallerIDDerivedFromBearer(t *testing.T) {
// (column order: workspace_id, activity_type, source_id, target_id, ...)
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(
"ws-target", // $1 workspace_id
"a2a_receive", // $2 activity_type
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
sqlmock.AnyArg(), // $4 target_id
sqlmock.AnyArg(), // $5 method
sqlmock.AnyArg(), // $6 summary
sqlmock.AnyArg(), // $7 request_body
sqlmock.AnyArg(), // $8 response_body
sqlmock.AnyArg(), // $9 tool_trace
sqlmock.AnyArg(), // $10 duration_ms
sqlmock.AnyArg(), // $11 status
sqlmock.AnyArg(), // $12 error_detail
"ws-target", // $1 workspace_id
"a2a_receive", // $2 activity_type
sqlmock.AnyArg(), // $3 source_id — *string("ws-caller"), checked below
sqlmock.AnyArg(), // $4 target_id
sqlmock.AnyArg(), // $5 method
sqlmock.AnyArg(), // $6 summary
sqlmock.AnyArg(), // $7 request_body
sqlmock.AnyArg(), // $8 response_body
sqlmock.AnyArg(), // $9 tool_trace
sqlmock.AnyArg(), // $10 duration_ms
sqlmock.AnyArg(), // $11 status
sqlmock.AnyArg(), // $12 error_detail
).
WillReturnResult(sqlmock.NewResult(0, 1))
@@ -1719,6 +1716,7 @@ func TestDispatchA2A_RejectsUnsafeURL(t *testing.T) {
}
}
// --- handleA2ADispatchError ---
func TestHandleA2ADispatchError_ContextDeadline(t *testing.T) {
@@ -1805,7 +1803,6 @@ func TestMaybeMarkContainerDead_CPOnly_NotRunning(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
cp := &fakeCPProv{running: false}
handler.SetCPProvisioner(cp)
@@ -1958,7 +1955,6 @@ func TestLogA2AFailure_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
// Sync workspace-name lookup (called in the caller goroutine).
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
@@ -1977,7 +1973,6 @@ func TestLogA2AFailure_EmptyNameFallback(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
// Empty name from DB → summary uses the workspaceID as the name.
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
@@ -1994,7 +1989,6 @@ func TestLogA2ASuccess_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
WithArgs("ws-ok").
@@ -2011,7 +2005,6 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id =`).
WithArgs("ws-err").
@@ -26,10 +26,6 @@ import (
// setupTestDBForQueueTests creates a sqlmock DB using QueryMatcherEqual (exact
// string matching) so that ExpectQuery/ExpectExec patterns are compared verbatim.
// Uses the same global db.DB as setupTestDB so the handler can use it.
//
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
// that tests running after this one are not polluted by a closed mock.
// Same fix as setupTestDB (handlers_test.go); same root cause as mc#975.
func setupTestDBForQueueTests(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
@@ -44,8 +44,8 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
// Update both when a new template is added.
var AllRuntimes = []string{
"claude-code", "langgraph", "autogen",
"hermes", "openclaw",
"claude-code", "langgraph", "crewai", "autogen",
"deepagents", "hermes", "gemini-cli", "openclaw",
}
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
@@ -699,8 +698,7 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
var result []map[string]interface{}
for rows.Next() {
var delegationID, callerID, calleeID, taskPreview, status string
var resultPreview, errorDetail sql.NullString
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
if err := rows.Scan(
&delegationID, &callerID, &calleeID, &taskPreview,
@@ -719,11 +717,11 @@ func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, works
"updated_at": updatedAt,
"_ledger": true, // marker so callers know this row is from the ledger
}
if resultPreview.Valid && resultPreview.String != "" {
entry["response_preview"] = textutil.TruncateBytes(resultPreview.String, 300)
if resultPreview != "" {
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
}
if errorDetail.Valid && errorDetail.String != "" {
entry["error"] = errorDetail.String
if errorDetail != "" {
entry["error"] = errorDetail
}
if lastHeartbeat != nil {
entry["last_heartbeat"] = lastHeartbeat
@@ -145,54 +145,6 @@ func TestListDelegationsFromLedger_MultipleRows(t *testing.T) {
}
}
func TestListDelegationsFromLedger_NullsOmitted(t *testing.T) {
// last_heartbeat, deadline, result_preview, error_detail are all NULL.
// Handler must not panic and must omit those keys from the map.
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
now := time.Now()
rows := sqlmock.NewRows([]string{
"delegation_id", "caller_id", "callee_id", "task_preview",
"status", "result_preview", "error_detail",
"last_heartbeat", "deadline", "created_at", "updated_at",
}).
AddRow("del-1", "ws-1", "ws-2", "task", "queued", nil, nil, nil, nil, now, now)
mock.ExpectQuery("SELECT .+ FROM delegations").
WithArgs("ws-1").
WillReturnRows(rows)
broadcaster := newTestBroadcaster()
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
dh := NewDelegationHandler(wh, broadcaster)
got := dh.listDelegationsFromLedger(context.Background(), "ws-1")
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
e := got[0]
if _, ok := e["last_heartbeat"]; ok {
t.Error("last_heartbeat should be absent when NULL")
}
if _, ok := e["deadline"]; ok {
t.Error("deadline should be absent when NULL")
}
if _, ok := e["response_preview"]; ok {
t.Error("response_preview should be absent when NULL result_preview")
}
if _, ok := e["error"]; ok {
t.Error("error should be absent when NULL error_detail")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
}
func TestListDelegationsFromLedger_QueryError(t *testing.T) {
// Query failure returns nil — graceful fallback, no panic.
mockDB, mock, err := sqlmock.New()
@@ -646,12 +646,8 @@ const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path.
# external machine today, pair with the Python SDK tab.
# 1. Install openclaw CLI + the workspace runtime wheel:
# The version pin (>=0.1.999) ensures the "molecule-mcp" console
# script is present — it is what keeps the workspace ALIVE on canvas
# (register-on-startup + 20s heartbeat). Older versions only ship
# a2a_mcp_server which does not heartbeat.
npm install -g openclaw@latest
pip install "molecule-ai-workspace-runtime>=0.1.999"
pip install molecule-ai-workspace-runtime
# 2. Onboard openclaw against your model provider (one-time setup).
# --non-interactive needs an explicit --provider + --model so it
@@ -29,11 +29,6 @@ func init() {
// setupTestDB creates a sqlmock DB and assigns it to the global db.DB.
// It also disables the SSRF URL check so that httptest.NewServer loopback
// URLs and fake hostnames (*.example) used in tests don't trigger rejections.
//
// IMPORTANT: db.DB is saved before assignment and restored via t.Cleanup so
// that tests running after this one are not polluted by a closed mock.
// This is the single root cause of the systemic CI/Platform (Go) failures on
// main HEAD 8026f020 (mc#975).
func setupTestDB(t *testing.T) sqlmock.Sqlmock {
t.Helper()
mockDB, mock, err := sqlmock.New()
@@ -62,11 +57,6 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
return mock
}
func waitForHandlerAsyncBeforeDBCleanup(t *testing.T, h *WorkspaceHandler) {
t.Helper()
t.Cleanup(h.waitAsyncForTest)
}
// setupTestRedis creates a miniredis instance and assigns it to the global db.RDB.
func setupTestRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
@@ -366,11 +356,6 @@ func TestWorkspaceCreate(t *testing.T) {
}
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
WithArgs("claude-code").
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
@@ -2,12 +2,10 @@ package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"time"
@@ -82,135 +80,117 @@ func TestInstructionsList_ByWorkspaceID(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 instructions, got %d", len(result))
if len(out) != 2 {
t.Errorf("expected 2 instructions, got %d", len(out))
}
if result[0].Scope != "global" || result[1].Scope != "workspace" {
t.Fatalf("expected global then workspace instructions, got %#v", result)
if out[0].Scope != "global" {
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_List_WithScopeFilter(t *testing.T) {
func TestInstructionsList_ByScope(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
rows := sqlmock.NewRows([]string{
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
}).AddRow("inst-1", "global", nil, "Be kind", "Always be kind", 10, true,
time.Now(), time.Now())
w, c := newGetRequest("/instructions?scope=global")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
mock.ExpectQuery(regexp.QuoteMeta("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1 AND scope = $1 ORDER BY scope, priority DESC, created_at")).
rows := sqlmock.NewRows(instructionCols).
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WithArgs("global").
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/instructions?scope=global", nil)
handler.List(c)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected 1 instruction, got %d", len(result))
}
if result[0].Scope != "global" {
t.Errorf("expected scope 'global', got %q", result[0].Scope)
if len(out) != 1 || out[0].Scope != "global" {
t.Errorf("unexpected response: %v", out)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_List_WithWorkspaceID(t *testing.T) {
func TestInstructionsList_AllNoParams(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
wsID := "ws-test-123"
h := NewInstructionsHandler()
rows := sqlmock.NewRows([]string{
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
}).AddRow("inst-1", "global", nil, "Global rule", "Stay safe", 5, true,
time.Now(), time.Now()).
AddRow("inst-2", "workspace", &wsID, "WS rule", "Use HTTPS", 10, true,
time.Now(), time.Now())
w, c := newGetRequest("/instructions")
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE enabled = true AND \\(").
WithArgs(wsID).
rows := sqlmock.NewRows(instructionCols)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/instructions?workspace_id="+wsID, nil)
handler.List(c)
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var result []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
var out []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 instructions, got %d", len(result))
// Empty slice, not nil
if out == nil {
t.Error("expected empty slice, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_List_QueryError(t *testing.T) {
func TestInstructionsList_DBError(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
w, c := newGetRequest("/instructions")
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnError(context.DeadlineExceeded)
WillReturnError(errors.New("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/instructions", nil)
handler.List(c)
h.List(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ── Create ──────────────────────────────────────────────────────────────────────
// ── Create ───────────────────────────────────────────────────────────────────
func TestInstructionsHandler_Create_Success(t *testing.T) {
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Be Helpful",
"content": "Always be helpful to the user.",
"priority": 10,
})
mock.ExpectQuery("INSERT INTO platform_instructions").
WithArgs("global", nil, "Be kind", "Always be kind", 5).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-id"))
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
body, _ := json.Marshal(map[string]interface{}{
"scope": "global",
"title": "Be kind",
"content": "Always be kind",
"priority": 5,
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
h.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
@@ -219,8 +199,8 @@ func TestInstructionsHandler_Create_Success(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
if out["id"] != "new-inst-id" {
t.Errorf("expected id new-inst-id, got %s", out["id"])
if out["id"] != "new-inst-1" {
t.Errorf("expected id new-inst-1, got %s", out["id"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
@@ -319,65 +299,56 @@ func TestInstructionsCreate_InvalidScope(t *testing.T) {
}
}
func TestInstructionsHandler_Create_WorkspaceScopeMissingScopeTarget(t *testing.T) {
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
body, _ := json.Marshal(map[string]interface{}{
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "workspace",
"title": "Test",
"content": "Test content",
"title": "Missing Target",
"content": "Workspace scope without scope_target.",
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsHandler_Create_ContentTooLong(t *testing.T) {
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
longContent := string(bytes.Repeat([]byte("x"), 8193))
body, _ := json.Marshal(map[string]interface{}{
// Build a string longer than maxInstructionContentLen (8192).
longContent := string(make([]byte, maxInstructionContentLen+1))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": "Test",
"title": "Too Long",
"content": longContent,
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsHandler_Create_TitleTooLong(t *testing.T) {
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
longTitle := string(bytes.Repeat([]byte("x"), 201))
body, _ := json.Marshal(map[string]interface{}{
longTitle := string(make([]byte, 201))
w, c := newPostRequest("/instructions", map[string]interface{}{
"scope": "global",
"title": longTitle,
"content": "Short content",
"content": "Short content.",
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/instructions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
@@ -871,250 +842,43 @@ func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
var out struct {
Instructions string `json:"instructions"`
}
}
func TestInstructionsHandler_Update_NotFound(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
mock.ExpectExec(regexp.QuoteMeta("UPDATE platform_instructions SET\n\t\t\t\ttitle = COALESCE($2, title),\n\t\t\t\tcontent = COALESCE($3, content),\n\t\t\t\tpriority = COALESCE($4, priority),\n\t\t\t\tenabled = COALESCE($5, enabled),\n\t\t\t\tupdated_at = NOW()\n\t\t\t\tWHERE id = $1")).
WithArgs("nonexistent", sqlmock.AnyArg(), nil, nil, nil).
WillReturnResult(sqlmock.NewResult(0, 0))
body, _ := json.Marshal(map[string]interface{}{"title": "Updated title"})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
c.Request = httptest.NewRequest("PUT", "/instructions/nonexistent", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("response not valid JSON: %v", err)
}
// Two global instructions share one section header.
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
t.Errorf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_Update_ContentTooLong(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
longContent := string(bytes.Repeat([]byte("x"), 8193))
body, _ := json.Marshal(map[string]interface{}{"content": longContent})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestInstructionsHandler_Update_TitleTooLong(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
longTitle := string(bytes.Repeat([]byte("x"), 201))
body, _ := json.Marshal(map[string]interface{}{"title": longTitle})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
c.Request = httptest.NewRequest("PUT", "/instructions/inst-1", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Update(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ── Delete ─────────────────────────────────────────────────────────────────────
func TestInstructionsHandler_Delete_Success(t *testing.T) {
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
h := NewInstructionsHandler()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
WithArgs("inst-1").
instID := "inst-empty-update"
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
c.Params = []gin.Param{{Key: "id", Value: instID}}
// COALESCE(nil, ...) = unchanged; still updates updated_at.
// Args order: ($1=id, $2=title, $3=content, $4=priority, $5=enabled)
mock.ExpectExec("UPDATE platform_instructions SET").
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "inst-1"}}
c.Request = httptest.NewRequest("DELETE", "/instructions/inst-1", nil)
handler.Delete(c)
h.Update(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_Delete_NotFound(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM platform_instructions WHERE id = $1")).
WithArgs("nonexistent").
WillReturnResult(sqlmock.NewResult(0, 0))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "nonexistent"}}
c.Request = httptest.NewRequest("DELETE", "/instructions/nonexistent", nil)
handler.Delete(c)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ── Resolve ────────────────────────────────────────────────────────────────────
func TestInstructionsHandler_Resolve_Empty(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
wsID := "ws-resolve-1"
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"scope", "title", "content"}))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
handler.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["workspace_id"] != wsID {
t.Errorf("expected workspace_id %q, got %v", wsID, resp["workspace_id"])
}
if resp["instructions"] != "" {
t.Errorf("expected empty instructions, got %q", resp["instructions"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_Resolve_WithInstructions(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
wsID := "ws-resolve-2"
rows := sqlmock.NewRows([]string{"scope", "title", "content"}).
AddRow("global", "Be safe", "No SSRF").
AddRow("workspace", "WS Rule", "Use HTTPS")
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions WHERE enabled = true AND").
WithArgs(wsID).
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+wsID+"/instructions/resolve", nil)
handler.Resolve(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
instructions, ok := resp["instructions"].(string)
if !ok {
t.Fatalf("instructions field is not a string: %T", resp["instructions"])
}
if instructions == "" {
t.Fatalf("expected non-empty instructions")
}
// Verify scope headers are present
if !bytes.Contains([]byte(instructions), []byte("Platform-Wide Rules")) {
t.Errorf("expected 'Platform-Wide Rules' header in instructions")
}
if !bytes.Contains([]byte(instructions), []byte("Role-Specific Rules")) {
t.Errorf("expected 'Role-Specific Rules' header in instructions")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestInstructionsHandler_Resolve_MissingWorkspaceID(t *testing.T) {
setupTestDB(t)
handler := NewInstructionsHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: ""}}
c.Request = httptest.NewRequest("GET", "/workspaces//instructions/resolve", nil)
handler.Resolve(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// scanInstructions is called by the List handler — verify it handles
// rows.Err() gracefully without panicking.
func TestInstructionsHandler_List_ScanErrorContinues(t *testing.T) {
mock := setupTestDB(t)
handler := NewInstructionsHandler()
rows := sqlmock.NewRows([]string{
"id", "scope", "scope_target", "title", "content", "priority", "enabled", "created_at", "updated_at",
}).AddRow("inst-1", "global", nil, "Good", "Content here", 5, true, time.Now(), time.Now()).
RowError(1, context.DeadlineExceeded) // error on row 2 (if it existed)
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
WillReturnRows(rows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/instructions", nil)
handler.List(c)
// Should still return 200 and the one valid row
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var result []Instruction
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// The valid row should still be returned (error is logged, not fatal)
if len(result) != 1 {
t.Fatalf("expected 1 instruction despite row error, got %d", len(result))
t.Errorf("unmet expectations: %v", err)
}
}
@@ -15,7 +15,6 @@ import (
"gopkg.in/yaml.v3"
)
// resolvePromptRef reads a prompt body from either an inline string or a
// file ref relative to the workspace's files_dir. Inline always wins when
// both are non-empty (caller-provided inline is more authoritative than a
@@ -177,7 +176,7 @@ func isEnvIdentPart(c byte) bool {
return isEnvIdentStart(c) || (c >= '0' && c <= '9')
}
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
@@ -104,8 +104,8 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
// documents this design choice; callers who need empty=resolved should
// pre-process the output before calling hasUnresolvedVarRef.
{"${VAR}", "", true},
{"${VAR}", "value", false}, // var replaced
{"$VAR", "value", false}, // bare var replaced
{"${VAR}", "value", false}, // var replaced
{"$VAR", "value", false}, // bare var replaced
{"prefix${VAR}suffix", "prefixvaluesuffix", false},
{"${A}${B}", "ab", false},
// FOO=FOO and BAR=BAR — both vars found and replaced. Expanded output
@@ -125,14 +125,14 @@ func TestHasUnresolvedVarRef_Resolved(t *testing.T) {
func TestHasUnresolvedVarRef_Unresolved(t *testing.T) {
// Expansion left the refs intact → unresolved.
cases := []struct {
orig string
orig string
expanded string
}{
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"${VAR}", "${VAR}"}, // untouched
{"$VAR", "$VAR"}, // bare untouched
{"prefix${VAR}suffix", "prefix${VAR}suffix"},
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
{"${A}${B}", "${A}${B}"}, // both unresolved
{"${FOO}", ""}, // empty result with var ref in original
}
for _, tc := range cases {
t.Run(tc.orig, func(t *testing.T) {
@@ -205,8 +205,8 @@ func TestMergeCategoryRouting_WorkspaceOverrides(t *testing.T) {
"ui": {"Frontend Engineer"},
}
ws := map[string][]string{
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"security": {"SRE Team"}, // narrows
"ui": {}, // drops
"infra": {"Platform Team"}, // adds
}
r := mergeCategoryRouting(defaults, ws)
@@ -467,44 +467,6 @@ func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
assert.Equal(t, "yes and ${NOT_SET}", result)
}
func TestExpandWithEnv_EmbeddedMissingProcessEnvStaysLiteral(t *testing.T) {
t.Setenv("MOL_TEST_EMBEDDED_MISSING", "")
result := expandWithEnv("prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", map[string]string{})
assert.Equal(t, "prefix/${MOL_TEST_EMBEDDED_MISSING}/suffix", result)
}
// POSIX identifier guard regression tests (CWE-78 fix).
// Keys not starting with [a-zA-Z_] must not be looked up in env or os.Getenv.
func TestExpandWithEnv_DigitPrefix_NotExpanded(t *testing.T) {
// ${0}, ${5}, ${1VAR} — numeric prefix → not a valid shell identifier.
// Guard must return "$0", "$5", "$1VAR" literally; no env lookup.
cases := []struct {
input string
want string
}{
{"${0}", "$0"},
{"${5}", "$5"},
{"${1VAR}", "$1VAR"},
{"prefix ${0} suffix", "prefix $0 suffix"},
{"$0", "$0"},
{"$5", "$5"},
{"HOME=${HOME}", "HOME=${HOME}"}, // HOME is valid but embedded in larger string
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := expandWithEnv(tc.input, map[string]string{})
assert.Equal(t, tc.want, got)
})
}
}
func TestExpandWithEnv_EmptyKey_ReturnsDollar(t *testing.T) {
// ${} → "$" (empty key, guard returns "$")
result := expandWithEnv("value=${}", map[string]string{})
assert.Equal(t, "value=$", result)
}
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
// ── Additional coverage: mergeCategoryRouting ──────────────────────
@@ -584,8 +546,8 @@ func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
routing := map[string][]string{
"zebra": {"RoleZ"},
"alpha": {"RoleA"},
"zebra": {"RoleZ"},
"alpha": {"RoleA"},
"middleware": {"RoleM"},
}
result, err := renderCategoryRoutingYAML(routing)
@@ -342,11 +342,6 @@ func TestPluginInstall_InstanceLookupError_Returns503(t *testing.T) {
// ---------- dispatch: uninstall ----------
func TestPluginUninstall_SaaS_DispatchesToEIC(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectExec("DELETE FROM workspace_plugins WHERE workspace_id").
WithArgs("ws-1", "browser-automation").
WillReturnResult(sqlmock.NewResult(0, 1))
stubReadPluginManifestViaEIC(t, func(ctx context.Context, instanceID, runtime, pluginName string) ([]byte, error) {
return []byte("name: browser-automation\nskills:\n - browse\n"), nil
})
@@ -629,9 +629,6 @@ func TestPluginInstall_RejectsUnknownScheme(t *testing.T) {
}
func TestPluginInstall_LocalSourceReachesContainerLookup(t *testing.T) {
mock := setupTestDB(t)
expectAllowlistAllowAll(mock)
base := t.TempDir()
pluginDir := filepath.Join(base, "demo")
_ = os.MkdirAll(pluginDir, 0o755)
@@ -958,14 +955,14 @@ func TestLogInstallLimitsOnce(t *testing.T) {
func TestRegexpEscapeForAwk(t *testing.T) {
cases := map[string]string{
"my-plugin": `my-plugin`,
"# Plugin: foo /": `# Plugin: foo \/`,
"# Plugin: a.b /": `# Plugin: a\.b \/`,
"foo[bar]": `foo\[bar\]`,
"a*b+c?": `a\*b\+c\?`,
"path|with|pipes": `path\|with\|pipes`,
`back\slash`: `back\\slash`,
"": ``,
"my-plugin": `my-plugin`,
"# Plugin: foo /": `# Plugin: foo \/`,
"# Plugin: a.b /": `# Plugin: a\.b \/`,
"foo[bar]": `foo\[bar\]`,
"a*b+c?": `a\*b\+c\?`,
"path|with|pipes": `path\|with\|pipes`,
`back\slash`: `back\\slash`,
"": ``,
}
for in, want := range cases {
got := regexpEscapeForAwk(in)
@@ -1250,7 +1247,7 @@ func TestPluginDownload_GithubSchemeStreamsTarball(t *testing.T) {
scheme: "github",
fetchFn: func(_ context.Context, _ string, dst string) (string, error) {
files := map[string]string{
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
"plugin.yaml": "name: remote-plugin\nversion: 1.0.0\n",
"skills/x/SKILL.md": "---\nname: x\n---\n",
"adapters/claude_code.py": "from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n",
}
@@ -0,0 +1,97 @@
package handlers
// Mirror of the third-party LLM provider registry baked into adapter
// templates (workspace-configs-templates/openclaw/adapter.py +
// workspace-configs-templates/claude-code-default/adapter.py + the
// hermes counterparts). Must stay in sync — if a provider is added in
// any of those adapters' base_url defaults, add it here too.
//
// Why this exists (RFC internal#417):
//
// Operators saving a vendor-scoped API key (e.g. MINIMAX_API_KEY) into
// the platform `global_secrets` table expect the workspace to talk to
// the canonical regional endpoint for that key. Pre-fix, when no
// matching <PROVIDER>_BASE_URL was also saved, the adapter inside the
// workspace fell back to its hardcoded registry default — which for
// MiniMax is api.minimaxi.com (China), while every operator key issued
// against api.minimax.io (global) hits 401 there. The fix is platform-
// side: when we see the API key but no URL, we inject the canonical URL
// so the workspace's adapter precedence chain (operator URL > runtime
// config > adapter default) ends up pointing at the right region.
//
// This is a FALLBACK, never an override. If the operator already
// saved <PROVIDER>_BASE_URL we leave it alone; their explicit choice
// always wins. If the API key isn't set, we don't inject a stray URL
// for a provider the workspace never uses.
//
// MINIMAX_BASE_URL is set to api.minimax.io (NOT api.minimaxi.com) —
// the global endpoint matches the keys all operator-host secrets are
// issued against. See RFC internal#417 §Phase 1 for the regional
// ambiguity table.
var ProviderBaseURLDefaults = map[string]string{
"OPENAI_BASE_URL": "https://api.openai.com/v1",
"GROQ_BASE_URL": "https://api.groq.com/openai/v1",
"OPENROUTER_BASE_URL": "https://openrouter.ai/api/v1",
"QIANFAN_BASE_URL": "https://qianfan.baidubce.com/v2",
"MINIMAX_BASE_URL": "https://api.minimax.io",
"MOONSHOT_BASE_URL": "https://api.moonshot.ai/v1",
}
// applyProviderBaseURLDefaults injects each provider's canonical
// BASE_URL into envVars when the matching <PROVIDER>_API_KEY is set
// and non-empty AND no <PROVIDER>_BASE_URL is already present.
//
// The function mutates envVars in place. It is a pure helper (no DB,
// no I/O) so unit tests drive it directly without sqlmock.
//
// Pairing rule: each entry in ProviderBaseURLDefaults names a key of
// the form <PREFIX>_BASE_URL; the paired API-key var is derived by
// swapping the suffix to _API_KEY (e.g. MINIMAX_BASE_URL ↔
// MINIMAX_API_KEY). This mirrors how every adapter registry pairs the
// two — see workspace-configs-templates/*/adapter.py for the canonical
// pairing and RFC internal#417 §Phase 2 for the design rationale.
//
// Returns the list of base-URL keys that were injected, so the caller
// can log which fallbacks fired without re-iterating the map.
func applyProviderBaseURLDefaults(envVars map[string]string) []string {
if envVars == nil {
return nil
}
var injected []string
for baseURLKey, defaultURL := range ProviderBaseURLDefaults {
// Derive the paired API-key var name. Every entry in the map
// MUST end in `_BASE_URL`; if a future entry breaks that
// convention this strip-and-swap silently misses it. The unit
// test TestApplyProviderBaseURLDefaults_KeyShape pins the
// shape so the miss surfaces immediately on next test run.
const baseSuffix = "_BASE_URL"
if len(baseURLKey) <= len(baseSuffix) ||
baseURLKey[len(baseURLKey)-len(baseSuffix):] != baseSuffix {
continue
}
apiKeyKey := baseURLKey[:len(baseURLKey)-len(baseSuffix)] + "_API_KEY"
// API key must be present AND non-empty. An empty string is
// treated as unset — operators sometimes save a placeholder
// row and we don't want to silently route them to a real
// endpoint with no credential.
apiKeyVal, hasKey := envVars[apiKeyKey]
if !hasKey || apiKeyVal == "" {
continue
}
// Don't overwrite an explicit operator URL. The operator may
// have legitimately scoped this provider to a custom or
// regional endpoint (e.g. a corporate proxy, a different
// MiniMax region). Their explicit value always wins — same
// precedence rule as molecule_runtime.adapter_base's
// resolve_provider_routing.
if existing, ok := envVars[baseURLKey]; ok && existing != "" {
continue
}
envVars[baseURLKey] = defaultURL
injected = append(injected, baseURLKey)
}
return injected
}
@@ -0,0 +1,118 @@
package handlers
import (
"strings"
"testing"
)
// TestApplyProviderBaseURLDefaults_InjectsWhenKeySetAndURLAbsent — the
// happy path that closes RFC internal#417's reported bug: operator
// saves MINIMAX_API_KEY only, fallback fills in the canonical URL.
func TestApplyProviderBaseURLDefaults_InjectsWhenKeySetAndURLAbsent(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "sk-real-key",
}
injected := applyProviderBaseURLDefaults(envVars)
if got := envVars["MINIMAX_BASE_URL"]; got != "https://api.minimax.io" {
t.Fatalf("MINIMAX_BASE_URL: got %q, want %q", got, "https://api.minimax.io")
}
if len(injected) != 1 || injected[0] != "MINIMAX_BASE_URL" {
t.Fatalf("injected list: got %v, want [MINIMAX_BASE_URL]", injected)
}
}
// TestApplyProviderBaseURLDefaults_DoesNotOverrideExplicitURL — operator
// override wins. If they saved both MINIMAX_API_KEY and a custom URL
// (say a corporate-proxy or a different region), we leave their URL
// alone. This is the precedence rule from RFC §Phase 2.
func TestApplyProviderBaseURLDefaults_DoesNotOverrideExplicitURL(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "sk-real-key",
"MINIMAX_BASE_URL": "https://custom.example.com",
}
injected := applyProviderBaseURLDefaults(envVars)
if got := envVars["MINIMAX_BASE_URL"]; got != "https://custom.example.com" {
t.Fatalf("MINIMAX_BASE_URL: got %q, want operator value %q",
got, "https://custom.example.com")
}
for _, k := range injected {
if k == "MINIMAX_BASE_URL" {
t.Fatalf("MINIMAX_BASE_URL should not appear in injected list when operator set it explicitly; got %v", injected)
}
}
}
// TestApplyProviderBaseURLDefaults_NoKeyNoInjection — provider whose
// API key isn't set should not get a stray URL injected. Keeps the
// workspace env clean and avoids accidentally signalling that the
// provider is available when it isn't.
func TestApplyProviderBaseURLDefaults_NoKeyNoInjection(t *testing.T) {
envVars := map[string]string{
// Some unrelated key set, but no MINIMAX_API_KEY.
"OPENAI_API_KEY": "sk-openai",
}
injected := applyProviderBaseURLDefaults(envVars)
if _, ok := envVars["MINIMAX_BASE_URL"]; ok {
t.Fatalf("MINIMAX_BASE_URL was injected without MINIMAX_API_KEY being set; envVars=%v", envVars)
}
// OPENAI_API_KEY is set, so OPENAI_BASE_URL should be injected —
// keeps this test honest about what "no injection" means (it's
// per-provider, not blanket).
if envVars["OPENAI_BASE_URL"] != "https://api.openai.com/v1" {
t.Fatalf("OPENAI_BASE_URL: got %q, want %q",
envVars["OPENAI_BASE_URL"], "https://api.openai.com/v1")
}
for _, k := range injected {
if k == "MINIMAX_BASE_URL" {
t.Fatalf("MINIMAX_BASE_URL should not appear in injected list; got %v", injected)
}
}
}
// TestApplyProviderBaseURLDefaults_EmptyKeyTreatedAsUnset — operators
// sometimes save a placeholder empty-string row (e.g. they cleared the
// key but didn't delete the row). Treat that the same as the key being
// absent: don't route to a real endpoint with no credential.
func TestApplyProviderBaseURLDefaults_EmptyKeyTreatedAsUnset(t *testing.T) {
envVars := map[string]string{
"MINIMAX_API_KEY": "", // explicitly empty
}
injected := applyProviderBaseURLDefaults(envVars)
if _, ok := envVars["MINIMAX_BASE_URL"]; ok {
t.Fatalf("MINIMAX_BASE_URL should not be injected when MINIMAX_API_KEY=\"\"; envVars=%v", envVars)
}
if len(injected) != 0 {
t.Fatalf("injected list should be empty when key is empty; got %v", injected)
}
}
// TestApplyProviderBaseURLDefaults_KeyShape — every key in the
// registry must end in `_BASE_URL` so the strip-and-swap in
// applyProviderBaseURLDefaults can derive the paired API-key var
// name. If a future entry breaks that convention the fallback
// silently misses it; this test catches the drift at next CI run.
func TestApplyProviderBaseURLDefaults_KeyShape(t *testing.T) {
for k := range ProviderBaseURLDefaults {
if !strings.HasSuffix(k, "_BASE_URL") {
t.Errorf("ProviderBaseURLDefaults key %q must end in _BASE_URL", k)
}
}
}
// TestApplyProviderBaseURLDefaults_NilMap — defensive: the helper is
// called from loadWorkspaceSecrets right before return, after both
// query loops. If a future refactor passes nil (e.g. an error path
// returns the bare nil map literal), the helper must not panic. The
// function's nil-guard pins this.
func TestApplyProviderBaseURLDefaults_NilMap(t *testing.T) {
// Must not panic. Return value can be nil/empty either way.
_ = applyProviderBaseURLDefaults(nil)
}
@@ -58,7 +58,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
// Non-blocking send — don't stall the restart cycle.
// Run in a detached goroutine so the caller (runRestartCycle) can
// proceed to stopForRestart without waiting.
h.goAsync(func() {
go func() {
signalCtx, cancel := context.WithTimeout(context.Background(), restartSignalTimeout)
defer cancel()
@@ -109,7 +109,7 @@ func (h *WorkspaceHandler) gracefulPreRestart(ctx context.Context, workspaceID s
} else {
log.Printf("A2AGracefulRestart: %s returned status %d — proceeding with stop", workspaceID, resp.StatusCode)
}
})
}()
}
// resolveAgentURLForRestartSignal returns the routable URL for the workspace
@@ -271,7 +271,6 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
WorkspaceHandler: newHandlerWithTestDeps(t),
errToReturn: context.DeadlineExceeded,
}
waitForHandlerAsyncBeforeDBCleanup(t, hWrapper.WorkspaceHandler)
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
time.Sleep(200 * time.Millisecond)
@@ -186,16 +186,11 @@ func (h *TemplatesHandler) List(c *gin.Context) {
model = raw.RuntimeConfig.Model
}
tier := raw.Tier
if h.wh != nil && h.wh.IsSaaS() {
tier = h.wh.DefaultTier()
}
templates = append(templates, templateSummary{
ID: id,
Name: raw.Name,
Description: raw.Description,
Tier: tier,
Tier: raw.Tier,
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
@@ -345,11 +340,6 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
if err != nil || path == walkRoot {
return nil
}
// Skip symlinks to prevent path traversal via malicious symlinks
// inside the workspace config directory (OFFSEC-010).
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(walkRoot, path)
// Enforce depth limit
if strings.Count(rel, string(filepath.Separator))+1 > depth {
@@ -847,58 +847,6 @@ func TestListFiles_FallbackToHost_WithTemplate(t *testing.T) {
}
}
func TestListFiles_FallbackToHost_SkipsSymlinks(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
tmplDir := filepath.Join(tmpDir, "test-agent")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Test Agent\n"), 0644); err != nil {
t.Fatal(err)
}
secret := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(secret, []byte("do-not-list"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(tmplDir, "leaked-secret")); err != nil {
t.Fatal(err)
}
handler := NewTemplatesHandler(tmpDir, nil, nil)
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-tmpl").
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).AddRow("Test Agent", "", ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-tmpl"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-tmpl/files", nil)
handler.ListFiles(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
for _, file := range resp {
if file["path"] == "leaked-secret" {
t.Fatalf("symlink should not be listed: %#v", resp)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== GET /workspaces/:id/files/*path ====================
func TestReadFile_PathTraversal(t *testing.T) {
@@ -1252,3 +1200,4 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) {
})
}
}
@@ -340,11 +340,6 @@ func TestSSHCommandCmd_BuildsArgv(t *testing.T) {
// a workspace must still be able to access its own terminal. The CanCommunicate
// fast-path returns true when callerID == targetID.
func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-alice").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
// CanCommunicate fast-path: callerID == targetID → returns true without DB.
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool { return callerID == targetID }
@@ -372,11 +367,6 @@ func TestTerminalConnect_KI005_AllowsOwnTerminal(t *testing.T) {
// skip the CanCommunicate check entirely and fall through to the Docker auth path.
// We assert they get the nil-docker 503 instead of 403.
func TestTerminalConnect_KI005_SkipsCheckWithoutHeader(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-any").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
h := NewTerminalHandler(nil) // nil docker → 503 if reached
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -449,9 +439,6 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
mock.ExpectExec(`UPDATE workspace_auth_tokens SET last_used_at`).
WithArgs(sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-dev").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
h := NewTerminalHandler(nil)
w := httptest.NewRecorder()
@@ -476,10 +463,7 @@ func TestTerminalConnect_KI005_AllowsSiblingWorkspace(t *testing.T) {
// introduced in GH#1885: internal routing uses org tokens which are not in
// workspace_auth_tokens, so ValidateToken would always fail for them.
func TestKI005_OrgToken_SkipsValidateToken(t *testing.T) {
mock := setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
mock.ExpectQuery("SELECT COALESCE").
WithArgs("ws-target").
WillReturnRows(sqlmock.NewRows([]string{"instance_id"}).AddRow(""))
setupTestDB(t) // no ValidateToken ExpectQuery — none should fire
prev := canCommunicateCheck
canCommunicateCheck = func(callerID, targetID string) bool {
// Simulate platform agent → target workspace (same org).
@@ -560,3 +544,4 @@ func TestSSHCommandCmd_ConnectTimeoutPresent(t *testing.T) {
args)
}
}
@@ -164,14 +164,15 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
id := uuid.New().String()
awarenessNamespace := workspaceAwarenessNamespace(id)
if h.IsSaaS() {
// SaaS hard gate: every hosted workspace gets its own sibling
// EC2 instance, so T4 is the only meaningful runtime boundary.
// Do not trust stale clients/templates that still send T1/T2/T3.
payload.Tier = 4
} else if payload.Tier == 0 {
// Self-hosted default remains T3. Lower tiers (T1 sandboxed,
// T2 standard) stay explicit opt-ins for low-trust local agents.
if payload.Tier == 0 {
// SaaS-aware default. SaaS → T4 (full host access; each
// workspace runs on its own sibling EC2 so the tier boundary
// is a Docker resource limit on the only container present —
// no neighbour to protect from). Self-hosted → T3 (read-write
// workspace mount + Docker daemon access, most templates'
// baseline). Lower tiers (T1 sandboxed, T2 standard) remain
// explicit opt-ins for low-trust agents. Matches the canvas
// CreateWorkspaceDialog defaults so the API and the UI agree.
payload.Tier = h.DefaultTier()
}
@@ -3,7 +3,7 @@ package handlers
// workspace_broadcast.go — POST /workspaces/:id/broadcast
//
// Allows a workspace with broadcast_enabled=true to send a message to every
// non-removed agent workspace in the SAME ORG. The message is:
// non-removed agent workspace in the org. The message is:
//
// • Persisted in each recipient's activity_logs (type='broadcast_receive')
// so poll-mode agents pick it up via GET /activity.
@@ -16,11 +16,6 @@ package handlers
// Auth: WorkspaceAuth (the agent triggers this with its own bearer token).
// The handler re-validates broadcast_enabled inside the DB lookup to prevent
// TOCTOU — the middleware only proved the token is valid, not the ability.
//
// Org isolation (OFFSEC-015): recipients are scoped to the sender's org using
// a recursive CTE that walks the parent_id chain to find the org root. This
// prevents a compromised or misconfigured workspace from broadcasting to
// workspaces in other tenants' orgs.
import (
"log"
@@ -79,49 +74,11 @@ func (h *BroadcastHandler) Broadcast(c *gin.Context) {
return
}
// Find the sender's org root by walking the parent_id chain.
// Workspaces with parent_id = NULL are org roots; every other workspace
// belongs to the org identified by its topmost ancestor.
var orgRootID string
err = db.DB.QueryRowContext(ctx, `
WITH RECURSIVE org_chain AS (
SELECT id, parent_id, id AS root_id
FROM workspaces
WHERE id = $1
UNION ALL
SELECT w.id, w.parent_id, c.root_id
FROM workspaces w
JOIN org_chain c ON w.id = c.parent_id
)
SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
`, senderID).Scan(&orgRootID)
if err != nil {
log.Printf("Broadcast: org root lookup for %s: %v", senderID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
// Collect all non-removed agent workspaces in the SAME ORG (same root_id),
// excluding the sender itself.
rows, err := db.DB.QueryContext(ctx, `
WITH RECURSIVE org_chain AS (
SELECT id, parent_id, id AS root_id
FROM workspaces
WHERE parent_id IS NULL
UNION ALL
SELECT w.id, w.parent_id, c.root_id
FROM workspaces w
JOIN org_chain c ON w.parent_id = c.id
)
SELECT c.id
FROM org_chain c
WHERE c.root_id = $1
AND c.id != $2
AND EXISTS (
SELECT 1 FROM workspaces w
WHERE w.id = c.id AND w.status != 'removed'
)
`, orgRootID, senderID)
// Collect all non-removed agent workspaces (excludes the sender itself).
rows, err := db.DB.QueryContext(ctx,
`SELECT id FROM workspaces WHERE status != 'removed' AND id != $1`,
senderID,
)
if err != nil {
log.Printf("Broadcast: recipient query failed for %s: %v", senderID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
@@ -1,428 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// -------- Org-scoped recipient query tests (OFFSEC-015) --------
// TestBroadcast_OrgScopedRecipients verifies that a broadcast from Org-A does
// NOT reach workspaces belonging to Org-B. This is the core regression test
// for OFFSEC-015: the original query had no org filter, so a workspace in
// Org-A could broadcast to every non-removed workspace in the entire DB,
// including workspaces owned by other tenants.
func TestBroadcast_OrgScopedRecipients(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
// Org-A structure:
// org-a-root (parent_id = NULL) ← sender
// ├── ws-a-child
// Org-B structure:
// org-b-root (parent_id = NULL)
// └── ws-b-child
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
wsAChild := "00000000-0000-0000-0000-000000000002"
// ws-b-child is in Org-B (different root); the org-scoped query MUST NOT include it.
// 1. Sender lookup
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Org-A Root", true))
// 2. Org root lookup — sender is its own root (parent_id = NULL)
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
// 3. Org-scoped recipient query — MUST include org filter so ws-b-child is NOT included.
// The query joins on org_chain.root_id = orgRootID, which scopes to Org-A only.
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID). // orgRootID, senderID (EXCLUDED)
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsAChild)) // only Org-A child
// Activity log inserts
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(wsAChild, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"hello from org-a"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != "sent" {
t.Errorf("expected status 'sent', got %v", resp["status"])
}
// ws-b-child is in a DIFFERENT org — the org-scoped query MUST NOT include it.
// If it were included, the mock would have an unmet expectation.
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations — cross-org workspace was included in broadcast: %v", err)
}
}
// TestBroadcast_OrgScoped_OrgRootSender verifies that when the sender IS the
// org root (parent_id = NULL), broadcasts still reach sibling workspaces.
func TestBroadcast_OrgScoped_OrgRootSender(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001" // org-a-root
siblingID := "00000000-0000-0000-0000-000000000002"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
// Sender is the org root — CTE returns sender's own ID as root
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
// Recipients in same org, excluding sender
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"hello siblings"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestBroadcast_OrgScoped_ChildWorkspaceSender verifies that a non-root child
// workspace can broadcast to siblings in the same org.
func TestBroadcast_OrgScoped_ChildWorkspaceSender(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
orgRootID := "00000000-0000-0000-0000-000000000001"
senderID := "00000000-0000-0000-0000-000000000002" // child workspace
siblingID := "00000000-0000-0000-0000-000000000003"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Child Agent", true))
// Org root lookup — walk up to find org-a-root
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(orgRootID))
// Recipients: same org, excluding sender
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(orgRootID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(siblingID))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(siblingID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"child broadcasting"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// -------- Non-regression cases --------
func TestBroadcast_NotFound(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000099"
// UUID is valid, but no workspace row matches
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnError(errors.New("workspace not found"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"test"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_Disabled(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"should not send"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["error"] != "broadcast_disabled" {
t.Errorf("expected error 'broadcast_disabled', got %v", resp["error"])
}
}
func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Lone Root", true))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
// No other workspaces in this org
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"hello org"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if resp["delivered"] != float64(0) {
t.Errorf("expected delivered=0, got %v", resp["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
body := `{"message":"test"}`
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
// finding the org root errors, the handler returns 500 instead of proceeding
// with an un-scoped query that would broadcast to all orgs.
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
// Org root CTE fails
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnError(context.DeadlineExceeded)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"should not broadcast"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
// The recipient query MUST NOT be called — it would broadcast cross-org
// if the org root lookup failed silently.
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
// from a workspace does not send a broadcast_receive to the sender itself
// (the sender logs broadcast_sent, not broadcast_receive).
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewBroadcastHandler(broadcaster)
senderID := "00000000-0000-0000-0000-000000000001"
peerID := "00000000-0000-0000-0000-000000000002"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Root Agent", true))
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
// Recipient query MUST exclude sender via id != senderID
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
WithArgs(senderID, senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerID))
// Peer receives broadcast_receive
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerID, senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
// Sender logs broadcast_sent (NOT broadcast_receive)
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
body := `{"message":"no echo to self"}`
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
func TestBroadcast_Truncate(t *testing.T) {
cases := []struct {
msg string
max int
expect string
}{
{"short", 120, "short"}, // under max — no truncation
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
// "this is a longer mes" = 20 runes; + "…" = 21 chars
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
// at-max boundary: 20 chars at max=20 → no truncation
{"exactly twenty chars", 20, "exactly twenty chars"},
// over max: 11 chars at max=10 → 10 + "…" = 11
{"hello world!", 10, "hello worl…"},
}
for _, tc := range cases {
result := broadcastTruncate(tc.msg, tc.max)
if result != tc.expect {
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.msg, tc.max, result, tc.expect)
}
}
}
@@ -15,7 +15,6 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"gopkg.in/yaml.v3"
)
// logProvisionPanic is the deferred recover at the top of every provision
@@ -473,10 +472,9 @@ func configDirName(workspaceID string) string {
// runtime means bumping both this list and the Docker image tags.
// knownRuntimes is populated from manifest.json at service init (see
// runtime_registry.go). The package init order is:
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
//
// 1. var knownRuntimes = fallbackRuntimes
// 2. init() calls initKnownRuntimes() which replaces it if
// manifest.json is readable.
// The fallback matters for unit tests that don't mount the manifest.
//
// "external" is a first-class runtime that intentionally does NOT
@@ -541,9 +539,6 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
}
// Sanitize name/role/model for YAML safety — always double-quote so
// a crafted value with a newline or colon can't terminate the scalar
@@ -559,11 +554,6 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
quoteModel := yamlQuote(model)
configYAML := fmt.Sprintf("name: %s\ndescription: %s\nversion: 1.0.0\ntier: %d\nruntime: %s\n",
quoteName, quoteRole, payload.Tier, runtime)
if runtime == "claude-code" {
if providersYAML := h.defaultTemplateProvidersYAML(runtime); providersYAML != "" {
configYAML += providersYAML + "\n"
}
}
// Model always at top level — config.py reads raw["model"] for all runtimes.
configYAML += fmt.Sprintf("model: %s\n", quoteModel)
@@ -573,11 +563,7 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// and preflight already validates that the env vars are present before
// the agent loop starts. Hardcoding token names here caused #1028
// (expired CLAUDE_CODE_OAUTH_TOKEN baked into config.yaml).
configYAML += "runtime_config:\n"
if runtime == "claude-code" {
configYAML += fmt.Sprintf(" model: %s\n", quoteModel)
}
configYAML += " timeout: 0\n"
configYAML += "runtime_config:\n timeout: 0\n"
files["config.yaml"] = []byte(configYAML)
@@ -585,60 +571,6 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
return files
}
func normalizeClaudeCodeModel(model string) string {
model = strings.TrimSpace(model)
if before, after, ok := strings.Cut(model, "/"); ok && before != "" && after != "" {
return after
}
return model
}
func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
if h.configsDir == "" {
return ""
}
templateName := runtime + "-default"
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
return ""
}
data, err := os.ReadFile(filepath.Join(templatePath, "config.yaml"))
if err != nil {
return ""
}
var root yaml.Node
if err := yaml.Unmarshal(data, &root); err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: invalid YAML: %v", runtime, err)
return ""
}
if len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode {
return ""
}
mapping := root.Content[0]
for i := 0; i+1 < len(mapping.Content); i += 2 {
if mapping.Content[i].Value != "providers" {
continue
}
out := yaml.Node{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{Kind: yaml.ScalarNode, Value: "providers"},
mapping.Content[i+1],
},
}
encoded, err := yaml.Marshal(&out)
if err != nil {
log.Printf("Provisioner: default template providers skipped for runtime %s: marshal failed: %v", runtime, err)
return ""
}
return strings.TrimRight(string(encoded), "\n")
}
return ""
}
// deriveProviderFromModelSlug maps a hermes-agent model slug prefix to
// its provider name — a Go translation of the case statement in
// workspace-configs-templates/hermes/scripts/derive-provider.sh that we
@@ -898,6 +830,21 @@ func loadWorkspaceSecrets(ctx context.Context, workspaceID string) (map[string]s
log.Printf("Provisioner: workspace_secrets rows.Err workspace=%s: %v", workspaceID, err)
}
}
// Provider BASE_URL fallback (RFC internal#417): when the operator
// saved a vendor API key (e.g. MINIMAX_API_KEY) but no matching
// <PROVIDER>_BASE_URL, inject the canonical regional URL so the
// in-workspace adapter doesn't fall back to its hardcoded default
// (which is wrong for at least MiniMax — api.minimaxi.com China vs
// api.minimax.io global). Operator-saved URLs win; this only fills
// holes. See provider_defaults.go for the registry + precedence.
if injected := applyProviderBaseURLDefaults(envVars); len(injected) > 0 {
for _, k := range injected {
log.Printf("Provisioner: provider BASE_URL fallback applied for %s: %s=%s",
workspaceID, k, envVars[k])
}
}
return envVars, ""
}
@@ -144,7 +144,6 @@ func TestProvisionWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
rec := &trackingCPProv{startErr: errors.New("simulated CP rejection")}
bcast := &concurrentSafeBroadcaster{}
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
h.SetCPProvisioner(rec)
wsID := "ws-routes-to-cp-0123456789abcdef"
@@ -596,7 +595,6 @@ func TestRestartWorkspaceAuto_RoutesToCPWhenSet(t *testing.T) {
// Mock DB so cpStopWithRetry can run without a real Postgres.
mock := setupTestDB(t)
waitForHandlerAsyncBeforeDBCleanup(t, h)
mock.MatchExpectationsInOrder(false)
// provisionWorkspaceCP runs in the goroutine and will hit secrets
// SELECTs + UPDATE workspace as failed (we make CP Start return
@@ -672,7 +670,6 @@ func TestRestartWorkspaceAuto_RoutesToDockerWhenOnlyDocker(t *testing.T) {
bcast := &concurrentSafeBroadcaster{}
h := NewWorkspaceHandler(bcast, nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, h)
stub := &stoppingLocalProv{}
h.provisioner = stub
@@ -2,7 +2,6 @@ package handlers
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
@@ -261,67 +260,6 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
}
}
func TestEnsureDefaultConfig_ClaudeCodeCopiesProviderRegistry(t *testing.T) {
broadcaster := newTestBroadcaster()
configsDir := t.TempDir()
templateDir := filepath.Join(configsDir, "claude-code-default")
if err := os.MkdirAll(templateDir, 0o755); err != nil {
t.Fatalf("mkdir template: %v", err)
}
if err := os.WriteFile(filepath.Join(templateDir, "config.yaml"), []byte(`
name: Claude Code Agent
runtime: claude-code
providers:
- name: anthropic-oauth
auth_mode: oauth
model_aliases: [sonnet]
auth_env: [CLAUDE_CODE_OAUTH_TOKEN]
- name: minimax
auth_mode: third_party_anthropic_compat
model_prefixes: [minimax-]
base_url: https://api.minimax.io/anthropic
auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN]
runtime_config:
model: sonnet
`), 0o644); err != nil {
t.Fatalf("write template: %v", err)
}
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", configsDir)
files := handler.ensureDefaultConfig("ws-code-123", models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 4,
Runtime: "claude-code",
Model: "minimax/MiniMax-M2.7",
})
var parsed struct {
Model string `yaml:"model"`
Providers []struct {
Name string `yaml:"name"`
ModelPrefixes []string `yaml:"model_prefixes"`
} `yaml:"providers"`
RuntimeConfig struct {
Model string `yaml:"model"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(files["config.yaml"], &parsed); err != nil {
t.Fatalf("generated YAML invalid: %v\n%s", err, files["config.yaml"])
}
if parsed.Model != "MiniMax-M2.7" {
t.Fatalf("top-level model = %q, want MiniMax-M2.7\n%s", parsed.Model, files["config.yaml"])
}
if parsed.RuntimeConfig.Model != "MiniMax-M2.7" {
t.Fatalf("runtime_config.model = %q, want MiniMax-M2.7\n%s", parsed.RuntimeConfig.Model, files["config.yaml"])
}
if len(parsed.Providers) != 2 {
t.Fatalf("providers len = %d, want 2\n%s", len(parsed.Providers), files["config.yaml"])
}
if parsed.Providers[1].Name != "minimax" || len(parsed.Providers[1].ModelPrefixes) != 1 || parsed.Providers[1].ModelPrefixes[0] != "minimax-" {
t.Fatalf("minimax provider registry not preserved: %+v\n%s", parsed.Providers, files["config.yaml"])
}
}
func TestEnsureDefaultConfig_CustomModel(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -696,11 +634,6 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
// ==================== buildProvisionerConfig ====================
func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-basic").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
broadcaster := newTestBroadcaster()
tmpDir := t.TempDir()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", tmpDir)
@@ -745,14 +678,6 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
}
func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-env").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT digest FROM runtime_image_pins`).
WithArgs("claude-code").
WillReturnError(sql.ErrNoRows)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
@@ -414,44 +414,6 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
}
}
func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
handler.SetCPProvisioner(&trackingCPProv{})
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE workspaces SET url").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestWorkspaceCreate_WithSecrets_Persists asserts that secrets in the create
// payload are written to workspace_secrets inside the same transaction as the
// workspace row, and that the handler returns 201.
@@ -23,8 +23,8 @@ package models
// - claude-code: "sonnet" — Anthropic's CLI accepts the short
// name and resolves it via the operator's anthropic-oauth or
// ANTHROPIC_API_KEY chain.
// - everything else (hermes, langgraph, autogen, codex, openclaw,
// external, ""): a fully-qualified
// - everything else (hermes, langgraph, crewai, autogen, deepagents,
// codex, openclaw, gemini-cli, external, ""): a fully-qualified
// vendor:model slug that the universal MODEL_PROVIDER chain in
// molecule-core PR #247 can route via per-vendor required_env.
//
@@ -21,9 +21,12 @@ func TestDefaultModel(t *testing.T) {
// as a generic "unknown" failure.
{"hermes", "anthropic:claude-opus-4-7"},
{"langgraph", "anthropic:claude-opus-4-7"},
{"crewai", "anthropic:claude-opus-4-7"},
{"autogen", "anthropic:claude-opus-4-7"},
{"deepagents", "anthropic:claude-opus-4-7"},
{"codex", "anthropic:claude-opus-4-7"},
{"openclaw", "anthropic:claude-opus-4-7"},
{"gemini-cli", "anthropic:claude-opus-4-7"},
{"external", "anthropic:claude-opus-4-7"},
// Unknown / empty — fall through to universal default rather
@@ -343,7 +343,6 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
}
return files, nil
}
// Stop terminates the workspace's EC2 instance via the control plane.
//
// Looks up the actual EC2 instance_id from the workspaces table before
@@ -498,9 +497,7 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
// Don't leak the body — upstream errors may echo headers.
return true, fmt.Errorf("cp provisioner: status: unexpected %d", resp.StatusCode)
}
var result struct {
State string `json:"state"`
}
var result struct{ State string `json:"state"` }
// Cap body read at 64 KiB for parity with Start — a misconfigured
// or compromised CP streaming a huge body could otherwise exhaust
// memory in this hot path (called reactively per-request from
@@ -217,59 +217,6 @@ func TestStart_HappyPath(t *testing.T) {
}
}
func TestStart_SendsTemplateAndGeneratedConfigFiles(t *testing.T) {
tmpl := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: template\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpl, "adapter.py"), bytes.Repeat([]byte("x"), cpConfigFilesMaxBytes), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(tmpl, "prompts"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpl, "prompts", "system.md"), []byte("hello"), 0o600); err != nil {
t.Fatal(err)
}
var body cpProvisionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "claude-code",
Tier: 4,
PlatformURL: "http://tenant",
TemplatePath: tmpl,
ConfigFiles: map[string][]byte{
"config.yaml": []byte("name: generated\n"),
},
})
if err != nil {
t.Fatalf("Start: %v", err)
}
wantConfig := base64.StdEncoding.EncodeToString([]byte("name: generated\n"))
if got := body.ConfigFiles["config.yaml"]; got != wantConfig {
t.Errorf("config.yaml payload = %q, want generated override %q", got, wantConfig)
}
wantPrompt := base64.StdEncoding.EncodeToString([]byte("hello"))
if got := body.ConfigFiles["prompts/system.md"]; got != wantPrompt {
t.Errorf("prompt payload = %q, want %q", got, wantPrompt)
}
if _, ok := body.ConfigFiles["adapter.py"]; ok {
t.Error("non-config template file adapter.py must not be sent to CP")
}
}
// TestStart_Non201ReturnsStructuredError — when CP returns 401 with a
// structured {"error":"..."} body, Start surfaces that error message.
// Verifies the defense against log-leaking raw upstream bodies.
@@ -572,9 +519,9 @@ func TestStop_4xxResponseSurfacesError(t *testing.T) {
func TestStop_2xxVariantsAllSucceed(t *testing.T) {
primeInstanceIDLookup(t, map[string]string{"ws-1": "i-ok"})
for _, code := range []int{
http.StatusOK, // 200
http.StatusAccepted, // 202
http.StatusNoContent, // 204
http.StatusOK, // 200
http.StatusAccepted, // 202
http.StatusNoContent, // 204
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(code)
@@ -642,11 +589,11 @@ func TestIsRunning_ParsesStateField(t *testing.T) {
_, _ = io.WriteString(w, `{"state":"`+state+`"}`)
}))
p := &CPProvisioner{
baseURL: srv.URL,
orgID: "org-1",
baseURL: srv.URL,
orgID: "org-1",
sharedSecret: "s3cret",
adminToken: "tok-xyz",
httpClient: srv.Client(),
httpClient: srv.Client(),
}
got, err := p.IsRunning(context.Background(), "ws-1")
srv.Close()
@@ -190,7 +190,7 @@ func TestEnsureLocalImage_RepoNotFound(t *testing.T) {
opts.HTTPClient = srv.Client()
opts.remoteHeadSha = nil // exercise real HTTP path
_, err := ensureLocalImageWithOpts(context.Background(), "hermes", opts)
_, err := ensureLocalImageWithOpts(context.Background(), "crewai", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
@@ -35,19 +35,6 @@ import (
// drift-risk #6.
var ErrNoBackend = errors.New("provisioner: no backend configured (zero-valued receiver)")
// ErrUnresolvableRuntime is returned by selectImage when a workspace
// names a runtime that has no resolvable image (not in RuntimeImages and
// no operator-pinned cfg.Image). RFC internal#483 + security review 4269:
// previously such a request silently fell through to DefaultImage
// (langgraph) — a user asking for crewai would get a langgraph container
// with no signal. The CTO standing directive
// (feedback_platform_must_hardgate_base_contract) is fail-closed: a
// named-but-unresolvable runtime must reject with a structured,
// runtime-naming error so the existing provision-failed notify/log path
// surfaces it, NOT silently degrade. The genuinely-unspecified (empty)
// runtime is still a distinct, legitimate path that keeps DefaultImage.
var ErrUnresolvableRuntime = errors.New("provisioner: requested runtime has no resolvable image")
// RuntimeImages maps runtime names to their Docker image refs.
// Each standalone template repo publishes its image via the reusable
// publish-template-image workflow in molecule-ci on every main merge.
@@ -117,33 +104,20 @@ type WorkspaceConfig struct {
// selectImage resolves the final Docker image ref for a workspace. The handler
// layer is the source of truth — if it set cfg.Image (the digest-pinned form
// from runtime_image_pins, #2272), honor that. Otherwise fall back to the
// runtime→tag lookup in RuntimeImages (legacy `:latest` behavior).
//
// Fail-closed contract (RFC internal#483 / security review 4269 /
// feedback_platform_must_hardgate_base_contract): if the workspace NAMES a
// runtime that resolves to no image (not in RuntimeImages, no pinned
// cfg.Image), reject with ErrUnresolvableRuntime instead of silently
// substituting DefaultImage. Pre-fix, removing crewai/deepagents/gemini-cli
// from the catalog left those create requests silently provisioning a
// langgraph container — the user asked for crewai and got langgraph with no
// signal. The error propagates through Start → markProvisionFailed, which
// already broadcasts WorkspaceProvisionFailed and records the message.
//
// The genuinely-unspecified runtime (empty cfg.Runtime, e.g. an org template
// that doesn't pin one) is an intended distinct path and still resolves to
// DefaultImage — only a NAMED-but-unresolvable runtime is rejected.
func selectImage(cfg WorkspaceConfig) (string, error) {
// runtime→tag lookup in RuntimeImages (legacy `:latest` behavior). When the
// runtime isn't recognized either, fall back to DefaultImage so Start() still
// has something to hand Docker — surfacing a "No such image" later is more
// actionable than a silent "" panic in ContainerCreate.
func selectImage(cfg WorkspaceConfig) string {
if cfg.Image != "" {
return cfg.Image, nil
return cfg.Image
}
if cfg.Runtime != "" {
if img, ok := RuntimeImages[cfg.Runtime]; ok {
return img, nil
return img
}
return "", fmt.Errorf("%w: runtime %q (known runtimes: %v)",
ErrUnresolvableRuntime, cfg.Runtime, knownRuntimes)
}
return DefaultImage, nil
return DefaultImage
}
// Workspace-access constants for #65. Matches the CHECK constraint on
@@ -215,24 +189,6 @@ const containerNamePrefix = "ws-"
// (the wiped-DB case after `docker compose down -v`).
const LabelManaged = "molecule.platform.managed"
// AgentUID / AgentGID are the uid/gid of the unprivileged `agent` user that
// every workspace template creates and drops to via `gosu agent` before
// exec'ing the runtime (the a2a_mcp_server runs under this uid). The value is
// fixed at 1000:1000 across all templates — see:
// - workspace-configs-templates/claude-code-default/Dockerfile (`useradd -u 1000 ... agent`)
// - workspace-configs-templates/hermes/Dockerfile (`useradd -u 1000 ... agent`)
// - workspace/entrypoint.sh (`exec gosu agent` — "uid 1000")
//
// Files the platform injects into /configs AFTER the entrypoint's
// `chown -R agent:agent /configs` (the post-start #418 re-injection and the
// pre-start #1877 volume write) must be owned by this uid/gid, otherwise the
// agent-uid MCP server hits EACCES reading /configs/.auth_token, sends an
// empty bearer, and the platform 401s on /registry/{id}/peers (list_peers).
const (
AgentUID = 1000
AgentGID = 1000
)
// managedLabels is the canonical label map applied to every workspace
// container + volume. Pulled out so a future addition (e.g. instance
// UUID for multi-platform-shared-daemon disambiguation) is one edit.
@@ -362,15 +318,7 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e
env := buildContainerEnv(cfg)
image, imgErr := selectImage(cfg)
if imgErr != nil {
// Fail-closed: a named-but-unresolvable runtime must not silently
// become DefaultImage (RFC internal#483 / review 4269). The caller's
// error path (markProvisionFailed) broadcasts the failure + records
// the message so the canvas surfaces it.
log.Printf("Provisioner: refusing to start %s: %v", cfg.WorkspaceID, imgErr)
return "", imgErr
}
image := selectImage(cfg)
// Local-build mode (issue #63 / Task #194): when MOLECULE_IMAGE_REGISTRY
// is unset, the OSS contributor path skips the registry pull entirely
@@ -825,15 +773,6 @@ func ApplyTierConfig(hostCfg *container.HostConfig, cfg WorkspaceConfig, configM
// CopyTemplateToContainer copies files from a host directory into /configs in the container.
func (p *Provisioner) CopyTemplateToContainer(ctx context.Context, containerID, templatePath string) error {
buf, err := buildTemplateTar(templatePath)
if err != nil {
return err
}
return p.cli.CopyToContainer(ctx, containerID, "/configs", buf, container.CopyToContainerOptions{})
}
func buildTemplateTar(templatePath string) (*bytes.Buffer, error) {
// Resolve symlinks at the root before walking. filepath.Walk does
// NOT follow a symlink that IS the root — it Lstats the path, sees
// a symlink (non-directory), and emits exactly one entry without
@@ -856,15 +795,6 @@ func buildTemplateTar(templatePath string) (*bytes.Buffer, error) {
if err != nil {
return err
}
// OFFSEC-010: skip symlinks to prevent path traversal via malicious
// template symlinks (e.g. template/.ssh → /root/.ssh). filepath.Walk
// follows symlinks by default, so without this guard a crafted symlink
// inside the template directory could escape to include arbitrary host
// files in the tar archive. We intentionally skip rather than error so
// a broken symlink in an org template is a silent no-op.
if info.Mode()&os.ModeSymlink != 0 {
return nil
}
rel, err := filepath.Rel(templatePath, path)
if err != nil {
return err
@@ -905,27 +835,17 @@ func buildTemplateTar(templatePath string) (*bytes.Buffer, error) {
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create tar from %s: %w", templatePath, err)
return fmt.Errorf("failed to create tar from %s: %w", templatePath, err)
}
if err := tw.Close(); err != nil {
return nil, fmt.Errorf("failed to close tar writer: %w", err)
return fmt.Errorf("failed to close tar writer: %w", err)
}
return &buf, nil
return p.cli.CopyToContainer(ctx, containerID, "/configs", &buf, container.CopyToContainerOptions{})
}
// buildConfigFilesTar builds the tar stream that WriteFilesToContainer streams
// into /configs via CopyToContainer. Every entry is stamped Uid/Gid = agent
// (AgentUID/AgentGID) so the files land agent-owned after extraction. This is
// the issue #418 post-start re-injection path: it runs AFTER the template
// entrypoint's `chown -R agent:agent /configs`, so without explicit ownership
// in the tar header the files extract as root:root (tar Uid/Gid default 0) and
// the agent-uid MCP server can no longer read /configs/.auth_token (and
// /configs/.platform_inbound_secret) → empty bearer → list_peers 401.
//
// Pulled out as a pure function so the ownership contract is unit-testable
// without a live Docker daemon (mirrors buildTemplateTar).
func buildConfigFilesTar(files map[string][]byte) (*bytes.Buffer, error) {
// WriteFilesToContainer writes in-memory files into /configs in the container.
func (p *Provisioner) WriteFilesToContainer(ctx context.Context, containerID string, files map[string][]byte) error {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
@@ -938,10 +858,8 @@ func buildConfigFilesTar(files map[string][]byte) (*bytes.Buffer, error) {
Typeflag: tar.TypeDir,
Name: dir + "/",
Mode: 0755,
Uid: AgentUID,
Gid: AgentGID,
}); err != nil {
return nil, fmt.Errorf("failed to write tar dir header for %s: %w", dir, err)
return fmt.Errorf("failed to write tar dir header for %s: %w", dir, err)
}
createdDirs[dir] = true
}
@@ -950,30 +868,19 @@ func buildConfigFilesTar(files map[string][]byte) (*bytes.Buffer, error) {
Name: name,
Mode: 0644,
Size: int64(len(data)),
Uid: AgentUID,
Gid: AgentGID,
}
if err := tw.WriteHeader(header); err != nil {
return nil, fmt.Errorf("failed to write tar header for %s: %w", name, err)
return fmt.Errorf("failed to write tar header for %s: %w", name, err)
}
if _, err := tw.Write(data); err != nil {
return nil, fmt.Errorf("failed to write tar data for %s: %w", name, err)
return fmt.Errorf("failed to write tar data for %s: %w", name, err)
}
}
if err := tw.Close(); err != nil {
return nil, fmt.Errorf("failed to close tar writer: %w", err)
return fmt.Errorf("failed to close tar writer: %w", err)
}
return &buf, nil
}
// WriteFilesToContainer writes in-memory files into /configs in the container,
// agent-owned (see buildConfigFilesTar).
func (p *Provisioner) WriteFilesToContainer(ctx context.Context, containerID string, files map[string][]byte) error {
buf, err := buildConfigFilesTar(files)
if err != nil {
return err
}
return p.cli.CopyToContainer(ctx, containerID, "/configs", buf, container.CopyToContainerOptions{})
return p.cli.CopyToContainer(ctx, containerID, "/configs", &buf, container.CopyToContainerOptions{})
}
// CopyToContainer exposes CopyToContainer from the Docker client for use by other packages.
@@ -1063,28 +970,13 @@ func (p *Provisioner) ReadFromVolume(ctx context.Context, volumeName, filePath s
return clean, nil
}
// writeAuthTokenVolumeCmd is the shell command the throwaway alpine container
// runs to seed /vol/.auth_token. alpine runs it as root, so without the
// explicit `chown 1000:1000` the file stays root:root after the template
// entrypoint's `chown -R agent:agent /configs` has already run — the agent-uid
// (AgentUID) MCP server then gets EACCES reading it → empty bearer →
// list_peers 401. Pulled out as a pure function so the ownership contract is
// unit-testable without a live Docker daemon. Issue #1877.
func writeAuthTokenVolumeCmd() string {
return fmt.Sprintf(
"mkdir -p /vol && printf '%%s' $TOKEN > /vol/.auth_token && chmod 0600 /vol/.auth_token && chown %d:%d /vol/.auth_token",
AgentUID, AgentGID,
)
}
// WriteAuthTokenToVolume writes the workspace auth token into the config volume
// BEFORE the container starts, eliminating the token-injection race window where
// a restarted container could read a stale token from /configs/.auth_token before
// WriteFilesToContainer writes the new one. Issue #1877.
//
// Uses a throwaway alpine container to write directly to the named volume,
// bypassing the container lifecycle entirely. The written file is chowned to
// the agent uid/gid (see writeAuthTokenVolumeCmd).
// bypassing the container lifecycle entirely.
func (p *Provisioner) WriteAuthTokenToVolume(ctx context.Context, workspaceID, token string) error {
if p == nil || p.cli == nil {
return ErrNoBackend
@@ -1092,7 +984,7 @@ func (p *Provisioner) WriteAuthTokenToVolume(ctx context.Context, workspaceID, t
volName := ConfigVolumeName(workspaceID)
resp, err := p.cli.ContainerCreate(ctx, &container.Config{
Image: "alpine",
Cmd: []string{"sh", "-c", writeAuthTokenVolumeCmd()},
Cmd: []string{"sh", "-c", "mkdir -p /vol && printf '%s' $TOKEN > /vol/.auth_token && chmod 0600 /vol/.auth_token"},
Env: []string{"TOKEN=" + token},
}, &container.HostConfig{
Binds: []string{volName + ":/vol"},
@@ -1,9 +1,7 @@
package provisioner
import (
"archive/tar"
"errors"
"io"
"os"
"path/filepath"
"strings"
@@ -64,72 +62,6 @@ func TestValidateConfigSource_TemplateIsDirName(t *testing.T) {
}
}
func TestStartSeedsConfigsBeforeContainerStart(t *testing.T) {
src, err := os.ReadFile("provisioner.go")
if err != nil {
t.Fatalf("read provisioner.go: %v", err)
}
text := string(src)
copyTemplate := strings.Index(text, "p.CopyTemplateToContainer(ctx, resp.ID, cfg.TemplatePath)")
writeFiles := strings.Index(text, "p.WriteFilesToContainer(ctx, resp.ID, cfg.ConfigFiles)")
start := strings.Index(text, "p.cli.ContainerStart(ctx, resp.ID, container.StartOptions{})")
if copyTemplate < 0 || writeFiles < 0 || start < 0 {
t.Fatalf("expected Start to copy template, write config files, and start container")
}
if copyTemplate >= start || writeFiles >= start {
t.Fatalf("config seeding must happen before ContainerStart: copyTemplate=%d writeFiles=%d start=%d", copyTemplate, writeFiles, start)
}
}
func TestBuildTemplateTar_SkipsSymlinks(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("name: safe\n"), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
outside := filepath.Join(t.TempDir(), "secret.txt")
if err := os.WriteFile(outside, []byte("do-not-copy\n"), 0644); err != nil {
t.Fatalf("write outside target: %v", err)
}
if err := os.Symlink(outside, filepath.Join(dir, "linked-secret.txt")); err != nil {
t.Fatalf("create symlink: %v", err)
}
buf, err := buildTemplateTar(dir)
if err != nil {
t.Fatalf("buildTemplateTar: %v", err)
}
names := map[string]string{}
tr := tar.NewReader(buf)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
body, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read body for %s: %v", hdr.Name, err)
}
names[hdr.Name] = string(body)
}
if got := names["config.yaml"]; got != "name: safe\n" {
t.Fatalf("config.yaml body = %q, want safe config", got)
}
if _, ok := names["linked-secret.txt"]; ok {
t.Fatalf("symlink entry was copied into template tar: %#v", names)
}
for name, body := range names {
if strings.Contains(body, "do-not-copy") {
t.Fatalf("symlink target leaked through %s: %q", name, body)
}
}
}
// baseHostConfig returns a fresh HostConfig with typical pre-tier binds,
// mimicking what Start() builds before calling ApplyTierConfig.
func baseHostConfig(pluginsPath string) *container.HostConfig {
@@ -513,10 +445,7 @@ func TestWorkspaceConfig_ResetClaudeSessionFieldPresent(t *testing.T) {
// we lose the "one bad publish doesn't break every workspace" guarantee.
func TestSelectImage_PrefersExplicitImage(t *testing.T) {
pinned := "ghcr.io/molecule-ai/workspace-template-claude-code@sha256:3d6761a97ed07d7d33cfc19a8fbab81175d9d9179618d493dbc00c5f7ef076a3"
got, err := selectImage(WorkspaceConfig{Runtime: "claude-code", Image: pinned})
if err != nil {
t.Fatalf("selectImage with cfg.Image=pinned: unexpected error %v", err)
}
got := selectImage(WorkspaceConfig{Runtime: "claude-code", Image: pinned})
if got != pinned {
t.Errorf("selectImage with cfg.Image=pinned: got %q, want %q", got, pinned)
}
@@ -526,46 +455,28 @@ func TestSelectImage_PrefersExplicitImage(t *testing.T) {
// pin lookup deliberately bypassed via WORKSPACE_IMAGE_LOCAL_OVERRIDE).
// selectImage must use the legacy runtime→:latest map.
func TestSelectImage_FallsBackToRuntimeMap(t *testing.T) {
got, err := selectImage(WorkspaceConfig{Runtime: "claude-code", Image: ""})
if err != nil {
t.Fatalf("selectImage with empty Image: unexpected error %v", err)
}
got := selectImage(WorkspaceConfig{Runtime: "claude-code", Image: ""})
want := RuntimeImages["claude-code"]
if got != want {
t.Errorf("selectImage with empty Image: got %q, want %q", got, want)
}
}
// TestSelectImage_NamedUnresolvableRuntimeRejects pins the fail-closed
// contract (RFC internal#483 / security review 4269 /
// feedback_platform_must_hardgate_base_contract): a NAMED runtime with no
// resolvable image must reject with ErrUnresolvableRuntime, NOT silently
// substitute DefaultImage. Pre-fix this returned langgraph — a user asking
// for a removed runtime (crewai/deepagents/gemini-cli) silently got a
// langgraph container. "crewai" is the concrete regression from the
// security finding.
func TestSelectImage_NamedUnresolvableRuntimeRejects(t *testing.T) {
for _, rt := range []string{"no-such-runtime", "crewai", "deepagents", "gemini-cli"} {
got, err := selectImage(WorkspaceConfig{Runtime: rt})
if !errors.Is(err, ErrUnresolvableRuntime) {
t.Errorf("selectImage(%q): got err %v, want ErrUnresolvableRuntime", rt, err)
}
if got != "" {
t.Errorf("selectImage(%q): got image %q, want \"\" on reject", rt, got)
}
if err != nil && !strings.Contains(err.Error(), rt) {
t.Errorf("selectImage(%q): error must name the offending runtime, got %v", rt, err)
}
// TestSelectImage_UnknownRuntimeFallsBackToDefault preserves today's
// behavior — an unrecognized runtime resolves to DefaultImage rather than
// "" so ContainerCreate gets a usable arg and surfaces a meaningful
// "No such image" error if the default itself is missing.
func TestSelectImage_UnknownRuntimeFallsBackToDefault(t *testing.T) {
got := selectImage(WorkspaceConfig{Runtime: "no-such-runtime"})
if got != DefaultImage {
t.Errorf("selectImage with unknown runtime: got %q, want DefaultImage %q", got, DefaultImage)
}
}
// TestSelectImage_EmptyRuntimeFallsBackToDefault: same invariant for the
// no-runtime-supplied path (legacy callers / older handler code).
func TestSelectImage_EmptyRuntimeFallsBackToDefault(t *testing.T) {
got, err := selectImage(WorkspaceConfig{})
if err != nil {
t.Fatalf("selectImage with zero cfg: unexpected error %v (empty runtime is a legitimate DefaultImage path)", err)
}
got := selectImage(WorkspaceConfig{})
if got != DefaultImage {
t.Errorf("selectImage with zero cfg: got %q, want DefaultImage %q", got, DefaultImage)
}
@@ -829,7 +740,7 @@ func TestIsImageNotFoundErr(t *testing.T) {
{"nil", nil, false},
{"moby no such image", fmtErr(`Error response from daemon: No such image: workspace-template:openclaw`), true},
{"no such image lowercase", fmtErr(`error: no such image: foo:bar`), true},
{"image not found", fmtErr(`Error: image "workspace-template:hermes" not found`), true},
{"image not found", fmtErr(`Error: image "workspace-template:crewai" not found`), true},
{"generic not found without image", fmtErr(`container not found`), false},
{"unrelated error", fmtErr(`connection refused`), false},
{"permission denied", fmtErr(`permission denied`), false},
@@ -21,6 +21,9 @@ var knownRuntimes = []string{
"autogen",
"claude-code",
"codex",
"crewai",
"deepagents",
"gemini-cli",
"hermes",
"langgraph",
"openclaw",
@@ -53,8 +53,8 @@ func TestRuntimeImage_AllKnownRuntimes(t *testing.T) {
}
}
// Pin the count so adding a runtime requires explicit test acknowledgement.
if len(knownRuntimes) != 6 {
t.Errorf("knownRuntimes length = %d, want 6 (autogen, claude-code, codex, hermes, langgraph, openclaw)", len(knownRuntimes))
if len(knownRuntimes) != 9 {
t.Errorf("knownRuntimes length = %d, want 9 (autogen, claude-code, codex, crewai, deepagents, gemini-cli, hermes, langgraph, openclaw)", len(knownRuntimes))
}
}
@@ -1,95 +0,0 @@
package provisioner
import (
"archive/tar"
"errors"
"io"
"strings"
"testing"
)
// These tests pin the P0 fix for the fleet-wide list_peers 401 (Hermes and
// every other template): the workspace-server token-injection paths wrote
// /configs/.auth_token (and /configs/.platform_inbound_secret) as root:root
// AFTER the template entrypoint's `chown -R agent:agent /configs` ran, so the
// agent-uid (1000) MCP server (a2a_mcp_server, running via `gosu agent`) hit
// `[Errno 13] Permission denied` reading the bearer → empty bearer → platform
// 401 on /registry/{id}/peers (the literal tool_list_peers path).
//
// The agent uid is 1000:1000, verified from the templates:
// - workspace-configs-templates/claude-code-default/Dockerfile: `useradd -u 1000 ... agent`
// - workspace-configs-templates/hermes/Dockerfile: `useradd -u 1000 ... agent`
// - workspace/entrypoint.sh / claude-code-default/entrypoint.sh: `exec gosu agent` ("uid 1000")
//
// Both tests assert the real artifact (the tar headers Docker's CopyToContainer
// honours for ownership, and the literal shell command the throwaway alpine
// container runs), not a mock that bypasses ownership. They FAIL on pre-fix
// code (no Uid/Gid in tar headers; no chown in the alpine command → root:root)
// and PASS post-fix (agent-owned).
// TestWriteFilesToContainerTar_FilesAreAgentOwned covers the issue #418
// post-start re-injection path (WriteFilesToContainer): the tar it streams
// into /configs via CopyToContainer must carry Uid/Gid = agent (1000) so the
// extracted files land agent-readable, not root:root. This is the path that
// (re)writes BOTH .auth_token and .platform_inbound_secret on a cadence.
func TestWriteFilesToContainerTar_FilesAreAgentOwned(t *testing.T) {
files := map[string][]byte{
".auth_token": []byte("tok-abc123"),
".platform_inbound_secret": []byte("inbound-secret-xyz"),
"nested/dir/file.txt": []byte("data"),
}
buf, err := buildConfigFilesTar(files)
if err != nil {
t.Fatalf("buildConfigFilesTar: %v", err)
}
tr := tar.NewReader(buf)
seen := map[string]bool{}
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("read tar: %v", err)
}
if _, err := io.Copy(io.Discard, tr); err != nil {
t.Fatalf("drain %s: %v", hdr.Name, err)
}
seen[hdr.Name] = true
if hdr.Uid != AgentUID {
t.Fatalf("tar entry %q Uid = %d, want %d (agent) — root-owned injection causes the list_peers 401",
hdr.Name, hdr.Uid, AgentUID)
}
if hdr.Gid != AgentGID {
t.Fatalf("tar entry %q Gid = %d, want %d (agent)", hdr.Name, hdr.Gid, AgentGID)
}
}
for _, want := range []string{".auth_token", ".platform_inbound_secret"} {
if !seen[want] {
t.Fatalf("tar missing %q (seen: %v)", want, seen)
}
}
}
// TestWriteAuthTokenVolumeCmd_ChownsToAgent covers the issue #1877 pre-start
// volume-write path (WriteAuthTokenToVolume): the throwaway alpine container
// writes /vol/.auth_token then chmod 0600 but, pre-fix, never chowns it, so it
// stays root:root (alpine runs the command as root). The literal command must
// chown the file to the agent uid:gid so the agent-uid MCP server can read it.
func TestWriteAuthTokenVolumeCmd_ChownsToAgent(t *testing.T) {
cmd := writeAuthTokenVolumeCmd()
if !strings.Contains(cmd, "chmod 0600 /vol/.auth_token") {
t.Fatalf("alpine cmd lost the 0600 chmod (regression): %q", cmd)
}
wantChown := "chown 1000:1000 /vol/.auth_token"
if !strings.Contains(cmd, wantChown) {
t.Fatalf("alpine cmd = %q, missing %q — without it .auth_token stays root:root "+
"and the agent-uid MCP server gets EACCES → empty bearer → list_peers 401",
cmd, wantChown)
}
}
@@ -14,9 +14,8 @@ func setupMockDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
t.Cleanup(func() { mockDB.Close() })
return mock
}
@@ -31,9 +31,8 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
t.Cleanup(func() { mockDB.Close() })
return mock
}
@@ -17,9 +17,8 @@ func setupHibernationMock(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
t.Cleanup(func() { mockDB.Close() })
return mock
}
@@ -18,9 +18,8 @@ func setupLivenessTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
t.Cleanup(func() { mockDB.Close() })
return mock
}
@@ -24,9 +24,8 @@ func setupTestDB(t *testing.T) sqlmock.Sqlmock {
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { mockDB.Close(); db.DB = prevDB })
t.Cleanup(func() { mockDB.Close() })
return mock
}
+3 -7
View File
@@ -692,8 +692,8 @@ def _format_channel_content(
# --- MCP Server (JSON-RPC over stdio) ---
def _assert_stdio_is_pipe_compatible(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
"""Assert that stdio fds are pipe/socket/char-device compatible.
def _warn_if_stdio_not_pipe(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
"""Warn when stdio isn't a pipe — but continue anyway.
The legacy asyncio.connect_read_pipe / connect_write_pipe transport
rejected regular files, PTYs, and sockets with:
@@ -717,10 +717,6 @@ def _assert_stdio_is_pipe_compatible(stdin_fd: int = 0, stdout_fd: int = 1) -> N
)
# Deprecated alias — the canonical name is _assert_stdio_is_pipe_compatible.
_warn_if_stdio_not_pipe = _assert_stdio_is_pipe_compatible
async def main(): # pragma: no cover
"""Run MCP server on stdio — reads JSON-RPC requests, writes responses.
@@ -977,7 +973,7 @@ def cli_main(transport: str = "stdio", port: int = 9100) -> None: # pragma: no
if transport == "http":
asyncio.run(_run_http_server(port))
else:
_assert_stdio_is_pipe_compatible()
_warn_if_stdio_not_pipe()
asyncio.run(main())
-47
View File
@@ -431,43 +431,6 @@ def _is_self_notify_row(row: dict[str, Any]) -> bool:
return source_id is None or source_id == ""
def _is_self_echo_row(row: dict[str, Any], workspace_id: str) -> bool:
"""Return True if ``row`` is a self-originated a2a_receive row.
Internal #469: when a workspace delegates to a target that never picks
up the task, ``tool_delegate_task`` calls ``report_activity`` which
POSTs to the platform with source_id set to the *sender's* workspace
UUID (mandated by spoof-defense in workspace-server's a2a_proxy). The
activity API exposes that row under type=a2a_receive, so the inbox
poller re-fetches it. Without this guard the row is surfaced as
kind='peer_agent' with the workspace's own identity as peer_id —
the workspace sees its own delegation-failure echoed back as if a
peer had delegated to it.
The guard mirrors the existing _is_self_notify_row pattern: both
skip rows that would otherwise create spurious inbound signal. The
long-term fix (making the platform write a distinct activity_type
for agent-outbound rows) is tracked separately; this guard stays
because it only excludes rows the agent never wants.
``workspace_id`` must be non-empty an empty-string workspace_id
(single-workspace legacy path) can never match a UUID source_id, so
the predicate is always False there, which is safe.
RFC #2829 PR-2 note: rows with method="delegate_result" are excluded
from the self-echo guard even when source_id matches our workspace_id.
The platform may write a delegation-result row with source_id set to
our workspace_id (e.g. a self-delegation or edge case in the platform's
result-writing path). Such rows must reach the inbox so that
message_from_activity can surface them as peer_agent inbound and the
runtime receives the delegation result. Silently filtering them as
self-echo would break delegation result delivery.
"""
if not workspace_id:
return False
return row.get("source_id") == workspace_id and row.get("method") != "delegate_result"
def message_from_activity(row: dict[str, Any]) -> InboxMessage:
"""Convert one /activity row into an InboxMessage.
@@ -660,16 +623,6 @@ def _poll_once(
# the same self-notify on every iteration.
last_id = str(row.get("id", "")) or last_id
continue
if _is_self_echo_row(row, workspace_id):
# Internal #469: tool_delegate_task writes its own a2a_receive
# row with source_id = this workspace's UUID (spoof-defense).
# The poll fetches it back as kind='peer_agent', making the
# workspace echo its own delegation-failure as an inbound from
# a phantom peer. Skip it — the real delegation-result path
# (delegate_result push) is separate and unaffected. Cursor
# still advances so the next poll doesn't re-seen this row.
last_id = str(row.get("id", "")) or last_id
continue
message = message_from_activity(row)
if not message.activity_id:
continue
+10 -10
View File
@@ -1826,8 +1826,8 @@ def test_inbox_bridge_swallows_closed_loop_runtime_error():
class TestStdioPipeAssertion:
"""Pin _assert_stdio_is_pipe_compatible — the canonical function name.
_warn_if_stdio_not_pipe is a deprecated alias.
"""Pin _warn_if_stdio_not_pipe — the diagnostic warning that replaces
the old fatal _assert_stdio_is_pipe_compatible guard.
The universal stdio transport now works with ANY file descriptor
(pipes, regular files, PTYs, sockets), so the old exit-2 behavior
@@ -1838,12 +1838,12 @@ class TestStdioPipeAssertion:
def test_pipe_pair_passes_silently(self, caplog):
"""Happy path — both fds are pipes. No warning emitted."""
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
from a2a_mcp_server import _warn_if_stdio_not_pipe
r, w = os.pipe()
try:
with caplog.at_level("WARNING"):
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=w)
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
assert "not a pipe" not in caplog.text
finally:
os.close(r)
@@ -1852,14 +1852,14 @@ class TestStdioPipeAssertion:
def test_regular_file_stdout_warns(self, tmp_path, caplog):
"""Reproducer for runtime#61: stdout redirected to a regular file.
Now emits a warning instead of exiting."""
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
from a2a_mcp_server import _warn_if_stdio_not_pipe
r, _w = os.pipe()
regular = tmp_path / "captured.log"
f = open(regular, "wb")
try:
with caplog.at_level("WARNING"):
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=f.fileno())
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=f.fileno())
assert "stdout" in caplog.text
assert "not a pipe" in caplog.text
finally:
@@ -1868,7 +1868,7 @@ class TestStdioPipeAssertion:
def test_regular_file_stdin_warns(self, tmp_path, caplog):
"""Symmetric case — stdin redirected from a regular file."""
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
from a2a_mcp_server import _warn_if_stdio_not_pipe
regular = tmp_path / "input.json"
regular.write_bytes(b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n')
@@ -1876,7 +1876,7 @@ class TestStdioPipeAssertion:
_r, w = os.pipe()
try:
with caplog.at_level("WARNING"):
_assert_stdio_is_pipe_compatible(stdin_fd=f.fileno(), stdout_fd=w)
_warn_if_stdio_not_pipe(stdin_fd=f.fileno(), stdout_fd=w)
assert "stdin" in caplog.text
assert "not a pipe" in caplog.text
finally:
@@ -1886,13 +1886,13 @@ class TestStdioPipeAssertion:
def test_closed_fd_warns_about_stat_error(self, caplog):
"""If stdio is closed, os.fstat raises OSError. Warning is
skipped silently (can't stat the fd)."""
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
from a2a_mcp_server import _warn_if_stdio_not_pipe
r, w = os.pipe()
os.close(w) # Now `w` is a stale fd — fstat will fail.
try:
with caplog.at_level("WARNING"):
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=w)
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
# No warning emitted because fstat failed before the check
assert "not a pipe" not in caplog.text
finally:
+2 -158
View File
@@ -192,22 +192,11 @@ def test_pop_missing_id_returns_none(state: inbox.InboxState):
def test_wait_returns_existing_head_immediately(state: inbox.InboxState):
state.record(_msg("a"))
wait_timeout = 5.0
start = time.monotonic()
msg = state.wait(timeout_secs=wait_timeout)
msg = state.wait(timeout_secs=5.0)
elapsed = time.monotonic() - start
assert msg is not None and msg.activity_id == "a"
# Structural assertion (NOT an absolute deadline): with a non-empty
# queue wait() returns its head without ever entering the timed
# block, so it consumes a tiny fraction of timeout_secs. Comparing
# against the timeout (rather than a magic "< 0.5s") keeps this
# robust on a CPU-starved CI host: only an implementation that
# actually blocks for the timeout (≈1.0×) trips it. 0.2× = wide
# jitter margin for a scheduler under contention.
assert elapsed < wait_timeout * 0.2, (
f"wait should not block when queue non-empty: took {elapsed:.2f}s "
f"({elapsed / wait_timeout:.2f}× the {wait_timeout:.0f}s timeout)"
)
assert elapsed < 0.5, f"wait should not block when queue non-empty (took {elapsed:.2f}s)"
def test_wait_blocks_until_message_arrives(state: inbox.InboxState):
@@ -506,151 +495,6 @@ def test_poll_once_skips_self_notify_rows(state: inbox.InboxState):
assert [m.activity_id for m in queue] == ["act-real"]
# ---------------------------------------------------------------------------
# _is_self_echo_row — internal #469 fix
# ---------------------------------------------------------------------------
#
# When a workspace delegates to a target that never picks up the task,
# tool_delegate_task calls report_activity("a2a_receive", ...) which POSTs
# to the platform with source_id set to the *sender's* workspace UUID
# (spoof-defense). The activity API returns that row under type=a2a_receive
# on the next poll, so message_from_activity sets peer_id = workspace's own
# UUID — the workspace sees its own delegation-failure as an inbound from
# a phantom peer. _is_self_echo_row guards against this.
#
# Internal #469 was live-reproduced on hongming.moleculesai.app 2026-05-16.
def test_is_self_echo_row_true_when_source_id_matches_workspace():
row = {"source_id": "ws-abc123", "method": "a2a_receive"}
assert inbox._is_self_echo_row(row, "ws-abc123") is True
def test_is_self_echo_row_false_when_source_id_differs():
"""A real peer agent (different workspace_id) must NOT be filtered."""
row = {"source_id": "ws-peer", "method": "a2a_receive"}
assert inbox._is_self_echo_row(row, "ws-1") is False
def test_is_self_echo_row_false_when_source_id_is_none():
"""Canvas-user inbound has no source_id — never an echo."""
row = {"source_id": None, "method": "a2a_receive"}
assert inbox._is_self_echo_row(row, "ws-1") is False
def test_is_self_echo_row_false_when_workspace_id_is_empty():
"""Single-workspace legacy path with empty workspace_id cannot
match a UUID source_id predicate is always False, which is safe."""
row = {"source_id": "ws-abc123", "method": "a2a_receive"}
assert inbox._is_self_echo_row(row, "") is False
def test_is_self_echo_row_false_when_source_id_key_absent():
row = {"method": "a2a_receive"}
assert inbox._is_self_echo_row(row, "ws-1") is False
def test_is_self_echo_row_false_for_delegate_result():
"""RFC #2829 PR-2 regression pin: a row with source_id matching our
workspace_id but method=delegate_result must NOT be filtered as a
self-echo. The platform may write a delegation-result row with our
workspace_id as source_id; such rows must reach the inbox so the
runtime receives the delegation result. Silently filtering them would
break delegate_result delivery."""
row = {"source_id": "ws-1", "method": "delegate_result"}
assert inbox._is_self_echo_row(row, "ws-1") is False
def test_poll_once_skips_self_echo_rows(state: inbox.InboxState):
"""Internal #469 regression pin: a row with source_id matching our
workspace_id must NOT land in the inbox queue it is our own
delegation-report echoing back, not a real peer inbound."""
rows = [
{
"id": "act-real-peer",
"source_id": "ws-peer",
"method": "a2a_receive",
"summary": None,
"request_body": {"parts": [{"type": "text", "text": "real peer inbound"}]},
"created_at": "2026-04-30T22:00:00Z",
},
{
"id": "act-self-echo",
"source_id": "ws-1",
"method": "a2a_receive",
"summary": "task result: target timed out",
"request_body": None,
"created_at": "2026-04-30T22:00:01Z",
},
]
resp = _make_response(200, rows)
p, _ = _patch_httpx(resp)
with p:
n = inbox._poll_once(state, "http://platform", "ws-1", {})
# Only the real peer inbound counted; self-echo silently dropped.
assert n == 1
queue = state.peek(10)
assert [m.activity_id for m in queue] == ["act-real-peer"]
assert queue[0].peer_id == "ws-peer"
def test_poll_once_advances_cursor_past_self_echo(state: inbox.InboxState):
"""Cursor must advance past self-echo rows even though we don't
enqueue them. Otherwise the next poll re-fetches the same self-echo
on every iteration, wasting requests and blocking real inbound."""
state.save_cursor("act-old")
rows = [
{
"id": "act-self-echo",
"source_id": "ws-1",
"method": "a2a_receive",
"summary": "task result: timeout",
"request_body": None,
"created_at": "2026-04-30T22:00:00Z",
},
]
resp = _make_response(200, rows)
p, _ = _patch_httpx(resp)
with p:
n = inbox._poll_once(state, "http://platform", "ws-1", {})
assert n == 0
assert state.peek(10) == []
# Cursor must move past the skipped row so we don't re-poll it.
assert state.load_cursor() == "act-self-echo"
def test_poll_once_self_echo_does_not_fire_notification(state: inbox.InboxState):
"""The notification callback (channel push to Claude Code etc.)
must not fire for self-echo rows. Same rationale as self-notify:
push-capable hosts would see the echo loop on the push channel."""
rows = [
{
"id": "act-self-echo",
"source_id": "ws-1",
"method": "a2a_receive",
"summary": "task result: timeout",
"request_body": None,
"created_at": "2026-04-30T22:00:00Z",
},
]
received: list[dict] = []
inbox.set_notification_callback(received.append)
try:
resp = _make_response(200, rows)
p, _ = _patch_httpx(resp)
with p:
inbox._poll_once(state, "http://platform", "ws-1", {})
finally:
inbox.set_notification_callback(None)
assert received == [], (
"self-echo rows must not surface as MCP notifications — "
"doing so re-creates the echo loop on push-capable hosts"
)
def test_poll_once_advances_cursor_past_self_notify(state: inbox.InboxState):
"""Cursor must advance past self-notify rows even though we don't
enqueue them. Otherwise the next poll re-fetches the same self-
+22 -45
View File
@@ -829,26 +829,16 @@ def _stub_client_for_batch(get_responses: dict[str, MagicMock]) -> MagicMock:
def test_batch_fetcher_runs_submitted_rows_concurrently():
# Three rows whose .get() blocks for ~PER_ROW each. With >=3 workers
# the batch should overlap the sleeps (~1× PER_ROW wall time), not
# run them back-to-back (~3× PER_ROW).
#
# NOTE: we assert a *structural* property — observed wall time is
# meaningfully below the serial sum — NOT an absolute deadline. An
# absolute "< 250ms" ceiling silently encodes "the runner host is
# idle"; under CI contention (load >100) the same concurrent run
# measures >250ms and false-reds an unrelated PR (motivating
# incident: #190 / PR#1348, a 1.6ms overshoot on a load-107 host).
# Host load scales serial and concurrent timing together, so the
# ratio between them stays a reliable concurrency discriminator.
# Three rows whose .get() blocks for ~120ms each. With 4 workers the
# batch should complete in ~120ms (parallel), not ~360ms (serial).
# The 250ms ceiling accommodates CI scheduler jitter while still
# discriminating concurrent (~120ms) from serial (~360ms).
import time
PER_ROW = 0.12
N_ROWS = 3
serial_total = PER_ROW * N_ROWS
barrier_start = [0.0]
def _slow_get(url: str, headers: dict[str, str] | None = None) -> MagicMock:
time.sleep(PER_ROW)
time.sleep(0.12)
for fid in ("a", "b", "c"):
if f"/pending-uploads/{fid}/content" in url:
return _make_resp(200, content=b"X", content_type="text/plain")
@@ -865,22 +855,16 @@ def test_batch_fetcher_runs_submitted_rows_concurrently():
client=client,
max_workers=4,
)
start = time.monotonic()
barrier_start[0] = time.time()
for fid in ("a", "b", "c"):
bf.submit(_row_with_id(f"act-{fid}", fid))
bf.wait_all()
elapsed = time.monotonic() - start
elapsed = time.time() - barrier_start[0]
bf.close()
# Concurrent execution overlaps the three sleeps, so wall time is far
# below the serial sum. 0.6× leaves generous headroom for scheduler
# jitter while still failing loudly if the rows ran serially (which
# would land at ~1.0× serial_total regardless of host load).
assert elapsed < serial_total * 0.6, (
f"3 rows × {PER_ROW}s with 4 workers ran in {elapsed:.3f}s; expected "
f"well under the {serial_total:.2f}s serial sum (got "
f"{elapsed / serial_total:.2f}× — suggests serial execution, "
"Phase 5b regression)"
assert elapsed < 0.25, (
f"3 rows × 120ms with 4 workers should finish in <250ms; got {elapsed:.3f}s "
"(suggests serial execution — Phase 5b regression)"
)
assert client.get.call_count == 3
assert client.post.call_count == 3
@@ -1066,14 +1050,13 @@ def test_batch_fetcher_close_after_timeout_does_not_block_on_running_workers():
import threading
import time
BLOCK_SECS = 5.0 # the leaked worker's max self-unblock window
blocker = threading.Event() # never set — workers stay running
def _hang_get(url, headers=None):
# Wait at most BLOCK_SECS so a buggy implementation eventually
# unblocks the test instead of timing out the whole pytest run,
# but nothing legitimate should reach this fallback.
blocker.wait(timeout=BLOCK_SECS)
# Wait at most ~5s so a buggy implementation eventually unblocks
# the test instead of timing out the whole pytest run, but
# nothing legitimate should reach this fallback.
blocker.wait(timeout=5.0)
return _make_resp(200, content=b"x", content_type="text/plain")
client = MagicMock()
@@ -1090,23 +1073,17 @@ def test_batch_fetcher_close_after_timeout_does_not_block_on_running_workers():
bf.submit(_row_with_id("act-a", "a"))
# Tiny timeout — wait_all must report the future as not_done.
bf.wait_all(timeout=0.05)
start = time.monotonic()
t0 = time.time()
bf.close()
elapsed = time.monotonic() - start
elapsed = time.time() - t0
# Unblock the lingering worker so it doesn't pollute later tests.
blocker.set()
# Structural assertion (NOT an absolute deadline): without the
# cancel-on-timeout fix, close() drains the leaked worker and blocks
# the FULL BLOCK_SECS; with the fix shutdown(wait=False) returns
# without draining, so close() finishes a small fraction of that.
# We compare against BLOCK_SECS rather than a magic "< 1.0s" so a
# CPU-starved CI host (which inflates both the drain and the
# non-drain path together) cannot false-fail this — only genuinely
# draining (≈1.0× BLOCK_SECS) trips it. 0.3× = wide jitter margin.
assert elapsed < BLOCK_SECS * 0.3, (
f"close() took {elapsed:.2f}s after wait_all timeout "
f"({elapsed / BLOCK_SECS:.2f}× the {BLOCK_SECS:.0f}s worker block) — "
# Without the cancel-on-timeout fix, close() would block until
# blocker.set() — i.e., the full ~5s. With the fix it returns
# immediately because shutdown(wait=False) doesn't drain.
assert elapsed < 1.0, (
f"close() blocked for {elapsed:.2f}s after wait_all timeout — "
"cancel-on-timeout regression: close() is draining instead of bailing"
)