Compare commits

...

20 Commits

Author SHA1 Message Date
Hongming Wang 4b16c95450 staging → main: auto-promote f1b72af
staging → main: auto-promote e39d818
2026-05-04 17:11:20 -07:00
Hongming Wang f1b72af97e Merge pull request #2798 from Molecule-AI/fix/org-import-saas-routing-1777938328
fix(org-import): route through provisionWorkspaceAuto so SaaS gets EC2 — closes #2486
2026-05-04 23:54:37 +00:00
Hongming Wang 31facfc5c4 Merge pull request #2797 from Molecule-AI/fix/synth-e2e-9c-parse
fix(synth-e2e): correct §9c stale-409 capture (curl --fail-with-body pollution)
2026-05-04 23:50:59 +00:00
Hongming Wang 19e7acdc22 fix(org-import): route through provisionWorkspaceAuto so SaaS gets EC2
Org-import called h.workspace.provisionWorkspace directly — same silent-
drop bug that bit TeamHandler.Expand on 2026-05-04 (see workspace.go
:121-125 comment + #2486). Symptom on SaaS: every claude-code workspace
sat in "provisioning" until the 600s sweeper marked it failed with
"container started but never called /registry/register" — because no
container ever existed; the goroutine returned silently when the Docker
provisioner field was nil.

User reproduced 2026-05-04 ~22:30Z importing a 7-workspace template on
the hongming prod tenant. Tenant CP logs (queried live via SSM) showed
ZERO "Provisioner: goroutine entered" or "CPProvisioner: goroutine
entered" lines for any of the 7 failed workspace UUIDs in the 60min
window — confirming the goroutine never ran past line 384 of
org_import.go because provisionWorkspace returned early in SaaS mode.

The fix is one line: replace h.workspace.provisionWorkspace with
h.workspace.provisionWorkspaceAuto. Auto is the single source of
truth for backend selection (workspace.go:130) — picks CP-mode when
h.cpProv is wired, Docker-mode when h.provisioner is wired, returns
false when neither.

ALSO adds a generic source-level gate
(TestNoCallSiteCallsDirectProvisionerExceptAuto) so the next future
caller can't repeat the pattern. Walks every non-test .go file in
handlers/ and fails if any direct call to provisionWorkspace( or
provisionWorkspaceCP( appears outside the dispatcher's own definition
file.

The gate currently allows workspace_restart.go which has its own
manual if-h.cpProv-else dispatch (functionally equivalent to Auto,
not the bug class — but is architectural duplication; follow-up
filed for proper de-dup).

Test plan:
- TestOrgImport_UsesAutoNotDirectDockerPath: pin the org_import.go
  call site
- TestNoCallSiteCallsDirectProvisionerExceptAuto: generic gate against
  future drift
- TestTeamExpand_UsesAutoNotDirectDockerPath (existing): symmetric for
  team.go

All 3 + the rest of the handler suite pass.

Closes #2486
Pairs with: PR #2794 (configurable provision concurrency) which made
            it possible to bisect concurrency-vs-routing as the cause

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:49:07 -07:00
Hongming Wang 1ce51abea4 fix(synth-e2e): correct §9c stale-409 capture — curl exit code polluted status
The §9c "Memory KV Edit round-trip" gate (added in #2787) captured the
expected-409 status code via:

  $(tenant_call ... -w "%{http_code}" || echo "000")

tenant_call uses CURL_COMMON which carries --fail-with-body. On the
expected 409, curl exits 22; the `|| echo "000"` then fires and
appends "000" to the captured stdout — yielding "409000" instead of
"409", failing the gate even though the contract was satisfied.

Caught on PR #2792's first E2E run (status got "409000"). Has been
silently failing the staging-SaaS E2E since #2787 merged earlier
today; nothing else surfaced it because the workflow is informational,
not required.

Fix: route -w into its own tempfile so curl's exit code can't pollute
the captured stdout. Wrap with set +e/-e so the 22 doesn't trip the
outer pipeline. Same shape as the §7c gate fix that PR #2779/#2783
landed for the same class of bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:46:35 -07:00
Hongming Wang 0ec226e119 Merge pull request #2795 from Molecule-AI/feat/python-critical-path-coverage-floor
ci(coverage): per-file 75% floor for MCP/inbox/auth Python critical paths (Phase A of #2790)
2026-05-04 23:39:06 +00:00
Hongming Wang 872b781f64 Merge pull request #2792 from Molecule-AI/feat/drop-shared-context
feat: drop shared_context — use memory v2 team namespace
2026-05-04 23:37:49 +00:00
Hongming Wang 0dd1244510 Merge pull request #2794 from Molecule-AI/fix/cfg-prov-conc-iso
feat(org-import): make provision concurrency configurable via env
2026-05-04 23:37:15 +00:00
Hongming Wang 26fa220bef ci(coverage): per-file 75% floor for MCP/inbox/auth Python critical paths
Closes part of #2790 (Phase A). The Python total floor at 86% (set in
workspace/pytest.ini, issue #1817) averages over ~6000 lines, so a
single MCP-critical file could regress to ~50% with no CI complaint as
long as other modules compensate. This is the same distribution gap
that #1823 closed Go-side: total floor passes while a critical handler
sits at 0%.

Added gates for these five files (per-file floor 75%):
- workspace/a2a_mcp_server.py — MCP dispatcher (PR #2766 / #2771)
- workspace/mcp_cli.py — molecule-mcp standalone CLI entry
- workspace/a2a_tools.py — workspace-scoped tool implementations
- workspace/inbox.py — multi-workspace inbox + per-workspace cursors
- workspace/platform_auth.py — per-workspace token resolver

These handle multi-tenant routing, auth tokens, and inbox dispatch.
Risk shape mirrors Go-side tokens*/secrets* — a 0%/50% file here is
exactly where the PR #2766 dispatcher bug class slips through without
a structural test.

Floor 75% is strictly additive — current actuals 80-96% (measured
2026-05-04). No existing PR fails. Ratchet plan in COVERAGE_FLOOR.md
target 90% by 2026-08-04.

Implementation: pytest already writes .coverage; new step emits a JSON
view scoped to the critical files via `coverage json --include="*name"`,
then jq extracts each file's percent_covered. Exact key match by
basename so workspace/builtin_tools/a2a_tools.py (a different 100%
file) doesn't shadow workspace/a2a_tools.py.

Verified locally with the actual coverage data:
- floor=75 → 0 failures (matches current state)
- floor=81 → 1 failure (a2a_tools.py at 80%) — proves the gate trips

Pairs with PR #2791 (Phase B — schema↔dispatcher AST drift gate). Phase
C (molecule-mcp e2e harness) remains the largest piece in #2790.

YAML validated locally before commit per
feedback_validate_yaml_before_commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:35:21 -07:00
Hongming Wang 5559e96400 Merge branch 'temp-staging' into try-merge
# Conflicts:
#	tests/e2e/test_staging_full_saas.sh
2026-05-04 16:34:55 -07:00
Hongming Wang 3bc7749e84 feat(org-import): make provision concurrency configurable via env
Org-import was hard-capped at 3 concurrent workspace provisions (#1084),
calibrated for Docker-mode workspaces where each provision was a
docker-run. Now that workspaces are EC2 instances, AWS RunInstances
parallelises happily and the artificial cap of 3 makes a 7-workspace
org-import take 3-4× longer than necessary (3 batches × ~70s/provision
≈ 4 min wall time when AWS could absorb all 7 in parallel for ~70s).

This PR makes the cap configurable via MOLECULE_PROVISION_CONCURRENCY:
  unset    → 3 (Docker-mode default, unchanged)
  "0"      → effectively unlimited (SaaS / EC2 backend; AWS rate-limit
             + vCPU quota are the real backpressure)
  N>0      → exactly N
  N<0      → fall back to default 3 + warning log
  garbage  → fall back to default 3 + warning log

The "0 = unlimited" mapping is the user-facing convention requested for
SaaS deployments — operators don't have to pick an arbitrary large
number. Implementation hands off 1<<20 internally so the channel-based
semaphore stays a no-op without infinite-buffer risk.

Test coverage (org_provision_concurrency_test.go, 6 cases / 15 subtests):
- unset → default
- "0" → large unlimited cap
- positive integer exact (1, 5, 10, 50)
- negative → default + warning
- non-numeric → default + warning
- whitespace-trimmed (" 7 " → 7)

Boot-time log line confirms the resolved cap so an operator can verify
their env is being honored without re-deploying.

Does NOT address the separate 600s "never registered" timeout the user
also reported during org-import — that's filed as molecule-core#2793
for proper investigation (parallel-provision contention, network
routing, register-retry budget, or container-start failure are all
candidates and need live SSM capture to bisect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:33:49 -07:00
Hongming Wang 6d7a7fc86f feat(org-import): make provision concurrency configurable via env
Org-import was hard-capped at 3 concurrent workspace provisions (#1084),
calibrated for Docker-mode workspaces where each provision was a
docker-run. Now that workspaces are EC2 instances, AWS RunInstances
parallelises happily and the artificial cap of 3 makes a 7-workspace
org-import take 3-4× longer than necessary (3 batches × ~70s/provision
≈ 4 min wall time when AWS could absorb all 7 in parallel for ~70s).

This PR makes the cap configurable via MOLECULE_PROVISION_CONCURRENCY:
  unset    → 3 (Docker-mode default, unchanged)
  "0"      → effectively unlimited (SaaS / EC2 backend; AWS rate-limit
             + vCPU quota are the real backpressure)
  N>0      → exactly N
  N<0      → fall back to default 3 + warning log
  garbage  → fall back to default 3 + warning log

The "0 = unlimited" mapping is the user-facing convention requested for
SaaS deployments — operators don't have to pick an arbitrary large
number. Implementation hands off 1<<20 internally so the channel-based
semaphore stays a no-op without infinite-buffer risk.

Test coverage (org_provision_concurrency_test.go, 6 cases / 15 subtests):
- unset → default
- "0" → large unlimited cap
- positive integer exact (1, 5, 10, 50)
- negative → default + warning
- non-numeric → default + warning
- whitespace-trimmed (" 7 " → 7)

Boot-time log line confirms the resolved cap so an operator can verify
their env is being honored without re-deploying.

Does NOT address the separate 600s "never registered" timeout the user
also reported during org-import — that's filed as molecule-core#2793
for proper investigation (parallel-provision contention, network
routing, register-retry budget, or container-start failure are all
candidates and need live SSM capture to bisect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:32:56 -07:00
Hongming Wang ecb3c75d74 Merge pull request #2791 from Molecule-AI/feat/mcp-dispatcher-schema-drift-gate
test(mcp): structural gate — schema↔dispatcher drift (Phase B of #2790)
2026-05-04 23:32:19 +00:00
Hongming Wang 2f7beb9bce feat: drop shared_context — use memory v2 team namespace instead
Parent → child knowledge sharing previously lived behind a `shared_context`
list in config.yaml: at boot, every child workspace HTTP-fetched its parent's
listed files via GET /workspaces/:id/shared-context and prepended them as
a "## Parent Context" block. That paid the full transfer cost on every
boot regardless of whether the agent needed it, single-parent SPOF, no team
or org scope, and broken if the parent was unreachable.

Replace with memory v2's team:<id> namespace: agents call recall_memory
on demand. For large blob-shaped artefacts see RFC #2789 (platform-owned
shared file storage).

Removed:
- workspace/coordinator.py: get_parent_context()
- workspace/prompt.py: parent_context arg + injection block
- workspace/adapter_base.py: import + call + arg pass
- workspace/config.py: shared_context field + parser entry
- workspace-server/internal/handlers/templates.go: SharedContext handler
- workspace-server/internal/router/router.go: GET /shared-context route
- canvas/src/components/tabs/ConfigTab.tsx: Shared Context tag input
- canvas/src/components/tabs/config/form-inputs.tsx: schema field + default
- canvas/src/components/tabs/config/yaml-utils.ts: serializer entry
- 6 tests pinning the removed behavior; 5 doc references

Added regression gates so any reintroduction is loud:
- workspace/tests/test_prompt.py: build_system_prompt must NOT emit
  "## Parent Context"
- workspace/tests/test_config.py: legacy YAML key loads cleanly but
  shared_context attr must NOT exist on WorkspaceConfig
- tests/e2e/test_staging_full_saas.sh §9d: GET /shared-context must NOT
  return 200 against a live tenant

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:30:26 -07:00
Hongming Wang bd881f8756 test(mcp): structural gate — schema↔dispatcher drift catches dropped kwargs
Closes part of #2790 (Phase B). Prevents a recurrence of the PR #2766 →
PR #2771 cycle: PR #2766 added ``source_workspace_id`` to four tools'
``input_schema`` and tool implementations, but the dispatcher in
``a2a_mcp_server.handle_tool_call`` silently dropped the kwarg for
``commit_memory`` / ``recall_memory`` / ``chat_history`` /
``get_workspace_info``. Schema lied; LLMs populated the param; every
call fell back to ``WORKSPACE_ID``, defeating multi-tenant isolation.
Existing dispatcher tests asserted return-value substrings (``"working"
in result``) instead of kwarg flow, so the bug shipped to main and was
only caught by re-reviewing post-merge.

This change adds an AST-driven gate. For every ToolSpec in
platform_tools.registry.TOOLS, the gate finds the matching
``elif name == "<tool>"`` arm in a2a_mcp_server.py and asserts that
every property declared in input_schema.properties is read by an
``arguments.get("<property>", ...)`` call inside that arm. A new schema
field the dispatcher forgets to forward fails CI loudly.

Three tests:

- test_every_dispatch_arm_reads_every_schema_property: main drift gate.
  Walks registry, matches dispatch arms by name, diffs declared vs
  read keys.

- test_dispatch_arms_reach_every_registered_tool: inverse direction.
  A registered tool with no dispatch arm is "Unknown tool" at runtime,
  even though docs/wrappers/schema all advertise it. Catches PRs that
  add a ToolSpec but forget the dispatcher.

- test_drift_gate_self_check_finds_known_arms: pin the AST parser. If
  handle_tool_call is refactored into a different shape (dict dispatch,
  registry-driven, etc.) and _load_dispatch_arms returns {}, the main
  gate vacuously passes — this self-check makes that failure mode
  explicit by requiring 12 known arms to be discovered.

Verified the gate catches the PR #2766 bug: stripping
``source_workspace_id=arguments.get(...)`` from the commit_memory arm
fails the gate with a descriptive error pointing at the missing kwarg
and referencing the prior incident. Restored → 3 tests pass.

Suite: 1733 passed (was 1730 + 3 new), 3 skipped, 2 xfailed.

Why AST, not runtime invocation: the runtime mock-based tests in
test_a2a_mcp_server.py already assert kwargs flow correctly for four
explicitly-tested tools. This gate is cheaper (~1ms), catches new
properties before someone has to remember the runtime test, and runs
as a structural invariant.

Phase A (Python coverage floor) and Phase C (molecule-mcp e2e harness)
remain in #2790 as separate follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:29:54 -07:00
hongming e39d818ac4 Merge pull request #2787 from Molecule-AI/feat/memory-tab-edit-affordance
feat(memory tab): add Edit affordance with optimistic-locking
2026-05-04 23:20:51 +00:00
molecule-ai[bot] ed4d24fb8c Merge pull request #2786 from Molecule-AI/staging
staging → main: auto-promote 095171f
2026-05-04 16:19:31 -07:00
Hongming Wang 3a5544a9e6 feat(memory tab): add Edit affordance with optimistic-locking
Memory tab supported only Add+Delete. Correcting an entry meant
deleting and re-adding, losing the row's version counter and any
concurrent-write guard the agent depends on.

Now: per-row Edit button reveals an inline editor (value textarea +
TTL). Save POSTs to the existing /memory upsert endpoint with
if_match_version pinned to the entry's current version. On 409 the
UI surfaces a retry hint and reloads.

Tests:
- 11 vitest cases covering pre-fill (JSON vs string), payload shape
  (parsed JSON, fallback to plain text, TTL inclusion/omission),
  cancel, 409 retry path, generic error path, and the no-version
  back-compat case.
- E2E gate 9c in test_staging_full_saas.sh: seed → GET version →
  conditional update → assert new value → stale-version POST must
  409. Pins the optimistic-locking contract end-to-end on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:18:08 -07:00
Hongming Wang 095171f163 Merge pull request #2785 from Molecule-AI/fix/readfile-eic-symmetry
fix(workspace files API): GET ReadFile via SSH-EIC for SaaS workspaces (fixes 'No config.yaml found' on Config tab)
2026-05-04 23:05:34 +00:00
Hongming Wang 9c7b34cb7f fix(workspace files API): GET ReadFile via SSH-EIC for SaaS workspaces
Pre-fix WriteFile (templates.go:436) had an `instance_id != ""` branch
that dispatched to writeFileViaEIC (SSH through EC2 Instance Connect),
but ReadFile (templates.go:362) skipped that branch entirely. ReadFile
always tried `findContainer` (which only works for local-Docker
workspaces, not SaaS EC2-per-workspace ones) and fell through to
`resolveTemplateDir` (which returns the seed template, not the
persisted workspace state).

Net effect on production: every Canvas Config tab open against a
SaaS workspace returned 404 "No config.yaml found" because GET
couldn't see what PUT had written. Visible to users after PR #2781
("show-misconfigured-state") surfaced the 404 as an error UX.

Caught by the synth-E2E 7c gate's GET-back assertion, but
misdiagnosed as a "test bug" and the GET assertion was dropped in
PR #2783 (rather than fixed at the source). This PR closes the loop:

1. New `readFileViaEIC` helper in template_files_eic.go that mirrors
   writeFileViaEIC's SSH-via-EIC dance and runs `sudo -n cat <path>`.
   Returns os.ErrNotExist on missing file (cat exits 1 with empty
   stdout under `2>/dev/null`) so the handler maps it cleanly to 404.

2. ReadFile dispatch now mirrors WriteFile's: when `instance_id` is
   non-empty, use readFileViaEIC; otherwise fall through to the
   local-Docker / template-dir path.

3. ReadFile's DB query expanded to also select instance_id + runtime
   (was just name). Three sqlmock-based tests updated to match the
   new column shape; the existing local-Docker fallback path stays
   green by passing instance_id="" in the mock rows.

Follow-up (separate PR): the synth-E2E 7c gate should restore the
GET-back marker assertion now that the read/write paths are unified.
That'll also catch any future Files API regression in the round-trip.
This PR doesn't touch the gate to keep the scope tight.

Verification:
- go build ./... clean
- full handlers test suite green (0.4s for ReadFile subset; 5.8s
  full)
- The 3 ReadFile sqlmock tests still cover the local-Docker fallback
  (instance_id=""); SaaS EIC dispatch is covered by the upcoming
  re-enabled synth-E2E 7c GET assertion (deferred to follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:02:26 -07:00
31 changed files with 1297 additions and 614 deletions
+66
View File
@@ -358,6 +358,72 @@ jobs:
- if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short
- if: needs.changes.outputs.python == 'true'
name: Per-file critical-path coverage (MCP / inbox / auth)
# MCP-critical Python files have a per-file floor on top of the
# 86% total floor in pytest.ini. Rationale (issue #2790, after
# the PR #2766 → PR #2771 cycle): the total floor averages ~6000
# lines, so a single MCP file could regress to ~50% with no
# complaint as long as other modules compensate. These five
# files handle multi-tenant routing + auth + inbox dispatch —
# a coverage drop here is the same risk shape as a Go-side
# workspace-server token/secrets file dropping below 10%.
#
# Floor 75% sits below current actuals (80-96%) so this gate is
# strictly additive — no existing PR fails. Ratchet plan in
# COVERAGE_FLOOR.md.
run: |
set -e
PER_FILE_FLOOR=75
CRITICAL_FILES=(
"a2a_mcp_server.py"
"mcp_cli.py"
"a2a_tools.py"
"inbox.py"
"platform_auth.py"
)
# pytest already wrote .coverage; emit a JSON view scoped to
# the critical files so jq/python can read the per-file pct
# without parsing tabular text. --include uses fnmatch, and
# the leading "*" allows the file to live anywhere under the
# workspace root (today they sit at workspace/<name>.py).
INCLUDES=$(printf '*%s,' "${CRITICAL_FILES[@]}")
INCLUDES="${INCLUDES%,}"
python -m coverage json -o /tmp/critical-cov.json --include="$INCLUDES"
FAILED=0
for f in "${CRITICAL_FILES[@]}"; do
# Match by top-level path key (e.g. "a2a_tools.py", not
# "builtin_tools/a2a_tools.py" — different file at 100%).
# The keys in coverage.json are paths relative to the run
# cwd (workspace/), so the critical-path entry sits at the
# bare basename.
pct=$(jq -r --arg f "$f" '.files | to_entries | map(select(.key == $f)) | .[0].value.summary.percent_covered // "MISSING"' /tmp/critical-cov.json)
if [ "$pct" = "MISSING" ]; then
echo "::error file=workspace/$f::No coverage data — file may have moved or test exclusion mis-set."
FAILED=$((FAILED+1))
continue
fi
echo "$f: ${pct}%"
if awk "BEGIN{exit !($pct < $PER_FILE_FLOOR)}"; then
echo "::error file=workspace/$f::${pct}% < ${PER_FILE_FLOOR}% per-file floor (MCP critical path). See COVERAGE_FLOOR.md."
FAILED=$((FAILED+1))
fi
done
if [ "$FAILED" -gt 0 ]; then
echo ""
echo "$FAILED MCP critical-path file(s) below the ${PER_FILE_FLOOR}% per-file floor."
echo "These paths handle multi-tenant routing, auth tokens, and inbox dispatch."
echo "A coverage drop here is the same risk shape as Go-side tokens/secrets files"
echo "dropping below 10% (see COVERAGE_FLOOR.md). Either:"
echo " (a) add tests to raise coverage back above ${PER_FILE_FLOOR}%, or"
echo " (b) if this is unavoidable historical debt, file an issue and propose"
echo " adjusting the floor with rationale in COVERAGE_FLOOR.md."
exit 1
fi
# SDK + plugin validation moved to standalone repo:
# github.com/Molecule-AI/molecule-sdk-python
+50 -2
View File
@@ -1,7 +1,7 @@
# Coverage Floor
CI enforces three coverage gates on `workspace-server` (Go). All defined in
`.github/workflows/ci.yml``platform-build` job.
CI enforces coverage gates on two surfaces — `workspace-server` (Go) and
`workspace/` (Python). All defined in `.github/workflows/ci.yml`.
## Current floors (2026-04-23)
@@ -76,3 +76,51 @@ This gate makes "no untested critical paths merged" a mechanical property of
the CI, not a behavioural property of QA agents or individual reviewers —
which is the only way to make it survive fleet outages, agent rotations, or
QA process changes.
## Python (workspace/) — added 2026-05-04 from #2790
The Python side has its own gates in the `python-lint` job:
| Gate | Threshold | Where |
|---|---|---|
| **Total floor** | `86%` | `workspace/pytest.ini` `--cov-fail-under=86` (issue #1817) |
| **Critical-path per-file floor** | `75%` | Inline shell step after the pytest run |
### Critical-path Python files
These handle multi-tenant routing, auth tokens, and inbox dispatch. A
coverage drop here is the same risk shape as a Go-side `tokens*` /
`secrets*` file regressing below 10%.
- `workspace/a2a_mcp_server.py` — MCP dispatcher (PR #2766 / #2771)
- `workspace/mcp_cli.py` — molecule-mcp standalone CLI entry
- `workspace/a2a_tools.py` — workspace-scoped tool implementations
- `workspace/inbox.py` — multi-workspace inbox + per-workspace cursors
- `workspace/platform_auth.py` — per-workspace token resolver
### Why 75% (vs 86% total)
The total floor averages ~6000 lines across `workspace/`. A single MCP
file could drop to ~50% with no CI complaint as long as other modules
compensate. The per-file floor closes that distribution gap. 75% sits
below current actuals (8096% as of 2026-05-04) — strictly additive,
no existing PR fails.
### Python ratchet plan
| Date | Total | Per-file critical | Notes |
|---|---|---|---|
| 2026-05-04 | 86% | 75% | Initial gate (this file). |
| 2026-06-04 | 86% | 80% | First ratchet — at-floor files must catch up. |
| 2026-07-04 | 88% | 85% | |
| 2026-08-04 | 90% | 90% | Target steady-state. |
### Why this Python gate exists
Issue #2790, after the PR #2766 → PR #2771 cycle. PR #2766 added
multi-workspace routing through `a2a_tools.py` + `a2a_mcp_server.py`,
shipped to main with green CI, but the dispatcher silently dropped a
load-bearing kwarg for 4 of 9 tools — caught only by post-merge code
review. The structural drift gate (`test_dispatcher_schema_drift.py`,
PR #2791) catches the schema↔dispatcher mismatch class; this floor
catches the broader "MCP-critical file regressed" class.
-1
View File
@@ -890,7 +890,6 @@ export function ConfigTab({ workspaceId }: Props) {
<TagList label="Skills" values={config.skills || []} onChange={(v) => update("skills", v)} placeholder="e.g. code-review" />
<TagList label="Tools" values={config.tools || []} onChange={(v) => update("tools", v)} placeholder="e.g. web_search, filesystem" />
<TagList label="Prompt Files" values={config.prompt_files || []} onChange={(v) => update("prompt_files", v)} placeholder="e.g. system-prompt.md" />
<TagList label="Shared Context" values={config.shared_context || []} onChange={(v) => update("shared_context", v)} placeholder="e.g. architecture.md" />
</Section>
<Section title="A2A Protocol" defaultOpen={false}>
+129 -14
View File
@@ -10,6 +10,7 @@ interface Props {
interface MemoryEntry {
key: string;
value: unknown;
version?: number;
expires_at: string | null;
updated_at: string;
}
@@ -28,6 +29,10 @@ export function MemoryTab({ workspaceId }: Props) {
const [newValue, setNewValue] = useState("");
const [newTTL, setNewTTL] = useState("");
const [error, setError] = useState<string | null>(null);
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editTTL, setEditTTL] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const awarenessUrl = useMemo(() => {
try {
@@ -109,6 +114,69 @@ export function MemoryTab({ workspaceId }: Props) {
}
};
const beginEdit = (entry: MemoryEntry) => {
setEditError(null);
setEditingKey(entry.key);
// Stringify objects/arrays as pretty JSON; render plain strings raw so the
// editor doesn't surprise users with surrounding quotes.
setEditValue(
typeof entry.value === "string"
? entry.value
: JSON.stringify(entry.value, null, 2),
);
if (entry.expires_at) {
const remainingMs = new Date(entry.expires_at).getTime() - Date.now();
const ttl = Math.max(0, Math.floor(remainingMs / 1000));
setEditTTL(ttl > 0 ? String(ttl) : "");
} else {
setEditTTL("");
}
};
const cancelEdit = () => {
setEditingKey(null);
setEditValue("");
setEditTTL("");
setEditError(null);
};
const handleEditSave = async (entry: MemoryEntry) => {
setEditError(null);
let parsedValue: unknown;
try {
parsedValue = JSON.parse(editValue);
} catch {
parsedValue = editValue;
}
// if_match_version closes the silent-overwrite hole when two writers
// race. The handler returns 409 with the current version on mismatch
// — surface that as a retry hint and reload to pick up the new state.
const body: Record<string, unknown> = { key: entry.key, value: parsedValue };
if (typeof entry.version === "number") {
body.if_match_version = entry.version;
}
if (editTTL) {
const ttl = parseInt(editTTL);
if (!Number.isNaN(ttl) && ttl > 0) body.ttl_seconds = ttl;
}
try {
await api.post(`/workspaces/${workspaceId}/memory`, body);
cancelEdit();
loadMemory();
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to save";
if (message.includes("409") || /if_match_version mismatch/i.test(message)) {
setEditError("This entry changed since you opened it. Reloading.");
loadMemory();
} else {
setEditError(message);
}
}
};
const openAwareness = () => {
window.open(awarenessUrl, "_blank", "noopener,noreferrer");
};
@@ -308,24 +376,71 @@ export function MemoryTab({ workspaceId }: Props) {
{expanded === entry.key && (
<div className="px-3 pb-2 space-y-2">
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(entry.value, null, 2)}
</pre>
{editingKey === entry.key ? (
<div className="space-y-2">
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
rows={4}
aria-label={`Edit value for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs font-mono text-ink focus:outline-none focus:border-accent resize-none"
/>
<input
value={editTTL}
onChange={(e) => setEditTTL(e.target.value)}
placeholder="TTL in seconds (blank = no expiry)"
aria-label={`Edit TTL for ${entry.key}`}
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-xs text-ink focus:outline-none focus:border-accent"
/>
{editError && (
<div role="alert" className="text-[10px] text-bad">
{editError}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditSave(entry)}
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white"
>
Save
</button>
<button
type="button"
onClick={cancelEdit}
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid"
>
Cancel
</button>
</div>
</div>
) : (
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
{JSON.stringify(entry.value, null, 2)}
</pre>
)}
<div className="flex items-center justify-between">
<span className="text-[9px] text-ink-soft">
Updated: {new Date(entry.updated_at).toLocaleString()}
</span>
<button
type="button"
onClick={() => handleDelete(entry.key)}
// hover:text-bad on top of text-bad was a no-op.
// Switch to a hover bg + focus-visible ring so
// the destructive button visibly responds and
// keyboard users see focus.
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
>
Delete
</button>
<div className="flex items-center gap-2">
{editingKey !== entry.key && (
<button
type="button"
onClick={() => beginEdit(entry)}
className="text-[10px] text-ink-mid hover:bg-surface-elevated rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
Edit
</button>
)}
<button
type="button"
onClick={() => handleDelete(entry.key)}
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
>
Delete
</button>
</div>
</div>
</div>
)}
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
//
// Pins the Edit affordance added to MemoryTab. Until this PR the Memory tab
// was Add+Delete only; an entry that needed correction had to be deleted and
// re-added — losing the version-counter and any in-flight optimistic-locking
// invariants other writers depend on.
//
// Each test pins one branch of the new flow. If any fails, the bug is back.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import React from "react";
afterEach(cleanup);
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiDel = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: (path: string) => apiGet(path),
post: (path: string, body: unknown) => apiPost(path, body),
del: (path: string) => apiDel(path),
patch: vi.fn(),
put: vi.fn(),
},
}));
import { MemoryTab } from "../MemoryTab";
const sampleEntries = [
{
key: "team_brief",
value: { goal: "ship v2" },
version: 3,
expires_at: null,
updated_at: "2026-05-04T10:00:00Z",
},
{
key: "plain_note",
value: "raw text note",
version: 1,
expires_at: "2099-01-01T00:00:00Z",
updated_at: "2026-05-04T10:01:00Z",
},
];
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiDel.mockReset();
apiGet.mockImplementation((path: string) => {
if (path === "/workspaces/ws-test/memory") {
return Promise.resolve(sampleEntries);
}
return Promise.reject(new Error(`unmocked api.get: ${path}`));
});
});
async function renderAndExpand(key: string) {
render(<MemoryTab workspaceId="ws-test" />);
await waitFor(() => expect(apiGet).toHaveBeenCalled());
// Reveal the Advanced section that hosts the entry list.
const showAdvanced = await screen.findByRole("button", { name: "Show" });
fireEvent.click(showAdvanced);
// Expand the row.
const row = await screen.findByRole("button", { name: new RegExp(key) });
fireEvent.click(row);
}
describe("MemoryTab Edit affordance", () => {
it("Edit button appears once a row is expanded", async () => {
await renderAndExpand("team_brief");
expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0);
});
it("clicking Edit on a JSON-valued entry pre-fills the textarea with pretty JSON", async () => {
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = (await screen.findByLabelText(
"Edit value for team_brief",
)) as HTMLTextAreaElement;
expect(textarea.value).toBe('{\n "goal": "ship v2"\n}');
});
it("clicking Edit on a string-valued entry pre-fills raw (no surrounding quotes)", async () => {
await renderAndExpand("plain_note");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = (await screen.findByLabelText(
"Edit value for plain_note",
)) as HTMLTextAreaElement;
expect(textarea.value).toBe("raw text note");
});
it("Save POSTs with if_match_version + parsed value, then reloads", async () => {
apiPost.mockResolvedValue({ status: "ok", key: "team_brief", version: 4 });
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = await screen.findByLabelText("Edit value for team_brief");
fireEvent.change(textarea, { target: { value: '{"goal":"ship v3"}' } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
expect(apiPost).toHaveBeenCalledWith("/workspaces/ws-test/memory", {
key: "team_brief",
value: { goal: "ship v3" },
if_match_version: 3,
});
// Reload after save → second GET.
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(2));
});
it("Save with non-JSON text falls back to plain string", async () => {
apiPost.mockResolvedValue({ status: "ok" });
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = await screen.findByLabelText("Edit value for team_brief");
fireEvent.change(textarea, { target: { value: "free-form note" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
expect(apiPost.mock.calls[0][1].value).toBe("free-form note");
});
it("TTL field is forwarded as ttl_seconds when set", async () => {
apiPost.mockResolvedValue({ status: "ok" });
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const ttlInput = await screen.findByLabelText("Edit TTL for team_brief");
fireEvent.change(ttlInput, { target: { value: "3600" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
expect(apiPost.mock.calls[0][1].ttl_seconds).toBe(3600);
});
it("blank/zero/non-numeric TTL is omitted from the payload", async () => {
apiPost.mockResolvedValue({ status: "ok" });
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const ttlInput = await screen.findByLabelText("Edit TTL for team_brief");
// Junk + zero both must drop out — payload must not contain ttl_seconds.
fireEvent.change(ttlInput, { target: { value: "abc" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
expect(apiPost.mock.calls[0][1]).not.toHaveProperty("ttl_seconds");
});
it("Cancel discards edits and restores the rendered value", async () => {
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = await screen.findByLabelText("Edit value for team_brief");
fireEvent.change(textarea, { target: { value: '{"goal":"discarded"}' } });
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(apiPost).not.toHaveBeenCalled();
// Editor is gone; the JSON pre-block is back.
expect(screen.queryByLabelText("Edit value for team_brief")).toBeNull();
expect(screen.getAllByText(/"goal": "ship v2"/i).length).toBeGreaterThan(0);
});
it("409 response surfaces a retry hint and reloads", async () => {
apiPost.mockRejectedValueOnce(
new Error("HTTP 409: if_match_version mismatch"),
);
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = await screen.findByLabelText("Edit value for team_brief");
fireEvent.change(textarea, { target: { value: '{"goal":"ship v3"}' } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/changed since you opened it/i);
// Initial mount load + post-conflict reload.
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(2));
});
it("non-409 error surfaces the message and does not reload", async () => {
apiPost.mockRejectedValueOnce(new Error("boom"));
await renderAndExpand("team_brief");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("boom");
// Only the initial mount load — no retry reload.
expect(apiGet).toHaveBeenCalledTimes(1);
});
it("entry with no version omits if_match_version (back-compat with older shape)", async () => {
// Pre-version-counter shape: drop the `version` field from the row.
apiGet.mockReset();
apiGet.mockImplementation((path: string) => {
if (path === "/workspaces/ws-test/memory") {
return Promise.resolve([
{
key: "old_entry",
value: "legacy",
expires_at: null,
updated_at: "2026-05-04T10:00:00Z",
},
]);
}
return Promise.reject(new Error(`unmocked: ${path}`));
});
apiPost.mockResolvedValue({ status: "ok" });
await renderAndExpand("old_entry");
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]);
const textarea = await screen.findByLabelText("Edit value for old_entry");
fireEvent.change(textarea, { target: { value: "updated" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPost).toHaveBeenCalledTimes(1));
const payload = apiPost.mock.calls[0][1];
expect(payload).not.toHaveProperty("if_match_version");
expect(payload.value).toBe("updated");
});
});
@@ -22,7 +22,6 @@ export interface ConfigData {
// task_budget maps to output_config.task_budget.total (requires beta header task-budgets-2026-03-13)
task_budget?: number;
prompt_files: string[];
shared_context: string[];
skills: string[];
tools: string[];
a2a: { port: number; streaming: boolean; push_notifications: boolean };
@@ -40,7 +39,6 @@ export const DEFAULT_CONFIG: ConfigData = {
effort: "",
task_budget: 0,
prompt_files: [],
shared_context: [],
skills: [],
tools: [],
a2a: { port: 8000, streaming: true, push_notifications: true },
@@ -120,7 +120,6 @@ export function toYaml(config: ConfigData): string {
if (config.effort) { lines.push(""); simple("effort", config.effort); }
if (config.task_budget && config.task_budget > 0) { simple("task_budget", config.task_budget); }
if (config.prompt_files?.length) { lines.push(""); list("prompt_files", config.prompt_files); }
if (config.shared_context?.length) { lines.push(""); list("shared_context", config.shared_context); }
lines.push(""); list("skills", config.skills);
if (config.tools?.length) { list("tools", config.tools); }
lines.push(""); obj("a2a", config.a2a as unknown as Record<string, unknown>);
+5 -7
View File
@@ -27,11 +27,11 @@ prompt_files:
# AGENTS.md-style example:
# prompt_files: [AGENTS.md]
# Files to share with direct children (1-level inheritance)
# Children fetch these at startup via GET /workspaces/:id/shared-context
shared_context:
- architecture.md
- conventions.md
# NOTE: `shared_context` (parent child file injection at boot) was removed.
# To share knowledge across a team, use memory v2's team:<id> namespace via
# the recall_memory MCP tool — the agent pulls it on demand instead of
# paying for it at every boot. For large blob-shaped artefacts, see RFC
# #2789 (platform-owned shared file storage).
# Skills to load -- folder names under skills/
skills:
@@ -123,7 +123,6 @@ env:
| `runtime` | No | Adapter to use: `langgraph` (default), `claude-code`, `crewai`, `autogen`, `deepagents`, `openclaw`. See [Agent Runtime Adapters](./cli-runtime.md). |
| `model` | Yes | LangChain-compatible provider string (e.g. `anthropic:claude-sonnet-4-6`). Overridden by `MODEL_PROVIDER` env var if set. |
| `prompt_files` | No | Ordered list of markdown files to load as system prompt. Defaults to `["system-prompt.md"]` if omitted. `MEMORY.md` and `USER.md` are auto-appended when present so frozen memory snapshots do not need to be duplicated here. Supports any agent framework's file structure (OpenClaw, Claude Code, etc.) |
| `shared_context` | No | Files from this workspace's config dir to share with direct children. Children fetch these at startup and inject into their system prompt as `## Parent Context`. 1-level inheritance only (grandchildren don't see grandparent's context). |
| `skills` | Yes | List of skill folder names to load from `skills/` |
| `tools` | No | Built-in tools from workspace-template |
| `memory` | No | Memory backend config (defaults to filesystem) |
@@ -157,7 +156,6 @@ The file watcher monitors the entire config directory. When `config.yaml` change
| `name`, `description`, `version` | Yes | Rebuild Agent Card with new metadata |
| `a2a` | **No** | Port and protocol changes require container restart |
| `delegation` | Yes | Retry/timeout defaults take effect on next delegation call |
| `shared_context` | Yes | Children fetch on next prompt rebuild; no restart needed |
| `sub_workspaces` | **No** | Team structure changes go through `POST /workspaces/:id/expand` |
See [Skills — Live Reload](./skills.md#live-reload) for the full file watcher flow.
+11 -13
View File
@@ -24,21 +24,19 @@ When you receive a task, break it into sub-tasks and delegate to your team.
Always review work before reporting completion to the caller.
```
### 2. Parent Context (if child workspace)
### 2. Team-shared knowledge (on demand)
If this workspace was created via team expansion (has a `PARENT_ID` env var), it fetches its parent's shared context files at startup via `GET /workspaces/{parent_id}/shared-context`. The parent declares which files to share in its `config.yaml`:
Team-scoped knowledge is no longer injected at boot. The previous
`shared_context` field + `GET /workspaces/{parent_id}/shared-context`
fetch was removed; agents now pull team-shared knowledge on demand via
memory v2's `team:<id>` namespace using the `recall_memory` MCP tool.
```yaml
shared_context:
- architecture.md
- conventions.md
```
These files are injected as a `## Parent Context` section, with each file rendered under a `### {filename}` heading. This gives children the parent's project knowledge (architecture, conventions, API schemas) without exposing the parent's system prompt or full config.
**1-level inheritance only:** A grandchild sees its direct parent's shared context, not its grandparent's. This mirrors the L2 Team Memory scope.
**Graceful degradation:** If the parent is offline or the endpoint returns an error, the child starts normally without parent context.
This shifts cost from "every boot, always" to "only when the agent
asks", and lets team members write to the shared store from anywhere
that can resolve the namespace (canvas Memory tab, agent
`commit_memory`, admin import). For large blob-shaped artefacts (full
architecture docs, brand assets, PDFs) see RFC #2789 (platform-owned
shared file storage).
### 3. Skill Instructions
-1
View File
@@ -199,7 +199,6 @@ Install safeguards bound the cost of a single install (env-tunable via `PLUGIN_I
| `GET` | `/templates` | List available templates. **Requires AdminAuth** (PR #701). |
| `GET` | `/org/templates` | List available org templates. **Requires AdminAuth** (PR #701). |
| `POST` | `/templates/import` | Import an agent folder as a new template |
| `GET` | `/workspaces/:id/shared-context` | Read parent shared-context files |
| `GET` | `/workspaces/:id/files` | List files under an allowed root |
| `GET` | `/workspaces/:id/files/*path` | Read a file |
| `PUT` | `/workspaces/:id/files/*path` | Write a file |
-1
View File
@@ -68,7 +68,6 @@ Full contract: `docs/runbooks/admin-auth.md`.
| GET | /channels/adapters | channels.go (list available platforms) |
| POST | /channels/discover | channels.go (auto-detect chats for a bot token) |
| POST | /webhooks/:type | channels.go (incoming social webhook) |
| GET | /workspaces/:id/shared-context | templates.go |
| GET/PUT/DELETE | /workspaces/:id/files[/*path] | templates.go |
| GET | /canvas/viewport | viewport.go — open, no auth required (cosmetic, bootstrap-friendly) |
| PUT | /canvas/viewport | viewport.go — `CanvasOrBearer` middleware; accepts bearer OR Origin matching `CORS_ORIGINS`. Cosmetic-only route — worst case viewport corruption, recovered by page refresh. |
+2 -1
View File
@@ -523,7 +523,8 @@ runtime_config: # Runtime-specific settings
skills: ["skill1", "skill2"] # Folder names under skills/
tools: ["web_search", "filesystem"] # Built-in tool names
prompt_files: ["system-prompt.md"] # Additional prompt text files
shared_context: [] # Files from parent workspace
# `shared_context` was removed; team-shared knowledge now lives in memory v2's
# team:<id> namespace (recall_memory MCP tool). See RFC #2789 for shared files.
a2a:
port: 8000
+73 -1
View File
@@ -706,8 +706,80 @@ print(json.dumps({
d=json.load(sys.stdin)
print(len(d if isinstance(d, list) else d.get('events', [])))" 2>/dev/null || echo 0)
log " Activity events observed: $ACTIVITY_COUNT"
# ─── 9c. Workspace KV memory Edit round-trip ─────────────────────────
# Pins the Edit affordance added to the canvas Memory tab. The UI calls
# POST /workspaces/:id/memory with if_match_version, so the contract is:
# 1. initial POST creates row at version 1
# 2. GET returns version 1 + value
# 3. POST with if_match_version=1 updates → version 2
# 4. POST with if_match_version=1 again → 409 (optimistic-lock enforcement)
# Without (3) there is no Edit; without (4) two concurrent writers can
# silently overwrite each other and the agent loses delegation-ledger state.
log "9c. Memory KV Edit round-trip (Edit affordance + 409 gate)"
EDIT_KEY="e2e_edit_gate_$SLUG"
# 1. seed
tenant_call POST "/workspaces/$PARENT_ID/memory" \
-H "Content-Type: application/json" \
-d "{\"key\":\"$EDIT_KEY\",\"value\":{\"step\":1}}" >/dev/null \
|| fail "memory KV seed POST failed"
# 2. read back, capture version
EDIT_GET=$(tenant_call GET "/workspaces/$PARENT_ID/memory/$EDIT_KEY")
EDIT_VER=$(echo "$EDIT_GET" | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "")
[ -z "$EDIT_VER" ] && fail "memory KV GET missing version field. Body: ${EDIT_GET:0:200}"
# 3. conditional update with matching version
tenant_call POST "/workspaces/$PARENT_ID/memory" \
-H "Content-Type: application/json" \
-d "{\"key\":\"$EDIT_KEY\",\"value\":{\"step\":2},\"if_match_version\":$EDIT_VER}" >/dev/null \
|| fail "memory KV conditional Edit failed (if_match_version=$EDIT_VER)"
# 4. value flipped + version incremented?
EDIT_GET2=$(tenant_call GET "/workspaces/$PARENT_ID/memory/$EDIT_KEY")
EDIT_VAL2=$(echo "$EDIT_GET2" | python3 -c "import json,sys; print(json.load(sys.stdin)['value'].get('step'))" 2>/dev/null || echo "")
[ "$EDIT_VAL2" = "2" ] || fail "memory KV Edit did not persist new value. Body: ${EDIT_GET2:0:200}"
# 5. stale-version POST must 409 — pin the optimistic-lock contract.
#
# tenant_call uses CURL_COMMON which carries --fail-with-body, so an
# expected-409 makes curl exit 22. The previous shape
# $(tenant_call ... -w "%{http_code}" || echo "000")
# concatenated the captured "409" with the fallback "000" giving a
# bogus "409000" value (caught on PR #2792's first E2E run, which is
# also why staging-saas E2E has been silent-failing this gate since
# PR #2787 merged). Fix: route the status code into its own tempfile
# so curl's exit code can't pollute the captured stdout. set +e/-e
# keeps the 22 from tripping the outer `set -e` pipeline.
set +e
tenant_call POST "/workspaces/$PARENT_ID/memory" \
-H "Content-Type: application/json" \
-d "{\"key\":\"$EDIT_KEY\",\"value\":{\"step\":3},\"if_match_version\":$EDIT_VER}" \
-o /tmp/memory_stale_resp.txt -w "%{http_code}" >/tmp/memory_stale_code.txt 2>/dev/null
set -e
EDIT_STALE_CODE=$(cat /tmp/memory_stale_code.txt 2>/dev/null || echo "000")
[ "$EDIT_STALE_CODE" = "409" ] || fail "memory KV stale Edit must 409 (optimistic-lock). Got '$EDIT_STALE_CODE': $(cat /tmp/memory_stale_resp.txt 2>/dev/null | head -c 200)"
# cleanup
tenant_call DELETE "/workspaces/$PARENT_ID/memory/$EDIT_KEY" >/dev/null 2>&1 || true
ok "Memory KV Edit round-trip + 409 gate passed"
# ─── 9d. shared_context removal gate ─────────────────────────────────
# Pin the deletion of GET /workspaces/:id/shared-context. The route + handler
# were removed; team-shared knowledge now flows through memory v2's
# team:<id> namespace. If anyone re-introduces a shared-context endpoint
# without going through RFC #2789, this gate fires.
set +e
SC_CODE=$(tenant_call GET "/workspaces/$PARENT_ID/shared-context" \
-o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
set -e
if [ "$SC_CODE" = "200" ]; then
fail "shared-context route should be gone but returned 200 — regression. See task #304."
fi
ok "shared-context route confirmed removed (HTTP $SC_CODE)"
else
log "9/11 Canary mode — skipping HMA / peers / activity"
log "9/11 Canary mode — skipping HMA / peers / activity / memory-edit / shared-context-gone"
fi
# ─── 10. Delegation mechanics (full mode + child) ──────────────────────
@@ -8,8 +8,6 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
@@ -569,67 +567,6 @@ func TestProxyA2A_WorkspaceOffline(t *testing.T) {
}
}
// ---------- TestSharedContext ----------
func TestSharedContext(t *testing.T) {
mock := setupTestDB(t)
// Create a temp configs directory with a workspace config
tmpDir := t.TempDir()
wsDir := filepath.Join(tmpDir, "test-workspace")
if err := os.MkdirAll(wsDir, 0755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
// Write config.yaml with shared_context
configYAML := "name: Test Workspace\nshared_context:\n - test.md\n"
if err := os.WriteFile(filepath.Join(wsDir, "config.yaml"), []byte(configYAML), 0644); err != nil {
t.Fatalf("failed to write config.yaml: %v", err)
}
// Write the shared context file
testContent := "# Shared Context\nThis is shared context content."
if err := os.WriteFile(filepath.Join(wsDir, "test.md"), []byte(testContent), 0644); err != nil {
t.Fatalf("failed to write test.md: %v", err)
}
handler := NewTemplatesHandler(tmpDir, nil)
// Mock DB returning workspace name that normalizes to "test-workspace"
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-ctx").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Test Workspace"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-ctx"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-ctx/shared-context", nil)
handler.SharedContext(c)
if w.Code != http.StatusOK {
t.Errorf("expected status 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 parse response: %v", err)
}
if len(resp) != 1 {
t.Fatalf("expected 1 file, got %d", len(resp))
}
if resp[0]["path"] != "test.md" {
t.Errorf("expected path 'test.md', got %v", resp[0]["path"])
}
if resp[0]["content"] != testContent {
t.Errorf("expected content %q, got %v", testContent, resp[0]["content"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ---------- TestHeartbeatHandler_TaskChanged ----------
func TestHeartbeatHandler_TaskChanged(t *testing.T) {
@@ -1218,53 +1155,6 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
}
}
func TestSharedContext_NoSharedFiles(t *testing.T) {
mock := setupTestDB(t)
// Create a temp configs directory with a workspace config that has no shared_context
tmpDir := t.TempDir()
wsDir := filepath.Join(tmpDir, "empty-workspace")
if err := os.MkdirAll(wsDir, 0755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
// Write config.yaml without shared_context
configYAML := "name: Empty Workspace\ndescription: No shared context\n"
if err := os.WriteFile(filepath.Join(wsDir, "config.yaml"), []byte(configYAML), 0644); err != nil {
t.Fatalf("failed to write config.yaml: %v", err)
}
handler := NewTemplatesHandler(tmpDir, nil)
// Mock DB returning workspace name that normalizes to "empty-workspace"
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-empty").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Empty Workspace"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-empty"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-empty/shared-context", nil)
handler.SharedContext(c)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp []interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if len(resp) != 0 {
t.Errorf("expected empty array, got %d items", len(resp))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// TestActivityHandler_Report_SourceIDSpoofRejected verifies the #209 spoof
// guard: a workspace authenticated for :id cannot inject activity rows with
// source_id pointing at a different workspace. Bearer-auth middleware would
+68 -6
View File
@@ -11,6 +11,8 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/channels"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
@@ -25,10 +27,62 @@ import (
// during org import. Prevents overwhelming Docker when creating many containers.
const workspaceCreatePacingMs = 2000
// provisionConcurrency limits how many Docker containers can be provisioned
// simultaneously during org import. Without this, importing 39+ workspaces
// fires 39 goroutines that all hit Docker at once, causing timeouts (#1084).
const provisionConcurrency = 3
// defaultProvisionConcurrency is the fallback cap for parallel
// workspace-provision goroutines when MOLECULE_PROVISION_CONCURRENCY
// is unset. Originally a hard constant of 3 (PR #1084) calibrated for
// Docker-mode workspaces. The constant is now a default — operators
// running on EC2 (where each provision is a RunInstances call AWS
// happily parallelises) typically want a much higher cap, while
// Docker-mode dev environments still prefer the conservative 3.
//
// 3 keeps the existing Docker-mode behavior. SaaS deployments override
// via env (see resolveProvisionConcurrency below).
const defaultProvisionConcurrency = 3
// resolveProvisionConcurrency returns the effective semaphore size for
// org-import workspace provisioning, honoring MOLECULE_PROVISION_CONCURRENCY:
//
// - unset / empty / non-numeric → defaultProvisionConcurrency (3)
// - "0" → unlimited (a very large cap;
// practically no semaphore — used on
// SaaS where AWS RunInstances is the
// rate-limiter, not us)
// - any positive integer N → N
// - negative integer → defaultProvisionConcurrency (3),
// log warning so operator notices
// the misconfiguration
//
// The "0 = unlimited" mapping was a deliberate choice: an env var of "0"
// is the natural shorthand for "no cap" without forcing operators to
// type a magic large number. The implementation hands off a large but
// finite value (1<<20) so the channel still works as a regular
// buffered chan; goroutines will never block on the semaphore in
// practice.
func resolveProvisionConcurrency() int {
raw := strings.TrimSpace(os.Getenv("MOLECULE_PROVISION_CONCURRENCY"))
if raw == "" {
return defaultProvisionConcurrency
}
n, err := strconv.Atoi(raw)
if err != nil {
log.Printf("org_import: MOLECULE_PROVISION_CONCURRENCY=%q is not an integer; falling back to default %d",
raw, defaultProvisionConcurrency)
return defaultProvisionConcurrency
}
if n < 0 {
log.Printf("org_import: MOLECULE_PROVISION_CONCURRENCY=%d is negative; falling back to default %d",
n, defaultProvisionConcurrency)
return defaultProvisionConcurrency
}
if n == 0 {
// Unlimited semantics — use a large but finite cap so the
// chan-based semaphore stays a no-op. 1M is well past any
// realistic org-import size; AWS RunInstances rate-limit and
// account vCPU quota are the real backpressure here.
return 1 << 20
}
return n
}
// Child grid layout constants — kept in sync with canvas-topology.ts on
// the client. Children laid on import use the same 2-column grid so the
@@ -600,8 +654,16 @@ func (h *OrgHandler) Import(c *gin.Context) {
results := []map[string]interface{}{}
var createErr error
// Semaphore limits concurrent Docker provisioning (#1084).
provisionSem := make(chan struct{}, provisionConcurrency)
// Semaphore limits concurrent provision goroutines (#1084).
// Cap is configurable via MOLECULE_PROVISION_CONCURRENCY:
// unset → 3 (Docker-mode default)
// "0" → effectively unlimited (SaaS / EC2 backend)
// N>0 → exactly N
// See resolveProvisionConcurrency for the full env-parse contract.
concurrency := resolveProvisionConcurrency()
provisionSem := make(chan struct{}, concurrency)
log.Printf("org_import: provision concurrency cap=%d (env MOLECULE_PROVISION_CONCURRENCY=%q)",
concurrency, os.Getenv("MOLECULE_PROVISION_CONCURRENCY"))
// Recursively create workspaces. Root workspaces keep their YAML
// canvas coords; children are positioned by createWorkspaceTree
@@ -377,11 +377,22 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
}
}
// #1084: limit concurrent Docker provisioning via semaphore.
// #1084: limit concurrent provisioning via semaphore.
// Use provisionWorkspaceAuto so SaaS deployments route through
// the CP (EC2) path — calling provisionWorkspace directly was
// the same silent-drop bug that bit TeamHandler.Expand on
// 2026-05-04 (see workspace.go:121-125 comment + #2486). Symptom:
// every claude-code workspace from org-import on SaaS sat in
// "provisioning" until the 600s sweeper marked it failed with
// "container started but never called /registry/register" —
// because there was no container, just a workspace row.
// provisionWorkspaceAuto picks CP-mode when h.cpProv is wired,
// Docker-mode otherwise; the org-import call site doesn't need
// to know which.
provisionSem <- struct{}{} // acquire
go func(wID, tPath string, cFiles map[string][]byte, p models.CreateWorkspacePayload) {
defer func() { <-provisionSem }() // release
h.workspace.provisionWorkspace(wID, tPath, cFiles, p)
h.workspace.provisionWorkspaceAuto(wID, tPath, cFiles, p)
}(id, templatePath, configFiles, payload)
}
@@ -0,0 +1,96 @@
package handlers
import (
"testing"
)
// Tests for resolveProvisionConcurrency — the env-parse contract that
// turns MOLECULE_PROVISION_CONCURRENCY into the channel-buffer size for
// the org-import provision semaphore.
//
// Why this matters: with the wrong cap, org-import either serializes
// (cap=1, slow) or stampedes the provider (cap=infinity on a backend
// that can't take it). The defaults — 3 for Docker, "0=unlimited" for
// EC2/SaaS — are what most operators want; the parse logic exists to
// route the env var to the right behavior without surprise.
//
// The "0 → unlimited" mapping is the user-facing piece worth pinning
// in tests: easy to misread as "0 means stop entirely" if someone
// re-reads the constant block years later.
func TestResolveProvisionConcurrency_UnsetUsesDefault(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "")
if got := resolveProvisionConcurrency(); got != defaultProvisionConcurrency {
t.Errorf("unset env: got %d, want %d", got, defaultProvisionConcurrency)
}
}
func TestResolveProvisionConcurrency_ZeroIsUnlimited(t *testing.T) {
// "0" is the user-facing shorthand for "no cap". The implementation
// returns a large but finite cap so the channel-based semaphore
// stays a no-op without infinite-buffer risk.
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "0")
got := resolveProvisionConcurrency()
if got <= defaultProvisionConcurrency {
t.Errorf("0 should map to large 'unlimited' cap, got %d", got)
}
// 1<<20 today; pin the lower bound rather than the exact value so
// future tuning of the magic number doesn't break this test.
if got < 1024 {
t.Errorf("0 should map to a cap >= 1024 (effectively unlimited), got %d", got)
}
}
func TestResolveProvisionConcurrency_PositiveIntegerExact(t *testing.T) {
cases := []struct {
env string
want int
}{
{"1", 1},
{"5", 5},
{"10", 10},
{"50", 50},
}
for _, tc := range cases {
t.Run(tc.env, func(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", tc.env)
if got := resolveProvisionConcurrency(); got != tc.want {
t.Errorf("env=%q: got %d, want %d", tc.env, got, tc.want)
}
})
}
}
func TestResolveProvisionConcurrency_NegativeFallsBackToDefault(t *testing.T) {
// Negative values are operator misconfiguration. Fall back to the
// safe default rather than passing through to make(chan, -5) which
// panics. The handler logs a warning so the operator notices.
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", "-5")
if got := resolveProvisionConcurrency(); got != defaultProvisionConcurrency {
t.Errorf("negative env: got %d, want default %d", got, defaultProvisionConcurrency)
}
}
func TestResolveProvisionConcurrency_NonNumericFallsBackToDefault(t *testing.T) {
// Garbage in env shouldn't crash org-import. Common in dev when an
// operator types `MOLECULE_PROVISION_CONCURRENCY=true` or similar.
cases := []string{"true", "yes", "infinity", "ten", "3.5", "0x10"}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", raw)
if got := resolveProvisionConcurrency(); got != defaultProvisionConcurrency {
t.Errorf("non-numeric env=%q: got %d, want default %d",
raw, got, defaultProvisionConcurrency)
}
})
}
}
func TestResolveProvisionConcurrency_WhitespaceTrimmed(t *testing.T) {
// Operators frequently set env vars with stray whitespace from
// copy-paste. Trim before parse so " 7 " == "7".
t.Setenv("MOLECULE_PROVISION_CONCURRENCY", " 7 ")
if got := resolveProvisionConcurrency(); got != 7 {
t.Errorf("whitespace env: got %d, want 7", got)
}
}
@@ -204,3 +204,116 @@ func writeFileViaEIC(ctx context.Context, instanceID, runtime, relPath string, c
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// readFileViaEIC reads a single file from the workspace EC2 at the
// absolute path that resolveWorkspaceFilePath computes. Mirrors
// writeFileViaEIC end-to-end (ephemeral keypair, EIC tunnel, ssh) so
// canvas's Config tab can GET back what it just PUT. Pre-fix the GET
// path (templates.go ReadFile) only handled local Docker containers
// + a host-side template fallback; SaaS workspaces (EC2-per-workspace)
// always 404'd because neither handles their on-EC2 layout.
//
// Returns ("", os.ErrNotExist) when the remote path doesn't exist so
// the handler can map it to HTTP 404 cleanly. Other errors propagate.
func readFileViaEIC(ctx context.Context, instanceID, runtime, relPath string) ([]byte, error) {
if instanceID == "" {
return nil, fmt.Errorf("workspace has no instance_id — not a SaaS EC2 workspace")
}
absPath, err := resolveWorkspaceFilePath(runtime, relPath)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
osUser := os.Getenv("WORKSPACE_EC2_OS_USER")
if osUser == "" {
osUser = "ubuntu"
}
region := os.Getenv("AWS_REGION")
if region == "" {
region = "us-east-2"
}
ctx, cancel := context.WithTimeout(ctx, eicFileWriteTimeout)
defer cancel()
keyDir, err := os.MkdirTemp("", "molecule-fileread-*")
if err != nil {
return nil, fmt.Errorf("keydir mkdir: %w", err)
}
defer func() { _ = os.RemoveAll(keyDir) }()
keyPath := keyDir + "/id"
if out, kerr := exec.CommandContext(ctx, "ssh-keygen",
"-t", "ed25519", "-f", keyPath, "-N", "", "-q",
"-C", "molecule-fileread",
).CombinedOutput(); kerr != nil {
return nil, fmt.Errorf("ssh-keygen: %w (%s)", kerr, strings.TrimSpace(string(out)))
}
pubKey, err := os.ReadFile(keyPath + ".pub")
if err != nil {
return nil, fmt.Errorf("read pubkey: %w", err)
}
if err := sendSSHPublicKey(ctx, region, instanceID, osUser, strings.TrimSpace(string(pubKey))); err != nil {
return nil, fmt.Errorf("send-ssh-public-key: %w", err)
}
localPort, err := pickFreePort()
if err != nil {
return nil, fmt.Errorf("pick free port: %w", err)
}
tunnel := openTunnelCmd(eicSSHOptions{
InstanceID: instanceID,
OSUser: osUser,
Region: region,
LocalPort: localPort,
PrivateKeyPath: keyPath,
})
tunnel.Env = os.Environ()
if err := tunnel.Start(); err != nil {
return nil, fmt.Errorf("open-tunnel start: %w", err)
}
defer func() {
if tunnel.Process != nil {
_ = tunnel.Process.Kill()
}
_ = tunnel.Wait()
}()
if err := waitForPort(ctx, "127.0.0.1", localPort, 10*time.Second); err != nil {
return nil, fmt.Errorf("tunnel never listened: %w", err)
}
// `sudo -n cat`: /configs is root-owned by cloud-init (same reason
// writeFileViaEIC needs sudo to install). The path is built from a
// validated map + Clean(), so no user-controlled string reaches the
// shell here. `2>/dev/null` swallows `cat: ...: No such file` so
// the missing-file case returns empty stdout + non-zero exit, which
// we translate to os.ErrNotExist below.
sshCmd := exec.CommandContext(ctx, "ssh",
"-i", keyPath,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ServerAliveInterval=15",
"-p", fmt.Sprintf("%d", localPort),
fmt.Sprintf("%s@127.0.0.1", osUser),
fmt.Sprintf("sudo -n cat %s 2>/dev/null", shellQuote(absPath)),
)
sshCmd.Env = os.Environ()
var stdout, stderr bytes.Buffer
sshCmd.Stdout = &stdout
sshCmd.Stderr = &stderr
runErr := sshCmd.Run()
out := stdout.Bytes()
if runErr != nil {
// `cat` returns 1 on missing file; with 2>/dev/null we have no
// stderr distinguisher. Treat empty-stdout + non-zero exit as
// not-found rather than a tunnel/auth error (those usually
// produce stderr from ssh itself, not from the remote command).
if len(out) == 0 && stderr.Len() == 0 {
return nil, os.ErrNotExist
}
return nil, fmt.Errorf("ssh cat: %w (%s)", runErr, strings.TrimSpace(stderr.String()))
}
log.Printf("readFileViaEIC: ws instance=%s runtime=%s read %d bytes ← %s",
instanceID, runtime, len(out), absPath)
return out, nil
}
+43 -93
View File
@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"fmt"
"log"
"net/http"
@@ -349,16 +350,52 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
return
}
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
var wsName, instanceID, runtime string
if err := db.DB.QueryRowContext(ctx,
`SELECT name, COALESCE(instance_id, ''), COALESCE(runtime, '') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsName, &instanceID, &runtime); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
// Try container first. `cat` wants a single path argument — passing
// rootPath and filePath as two args would make `cat` try to read the
// rootPath directory (error) and then resolve filePath relative to
// the container's cwd, which isn't guaranteed to equal rootPath.
// SaaS workspace (EC2-per-workspace) — no Docker on this tenant. Read
// via SSH through the EIC endpoint, mirroring WriteFile's dispatch
// in this same file. Pre-fix this branch was missing and SaaS
// workspaces always fell through to the local-Docker container check
// (finds nothing on a SaaS tenant) + template-dir fallback (returns
// the seed template, not the persisted state). Net effect: the
// canvas Config tab always 404'd for SaaS workspaces — visible to
// users after #2781 added the "no config.yaml" error UX.
//
// The ?root= query param is intentionally ignored on the SaaS path —
// it's a local-Docker concept (arbitrary container roots). The
// runtime → base-path map (workspaceFilePathPrefix in
// template_files_eic.go) is the SaaS source of truth.
if instanceID != "" {
content, err := readFileViaEIC(ctx, instanceID, runtime, filePath)
if err == nil {
c.JSON(http.StatusOK, gin.H{
"path": filePath,
"content": string(content),
"size": len(content),
})
return
}
if errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found on workspace"})
return
}
log.Printf("ReadFile EIC for %s path=%s: %v", workspaceID, filePath, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
return
}
// Local Docker path: try the workspace container first. `cat` wants a
// single path argument — passing rootPath and filePath as two args
// would make `cat` try to read the rootPath directory (error) and
// then resolve filePath relative to the container's cwd, which
// isn't guaranteed to equal rootPath.
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
fullPath := strings.TrimRight(rootPath, "/") + "/" + filePath
content, err := h.execInContainer(ctx, containerName, []string{"cat", fullPath})
@@ -511,90 +548,3 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "deleted", "path": filePath})
}
// SharedContext handles GET /workspaces/:id/shared-context
// Returns the files listed in the workspace's config.yaml shared_context field.
func (h *TemplatesHandler) SharedContext(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var wsName string
if err := db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
type contextFile struct {
Path string `json:"path"`
Content string `json:"content"`
}
// Try reading from running container first
if containerName := h.findContainer(ctx, workspaceID); containerName != "" {
configData, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs/config.yaml"})
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
var cfg struct {
SharedContext []string `yaml:"shared_context"`
}
if err := yaml.Unmarshal([]byte(configData), &cfg); err != nil || len(cfg.SharedContext) == 0 {
c.JSON(http.StatusOK, []interface{}{})
return
}
files := make([]contextFile, 0, len(cfg.SharedContext))
for _, relPath := range cfg.SharedContext {
if err := validateRelPath(relPath); err != nil {
continue
}
// CWE-78: pass path components as separate exec args instead of
// concatenating into a single string. validateRelPath above is the
// primary guard; separate args is defence-in-depth (no shell
// interpolation possible in exec form).
content, err := h.execInContainer(ctx, containerName, []string{"cat", "/configs", relPath})
if err != nil {
continue
}
files = append(files, contextFile{Path: relPath, Content: content})
}
c.JSON(http.StatusOK, files)
return
}
// Fallback to host-side template dir
configDir := h.resolveTemplateDir(wsName)
if configDir == "" {
c.JSON(http.StatusOK, []interface{}{})
return
}
configData, err := os.ReadFile(filepath.Join(configDir, "config.yaml"))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
var cfg struct {
SharedContext []string `yaml:"shared_context"`
}
if err := yaml.Unmarshal(configData, &cfg); err != nil || len(cfg.SharedContext) == 0 {
c.JSON(http.StatusOK, []interface{}{})
return
}
files := make([]contextFile, 0, len(cfg.SharedContext))
for _, relPath := range cfg.SharedContext {
if err := validateRelPath(relPath); err != nil {
continue
}
data, err := os.ReadFile(filepath.Join(configDir, relPath))
if err != nil {
continue
}
files = append(files, contextFile{Path: relPath, Content: string(data)})
}
c.JSON(http.StatusOK, files)
}
@@ -894,7 +894,7 @@ func TestReadFile_WorkspaceNotFound(t *testing.T) {
handler := NewTemplatesHandler(t.TempDir(), nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-nf").
WillReturnError(sql.ErrNoRows)
@@ -928,9 +928,14 @@ func TestReadFile_FallbackToHost_Success(t *testing.T) {
handler := NewTemplatesHandler(tmpDir, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
// instance_id="" → SaaS branch skipped → falls through to local
// Docker / template-dir host fallback (the only path the test
// exercises). When instance_id is set, ReadFile would dispatch
// through readFileViaEIC, which is covered by integration tests.
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-read").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Reader Agent"))
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("Reader Agent", "", ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -964,9 +969,10 @@ func TestReadFile_FallbackToHost_NotFound(t *testing.T) {
tmpDir := t.TempDir()
handler := NewTemplatesHandler(tmpDir, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
mock.ExpectQuery(`SELECT name, COALESCE\(instance_id, ''\), COALESCE\(runtime, ''\) FROM workspaces WHERE id =`).
WithArgs("ws-nofile").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("No File Agent"))
WillReturnRows(sqlmock.NewRows([]string{"name", "instance_id", "runtime"}).
AddRow("No File Agent", "", ""))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1120,107 +1126,6 @@ func TestDeleteFile_WorkspaceNotFound(t *testing.T) {
}
}
// ==================== GET /workspaces/:id/shared-context ====================
func TestSharedContext_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewTemplatesHandler(t.TempDir(), nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-sc-nf").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-sc-nf"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-nf/shared-context", nil)
handler.SharedContext(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 sqlmock expectations: %v", err)
}
}
func TestSharedContext_NoTemplate(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
handler := NewTemplatesHandler(tmpDir, nil) // no docker
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-sc-nt").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Unknown Agent"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-sc-nt"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-nt/shared-context", nil)
handler.SharedContext(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Should return empty array
var resp []interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 0 {
t.Errorf("expected empty list, got %d items", len(resp))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestSharedContext_WithFiles(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
tmplDir := filepath.Join(tmpDir, "ctx-agent")
os.MkdirAll(tmplDir, 0755)
os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte("name: Ctx Agent\nshared_context:\n - rules.md\n - style.md\n"), 0644)
os.WriteFile(filepath.Join(tmplDir, "rules.md"), []byte("# Rules\nBe nice"), 0644)
os.WriteFile(filepath.Join(tmplDir, "style.md"), []byte("# Style\nBe clear"), 0644)
handler := NewTemplatesHandler(tmpDir, nil)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-sc-ok").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Ctx Agent"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-sc-ok"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-sc-ok/shared-context", nil)
handler.SharedContext(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if len(resp) != 2 {
t.Fatalf("expected 2 context files, got %d", len(resp))
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// ==================== resolveTemplateDir ====================
func TestResolveTemplateDir_ByNormalizedName(t *testing.T) {
@@ -1247,7 +1152,7 @@ func TestResolveTemplateDir_NotFound(t *testing.T) {
}
// ==================== CWE-78 hardening regression (issue #2011) ====================
// These tests lock in the defence-in-depth guards for DeleteFile and SharedContext.
// These tests lock in the defence-in-depth guards for DeleteFile.
// The primary guard is validateRelPath (fires before any exec/file-read path);
// the exec-form path construction (filepath.Join / separate args) is defence-in-depth.
@@ -1292,60 +1197,3 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) {
}
}
// TestCWE78_SharedContext_SkipsTraversalPaths asserts that when a workspace's
// config.yaml lists traversal paths in shared_context, SharedContext skips them
// via validateRelPath rather than passing them to exec or os.ReadFile.
// Uses the filesystem fallback path (no docker client) so no container mock needed.
func TestCWE78_SharedContext_SkipsTraversalPaths(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
// Create a template directory that SharedContext will resolve for "Cwe Agent".
tmplDir := filepath.Join(tmpDir, "cwe-agent")
os.MkdirAll(tmplDir, 0755)
// config.yaml with a mix of safe and traversal-attack paths.
configYAML := "name: Cwe Agent\nshared_context:\n - safe-file.md\n - ../../etc/passwd\n - ../shadow\n - another-safe.md\n"
os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYAML), 0644)
// Only write the safe files — traversal paths must not be reachable.
os.WriteFile(filepath.Join(tmplDir, "safe-file.md"), []byte("# safe"), 0644)
os.WriteFile(filepath.Join(tmplDir, "another-safe.md"), []byte("# also safe"), 0644)
mock.ExpectQuery("SELECT name FROM workspaces WHERE id =").
WithArgs("ws-cwe78-sc").
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Cwe Agent"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-cwe78-sc"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-cwe78-sc/shared-context", nil)
handler := NewTemplatesHandler(tmpDir, nil) // nil docker → filesystem fallback
handler.SharedContext(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var files []struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(w.Body.Bytes(), &files); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Only the two safe files must appear; traversal paths must be absent.
if len(files) != 2 {
t.Errorf("expected 2 safe files, got %d: %v", len(files), files)
}
for _, f := range files {
if strings.Contains(f.Path, "..") || strings.Contains(f.Path, "etc") || strings.Contains(f.Path, "shadow") {
t.Errorf("traversal path %q must not appear in shared-context response", f.Path)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -168,3 +168,120 @@ func TestTeamExpand_UsesAutoNotDirectDockerPath(t *testing.T) {
t.Errorf("team.go must call h.wh.provisionWorkspaceAuto for child provisioning — current code does not")
}
}
// TestNoCallSiteCallsDirectProvisionerExceptAuto — generic source-level
// gate covering ANY future caller, not just team.go and org_import.go.
//
// The architectural intent is: provisionWorkspaceAuto is the single
// source of truth for "how to start a workspace"; the per-backend
// helpers (provisionWorkspace = Docker, provisionWorkspaceCP = CP) are
// implementation details Auto routes between based on which backend is
// wired. Pre-2026-05-04 we had this abstraction but enforced only by
// convention — TeamHandler.Expand violated it (silent SaaS bug), then
// org_import.go violated it the same way. The fixes were identical:
// route through Auto. This gate prevents the *next* call site from
// repeating the pattern.
//
// Walks every .go file under handlers/ (except the dispatcher itself
// in workspace.go, and tests). Fails if any non-test handler calls
// h.*.provisionWorkspace( or h.*.provisionWorkspaceCP( directly —
// they should ALL go through provisionWorkspaceAuto.
func TestNoCallSiteCallsDirectProvisionerExceptAuto(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
entries, err := os.ReadDir(wd)
if err != nil {
t.Fatalf("readdir: %v", err)
}
directRe := []string{
// Receiver could be anything, so match on the suffix.
".provisionWorkspace(",
".provisionWorkspaceCP(",
}
allowedFiles := map[string]bool{
// workspace.go DEFINES the methods + the Auto dispatcher; it's
// allowed to reference them directly.
"workspace.go": true,
// workspace_provision.go DEFINES the bodies of the direct
// methods (and the Auto-internal call from CP-mode itself).
"workspace_provision.go": true,
// workspace_restart.go pre-dates the Auto dispatcher and has
// its own if-cpProv-else manual dispatch (line 219-228, 571-575,
// 704-708). Functionally equivalent to Auto, so it's not the
// bug class this gate targets — but it IS architectural
// duplication, tracked as a follow-up for proper de-dup.
// See <follow-up issue> filed alongside this PR.
"workspace_restart.go": true,
}
for _, entry := range entries {
name := entry.Name()
if !filepath.IsAbs(name) && entry.IsDir() {
continue
}
if filepath.Ext(name) != ".go" {
continue
}
// Skip tests — tests legitimately stub or call the helpers
// to exercise their behavior.
if filepath.Base(name) != name {
continue
}
if filepath.Ext(name) == ".go" && len(name) > len("_test.go") &&
name[len(name)-len("_test.go"):] == "_test.go" {
continue
}
if allowedFiles[name] {
continue
}
src, err := os.ReadFile(filepath.Join(wd, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, needle := range directRe {
if bytes.Contains(src, []byte(needle)) {
t.Errorf("%s calls h.X%s directly — must use h.X.provisionWorkspaceAuto so backend routing stays centralized. "+
"Pre-2026-05-04 the same pattern caused the silent-drop bug in TeamHandler.Expand, then again in org_import.go (#2486). "+
"Fix: replace the call with h.X.provisionWorkspaceAuto(...) — Auto picks Docker vs CP based on which backend is wired.",
name, needle)
}
}
}
}
// TestOrgImport_UsesAutoNotDirectDockerPath — source-level guard for
// the org_import.go call site. Same bug pattern as team.go above:
// pre-2026-05-04 #2 (this PR), org_import called h.workspace.provisionWorkspace
// directly, sending every imported workspace down the Docker path on
// SaaS. User reproduced 2026-05-04 ~22:30Z importing a 7-workspace
// "Director Pattern" template on the hongming prod tenant — every
// workspace sat in "provisioning" until the 600s sweeper marked it
// failed with "container started but never called /registry/register",
// because no container ever existed (the Docker provisioner was nil
// in SaaS, the goroutine returned silently, no log emitted from
// provisionWorkspaceCP because that function was never invoked).
//
// The repro pattern was identical to issue #2486. The fix is identical
// to the team.go fix above: route through provisionWorkspaceAuto.
//
// This test pins the call site so a future refactor can't re-introduce
// the bug. Substring match on the source — same rationale as the team.go
// gate above.
func TestOrgImport_UsesAutoNotDirectDockerPath(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
src, err := os.ReadFile(filepath.Join(wd, "org_import.go"))
if err != nil {
t.Fatalf("read org_import.go: %v", err)
}
if bytes.Contains(src, []byte("h.workspace.provisionWorkspace(")) {
t.Errorf("org_import.go calls h.workspace.provisionWorkspace directly — must use h.workspace.provisionWorkspaceAuto so SaaS tenants route to CP. " +
"Pre-fix repro: 7-workspace org-import on hongming prod tenant 2026-05-04 ~22:30Z, every workspace timed out at 600s with the misleading 'container started but never called /registry/register' message — see #2486.")
}
if !bytes.Contains(src, []byte("h.workspace.provisionWorkspaceAuto(")) {
t.Errorf("org_import.go must call h.workspace.provisionWorkspaceAuto for child provisioning — current code does not")
}
}
@@ -505,7 +505,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
tmplAdmin.GET("/templates", tmplh.List)
tmplAdmin.POST("/templates/import", tmplh.Import)
}
wsAuth.GET("/shared-context", tmplh.SharedContext)
wsAuth.PUT("/files", tmplh.ReplaceFiles)
wsAuth.GET("/files", tmplh.ListFiles)
wsAuth.GET("/files/*path", tmplh.ReadFile)
+8 -6
View File
@@ -444,7 +444,7 @@ class BaseAdapter(ABC):
"""
from plugins import load_plugins
from skill_loader.loader import load_skills
from coordinator import get_children, get_parent_context, build_children_description
from coordinator import get_children, build_children_description
from prompt import build_system_prompt, get_peer_capabilities, get_platform_instructions
from builtin_tools.approval import request_approval
from builtin_tools.delegation import delegate_task, delegate_task_async, check_task_status
@@ -500,10 +500,13 @@ class BaseAdapter(ABC):
logger.info(f"Coordinator mode: {len(children)} children")
all_tools.append(route_task_to_team)
# Parent context (if this is a child workspace)
parent_context = await get_parent_context()
# Build system prompt with all context
# Build system prompt with all context. Parent→child knowledge sharing
# was previously handled by `shared_context` (parent's config.yaml file
# paths injected into the child's prompt at boot). That path was removed
# — agents now pull team-scoped knowledge via memory v2's team:<id>
# namespace (recall_memory) on demand instead of paying for it on every
# boot regardless of need. See RFC #2789 for the future shared-file
# storage that complements this for large blob-shaped artefacts.
peers = await get_peer_capabilities(platform_url, config.workspace_id)
platform_instructions = await get_platform_instructions(platform_url, config.workspace_id)
coordinator_prompt = build_children_description(children) if is_coordinator else ""
@@ -516,7 +519,6 @@ class BaseAdapter(ABC):
prompt_files=config.prompt_files,
plugin_rules=plugins.rules,
plugin_prompts=extra_prompts,
parent_context=parent_context,
platform_instructions=platform_instructions,
)
-2
View File
@@ -347,7 +347,6 @@ class WorkspaceConfig:
plugins: list[str] = field(default_factory=list) # installed plugin names
tools: list[str] = field(default_factory=list)
prompt_files: list[str] = field(default_factory=list)
shared_context: list[str] = field(default_factory=list)
a2a: A2AConfig = field(default_factory=A2AConfig)
delegation: DelegationConfig = field(default_factory=DelegationConfig)
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
@@ -555,7 +554,6 @@ def load_config(config_path: Optional[str] = None) -> WorkspaceConfig:
plugins=raw.get("plugins", []),
tools=raw.get("tools", []),
prompt_files=raw.get("prompt_files", []),
shared_context=raw.get("shared_context", []),
a2a=A2AConfig(
port=a2a_raw.get("port", 8000),
streaming=a2a_raw.get("streaming", True),
-23
View File
@@ -32,29 +32,6 @@ if not _WORKSPACE_ID_raw:
WORKSPACE_ID = _WORKSPACE_ID_raw
async def get_parent_context() -> list[dict]:
"""Fetch shared context files from this workspace's parent.
Returns a list of {"path": str, "content": str} dicts.
Returns empty list if no parent, parent unreachable, or no shared context.
"""
parent_id = os.environ.get("PARENT_ID", "")
if not parent_id:
return []
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{PLATFORM_URL}/workspaces/{parent_id}/shared-context",
headers={"X-Workspace-ID": WORKSPACE_ID},
)
if resp.status_code == 200:
return resp.json()
except Exception as e:
logger.warning("Failed to fetch parent context: %s", e)
return []
async def get_children() -> list[dict]:
"""Fetch this workspace's children from the platform."""
try:
-13
View File
@@ -71,7 +71,6 @@ def build_system_prompt(
prompt_files: list[str] | None = None,
plugin_rules: list[str] | None = None,
plugin_prompts: list[str] | None = None,
parent_context: list[dict] | None = None,
platform_instructions: str = "",
a2a_mcp: bool = True,
) -> str:
@@ -135,18 +134,6 @@ def build_system_prompt(
if content:
parts.append(content)
# Inject parent's shared context (if this workspace is a child)
if parent_context:
parts.append("\n## Parent Context\n")
parts.append("The following context was shared by your parent workspace:\n")
for ctx_file in parent_context:
path = ctx_file.get("path", "unknown")
content = ctx_file.get("content", "")
if content.strip():
parts.append(f"### {path}")
parts.append(content.strip())
parts.append("")
# Inject plugin rules (always-on guidelines from ECC, Superpowers, etc.)
if plugin_rules:
parts.append("\n## Platform Rules\n")
-1
View File
@@ -437,7 +437,6 @@ if "coordinator" not in sys.modules:
except (ImportError, RuntimeError):
coordinator_mod = ModuleType("coordinator")
coordinator_mod.get_children = MagicMock()
coordinator_mod.get_parent_context = MagicMock()
coordinator_mod.build_children_description = MagicMock()
coordinator_mod.route_task_to_team = MagicMock()
coordinator_mod.route_task_to_team.name = "route_task_to_team"
+11 -11
View File
@@ -496,24 +496,24 @@ def test_initial_prompt_file_missing(tmp_path):
assert cfg.initial_prompt == ""
def test_shared_context_default(tmp_path):
"""shared_context defaults to empty list when not specified in YAML."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(yaml.dump({}))
def test_shared_context_field_removed(tmp_path):
"""Drop-shared_context regression gate: a config.yaml that still uses
the legacy `shared_context` key must load without crashing AND must
NOT carry it onto the WorkspaceConfig dataclass.
cfg = load_config(str(tmp_path))
assert cfg.shared_context == []
def test_shared_context_from_yaml(tmp_path):
"""shared_context reads file paths from YAML."""
The field was removed; YAML files in the wild may still mention it
until operators migrate. Loader silently ignores unknown YAML keys —
we pin the behavior so a future re-introduction is loud."""
config_yaml = tmp_path / "config.yaml"
config_yaml.write_text(
yaml.dump({"shared_context": ["guidelines.md", "architecture.md"]})
)
cfg = load_config(str(tmp_path))
assert cfg.shared_context == ["guidelines.md", "architecture.md"]
assert not hasattr(cfg, "shared_context"), (
"shared_context is removed; reintroducing it requires a new design "
"(see RFC #2789 for platform-owned shared file storage)"
)
# ===== Compliance default lock (#2059) =====
+7 -71
View File
@@ -1,79 +1,15 @@
"""Tests for coordinator.py — get_parent_context() and get_children() functions."""
"""Tests for coordinator.get_children() and build_children_description().
shared_context / get_parent_context was removed: parent→child knowledge
sharing now flows through memory v2's team:<id> namespace via recall_memory
on demand, not through file paths injected at boot.
"""
import asyncio
from unittest.mock import AsyncMock, patch, MagicMock
import pytest
from coordinator import get_parent_context, get_children, build_children_description
@pytest.mark.asyncio
async def test_get_parent_context_no_env(monkeypatch):
"""Returns empty list when PARENT_ID is not set."""
monkeypatch.delenv("PARENT_ID", raising=False)
result = await get_parent_context()
assert result == []
@pytest.mark.asyncio
async def test_get_parent_context_success(monkeypatch):
"""Fetches shared context files from parent workspace via httpx."""
monkeypatch.setenv("PARENT_ID", "parent-123")
monkeypatch.setenv("WORKSPACE_ID", "child-456")
monkeypatch.setenv("PLATFORM_URL", "http://localhost:8080")
# Reload module-level constants after env change
import coordinator
monkeypatch.setattr(coordinator, "PLATFORM_URL", "http://localhost:8080")
monkeypatch.setattr(coordinator, "WORKSPACE_ID", "child-456")
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"path": "guidelines.md", "content": "Be concise."},
{"path": "arch.md", "content": "Use microservices."},
]
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("coordinator.httpx.AsyncClient", return_value=mock_client):
result = await get_parent_context()
assert len(result) == 2
assert result[0]["path"] == "guidelines.md"
assert result[0]["content"] == "Be concise."
assert result[1]["path"] == "arch.md"
# Verify the correct URL was called
mock_client.get.assert_called_once_with(
"http://localhost:8080/workspaces/parent-123/shared-context",
headers={"X-Workspace-ID": "child-456"},
)
@pytest.mark.asyncio
async def test_get_parent_context_failure(monkeypatch):
"""Returns empty list when httpx raises an exception."""
monkeypatch.setenv("PARENT_ID", "parent-123")
monkeypatch.setenv("WORKSPACE_ID", "child-456")
import coordinator
monkeypatch.setattr(coordinator, "PLATFORM_URL", "http://localhost:8080")
monkeypatch.setattr(coordinator, "WORKSPACE_ID", "child-456")
mock_client = AsyncMock()
mock_client.get.side_effect = Exception("Connection refused")
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("coordinator.httpx.AsyncClient", return_value=mock_client):
result = await get_parent_context()
assert result == []
from coordinator import get_children, build_children_description
# ---------------------------------------------------------------------------
@@ -0,0 +1,245 @@
"""Drift gate: every property declared in a tool's ``input_schema`` MUST
be read by the matching dispatch arm in ``a2a_mcp_server.handle_tool_call``.
Why this exists (issue #2790):
PR #2766 added ``source_workspace_id`` to four tools' ``input_schema``
and tool implementations, but the dispatcher in ``a2a_mcp_server.py``
silently dropped the kwarg for ``commit_memory`` / ``recall_memory``
/ ``chat_history`` / ``get_workspace_info``. The schema lied: the LLM
saw the parameter as valid, populated it correctly, and every call
fell back to ``WORKSPACE_ID`` defeating multi-tenant isolation.
Existing dispatcher tests asserted return-value substrings instead
of kwarg flow (``"working" in result``), so the bug shipped to main.
What this test catches:
For every ``ToolSpec`` registered in ``platform_tools.registry``
whose ``input_schema`` declares a property ``X``, the matching
``elif name == "<tool_name>"`` arm in ``handle_tool_call`` must
contain a literal string ``"X"`` passed to ``arguments.get(...)``.
A future PR that adds a new property to the schema but forgets the
dispatcher will fail this gate at CI time, before the bad code hits
main.
Why an AST check, not a runtime invocation:
The dispatcher is a long if/elif chain. Runtime invocation would
need to mock every inner tool, then call the dispatcher with each
name and assert the kwargs were forwarded. That's exactly what
``test_a2a_mcp_server.py::test_dispatch_*_forwards_source_workspace_id``
already does for the four tools we explicitly tested. This gate is
cheaper (~1ms) and catches the structural drift before someone has
to remember to write the runtime test for each new property.
"""
from __future__ import annotations
import ast
from pathlib import Path
import pytest
_DISPATCHER_PATH = (
Path(__file__).resolve().parents[1] / "a2a_mcp_server.py"
)
def _load_dispatch_arms() -> dict[str, ast.If]:
"""Parse ``a2a_mcp_server.py`` and return a mapping of tool name
→ the AST node for its ``elif name == "<tool_name>"`` arm.
Walks the body of ``handle_tool_call`` and matches each If/elif
branch whose test compares ``name`` against a string literal.
"""
source = _DISPATCHER_PATH.read_text()
tree = ast.parse(source)
# Find handle_tool_call (sync def doesn't matter — same shape).
handle_fn: ast.AsyncFunctionDef | None = None
for node in ast.walk(tree):
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)) and node.name == "handle_tool_call":
handle_fn = node # type: ignore[assignment]
break
assert handle_fn is not None, "handle_tool_call not found in a2a_mcp_server.py"
arms: dict[str, ast.If] = {}
def _walk_if_chain(if_node: ast.If) -> None:
# Each If has a `test` like `name == "delegate_task"` and may
# carry an `orelse` that is either another If (elif) or a final
# else block.
test = if_node.test
if (
isinstance(test, ast.Compare)
and len(test.ops) == 1
and isinstance(test.ops[0], ast.Eq)
and isinstance(test.left, ast.Name)
and test.left.id == "name"
and len(test.comparators) == 1
and isinstance(test.comparators[0], ast.Constant)
and isinstance(test.comparators[0].value, str)
):
arms[test.comparators[0].value] = if_node
if len(if_node.orelse) == 1 and isinstance(if_node.orelse[0], ast.If):
_walk_if_chain(if_node.orelse[0])
for stmt in handle_fn.body:
if isinstance(stmt, ast.If):
_walk_if_chain(stmt)
break # Only the top-level if/elif chain matters.
return arms
def _extract_arguments_get_keys(arm: ast.If) -> set[str]:
"""Return every string literal passed as the first positional arg to
a call shaped like ``arguments.get("X", ...)`` inside this arm's body.
These represent the schema-property names this dispatch arm reads.
A property declared in ``input_schema`` but NOT pulled by an
``arguments.get(...)`` call here is the drift the gate catches.
"""
keys: set[str] = set()
class _Visitor(ast.NodeVisitor):
def visit_Call(self, node: ast.Call) -> None:
# arguments.get("foo", ...) / arguments.get("foo")
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr == "get"
and isinstance(func.value, ast.Name)
and func.value.id == "arguments"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
keys.add(node.args[0].value)
self.generic_visit(node)
visitor = _Visitor()
# Walk only the body (not the test or orelse) so nested elifs don't
# bleed their keys upward.
for stmt in arm.body:
visitor.visit(stmt)
return keys
def _registry_tool_schemas() -> dict[str, dict]:
"""Return a mapping of ToolSpec.name → ``input_schema.properties``
dict. Imports the registry module so this gate stays in sync with
whatever the registry exposes (no manual list to update)."""
from platform_tools import registry
out: dict[str, dict] = {}
for spec in registry.TOOLS:
schema = spec.input_schema or {}
props = schema.get("properties") or {}
out[spec.name] = props
return out
# ---------------------------------------------------------------------------
# The actual gate
# ---------------------------------------------------------------------------
def test_every_dispatch_arm_reads_every_schema_property():
"""Schema↔dispatcher drift gate. PR #2766 → PR #2771 cycle protection.
Walks every ToolSpec in the registry, finds its dispatch arm in
``a2a_mcp_server.handle_tool_call``, and asserts that every property
name declared in ``input_schema.properties`` is read by an
``arguments.get("<name>", ...)`` call inside that arm.
Failure mode the gate prevents: a new schema property advertised to
the LLM but silently dropped by the dispatcher (the exact PR #2766
bug — schema said ``source_workspace_id`` was a valid param,
dispatcher ignored it, every call fell back to ``WORKSPACE_ID``).
"""
arms = _load_dispatch_arms()
schemas = _registry_tool_schemas()
failures: list[str] = []
for tool_name, props in schemas.items():
if tool_name not in arms:
# Tool registered but not dispatched — the registry's
# ``ALL_SPECS`` is the canonical list of MCP-exposed tools,
# so a missing arm IS a bug. Surface it clearly.
failures.append(
f"Tool {tool_name!r} is registered in platform_tools.registry "
f"but has no dispatch arm in a2a_mcp_server.handle_tool_call. "
f"LLM clients will receive 'Unknown tool' for every call."
)
continue
arm = arms[tool_name]
read_keys = _extract_arguments_get_keys(arm)
declared_keys = set(props.keys())
missing = declared_keys - read_keys
if missing:
failures.append(
f"Tool {tool_name!r} declares schema properties "
f"{sorted(missing)} that the dispatch arm in "
f"a2a_mcp_server.handle_tool_call does NOT read via "
f"arguments.get(). The schema is lying — LLMs will pass "
f"these parameters and the dispatcher will silently drop "
f"them. (See PR #2766 → PR #2771 for the prior incident.)"
)
if failures:
pytest.fail("\n\n".join(failures))
def test_dispatch_arms_reach_every_registered_tool():
"""Inverse direction: every dispatched tool name corresponds to a
registered ToolSpec. Catches a dispatch arm for a tool that was
removed from the registry (would still serve, but the schema /
docs / wrappers wouldn't know about it).
"""
arms = _load_dispatch_arms()
schemas = _registry_tool_schemas()
orphan_arms = set(arms.keys()) - set(schemas.keys())
if orphan_arms:
pytest.fail(
f"Dispatch arms for {sorted(orphan_arms)} have no matching "
f"ToolSpec in platform_tools.registry. Either remove the arm "
f"or re-register the ToolSpec — keeping a dispatched-but-"
f"unregistered tool means the schema, docs, and LangChain "
f"wrappers all silently disagree with what the MCP server "
f"actually exposes."
)
def test_drift_gate_self_check_finds_known_arms():
"""Sanity: if the AST parsing is wrong (e.g. handle_tool_call
refactored into a dict-dispatch), this test catches it. Pin the
minimum-known set of dispatch arms — at least the 9 workspace-
scoped tools shipped through PR #2766 and #2771 must be present.
Without this, a refactor that breaks _load_dispatch_arms returns
{} silently, and the main gate vacuously passes.
"""
arms = _load_dispatch_arms()
expected_minimum = {
"delegate_task",
"delegate_task_async",
"check_task_status",
"send_message_to_user",
"list_peers",
"get_workspace_info",
"commit_memory",
"recall_memory",
"chat_history",
"wait_for_message",
"inbox_peek",
"inbox_pop",
}
missing = expected_minimum - set(arms.keys())
assert not missing, (
f"AST gate failed self-check: dispatch arms {sorted(missing)} "
f"weren't recognised by _load_dispatch_arms. Likely cause: "
f"handle_tool_call was refactored into a different shape (dict "
f"dispatch, registry-driven, etc.). Update this test's parser "
f"so the main schema-drift gate still works."
)
+8 -67
View File
@@ -254,33 +254,14 @@ def test_delegation_failure_section_always_present(tmp_path):
assert "Retry transient failures" in result
def test_parent_context_injection(tmp_path):
"""parent_context creates a '## Parent Context' section with file contents."""
(tmp_path / "system-prompt.md").write_text("Base.")
def test_no_parent_context_section_after_shared_context_removal(tmp_path):
"""Drop-shared_context regression gate: build_system_prompt must NOT
emit a '## Parent Context' section, since parent→child knowledge sharing
now flows through memory v2's team:<id> namespace via recall_memory.
parent_context = [
{"path": "guidelines.md", "content": "Always use type hints."},
{"path": "architecture.md", "content": "We use hexagonal architecture."},
]
result = build_system_prompt(
config_path=str(tmp_path),
workspace_id="ws-1",
loaded_skills=[],
peers=[],
parent_context=parent_context,
)
assert "## Parent Context" in result
assert "shared by your parent workspace" in result
assert "### guidelines.md" in result
assert "Always use type hints." in result
assert "### architecture.md" in result
assert "We use hexagonal architecture." in result
def test_parent_context_empty(tmp_path):
"""No '## Parent Context' section when parent_context is an empty list."""
The previous parent_context= kwarg was removed wholesale; if anyone
re-introduces a path that injects parent files at boot, this gate
fails so the regression is visible in CI."""
(tmp_path / "system-prompt.md").write_text("Base.")
result = build_system_prompt(
@@ -288,50 +269,10 @@ def test_parent_context_empty(tmp_path):
workspace_id="ws-1",
loaded_skills=[],
peers=[],
parent_context=[],
)
assert "## Parent Context" not in result
def test_parent_context_none(tmp_path):
"""No '## Parent Context' section when parent_context is None."""
(tmp_path / "system-prompt.md").write_text("Base.")
result = build_system_prompt(
config_path=str(tmp_path),
workspace_id="ws-1",
loaded_skills=[],
peers=[],
parent_context=None,
)
assert "## Parent Context" not in result
def test_parent_context_skips_empty_content(tmp_path):
"""Files with empty/whitespace-only content are skipped."""
(tmp_path / "system-prompt.md").write_text("Base.")
parent_context = [
{"path": "empty.md", "content": ""},
{"path": "whitespace.md", "content": " \n "},
{"path": "real.md", "content": "Real content here."},
]
result = build_system_prompt(
config_path=str(tmp_path),
workspace_id="ws-1",
loaded_skills=[],
peers=[],
parent_context=parent_context,
)
assert "## Parent Context" in result
assert "### empty.md" not in result
assert "### whitespace.md" not in result
assert "### real.md" in result
assert "Real content here." in result
assert "shared by your parent workspace" not in result
# ---------------------------------------------------------------------------