Compare commits

..

1 Commits

Author SHA1 Message Date
core-devops ec53cac4a1 fix(watchdog): add HEAD-recheck + settling delay to suppress cancel-cascade false-positives
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 9s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 25s
CI / Python Lint & Test (pull_request) Successful in 13s
E2E API Smoke Test / detect-changes (pull_request) Successful in 18s
E2E Chat / detect-changes (pull_request) Successful in 12s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m38s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
CI / Platform (Go) (pull_request) Successful in 4m41s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
gate-check-v3 / gate-check (pull_request) Successful in 7s
qa-review / approved (pull_request) Successful in 4s
security-review / approved (pull_request) Successful in 4s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 3s
sop-checklist / review-refire (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 5s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m13s
CI / Canvas (Next.js) (pull_request) Successful in 5m54s
CI / all-required (pull_request) Successful in 5m32s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m38s
E2E Chat / E2E Chat (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8s
Harness Replays / Harness Replays (pull_request) Successful in 2s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m39s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Successful in 6s
Adds a 90s settling window + HEAD-recheck before filing `[main-red]`
issues. After initial red detection, the watchdog now:

  1. Re-fetches HEAD SHA — if main moved on (new commit landed
     mid-tick), skip-file and let the next cron tick re-evaluate.
  2. Re-fetches combined status on the same SHA — if it recovered
     (transient cancel-cascade rolled forward to success on retry),
     skip-file.

Both skip paths emit distinct Loki events
(`main_red_skipped_head_drift`, `main_red_skipped_recovered`) so obs
queries can track filter activity vs the genuine `main_red_detected`
path.

Background — 7 false-positive `[main-red]` issues filed in 24h
(mc#1597, #1605, #1609, #1613, #1626, #1627, #1630), all closed in
triage 2026-05-21 04:55 as 6 cancel-cascade + 1 emission artifact,
zero real regressions.

Empirical 7-day DB sweep on 2026-05-20 showed that of 702
`action_run.status=3` (Cancelled) entries that wrote a
`commit_status.state='failure'` row, only 76 (~11%) carried
description=`'Has been cancelled'` (the existing mc#1564 filter's
match string). 89% used `'Failing after Ns'`, indistinguishable from
real `status=2` (Failure) at the commit-status layer.

The canonical filter (only file when `action_run.status=2`) is not
reachable from a Gitea Actions runner — Gitea 1.22.6 exposes no REST
endpoint for `action_run.status` (probed empirically:
`/api/v1/.../actions/runs/{id}`, `/jobs/{id}`, `/tasks/{id}` all
return HTTP 404; swagger.v1.json contains no read endpoints for
action runs). The SPA backend requires a session CSRF token, and DB
access (`mol_action_status`, `docker exec ... psql`) lives only on
the operator host. The HEAD-recheck is the strongest signal a runner
can produce without that endpoint, and it caught all 7 of the
mc#1597..1630 false-positives in offline replay (HEAD had moved past
each of the 7 SHAs by the time the issue filed).

The PR also adds `_resolve_action_run_status(target_url)` as an
extensibility hook returning None today; when Gitea >=1.23 or an
op-host proxy exposes the status endpoint, the function body can be
filled in without changing callers.

Tests (4 new + 1 hook test):
  - `test_head_recheck_skips_file_when_head_moved` —
    SHA_A initial → SHA_B on recheck → no POST.
  - `test_head_recheck_skips_file_when_recheck_status_recovered` —
    failure → success on same SHA → no POST.
  - `test_head_recheck_files_when_still_red_after_settling` —
    over-filter regression guard: persistent red MUST file.
  - `test_head_recheck_skips_when_initial_was_only_cancel_cascade` —
    cancel-cascade filter ordering guard.
  - `test_resolve_action_run_status_returns_none_on_no_endpoint` —
    pins the extensibility-hook return contract.

An autouse `_stub_time_sleep` fixture is added so the existing
integration-style tests (`test_red_detected_opens_issue` et al.) don't
each block 90s on the new sleep call — pre-fix suite ran ~0.1s; with
the bare implementation it took >4 minutes (the stub keeps the suite
fast and deterministic without per-test patching).

41 tests pass in 0.15s. `--dry-run` against live Gitea returns the
correct PENDING/no-action result on the current `main` head.

References:
  - reference_chronic_red_sweep_cancelled_vs_failed_filter
  - feedback_gitea_status_enum_use_helper_not_raw_int
  - reference_gitea_action_status_enum_corrected_2026_05_19
  - feedback_dispatch_investigation_and_fix_default_dont_ask
  - feedback_decide_routine_prod_ops_no_go_ask

Task: #394
Closes: mc#1597, #1605, #1609, #1613, #1626, #1627, #1630 (already
manually closed in triage; this PR is the structural fix that
prevents the recurrence class)
2026-05-20 22:25:07 -07:00
125 changed files with 1050 additions and 7852 deletions
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""Shared path-filter helper for Gitea Actions workflows.
Computes changed files against the PR base SHA or push-before SHA and writes
boolean outputs to GITHUB_OUTPUT. If the diff base is missing or untrusted, the
helper fails open by setting every output in the selected profile to true.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
PROFILES: dict[str, dict[str, str]] = {
"ci": {
"platform": r"^workspace-server/",
"canvas": r"^canvas/",
"python": r"^workspace/",
"scripts": r"^tests/e2e/|^scripts/|^infra/scripts/",
},
"handlers-postgres": {
"handlers": (
r"^workspace-server/internal/handlers/"
r"|^workspace-server/internal/wsauth/"
r"|^workspace-server/migrations/"
r"|^\.gitea/workflows/handlers-postgres-integration\.yml$"
),
},
"e2e-api": {
"api": r"^workspace-server/|^tests/e2e/|^\.gitea/workflows/e2e-api\.yml$",
},
}
def classify(profile: str, paths: list[str]) -> dict[str, bool]:
patterns = PROFILES[profile]
return {
name: any(re.search(pattern, path) for path in paths)
for name, pattern in patterns.items()
}
def all_true(profile: str) -> dict[str, bool]:
return {name: True for name in PROFILES[profile]}
def resolve_base(event_name: str, pr_base_sha: str, push_before: str) -> str:
if event_name == "pull_request" and pr_base_sha:
return pr_base_sha
return push_before
def is_zero_sha(value: str) -> bool:
return not value or bool(re.fullmatch(r"0+", value))
def run_git(args: list[str], *, timeout: int = 30) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
check=False,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
def base_exists(base: str) -> bool:
return run_git(["cat-file", "-e", base]).returncode == 0
def fetch_base(base: str, base_ref: str) -> None:
# Gitea may reject fetching an arbitrary unadvertised SHA from a shallow
# PR checkout. Fetch the advertised base branch first, then fall back to
# the SHA for hosts that allow it.
if base_ref:
run_git(["fetch", "--depth=1", "origin", base_ref])
if not base_exists(base):
run_git(["fetch", "--depth=1", "origin", base])
def deepen_base_ref(base_ref: str) -> None:
if base_ref:
run_git(["fetch", "--deepen=200", "origin", base_ref], timeout=60)
def merge_base(base: str) -> str | None:
proc = run_git(["merge-base", base, "HEAD"])
if proc.returncode != 0:
return None
value = proc.stdout.strip()
return value or None
def changed_paths(base: str, *, use_merge_base: bool) -> list[str] | None:
compare_base = base
if use_merge_base:
compare_base = merge_base(base) or ""
if not compare_base:
return None
proc = run_git(["diff", "--name-only", compare_base, "HEAD"])
if proc.returncode != 0:
return None
return [line for line in proc.stdout.splitlines() if line]
def write_outputs(values: dict[str, bool], output_path: str | None) -> None:
lines = [f"{name}={'true' if value else 'false'}" for name, value in values.items()]
if output_path:
with Path(output_path).open("a", encoding="utf-8") as fh:
for line in lines:
fh.write(line + "\n")
else:
for line in lines:
print(line)
def detect(
profile: str,
event_name: str,
pr_base_sha: str,
push_before: str,
base_ref: str = "",
) -> dict[str, bool]:
base = resolve_base(event_name, pr_base_sha, push_before)
if is_zero_sha(base):
return all_true(profile)
if not base_exists(base):
fetch_base(base, base_ref)
if not base_exists(base):
return all_true(profile)
use_merge_base = event_name == "pull_request"
if use_merge_base and base_ref and merge_base(base) is None:
deepen_base_ref(base_ref)
paths = changed_paths(base, use_merge_base=use_merge_base)
if paths is None:
return all_true(profile)
return classify(profile, paths)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--profile", required=True, choices=sorted(PROFILES))
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
parser.add_argument("--pr-base-sha", default="")
parser.add_argument("--base-ref", default="")
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
values = detect(
args.profile,
args.event_name,
args.pr_base_sha,
args.push_before,
args.base_ref,
)
write_outputs(values, os.environ.get("GITHUB_OUTPUT"))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+8 -9
View File
@@ -104,13 +104,10 @@ if [ "${SOP_REFIRE_DISABLE_RATE_LIMIT:-}" != "1" ]; then
fi
fi
# 3. Invoke sop-tier-check.sh with the env it expects.
# The canonical workflow intentionally fail-opens the job conclusion
# (`bash .gitea/scripts/sop-tier-check.sh || true`) while Gitea branch
# protection enforces reviewer approvals separately. Keep the refire path
# aligned with that workflow status behavior; otherwise /refire-tier-check can
# post a hard failure that the canonical pull_request_target workflow would
# not publish.
# 3. Invoke sop-tier-check.sh with the env it expects. Capture exit code.
# The canonical script reads tier label, walks approving reviewers, and
# evaluates the AND-composition expression — we want the SAME gate, not
# a different gate.
#
# SOP_REFIRE_TIER_CHECK_SCRIPT env var lets tests substitute a mock —
# sop-tier-check.sh uses bash 4+ associative arrays which trigger a known
@@ -126,6 +123,7 @@ fi
# Re-invoke. Pipe stdout/stderr through so the runner log shows the
# tier-check decision inline.
set +e
GITEA_TOKEN="$GITEA_TOKEN" \
GITEA_HOST="$GITEA_HOST" \
REPO="$REPO" \
@@ -133,8 +131,9 @@ GITEA_TOKEN="$GITEA_TOKEN" \
PR_AUTHOR="$PR_AUTHOR" \
SOP_DEBUG="${SOP_DEBUG:-0}" \
SOP_LEGACY_CHECK="${SOP_LEGACY_CHECK:-0}" \
bash "$SCRIPT" || true
TIER_EXIT=0
bash "$SCRIPT"
TIER_EXIT=$?
set -e
debug "sop-tier-check.sh exit=$TIER_EXIT"
# 4. POST the resulting status.
+3 -33
View File
@@ -47,9 +47,7 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
Parse context as `<workflow_name> / <job_name> (push)`.
Look up workflow_name in the trigger map:
- missing → log ::notice:: and skip (conservative).
- has_push_trigger=True and description == "Has been cancelled"
→ compensate cancelled/superseded push noise.
- has_push_trigger=True otherwise → preserve (real defect signal).
- has_push_trigger=True → preserve (real defect signal).
- has_push_trigger=False → POST a compensating
`state=success` status to /statuses/{sha} with the same
context (Gitea de-dups by context) and a description
@@ -143,11 +141,6 @@ PR_SHADOW_COMPENSATION_DESCRIPTION = (
"shadowed by successful push status on same SHA; see "
".gitea/scripts/status-reaper.py)"
)
CANCELLED_PUSH_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (push run was cancelled/superseded; "
"Gitea 1.22.6 reports cancelled runs as failure statuses)"
)
CANCELLED_DESCRIPTION = "Has been cancelled"
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
# default-branch workflow runs.
@@ -483,7 +476,7 @@ def reap(
{compensated, preserved_real_push, preserved_unknown,
preserved_non_failure, preserved_non_push_suffix,
preserved_unparseable, compensated_pr_shadowed_by_push_success,
preserved_pr_without_push_success, compensated_cancelled_push,
preserved_pr_without_push_success,
compensated_contexts: [<context>, ...]}
`compensated_contexts` is rev2-added so `reap_branch` can build
@@ -497,7 +490,6 @@ def reap(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_contexts": [],
}
@@ -575,27 +567,8 @@ def reap(
counters["preserved_unknown"] += 1
continue
if (s.get("description") or "").strip() == CANCELLED_DESCRIPTION:
# Gitea 1.22.6 maps cancelled action runs to failure commit
# statuses. During merge bursts, older push runs can be
# superseded and cancelled even though a newer run for the
# same branch is the real signal. Compensate only the exact
# Gitea cancellation description; real push failures remain red.
post_compensating_status(
sha,
context,
s.get("target_url"),
description=CANCELLED_PUSH_COMPENSATION_DESCRIPTION,
dry_run=dry_run,
)
counters["compensated"] += 1
counters["compensated_cancelled_push"] += 1
counters["compensated_contexts"].append(context)
continue
if workflow_trigger_map[workflow_name]:
# Real push trigger with a non-cancelled failure description
# remains a defect signal. Preserve.
# Real push trigger → real defect signal. Preserve.
counters["preserved_real_push"] += 1
continue
@@ -701,7 +674,6 @@ def reap_branch(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
"skipped": True,
@@ -717,7 +689,6 @@ def reap_branch(
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"compensated_cancelled_push": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
}
@@ -757,7 +728,6 @@ def reap_branch(
"preserved_non_push_suffix",
"preserved_unparseable",
"compensated_pr_shadowed_by_push_success",
"compensated_cancelled_push",
"preserved_pr_without_push_success",
):
aggregate[key] += per_sha[key]
+30 -18
View File
@@ -6,10 +6,9 @@
# T1: PR open + APPROVED via tier:low → script invokes sop-tier-check
# and POSTs status=success.
# T2: PR open + missing tier label → sop-tier-check exits non-zero;
# refire still POSTs status=success, matching the canonical
# pull_request_target workflow's fail-open job conclusion.
# refire POSTs status=failure (description mentions failure).
# T3: PR open + tier:low but NO approving reviews → sop-tier-check
# exits non-zero; refire still POSTs status=success for the same reason.
# exits non-zero; refire POSTs status=failure.
# T4: PR CLOSED → refire exits 0 with no status POST (no-op on closed).
# T5: Rate-limit — recent status update within 30s → refire skips,
# no new POST.
@@ -33,7 +32,7 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/sop-checklist.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0
@@ -89,7 +88,7 @@ assert_file_exists() {
echo
echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "SSOT dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then
echo
@@ -134,15 +133,15 @@ else
fi
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
assert_eq "T6e SSOT dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
assert_contains "T6f SSOT dispatcher listens on issue_comment" \
assert_contains "T6f dispatcher listens on issue_comment" \
"issue_comment" "$DISPATCH_CONTENT"
assert_contains "T6g SSOT dispatcher handles /qa-recheck" \
assert_contains "T6g dispatcher handles /qa-recheck" \
"/qa-recheck" "$DISPATCH_CONTENT"
assert_contains "T6h SSOT dispatcher handles /security-recheck" \
assert_contains "T6h dispatcher handles /security-recheck" \
"/security-recheck" "$DISPATCH_CONTENT"
assert_contains "T6i SSOT dispatcher handles /refire-tier-check" \
assert_contains "T6i dispatcher handles /refire-tier-check" \
"/refire-tier-check" "$DISPATCH_CONTENT"
# T1-T5 — script behavior against a local Gitea-fixture
@@ -246,21 +245,34 @@ assert_contains "T1 POST context is sop-tier-check / tier-check" \
'"context": "sop-tier-check / tier-check (pull_request)"' "$POSTED"
assert_contains "T1 description names commenter" "test-runner" "$POSTED"
# T2: missing tier label → tier-check fails internally, but refire status
# matches the canonical workflow's fail-open job conclusion.
# T2: missing tier label → tier-check fails → failure status POSTed
run_scenario "T2_no_tier_label" "fail_no_label"
RC=$(cat "$FIX_STATE_DIR/last_rc")
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
assert_eq "T2 exit code 0 (canonical fail-open)" "0" "$RC"
assert_contains "T2 POSTed state=success" '"state": "success"' "$POSTED"
# tier-check.sh exits 1; refire script forwards that exit, so RC != 0
if [ "$RC" -ne 0 ]; then
echo " PASS T2 exit code non-zero (got $RC)"
PASS=$((PASS + 1))
else
echo " FAIL T2 exit code should be non-zero, got 0"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T2_rc"
fi
assert_contains "T2 POSTed state=failure" '"state": "failure"' "$POSTED"
# T3: tier:low present but ZERO approving reviews → internal tier check fails,
# refire status remains aligned with the canonical workflow.
# T3: tier:low present but ZERO approving reviews → failure
run_scenario "T3_no_approvals" "fail_no_approvals"
RC=$(cat "$FIX_STATE_DIR/last_rc")
POSTED=$(cat "$FIX_STATE_DIR/posted_statuses.jsonl" 2>/dev/null || true)
assert_eq "T3 exit code 0 (canonical fail-open)" "0" "$RC"
assert_contains "T3 POSTed state=success" '"state": "success"' "$POSTED"
if [ "$RC" -ne 0 ]; then
echo " PASS T3 exit code non-zero (got $RC)"
PASS=$((PASS + 1))
else
echo " FAIL T3 exit code should be non-zero, got 0"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T3_rc"
fi
assert_contains "T3 POSTed state=failure" '"state": "failure"' "$POSTED"
# T4: closed PR — refire is a no-op (no POST, exit 0)
run_scenario "T4_closed" "pass"
-186
View File
@@ -1,186 +0,0 @@
# ci-arm64-advisory — Mac arm64 self-hosted ADVISORY fast-check lane.
#
# === WHY ===
#
# The amd64 Gitea runner pool (molecule-runner-1..20) is queue-contended
# (internal#418). This lane offloads the *genuinely container-independent*
# fast checks (Go build/vet/lint, shellcheck, Python lint) onto the Mac
# arm64 self-hosted runner so developers get a fast arm64 signal WITHOUT
# adding load to the starved amd64 pool — capability-honestly, as an
# additive pilot. Pilot ② of the Mac-CI strategy (CTO-delegated 2026-05-17).
#
# === NON-NEGOTIABLE SAFETY CONTRACT (the prime directive) ===
#
# This lane is **ADVISORY ONLY**. It is provably incapable of hanging a
# merge. Concretely:
#
# 1. It is a SEPARATE workflow file. `ci.yml` is byte-for-byte
# untouched by this PR. The `CI / all-required` aggregator sentinel
# and the five contexts it polls
# (`CI / Detect changes|Platform (Go)|Canvas (Next.js)|
# Shellcheck (E2E scripts)|Python Lint & Test (pull_request)`)
# are unchanged. The canonical required gate stays 100% on the
# existing amd64 pool.
#
# 2. The context this workflow emits is
# `ci-arm64-advisory / fast-checks (pull_request)`. That string is
# DELIBERATELY NOT present in, and this PR does NOT add it to:
# - branch_protections/{main,staging}.status_check_contexts
# (DB-verified pb 86/75 = exactly
# ["CI / all-required (pull_request)",
# "sop-checklist / all-items-acked (pull_request)"])
# - audit-force-merge.yml REQUIRED_CHECKS env
# - ci.yml `all-required` sentinel's hardcoded `required[]` list
# Branch protection therefore never waits on this context. If the
# Mac runner is absent / offline / removed, this workflow's status
# simply never appears — and because nothing requires it, every
# merge proceeds exactly as it does today. There is no path by
# which a missing/red arm64 status blocks a merge.
#
# 3. `continue-on-error: true` on the job — even a genuine arm64-only
# failure (toolchain drift, arch-specific test flake) is surfaced
# as information, never as a merge blocker, for the duration of
# the pilot.
#
# 4. The job carries a `github.event_name` `if:` gate. Beyond its
# functional purpose this also keeps the job OUT of
# `ci-required-drift.py:ci_job_names()` (which excludes
# `github.event_name`/`github.ref`-gated jobs), so the hourly
# ci-required-drift sentinel's F1 ("job not under sentinel needs")
# cannot ever flag this advisory job. F2/F3 are untouched because
# this context is absent from BP and from REQUIRED_CHECKS.
# `lint-bp-context-emit-match` only fails on BP→emitter gaps; an
# emitter without a BP context is explicitly informational there.
#
# === RUNNER TARGETING ===
#
# The Mac runner is `hongming-pc-runner-1`. The bare `self-hosted`
# label is POLLUTED in this Gitea instance: molecule-runner-1..20
# (the contended amd64 pool) also advertise `self-hosted`. Targeting
# bare `self-hosted` would route back onto the very pool we are trying
# to relieve — and onto amd64 hardware. We therefore require an
# AND-set of labels that ONLY the Mac satisfies. `macos-self-hosted`
# is Mac-exclusive (the amd64 pool does not carry it). Until the
# label-install burst (a10862b2) lands `self-hosted`+`macos-self-hosted`
# on the Mac, the runner's current unique label `hongming-pc-laptop`
# is also listed; AND-semantics over the labels a runner advertises
# means a job requiring [self-hosted, macos-self-hosted] can ONLY be
# claimed once the Mac advertises both. If neither label set is yet
# present on the Mac, the workflow stays queued harmlessly and is
# garbage-collected by the normal stale-run reaper — it blocks nothing
# (see safety contract point 2).
#
# === ROLLBACK ===
#
# Delete this single file (`git rm .gitea/workflows/ci-arm64-advisory.yml`)
# and merge. No branch-protection edit, no ci.yml edit, no
# REQUIRED_CHECKS edit is required to roll back, because none were made
# to roll forward. Zero blast radius either direction.
name: ci-arm64-advisory
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
# Per-ref cancel: a newer commit on the same ref supersedes the older
# advisory run. Distinct from ci.yml's `ci-${ref}` group so this lane
# never cancels (or is cancelled by) the canonical required CI.
concurrency:
group: ci-arm64-advisory-${{ github.ref }}
cancel-in-progress: true
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
fast-checks:
name: fast-checks
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
runs-on: [self-hosted, macos-self-hosted]
# ADVISORY: never blocks. See safety contract point 3.
continue-on-error: true
# event_name gate: functional (only meaningful on push/PR) AND keeps
# this job out of ci-required-drift.py:ci_job_names() so F1 can never
# flag it. See safety contract point 4.
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
timeout-minutes: 20
steps:
- name: Provenance — advisory lane, non-gating
run: |
echo "This is the arm64 ADVISORY fast-check lane."
echo "It does NOT gate merges. Canonical required CI is ci.yml"
echo "on the amd64 pool. Arch: $(uname -m) on $(uname -s)."
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ---- Go: build + vet + lint (container-independent: needs only the
# Go toolchain; no amd64 ECR image, no docker-in-job). Race-detector
# unit-test + coverage gates are deliberately NOT duplicated here —
# those stay authoritative on amd64 ci.yml `Platform (Go)`. This lane
# is fast-feedback for the compile/vet/lint surface only. ----
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- name: Go build + vet (workspace-server)
working-directory: workspace-server
run: |
go mod download
go build ./cmd/server
go vet ./...
- name: golangci-lint (workspace-server)
working-directory: workspace-server
run: |
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
"$(go env GOPATH)/bin/golangci-lint" run --timeout 3m ./...
# ---- Shellcheck (container-independent: shellcheck binary only).
# Mirrors ci.yml `Shellcheck (E2E scripts)` bulk pass scope. ----
- name: Install shellcheck (arm64)
run: |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck not preinstalled on this self-hosted runner."
echo "Attempting Homebrew install (Mac arm64)."
brew install shellcheck || {
echo "::warning::shellcheck unavailable on runner; advisory shellcheck skipped."
exit 0
}
fi
shellcheck --version
- name: Shellcheck tests/e2e + infra/scripts
run: |
command -v shellcheck >/dev/null 2>&1 || { echo "skip"; exit 0; }
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
# ---- Python lint/compile (container-independent: CPython only).
# Lint + import-compile surface; the authoritative pytest + coverage
# floors stay on amd64 ci.yml `Python Lint & Test`. ----
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
- name: Python byte-compile (workspace)
working-directory: workspace
run: |
python -m pip install --quiet ruff || true
python -m compileall -q .
if command -v ruff >/dev/null 2>&1; then
ruff check . || echo "::warning::ruff findings (advisory only)"
fi
- name: Advisory summary
if: always()
run: |
{
echo "## arm64 advisory fast-checks complete"
echo ""
echo "This lane is **advisory** — it does not gate merges."
echo "Authoritative required CI remains \`CI / all-required\`"
echo "on the amd64 pool (\`ci.yml\`, unchanged by this PR)."
} >> "$GITHUB_STEP_SUMMARY"
+73 -47
View File
@@ -86,25 +86,53 @@ jobs:
with:
fetch-depth: 0
- id: check
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
python3 .gitea/scripts/detect-changes.py \
--profile ci \
--event-name "${{ github.event_name }}" \
--pr-base-sha "$PR_BASE_SHA" \
--base-ref "$PR_BASE_REF" \
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
# For PR events: diff against the base branch (not HEAD~1 of the branch,
# which may be unrelated after force-pushes). When a push updates a PR,
# both pull_request and push events fire — prefer the PR base so that
# the diff is always computed against the actual merge base, not the
# previous SHA on the branch which may be on a different history line.
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
# GITHUB_BASE_REF is set for PR events (the base branch name).
# For pull_request events we use the stored base.sha; for push events
# (or when base.sha is unavailable) fall back to github.event.before.
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
# Fallback: if BASE is empty or all zeros (new branch), run everything
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Workflow-only edits are covered by the workflow lint family
# and by this workflow's always-present required jobs. Do not fan
# those edits out into Go/Canvas/Python/shellcheck work; the
# downstream jobs still emit their required contexts via no-op
# steps when their surface flag is false.
#
# If the diff itself cannot be trusted, fail open by running every
# surface instead of silently under-testing the PR.
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The job always
# emits the required context, but expensive steps are path-scoped on every
# event so docs/E2E/Canvas-only main pushes do not block deploy on unrelated
# Go bootstrap work.
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
# contract (so when this Gitea port becomes a required check in Phase 4,
# the name match works on PRs that don't touch workspace-server/).
platform-build:
name: Platform (Go)
needs: changes
runs-on: ubuntu-latest
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
@@ -125,29 +153,29 @@ jobs:
run:
working-directory: workspace-server
steps:
- if: ${{ needs.changes.outputs.platform != 'true' }}
- if: false
working-directory: .
run: echo "No workspace-server/** changes — Platform (Go) gate satisfied without running Go build/test/lint."
- if: ${{ needs.changes.outputs.platform == 'true' }}
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
run: go mod download
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
run: go vet ./...
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -163,7 +191,7 @@ jobs:
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 10m per-step timeout
@@ -171,7 +199,7 @@ jobs:
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst
@@ -185,7 +213,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: ${{ needs.changes.outputs.platform == 'true' }}
- if: always()
name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
@@ -273,7 +301,6 @@ jobs:
# siblings — verified empirically on PR #2314).
canvas-build:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
@@ -282,20 +309,20 @@ jobs:
run:
working-directory: canvas
steps:
- if: ${{ needs.changes.outputs.canvas != 'true' }}
- if: false
working-directory: .
run: echo "No canvas/** changes — Canvas (Next.js) gate satisfied without running npm build/test."
- if: ${{ needs.changes.outputs.canvas == 'true' }}
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ needs.changes.outputs.canvas == 'true' }}
- if: always()
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: ${{ needs.changes.outputs.canvas == 'true' }}
- if: always()
run: npm ci --include=optional --prefer-offline
- if: ${{ needs.changes.outputs.canvas == 'true' }}
- if: always()
run: npm run build
- if: ${{ needs.changes.outputs.canvas == 'true' }}
- if: always()
name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of
@@ -304,7 +331,7 @@ jobs:
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: ${{ needs.changes.outputs.canvas == 'true' }}
if: always()
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
@@ -318,19 +345,18 @@ jobs:
retention-days: 7
if-no-files-found: warn
# Shellcheck (E2E scripts) — required context, path-scoped heavy steps.
# Shellcheck (E2E scripts) — required check, always runs.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: ${{ needs.changes.outputs.scripts != 'true' }}
run: echo "No tests/e2e, scripts, or infra/scripts changes — Shellcheck gate satisfied without running script checks."
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: false
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
- if: always()
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: always()
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the
@@ -341,16 +367,16 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: always()
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: always()
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: always()
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
@@ -360,7 +386,7 @@ jobs:
run: |
bash scripts/test-promote-tenant-image.sh
- if: ${{ needs.changes.outputs.scripts == 'true' }}
- if: always()
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
+4 -15
View File
@@ -118,7 +118,7 @@ jobs:
timeout-minutes: 20
env:
# claude-code default: cold-start ~5 min (comparable to langgraph),
# but uses MiniMax-M2 via the template's third-party-
# but uses MiniMax-M2.7-highspeed via the template's third-party-
# Anthropic-compat path (workspace-configs-templates/claude-code-
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
@@ -131,9 +131,9 @@ jobs:
# on the per-runtime default ("sonnet" → routes to direct
# Anthropic, defeats the cost saving). Operators can override
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
# input if they need to exercise a specific model. MiniMax-M2 is the
# stable staging MiniMax path used by the full-SaaS smoke.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2' }}
# input if they need to exercise a specific model. M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
# Bound to 10 min so a stuck provision fails the run instead of
# holding up the next cron firing. 15-min default in the script
# is for the on-PR full lifecycle where we have more headroom.
@@ -145,11 +145,6 @@ jobs:
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax key is the canary's PRIMARY auth path. claude-code
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
@@ -190,12 +185,6 @@ jobs:
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
exit 1
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var secret missing — EC2 leak verification cannot run"
exit 1
fi
done
# LLM-key requirement is per-runtime: claude-code accepts
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
+24 -6
View File
@@ -132,13 +132,31 @@ jobs:
with:
fetch-depth: 0
- id: decide
# Inline replacement for dorny/paths-filter — same pattern PR#372's
# ci.yml port used. Diffs against the PR base or push BEFORE SHA,
# then matches against the api-relevant path set.
run: |
python3 .gitea/scripts/detect-changes.py \
--profile e2e-api \
--event-name "${{ github.event_name }}" \
--pr-base-sha "${{ github.event.pull_request.base.sha }}" \
--base-ref "${{ github.event.pull_request.base.ref }}" \
--push-before "${GITHUB_EVENT_BEFORE:-${{ github.event.before }}}"
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "api=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "api=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(workspace-server/|tests/e2e/|\.gitea/workflows/e2e-api\.yml$)'; then
echo "api=true" >> "$GITHUB_OUTPUT"
else
echo "api=false" >> "$GITHUB_OUTPUT"
fi
# ONE job (no job-level `if:`) that always runs and reports under the
# required-check name `E2E API Smoke Test`. Real work is gated per-step
-10
View File
@@ -44,8 +44,6 @@ name: E2E Peer Visibility (literal MCP list_peers)
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
# - 2026-05-21 retrigger: verify fresh platform-tenant image after the
# publish Buildx DOCKER_CONFIG fix restored staging-latest image updates.
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned
# (feedback_act_runner_github_server_url).
@@ -86,7 +84,6 @@ on:
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
@@ -99,7 +96,6 @@ on:
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- 'tests/e2e/test_peer_visibility_token_mint_staging.sh'
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
- 'tests/e2e/lib/peer_visibility_assert.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
@@ -139,14 +135,8 @@ jobs:
echo "lib/peer_visibility_assert.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_token_mint_staging.sh
echo "test_peer_visibility_token_mint_staging.sh — bash syntax OK"
bash -n tests/e2e/test_peer_visibility_mcp_local.sh
echo "test_peer_visibility_mcp_local.sh — bash syntax OK"
if rg -n '/admin/workspaces/.*/test-token|test-token' tests/e2e/test_*staging*.sh; then
echo "::error::staging E2E must not use dev-only /admin/workspaces/:id/test-token; use production-safe admin token minting instead"
exit 1
fi
echo "Staging fresh-provision MCP list_peers E2E runs on push to"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
echo "The LOCAL backend runs in the peer-visibility-local job"
+1 -16
View File
@@ -49,8 +49,6 @@ on:
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/provisioner/**'
- 'tests/e2e/test_staging_full_saas.sh'
- 'tests/e2e/lib/aws_leak_check.sh'
- 'tests/e2e/test_aws_leak_check.sh'
- '.gitea/workflows/e2e-staging-saas.yml'
pull_request:
branches: [main]
@@ -61,8 +59,6 @@ on:
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/provisioner/**'
- 'tests/e2e/test_staging_full_saas.sh'
- 'tests/e2e/lib/aws_leak_check.sh'
- 'tests/e2e/test_aws_leak_check.sh'
- '.gitea/workflows/e2e-staging-saas.yml'
workflow_dispatch:
schedule:
@@ -131,11 +127,6 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
# from hermes+OpenAI default after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
@@ -161,7 +152,7 @@ jobs:
# and defeats the cost saving. Operators can override via the
# workflow_dispatch flow (no input wired here yet — runtime
# override is enough for ad-hoc).
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2' }}
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
@@ -174,12 +165,6 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
echo "Admin token present ✓"
- name: Verify LLM key present
-11
View File
@@ -47,11 +47,6 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
E2E_MODE: smoke
E2E_RUNTIME: hermes
E2E_RUN_ID: "sanity-${{ github.run_id }}"
@@ -66,12 +61,6 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
# Inverted assertion: the run MUST fail. If it passes, the
# E2E_INTENTIONAL_FAILURE path is broken.
@@ -101,13 +101,36 @@ jobs:
# not present in the shallow checkout.
fetch-depth: 2
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
python3 .gitea/scripts/detect-changes.py \
--profile handlers-postgres \
--event-name "${{ github.event_name }}" \
--pr-base-sha "${{ github.event.pull_request.base.sha }}" \
--base-ref "${{ github.event.pull_request.base.ref }}" \
--push-before "${GITHUB_EVENT_BEFORE:-}"
# Gitea Actions evaluates github.event.before to empty string in shell
# scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea
# correctly populates it for push events). PR case uses template var.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# timeout 30 guards against the case where BASE points to a ref that
# git can resolve but cat-file hangs (rare on corrupted objects).
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(workspace-server/internal/handlers/|workspace-server/internal/wsauth/|workspace-server/migrations/|\.gitea/workflows/handlers-postgres-integration\.yml$)'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
else
echo "handlers=false" >> "$GITHUB_OUTPUT"
fi
# Single-job-with-per-step-if pattern: always runs to satisfy the
# required-check name on branch protection; real work gates on the
@@ -25,12 +25,8 @@ name: publish-workspace-server-image
# staging-<sha>. Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true
# to stop production rollout while keeping image publishing enabled.
#
# Primary ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Optional staging tenant mirror target:
# 004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
# Staging ECR grants the primary SSOT-managed publisher principal repository
# policy access, so no persistent staging AWS access keys are required.
#
# mc#711: Docker daemon not accessible on ubuntu-latest runner (molecule-canonical-1
# shows client-only in `docker info` — daemon not running). DinD mount is present but
@@ -69,7 +65,6 @@ env:
# use below in this repo's staging-verify.yml.
IMAGE_NAME: ${{ vars.ECR_REGISTRY || '153263036946.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform
TENANT_IMAGE_NAME: ${{ vars.ECR_REGISTRY || '153263036946.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform-tenant
STAGING_TENANT_IMAGE_NAME: ${{ vars.STAGING_ECR_REGISTRY || '004947743811.dkr.ecr.us-east-2.amazonaws.com' }}/molecule-ai/platform-tenant
jobs:
build-and-push:
@@ -140,18 +135,6 @@ jobs:
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Keep Buildx state inside the job temp dir. The publish runner's
# inherited DOCKER_CONFIG can point at a host-owned ECR config path
# (/home/hongming/.docker-ecr), which caused setup-buildx-action to
# fail before image build with EACCES creating buildx/certs.
- name: Prepare writable Docker config
run: |
set -euo pipefail
export DOCKER_CONFIG="$RUNNER_TEMP/docker-config"
mkdir -p "$DOCKER_CONFIG/buildx/certs"
echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV"
docker buildx version
# Build + push platform image (inline ECR auth — mirrors the operator-host
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
@@ -187,14 +170,9 @@ jobs:
--push .
# Build + push tenant image (Go platform + Next.js canvas in one image).
# Push the same build to the staging account too so fresh staging/E2E
# tenants can pull without cross-account ECR reads. The staging ECR repo
# policy trusts the primary SSOT-managed publisher principal; do not add
# separate persistent staging AWS access keys here.
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
STAGING_TENANT_IMAGE_NAME: ${{ env.STAGING_TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
@@ -205,19 +183,8 @@ jobs:
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
STAGING_ECR_REGISTRY="${STAGING_TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${STAGING_ECR_REGISTRY}"
build_tags=(
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_SHA}"
--tag "${STAGING_TENANT_IMAGE_NAME}:${TAG_LATEST}"
)
docker buildx build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
@@ -226,7 +193,8 @@ jobs:
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
"${build_tags[@]}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
--push .
# bp-exempt: production deploy side-effect; merge is gated by CI / all-required and this job waits for push CI before acting.
+3 -14
View File
@@ -81,11 +81,6 @@ jobs:
# (dead in org secret store) to CP_STAGING_ADMIN_API_TOKEN per
# internal#322 — see this PR for the cross-workflow sweep.
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
E2E_AWS_LEAK_CHECK: required
E2E_AWS_TERMINATE_LEAKS: '1'
# MiniMax is the smoke's PRIMARY LLM auth path post-2026-05-04.
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
# account went over quota and stayed dead for 36+ hours, taking
@@ -112,9 +107,9 @@ jobs:
E2E_RUNTIME: claude-code
# Pin the smoke to a specific MiniMax model rather than relying
# on the per-runtime default (which could resolve to "sonnet" →
# direct Anthropic and defeat the cost saving). MiniMax-M2 is the
# stable staging MiniMax path used by the full-SaaS smoke.
E2E_MODEL_SLUG: MiniMax-M2
# direct Anthropic and defeat the cost saving). M2.7-highspeed
# is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
E2E_RUN_ID: "smoke-${{ github.run_id }}"
# Debug-only: when an operator dispatches with keep_on_failure=true,
# the smoke script's E2E_KEEP_ORG=1 path skips teardown so the
@@ -134,12 +129,6 @@ jobs:
echo "::error::CP_STAGING_ADMIN_API_TOKEN not set"
exit 2
fi
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [ -z "${!var:-}" ]; then
echo "::error::$var not set — EC2 leak verification cannot run"
exit 2
fi
done
- name: Verify LLM key present
run: |
+20 -28
View File
@@ -40,12 +40,14 @@ name: Sweep stale AWS Secrets Manager secrets
# the mostly-orphan tunnels) refuses to nuke past the threshold.
on:
schedule:
# Hourly at :30, offset from sweep-cf-orphans (:15) and
# sweep-cf-tunnels (:45). This janitor is intentionally schedule-only
# for deletes; manual dispatch is forced to dry-run below because Gitea
# 1.22.6 rejects workflow_dispatch.inputs.
- cron: '30 * * * *'
# Disabled as an hourly schedule until the dedicated
# AWS_SECRETS_JANITOR_* key exists in the key-management SSOT and is
# mirrored into Gitea. Falling back to the molecule-cp app principal is
# intentionally not allowed: it lacks account-wide ListSecrets, and
# granting that to an application credential would weaken least privilege.
#
# Keep the manual trigger so operators can validate the workflow immediately
# after provisioning the janitor key, then restore the hourly :30 schedule.
workflow_dispatch:
# Don't let two sweeps race the same AWS account.
concurrency:
@@ -62,24 +64,22 @@ jobs:
sweep:
name: Sweep AWS Secrets Manager
runs-on: ubuntu-latest
# This is a cost/leak janitor. A scheduled failure must be red so
# operators know tenant bootstrap secrets may be leaking.
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
# under the 8-way xargs parallelism, but the cap is set generously
# to leave headroom for any actual API hang.
timeout-minutes: 30
env:
# Keep this literal. Gitea/act_runner 1.22.6 can mis-render
# secret-backed expressions with `||`, which produced an invalid
# Secrets Manager endpoint in the scheduled janitor.
AWS_REGION: us-east-2
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_SECRETS_JANITOR_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRETS_JANITOR_SECRET_ACCESS_KEY }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
MAX_DELETE_PCT: 50
GRACE_HOURS: 24
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -114,25 +114,17 @@ jobs:
- name: Run sweep
if: steps.verify.outputs.skip != 'true'
# Schedule-vs-dispatch dry-run asymmetry:
# - schedule: execute (the whole point of an hourly janitor).
# - workflow_dispatch: dry-run. Gitea 1.22.6 rejects
# workflow_dispatch.inputs, so there is no safe manual
# "flip it to execute" toggle in this workflow.
# The script's MAX_DELETE_PCT gate (default 50%) remains the
# second line of defense regardless of trigger.
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
# - Scheduled: input empty → "false" → --execute (the whole
# point of an hourly janitor).
# - Manual workflow_dispatch: input default true → dry-run;
# operator must flip it to actually delete.
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
echo "Running in dry-run mode — no deletions"
bash scripts/ops/sweep-aws-secrets.sh
else
echo "Running with --execute — will delete identified orphans"
bash scripts/ops/sweep-aws-secrets.sh --execute
fi
- name: Notify on sweep failure
if: failure()
run: |
echo "::error::sweep-aws-secrets FAILED — AWS tenant bootstrap secrets may be leaking. Check missing Gitea secrets, staging/prod CP admin tokens, AWS janitor IAM permissions, or the script safety gate."
exit 1
+1 -18
View File
@@ -127,11 +127,7 @@ cd workspace-server && go test -race ./...
cd canvas && npm test
# Workspace runtime (Python)
# Runtime code is SSOT in molecule-ai-workspace-runtime, not molecule-core/workspace.
cd ../molecule-ai-workspace-runtime
python -m venv .venv && source .venv/bin/activate
pip install --index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ -e . pytest pytest-asyncio
pytest -q
cd workspace && python -m pytest -v
# E2E API tests (requires running platform)
bash tests/e2e/test_api.sh
@@ -163,19 +159,6 @@ and run CI manually.
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
| ops-scripts | Python unittest suite for `scripts/*.py` |
### Workspace runtime SSOT
Runtime code lives in
[`molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime).
Do not reintroduce `molecule-core/workspace/` or vendored `molecule_runtime/`
copies in consumers. Core and templates consume the published runtime package
from the Gitea package registry.
For local external MCP agents, multi-workspace config is
`MOLECULE_WORKSPACES=[{"id":"...","token":"...","platform_url":"..."}]`.
`platform_url` selects the tenant; `org_id` is not part of this config.
Workspace IDs can differ across orgs.
## Local Testing
### review-check.sh
+1 -21
View File
@@ -4,7 +4,7 @@
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check
.PHONY: help dev up down logs build test e2e-peer-visibility
help: ## Show this help.
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
@@ -36,23 +36,3 @@ test: ## Run Go unit tests in workspace-server/.
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
bash tests/e2e/test_peer_visibility_mcp_local.sh
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
# swaggo annotations on the gin handlers. Commit the output. CI runs
# `make openapi-spec-check` to assert no drift between annotations and
# the committed file — if a PR changes a handler but forgets to
# regenerate, CI fails with a diff.
openapi-spec: ## Regenerate OpenAPI spec from workspace-server handler annotations.
@command -v swag >/dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@v1.16.4
cd workspace-server && swag init \
--generalInfo cmd/server/main.go \
--output docs/openapi \
--outputTypes yaml,json \
--dir . \
--parseDependency=false \
--parseInternal=true
openapi-spec-check: openapi-spec ## CI gate — fail if openapi-spec produces a diff vs the committed file.
@git diff --exit-code -- workspace-server/docs/openapi/ \
|| (echo "openapi-spec is stale — run 'make openapi-spec' and commit the result" && exit 1)
-6
View File
@@ -9,8 +9,6 @@ import { DetailsTab } from "./tabs/DetailsTab";
import { SkillsTab } from "./tabs/SkillsTab";
import { ChatTab } from "./tabs/ChatTab";
import { ConfigTab } from "./tabs/ConfigTab";
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
import { DisplayTab } from "./tabs/DisplayTab";
import { TerminalTab } from "./tabs/TerminalTab";
import { FilesTab } from "./tabs/FilesTab";
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
@@ -33,8 +31,6 @@ const TABS: { id: PanelTab; label: string; icon: string }[] = [
{ id: "details", label: "Details", icon: "◉" },
{ id: "skills", label: "Plugins", icon: "✦" },
{ id: "terminal", label: "Terminal", icon: "▸" },
{ id: "display", label: "Display", icon: "▣" },
{ id: "container-config", label: "Container", icon: "▤" },
{ id: "config", label: "Config", icon: "⚙" },
{ id: "schedule", label: "Schedule", icon: "⏲" },
{ id: "channels", label: "Channels", icon: "⇌" },
@@ -304,8 +300,6 @@ export function SidePanel() {
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
@@ -11,8 +11,6 @@ vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => null }));
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => null }));
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
@@ -76,7 +74,7 @@ import { SidePanel } from "../SidePanel";
const TABS = [
"chat", "activity", "details", "skills", "terminal",
"display", "container-config", "config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
"config", "schedule", "channels", "files", "memory", "traces", "events", "audit",
];
describe("SidePanel — ARIA tablist pattern", () => {
@@ -87,20 +85,10 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
});
it("renders exactly 15 tab buttons", () => {
it("renders exactly 13 tab buttons", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
expect(tabs.length).toBe(15);
});
it("renders the Display tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-display")).toBeTruthy();
});
it("renders the Container Config tab", () => {
render(<SidePanel />);
expect(document.getElementById("tab-container-config")).toBeTruthy();
expect(tabs.length).toBe(13);
});
it("active tab (chat) has aria-selected='true'", () => {
@@ -111,11 +99,11 @@ describe("SidePanel — ARIA tablist pattern", () => {
expect(chatTab?.getAttribute("aria-selected")).toBe("true");
});
it("all other 14 tabs have aria-selected='false'", () => {
it("all other 12 tabs have aria-selected='false'", () => {
render(<SidePanel />);
const tabs = screen.getAllByRole("tab");
const inactive = tabs.filter((t) => t.id !== "tab-chat");
expect(inactive.length).toBe(14);
expect(inactive.length).toBe(12);
for (const tab of inactive) {
expect(tab.getAttribute("aria-selected")).toBe("false");
}
@@ -128,7 +116,7 @@ describe("SidePanel — ARIA tablist pattern", () => {
const minusOnes = tabs.filter((t) => t.getAttribute("tabindex") === "-1");
expect(zeros.length).toBe(1);
expect(zeros[0].id).toBe("tab-chat");
expect(minusOnes.length).toBe(14);
expect(minusOnes.length).toBe(12);
});
it("active tab has aria-controls='panel-chat' and id='tab-chat'", () => {
@@ -1,96 +0,0 @@
"use client";
import { runtimeDisplayName } from "@/lib/runtime-names";
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
export function ContainerConfigTab({ data }: Props) {
const runtime = data.runtime || "unknown";
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const mountedPath = "/workspace";
const privilegeStatus = "standard";
const deliveryMode = data.deliveryMode || "push";
return (
<div className="p-4 space-y-4">
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
</div>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value={mountedPath} />
<ConfigRow label="Container privileges" value={privilegeStatus} />
<ConfigRow label="Delivery mode" value={deliveryMode} />
</dl>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
<div className="grid grid-cols-2 gap-2">
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
<ReadOnlyAction label="Reset session" />
</div>
</section>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Status</h3>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Container status" value={data.status} />
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
}
function ConfigRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<dt className="text-ink-mid">{label}</dt>
<dd className="min-w-0 text-right">
<div className="font-mono text-ink break-words">{value}</div>
{detail && detail !== value && (
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
)}
</dd>
</div>
);
}
function ReadOnlyAction({ label }: { label: string }) {
return (
<button
type="button"
disabled
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
>
{label}
</button>
);
}
-196
View File
@@ -1,196 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
interface DisplayStatus {
available: boolean;
reason?: string;
mode?: string;
status?: string;
protocol?: string;
width?: number;
height?: number;
}
interface DisplayControlStatus {
controller: "none" | "user" | "agent";
controlled_by?: string;
expires_at?: string;
}
interface Props {
workspaceId: string;
}
export function DisplayTab({ workspaceId }: Props) {
const [status, setStatus] = useState<DisplayStatus | null>(null);
const [control, setControl] = useState<DisplayControlStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [controlError, setControlError] = useState<string | null>(null);
const [controlBusy, setControlBusy] = useState(false);
const requestGeneration = useRef(0);
useEffect(() => {
const generation = requestGeneration.current + 1;
requestGeneration.current = generation;
let cancelled = false;
setStatus(null);
setControl(null);
setError(null);
setControlError(null);
setControlBusy(false);
async function load() {
try {
const displayStatus = await api.get<DisplayStatus>(`/workspaces/${workspaceId}/display`);
if (cancelled || requestGeneration.current !== generation) return;
setStatus(displayStatus);
if (displayStatus.reason === "display_not_enabled") return;
try {
const displayControl = await api.get<DisplayControlStatus>(`/workspaces/${workspaceId}/display/control`);
if (!cancelled && requestGeneration.current === generation) setControl(displayControl);
} catch (err) {
if (!cancelled && requestGeneration.current === generation) {
setControl(null);
setControlError("Display control unavailable");
}
}
} catch (err) {
if (!cancelled && requestGeneration.current === generation) setError("The display status could not be loaded.");
}
}
load();
return () => {
cancelled = true;
};
}, [workspaceId]);
const acquireControl = async () => {
const generation = requestGeneration.current;
const controlPath = `/workspaces/${workspaceId}/display/control`;
setControlBusy(true);
setControlError(null);
try {
const next = await api.post<DisplayControlStatus>(`${controlPath}/acquire`, {
controller: "user",
ttl_seconds: 300,
});
if (requestGeneration.current !== generation) return;
setControl(next);
} catch (err) {
if (requestGeneration.current !== generation) return;
setControlError("Failed to take control");
try {
const latest = await api.get<DisplayControlStatus>(controlPath);
if (requestGeneration.current !== generation) return;
setControl(latest);
} catch {
if (requestGeneration.current !== generation) return;
setControl(null);
}
} finally {
if (requestGeneration.current === generation) setControlBusy(false);
}
};
if (error) {
return (
<div className="p-5">
<div className="rounded-lg border border-red-500/20 bg-red-950/20 p-4">
<h3 className="text-sm font-medium text-red-200">Display status unavailable</h3>
<p className="mt-2 text-[11px] leading-relaxed text-red-200/75">{error}</p>
</div>
</div>
);
}
if (!status) {
return (
<div className="p-5">
<div className="h-24 rounded-lg border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
</div>
);
}
if (!status.available) {
const isNotEnabled = status.reason === "display_not_enabled";
return (
<div className="flex min-h-full flex-col items-center justify-center bg-surface-sunken/30 p-8 text-center">
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
aria-hidden="true"
className="mb-4 text-ink-mid"
>
<rect x="12" y="14" width="48" height="36" rx="4" stroke="currentColor" strokeWidth="2.5" opacity="0.65" />
<path d="M28 58h16M36 50v8M16 16l40 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
<h3 className="mb-1.5 text-sm font-medium text-ink">
{isNotEnabled ? "Display is not enabled for this workspace." : "Display session is not ready."}
</h3>
<p className="max-w-xs text-[11px] leading-relaxed text-ink-mid">
{isNotEnabled
? "Recreate this workspace with display enabled to view and take over its desktop."
: "This workspace has display configuration, but the desktop session infrastructure is not configured yet."}
</p>
{!isNotEnabled && (
<>
<dl className="mt-5 grid grid-cols-2 gap-x-4 gap-y-2 text-left text-[11px]">
<dt className="text-ink-mid">Mode</dt>
<dd className="font-mono text-ink">{status.mode || "unknown"}</dd>
<dt className="text-ink-mid">Status</dt>
<dd className="font-mono text-ink">{status.status || "unknown"}</dd>
</dl>
<div className="mt-5 w-full max-w-xs border-t border-line/50 pt-4">
{control ? (
<div className="flex items-center justify-between gap-3 text-left">
<div className="min-w-0">
<p className="text-[11px] font-medium text-ink">
{control.controller === "none"
? "No active controller"
: `Controlled by ${displayControlActorLabel(control)}`}
</p>
{control.expires_at && (
<p className="mt-1 truncate font-mono text-[10px] text-ink-mid">
Until {new Date(control.expires_at).toLocaleTimeString()}
</p>
)}
{controlError && <p className="mt-1 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
{control.controller === "none" && (
<button
type="button"
onClick={acquireControl}
disabled={controlBusy}
className="h-8 shrink-0 rounded border border-line bg-surface px-3 text-[11px] font-medium text-ink hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
Take control
</button>
)}
</div>
) : (
<div className="text-left">
{!controlError && (
<div className="h-8 rounded border border-line/40 bg-surface-sunken/30 motion-safe:animate-pulse" />
)}
{controlError && <p className="mt-2 text-[10px] leading-snug text-red-200">{controlError}</p>}
</div>
)}
</div>
</>
)}
</div>
);
}
return null;
}
function displayControlActorLabel(control: DisplayControlStatus): string {
if (control.controller === "agent") return "Agent";
if (control.controlled_by === "admin-token") return "Admin";
if (control.controlled_by?.startsWith("org-token:")) return "Automation";
return "User";
}
@@ -1,42 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/runtime-names", () => ({
runtimeDisplayName: (runtime: string) => runtime,
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
describe("ContainerConfigTab", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 2,
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
}}
/>,
);
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("/workspace")).toBeTruthy();
expect(screen.getByText("Container privileges")).toBeTruthy();
expect(screen.queryByText("Instance type")).toBeNull();
expect(screen.queryByText("Root volume")).toBeNull();
});
});
@@ -1,250 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet, mockPost } = vi.hoisted(() => ({ mockGet: vi.fn(), mockPost: vi.fn() }));
vi.mock("@/lib/api", () => ({
api: {
get: mockGet,
post: mockPost,
},
}));
import { DisplayTab } from "../DisplayTab";
describe("DisplayTab", () => {
beforeEach(() => {
cleanup();
mockGet.mockReset();
mockPost.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
mockGet.mockResolvedValueOnce({
available: false,
reason: "display_not_enabled",
});
render(<DisplayTab workspaceId="ws-no-display" />);
await waitFor(() => {
expect(screen.getByText("Display is not enabled for this workspace.")).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-no-display/display");
expect(mockGet).not.toHaveBeenCalledWith("/workspaces/ws-no-display/display/control");
});
it("renders control acquisition for display-configured workspaces", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display");
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Admin")).toBeTruthy();
});
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("renders active display control locks as observe-only", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Release" })).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(mockPost).not.toHaveBeenCalled();
});
it("labels org-token display control locks as automation", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "user",
controlled_by: "org-token:abc123",
expires_at: "2026-05-23T08:48:27Z",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Controlled by Automation")).toBeTruthy();
});
expect(screen.queryByText("org-token:abc123")).toBeNull();
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
});
it("refreshes display control state after failed acquisition", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
controller: "agent",
controlled_by: "sidecar",
expires_at: "2026-05-23T08:48:27Z",
});
mockPost.mockRejectedValueOnce(new Error("API POST /workspaces/ws-display/display/control/acquire: 409 conflict"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
await waitFor(() => {
expect(screen.getByText("Controlled by Agent")).toBeTruthy();
});
expect(screen.getByText("Failed to take control")).toBeTruthy();
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-display/display/control");
expect(mockGet).toHaveBeenCalledTimes(3);
expect(mockPost).toHaveBeenCalledWith("/workspaces/ws-display/display/control/acquire", {
controller: "user",
ttl_seconds: 300,
});
});
it("keeps display status visible without takeover actions when control status fails", async () => {
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display/control: 401 unauthorized"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display session is not ready.")).toBeTruthy();
});
expect(screen.queryByRole("button", { name: "Take control" })).toBeNull();
expect(screen.getByText("Display control unavailable")).toBeTruthy();
});
it("does not render raw display status errors", async () => {
mockGet.mockRejectedValueOnce(new Error("API GET /workspaces/ws-display/display: 500 secret backend details"));
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByText("Display status unavailable")).toBeTruthy();
});
expect(screen.queryByText(/secret backend details/)).toBeNull();
});
it("ignores stale acquire responses after workspace changes", async () => {
const acquire = deferred<{ controller: "user"; controlled_by: string; expires_at: string }>();
mockGet
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
})
.mockResolvedValueOnce({
available: false,
reason: "display_session_unavailable",
mode: "desktop-control",
status: "not_configured",
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockReturnValueOnce(acquire.promise);
const { rerender } = render(<DisplayTab workspaceId="ws-a" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
rerender(<DisplayTab workspaceId="ws-b" />);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-b/display/control");
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
acquire.resolve({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
});
await acquire.promise;
await waitFor(() => {
expect(screen.queryByText("Controlled by Admin")).toBeNull();
});
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
-2
View File
@@ -513,8 +513,6 @@ export function buildNodesAndEdges(
parentId: ws.parent_id,
currentTask: ws.current_task || "",
runtime: ws.runtime || "",
workspaceAccess: ws.workspace_access,
maxConcurrentTasks: ws.max_concurrent_tasks ?? null,
needsRestart: false,
budgetLimit: ws.budget_limit ?? null,
budgetUsed: ws.budget_used ?? null,
+1 -3
View File
@@ -88,8 +88,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
parentId: string | null;
currentTask: string;
runtime: string;
workspaceAccess?: string | null;
maxConcurrentTasks?: number | null;
needsRestart: boolean;
/** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */
budgetLimit: number | null;
@@ -132,7 +130,7 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
deliveryMode?: string;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
export interface ContextMenuState {
x: number;
-2
View File
@@ -320,13 +320,11 @@ export interface WorkspaceData {
url: string;
parent_id: string | null;
active_tasks: number;
max_concurrent_tasks?: number | null;
last_error_rate: number;
last_sample_error: string;
uptime_seconds: number;
current_task: string;
runtime: string;
workspace_access?: string | null;
x: number;
y: number;
collapsed: boolean;
@@ -285,39 +285,6 @@ Canvas requests (no `X-Workspace-ID` header) and system callers
---
## Multiple Workspaces From One Local MCP Bridge
The standalone runtime package includes `molecule-mcp`, a local MCP bridge for
external agents such as Claude Code, Codex, Hermes, and other tools that run
outside the platform container fleet. One local bridge can serve multiple
external workspaces by setting `MOLECULE_WORKSPACES`:
```json
[
{
"id": "workspace-id-local-to-hongming-org",
"token": "...",
"platform_url": "https://hongming.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-agents-team-org",
"token": "...",
"platform_url": "https://agents-team.moleculesai.app"
}
]
```
`platform_url` is the tenant routing key. The bridge registers, heartbeats,
polls inboxes, and sends outbound A2A calls against the URL attached to the
workspace that is doing the work.
Do not add `org_id` to this config. The tenant already comes from
`platform_url`, and the bearer token is issued by that tenant. Workspace IDs
also do not need to be shared across orgs; each tenant can return its own
workspace ID and token for the same local agent process.
---
## Canvas Appearance
External workspaces appear on the canvas with a purple **REMOTE** badge
@@ -135,33 +135,6 @@ The `id` field is your workspace ID — remember it.
---
## Optional — one local MCP bridge, multiple tenants
If your local agent runtime uses `molecule-mcp`, one process can serve more
than one external workspace:
```bash
export MOLECULE_WORKSPACES='[
{
"id": "workspace-id-local-to-you-org",
"token": "...",
"platform_url": "https://you.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-team-org",
"token": "...",
"platform_url": "https://team.moleculesai.app"
}
]'
molecule-mcp
```
Use the workspace ID and token returned by each tenant. The IDs may differ
across orgs. `org_id` is not required here because `platform_url` selects the
tenant and the token is tenant-scoped.
---
## Step 4 — Chat with it
1. Open your Molecule canvas at `https://<TENANT>`
-27
View File
@@ -125,33 +125,6 @@ The agent appears on the canvas with a **purple REMOTE badge** within seconds. F
---
## Multi-Tenant Local MCP Bridge
For local MCP-driven agents, use the standalone runtime's `molecule-mcp`
entrypoint. A single local bridge can serve multiple external workspaces by
setting `MOLECULE_WORKSPACES`:
```json
[
{
"id": "workspace-id-local-to-acme",
"token": "...",
"platform_url": "https://acme.moleculesai.app"
},
{
"id": "different-workspace-id-local-to-ops",
"token": "...",
"platform_url": "https://ops.moleculesai.app"
}
]
```
`platform_url` selects the tenant for registration, heartbeat, inbox polling,
and outbound A2A routing. `org_id` is not required in this config, and the
workspace IDs do not need to match across tenants.
---
## What Phase 30 Covers
| Phase | What shipped | Endpoint |
+1 -1
View File
@@ -36,7 +36,7 @@ e2e_mint_test_token() {
local admin_bearer="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
local admin_auth=()
[ -n "$admin_bearer" ] && admin_auth=(-H "Authorization: Bearer $admin_bearer")
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" ${admin_auth[@]+"${admin_auth[@]}"})
body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" "${admin_auth[@]}")
local code
code=$(printf '%s' "$body" | tail -n1)
local json
-116
View File
@@ -1,116 +0,0 @@
#!/usr/bin/env bash
# EC2 leak check for staging E2E harnesses.
#
# Modes:
# E2E_AWS_LEAK_CHECK=off skip
# E2E_AWS_LEAK_CHECK=auto check only when aws + credentials exist
# E2E_AWS_LEAK_CHECK=required fail if aws + credentials are unavailable
#
# Optional:
# E2E_AWS_LEAK_CHECK_SECS poll budget, default 90
# E2E_AWS_LEAK_CHECK_INTERVAL poll interval, default 10
# E2E_AWS_TERMINATE_LEAKS=1 terminate matching leaked instances
e2e_aws_leak_mode() {
echo "${E2E_AWS_LEAK_CHECK:-auto}"
}
e2e_aws_region() {
echo "${E2E_AWS_REGION:-${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-2}}}"
}
e2e_aws_creds_available() {
command -v aws >/dev/null 2>&1 || return 1
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || return 1
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || return 1
}
e2e_ec2_instances_for_slug() {
local slug="$1"
local region
region=$(e2e_aws_region)
# shellcheck disable=SC2016
aws ec2 describe-instances \
--region "$region" \
--filters "Name=tag:Name,Values=*$slug*" \
"Name=instance-state-name,Values=pending,running,stopping,stopped" \
--query 'Reservations[].Instances[].[InstanceId,State.Name,Tags[?Key==`Name`].Value|[0]]' \
--output text
}
e2e_terminate_instances() {
local ids="$1"
local region
region=$(e2e_aws_region)
[ -n "$ids" ] || return 0
# shellcheck disable=SC2086
aws ec2 terminate-instances --region "$region" --instance-ids $ids >/dev/null
}
e2e_verify_no_ec2_leaks_for_slug() {
local slug="$1"
local mode
local max_secs
local interval
local elapsed=0
local rows=""
local ids=""
mode=$(e2e_aws_leak_mode)
case "$mode" in
off)
echo "[aws-leak-check] skipped: E2E_AWS_LEAK_CHECK=off" >&2
return 0
;;
auto|required) ;;
*)
echo "[aws-leak-check] invalid E2E_AWS_LEAK_CHECK=$mode (expected off|auto|required)" >&2
return 2
;;
esac
if ! e2e_aws_creds_available; then
if [ "$mode" = "required" ]; then
echo "[aws-leak-check] required but aws CLI or AWS credentials are unavailable" >&2
return 2
fi
echo "[aws-leak-check] skipped: aws CLI or AWS credentials unavailable" >&2
return 0
fi
max_secs="${E2E_AWS_LEAK_CHECK_SECS:-90}"
interval="${E2E_AWS_LEAK_CHECK_INTERVAL:-10}"
while true; do
rows=$(e2e_ec2_instances_for_slug "$slug" 2>&1) || {
echo "[aws-leak-check] aws ec2 describe-instances failed for slug=$slug" >&2
echo "$rows" >&2
return 2
}
if [ -z "$rows" ] || [ "$rows" = "None" ]; then
echo "[aws-leak-check] no live EC2 instances for slug=$slug" >&2
return 0
fi
if [ "$elapsed" -ge "$max_secs" ]; then
echo "[aws-leak-check] leaked EC2 instance(s) for slug=$slug after ${elapsed}s:" >&2
echo "$rows" >&2
if [ "${E2E_AWS_TERMINATE_LEAKS:-0}" = "1" ]; then
ids=$(echo "$rows" | awk 'NF {print $1}' | sort -u | tr '\n' ' ')
echo "[aws-leak-check] terminating leaked EC2 instance(s): $ids" >&2
e2e_terminate_instances "$ids" || {
echo "[aws-leak-check] terminate-instances failed for: $ids" >&2
return 4
}
fi
return 4
fi
sleep "$interval"
elapsed=$((elapsed + interval))
done
}
+6 -21
View File
@@ -19,18 +19,11 @@
# PR #2558+#2563+#2567 cleared the
# masking layers.)
#
# claude-code → auth-aware:
# E2E_MINIMAX_API_KEY → "MiniMax-M2"
# E2E_ANTHROPIC_API_KEY → "claude-sonnet-4-6"
# otherwise → "sonnet"
#
# claude-code provider routing is model-driven. The bare
# "sonnet" alias selects the OAuth provider, so it is only a
# good default when the canary is using Claude Code OAuth or
# intentionally exercising the missing-auth path. MiniMax and
# direct Anthropic API keys need model IDs that resolve to
# their provider entries, otherwise the workspace boots
# reachable but the first A2A call hits the wrong auth path.
# claude-code → "sonnet" (entry-id form: claude-code template's
# config.yaml uses bare model names,
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
# or ANTHROPIC_API_KEY rather than the
# slug.)
#
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
# operator dispatches the workflow to test a specific slug.
@@ -52,15 +45,7 @@ pick_model_slug() {
case "$runtime" in
hermes) printf 'openai/gpt-4o' ;;
langgraph) printf 'openai:gpt-4o' ;;
claude-code)
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
printf 'MiniMax-M2'
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
printf 'claude-sonnet-4-6'
else
printf 'sonnet'
fi
;;
claude-code) printf 'sonnet' ;;
*) printf 'openai/gpt-4o' ;; # safest fallback (matches hermes)
esac
}
+1 -1
View File
@@ -71,7 +71,7 @@ pv_assert_runtime() {
set +e
resp=$(curl -sS -X POST "$base_url/workspaces/$wid/mcp" \
-H "Authorization: Bearer $wtok" \
${org_header[@]+"${org_header[@]}"} \
"${org_header[@]}" \
-H "Content-Type: application/json" \
-d "$PV_RPC_BODY" \
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env bash
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck disable=SC1091
# shellcheck source=lib/aws_leak_check.sh
source "$SCRIPT_DIR/lib/aws_leak_check.sh"
PASS=0
FAIL=0
TMPDIR_E2E=$(mktemp -d -t aws-leak-check-e2e-XXXXXX)
trap 'rm -rf "$TMPDIR_E2E"' EXIT INT TERM
make_fake_aws() {
local body="$1"
mkdir -p "$TMPDIR_E2E/bin"
cat > "$TMPDIR_E2E/bin/aws" <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "\$*" >> "$TMPDIR_E2E/aws.calls"
$body
EOF
chmod +x "$TMPDIR_E2E/bin/aws"
}
reset_env() {
/bin/rm -f "$TMPDIR_E2E/aws.calls"
export PATH="$TMPDIR_E2E/bin:$ORIG_PATH"
export AWS_ACCESS_KEY_ID=test-access
export AWS_SECRET_ACCESS_KEY=test-secret
export AWS_DEFAULT_REGION=us-east-2
export E2E_AWS_LEAK_CHECK=required
export E2E_AWS_LEAK_CHECK_SECS=0
export E2E_AWS_LEAK_CHECK_INTERVAL=1
unset E2E_AWS_TERMINATE_LEAKS
}
assert_rc() {
local label="$1"
local expected="$2"
shift 2
local observed
"$@" >/tmp/aws-leak-check.out 2>/tmp/aws-leak-check.err
observed=$?
if [ "$observed" = "$expected" ]; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label: expected rc=$expected observed=$observed" >&2
echo " stderr:" >&2
sed 's/^/ /' /tmp/aws-leak-check.err >&2
FAIL=$((FAIL + 1))
fi
}
ORIG_PATH="$PATH"
echo "Test: AWS EC2 leak check helper"
reset_env
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
/bin/mkdir -p "$TMPDIR_E2E/noaws"
export PATH="$TMPDIR_E2E/noaws"
export E2E_AWS_LEAK_CHECK=auto
assert_rc "auto mode skips when aws is unavailable" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
/bin/rm -rf "${TMPDIR_E2E:?}/bin"
/bin/mkdir -p "$TMPDIR_E2E/noaws"
export PATH="$TMPDIR_E2E/noaws"
export E2E_AWS_LEAK_CHECK=required
assert_rc "required mode fails when aws is unavailable" 2 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
# shellcheck disable=SC2016
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then exit 0; fi'
assert_rc "no matching EC2 returns clean" 0 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
# shellcheck disable=SC2016
make_fake_aws 'if [ "$1 $2" = "ec2 describe-instances" ]; then echo "i-123 running ws-tenant-e2e-smoke-test-abc"; exit 0; fi'
assert_rc "persistent matching EC2 is a leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
reset_env
export E2E_AWS_TERMINATE_LEAKS=1
# shellcheck disable=SC2016
make_fake_aws '
if [ "$1 $2" = "ec2 describe-instances" ]; then
echo "i-123 running ws-tenant-e2e-smoke-test-abc"
exit 0
fi
if [ "$1 $2" = "ec2 terminate-instances" ]; then
echo "terminated" >/dev/null
exit 0
fi
'
assert_rc "terminate mode attempts cleanup before returning leak" 4 e2e_verify_no_ec2_leaks_for_slug e2e-smoke-test
if grep -q "terminate-instances" "$TMPDIR_E2E/aws.calls"; then
echo " PASS terminate-instances was called"
PASS=$((PASS + 1))
else
echo " FAIL terminate-instances was not called" >&2
FAIL=$((FAIL + 1))
fi
echo
echo "passed=$PASS failed=$FAIL"
[ "$FAIL" = "0" ]
+2 -5
View File
@@ -50,16 +50,13 @@ docker rm $(docker ps -aq --filter "name=ws-") 2>/dev/null || true
echo ""
echo "--- Create Workspaces ---"
# model is required at the Create boundary (CTO 2026-05-22 SSOT —
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Pass the same value the deleted DefaultModel("claude-code") returned.
ROOT=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","model":"sonnet","tier":3}' \
-d '{"name":"Root Agent","role":"Company coordinator","runtime":"claude-code","tier":3}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create root workspace" "-" "$ROOT"
CHILD=$(curl -s -X POST $PLATFORM/workspaces -H "Content-Type: application/json" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
-d "{\"name\":\"Child Agent\",\"role\":\"Sub-team member\",\"runtime\":\"claude-code\",\"tier\":2,\"parent_id\":\"$ROOT\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check_contains "Create child workspace" "-" "$CHILD"
+2 -11
View File
@@ -16,7 +16,7 @@ set -uo pipefail
# Resolve to the lib relative to this test file so the test runs from
# any cwd (CI, local invocation, repo root).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=tests/e2e/lib/model_slug.sh
# shellcheck source=lib/model_slug.sh
source "$SCRIPT_DIR/lib/model_slug.sh"
PASS=0
@@ -48,16 +48,7 @@ echo
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
run_test "claude-code → OAuth/default alias" claude-code "sonnet"
got=$(unset E2E_MODEL_SLUG E2E_ANTHROPIC_API_KEY; E2E_MINIMAX_API_KEY="mx-test" pick_model_slug claude-code)
assert_eq "claude-code + MiniMax key → MiniMax model" "$got" "MiniMax-M2"
got=$(unset E2E_MODEL_SLUG E2E_MINIMAX_API_KEY; E2E_ANTHROPIC_API_KEY="sk-ant-test" pick_model_slug claude-code)
assert_eq "claude-code + Anthropic API key → Anthropic API model" "$got" "claude-sonnet-4-6"
got=$(unset E2E_MODEL_SLUG; E2E_MINIMAX_API_KEY="mx-priority" E2E_ANTHROPIC_API_KEY="sk-ant-loser" pick_model_slug claude-code)
assert_eq "claude-code + both keys → MiniMax priority" "$got" "MiniMax-M2"
run_test "claude-code → bare model name (entry-id form)" claude-code "sonnet"
# ── Fallback for unknown runtime ──
# Picks slash-form (hermes-shaped) since hermes is the historical
+1 -5
View File
@@ -92,12 +92,8 @@ for _wid in $PRIOR; do
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
# model is required at the Create boundary (CTO 2026-05-22 SSOT — see
# feedback_workspace_model_required_no_platform_default_dynamic_credential_intake).
# Body had no runtime → defaults to langgraph; pass the langgraph-compatible
# default that the deleted DefaultModel("") would have returned.
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Notify E2E","tier":1,"model":"anthropic:claude-opus-4-7"}')
-d '{"name":"Notify E2E","tier":1}')
WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; }
echo "Created workspace $WSID"
+26 -56
View File
@@ -24,12 +24,13 @@
#
# Only PROVISIONING differs from staging:
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
# token + each workspace's MCP bearer from the POST /workspaces
# create response.
# token + each workspace's auth_token from the POST /workspaces resp.
# - local: POST /workspaces directly against the local stack
# (BASE, default http://localhost:8080), MCP bearer consumed inline
# from the create response (auth_token field). Same model every
# other local E2E uses; no new credential/provision flow.
# (BASE, default http://localhost:8080), MCP bearer minted via
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
# deterministic, gated by MOLECULE_ENV != production). Same model
# every other local E2E (test_priority_runtimes_e2e.sh,
# test_api.sh) already uses; no new credential/provision flow.
#
# By default the local backend creates external-mode workspace rows and
# drives the literal MCP path directly. That keeps the local peer-visibility
@@ -79,17 +80,6 @@ NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
log() { echo "[$(date +%H:%M:%S)] $*"; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
extract_auth_token() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null
}
CREATED_WSIDS=()
ADMIN_BEARER="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}"
ADMIN_AUTH=()
@@ -113,7 +103,7 @@ teardown() {
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
[ -n "$wid" ] || continue
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" "${ADMIN_AUTH[@]}" >/dev/null 2>&1 || true
done
exit $rc
}
@@ -122,7 +112,7 @@ trap teardown EXIT INT TERM
# Pre-sweep workspaces a prior crashed run of THIS script left behind
# (name prefix match only — never a blanket delete). The trap fires on
# normal exit, but a kill -9 / SIGPIPE can bypass it.
PRIOR=$(curl -s "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} | python3 -c '
PRIOR=$(curl -s "$BASE/workspaces" "${ADMIN_AUTH[@]}" | python3 -c '
import json, sys
try:
print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name","").startswith("PV-Local-")))
@@ -131,7 +121,7 @@ except Exception:
' 2>/dev/null)
for _wid in $PRIOR; do
log "Pre-sweeping prior PV-Local workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} >/dev/null 2>&1 || true
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" "${ADMIN_AUTH[@]}" >/dev/null 2>&1 || true
done
# ─── Local-stack preflight ─────────────────────────────────────────────
@@ -140,6 +130,17 @@ if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
exit 1
fi
# admin/test-token is the local MCP-bearer mint path; it 404s in
# production. If it is off, this gate cannot drive the literal call.
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" "${ADMIN_AUTH[@]}" -m 5 >/dev/null 2>&1; then
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" "${ADMIN_AUTH[@]}" -m 5 2>/dev/null)
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
exit 1
fi
fi
ok " local stack healthy"
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
@@ -239,31 +240,9 @@ else
fi
log "1/5 provisioning parent ($PARENT_RUNTIME, mode=$PV_LOCAL_PROVISION_MODE) + one sibling per runtime under test..."
# Map runtime → model per the CTO 2026-05-22 SSOT directive (model is
# required, no platform default). External runtimes are exempt by the
# Create-handler gate — for them the URL is the contract — but we still
# pass model="external:custom" defensively in case a downstream consumer
# of the create body asserts presence.
_model_for_runtime() {
case "$1" in
claude-code) echo "sonnet" ;;
codex) echo "gpt-5.5" ;;
kimi) echo "kimi-coding/kimi-k2-coding-6" ;;
minimax) echo "minimax/MiniMax-M2.7" ;;
external) echo "external:custom" ;;
*) echo "anthropic:claude-opus-4-7" ;;
esac
}
PARENT_MODEL=$(_model_for_runtime "$PARENT_RUNTIME")
P_RESP=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"model\":\"$PARENT_MODEL\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
P_RESP=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3$PARENT_EXTRA,\"secrets\":$PARENT_SECRETS}")
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
# PARENT_TOKEN captured for symmetry with the per-sibling auth-token
# capture in the runtime loop below + reserved for follow-up steps
# that need parent-side auth. Current downstream steps reach the parent
# via admin token, so the variable isn't dereferenced — SC2034.
# shellcheck disable=SC2034 # captured for downstream parent-auth use; see #1644 follow-up
PARENT_TOKEN=$(echo "$P_RESP" | extract_auth_token)
if [ -z "$PARENT_ID" ]; then
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
exit 1
@@ -279,8 +258,6 @@ log " PARENT_ID=$PARENT_ID runtime=$PARENT_RUNTIME"
WS_IDS_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
VERDICT_MAP=""
# shellcheck disable=SC2034 # map values are updated through portable eval-based helpers.
WS_TOKENS_MAP=""
_map_set() { # _map_set <mapvarname> <key> <value>
local __m="$1" __k="$2" __v="$3" __cur
eval "__cur=\$$__m"
@@ -313,21 +290,14 @@ for rt in $PV_RUNTIMES; do
CREATE_RUNTIME="$rt"
CREATE_EXTRA=""
fi
CREATE_MODEL=$(_model_for_runtime "$CREATE_RUNTIME")
R=$(curl -s -X POST "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"model\":\"$CREATE_MODEL\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: application/json" \
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$CREATE_RUNTIME\",\"tier\":2,\"parent_id\":\"$PARENT_ID\"$CREATE_EXTRA,\"secrets\":$SEC}")
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
WTOK=$(echo "$R" | extract_auth_token)
if [ -z "$WID" ]; then
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
exit 1
fi
if [ -z "$WTOK" ]; then
echo "::error::$rt workspace create did not return an auth_token — cannot drive the literal MCP call" >&2
exit 1
fi
_map_set WS_IDS_MAP "$rt" "$WID"
_map_set WS_TOKENS_MAP "$rt" "$WTOK"
CREATED_WSIDS+=("$WID")
ALL_WS_IDS="$ALL_WS_IDS $WID"
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
@@ -385,10 +355,10 @@ log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
echo ""
for rt in $ONLINE_RUNTIMES; do
wid="$(_map_get WS_IDS_MAP "$rt")"
WTOK="$(_map_get WS_TOKENS_MAP "$rt")"
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
if [ -z "$WTOK" ]; then
echo "--- $rt (ws=$wid) ---"
echo "$rt: workspace create did not return an auth_token — cannot drive the literal call"
echo "$rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
REGRESSED=1
echo ""
+21 -61
View File
@@ -40,10 +40,8 @@
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token is consumed
# inline from the POST /workspaces 201 response to drive its MCP call.
# No dev-only admin token-mint routes are used in this E2E
# (feedback_no_dev_only_routes_in_e2e).
# tenant workspace creation; each workspace's OWN auth_token (returned by
# POST /workspaces) drives its MCP call.
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
@@ -54,9 +52,6 @@
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
# LLM provider key injected so the runtime can boot
# PV_TOKEN_DIAGNOSTIC_ONLY
# 1 -> stop after create/token acquisition. Useful
# to classify Hermes-only vs shared auth-route issues.
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
#
# Exit codes:
@@ -109,46 +104,6 @@ tenant_call() {
-H "Content-Type: application/json" "$@"
}
tenant_call_capture() {
local method="$1" path="$2" out="$3"; shift 3
curl -sS -o "$out" -w "%{http_code}" -X "$method" "$TENANT_URL$path" \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" "$@"
}
redact_token_body() {
python3 -c '
import json, re, sys
raw = sys.stdin.read()
try:
data = json.loads(raw)
except Exception:
print(re.sub(r"(?i)([a-z0-9_]*token)=([^&\\s]+)", r"\1=<redacted>", raw)[:500])
raise SystemExit(0)
def scrub(v):
if isinstance(v, dict):
return {k: ("<redacted>" if "token" in k.lower() else scrub(val)) for k, val in v.items()}
if isinstance(v, list):
return [scrub(x) for x in v]
return v
print(json.dumps(scrub(data), separators=(",", ":"))[:500])
'
}
extract_auth_token() {
python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null
}
# ─── Scoped teardown ───────────────────────────────────────────────────
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
@@ -235,12 +190,6 @@ for i in $(seq 1 120); do
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
sleep 5
done
BUILDINFO=$(curl -fsS "$TENANT_URL/buildinfo" -m 10 2>/dev/null || true)
if [ -n "$BUILDINFO" ]; then
log " tenant buildinfo: $(echo "$BUILDINFO" | head -c 300)"
else
log " tenant buildinfo unavailable"
fi
# ─── 4. Provision the parent + one sibling per runtime under test ──────
# Inject the LLM provider key so each runtime can authenticate at boot.
@@ -269,20 +218,31 @@ for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
WTOK=$(echo "$R" | extract_auth_token)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo \"$R\" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace create did not return an auth_token — cannot drive its MCP call (workspace_id=$WID; create_resp: $(echo \"$R\" | redact_token_body))"
# auth_token is top-level for container runtimes; external-like nest it
# under connection.auth_token (verified vs staging response shape).
WTOK=$(echo "$R" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
if [ -z "$WTOK" ]; then
TTOK_RESP=$(tenant_call GET "/admin/workspaces/$WID/test-token" 2>/dev/null || true)
WTOK=$(echo "$TTOK_RESP" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or '')
" 2>/dev/null)
fi
[ -n "$WTOK" ] || fail "$rt workspace did not return or mint an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
WS_IDS[$rt]="$WID"
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
if [ "${PV_TOKEN_DIAGNOSTIC_ONLY:-0}" = "1" ]; then
ok "token diagnostic passed for runtimes: $PV_RUNTIMES"
exit 0
fi
# ─── 5. Wait for every sibling online ──────────────────────────────────
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# Staging E2E diagnostic — classify peer-visibility token acquisition.
#
# This is intentionally narrower than test_peer_visibility_mcp_staging.sh:
# it provisions the same throwaway org, creates managed sibling workspaces,
# and stops immediately after auth_token acquisition. The default runtime set
# compares hermes with claude-code so a failure is easy to classify:
# - hermes fails, claude-code passes -> Hermes/runtime-specific
# - both fail -> shared admin/auth/proxy route
#
# Required env matches test_peer_visibility_mcp_staging.sh:
# MOLECULE_ADMIN_TOKEN
# Optional:
# MOLECULE_CP_URL, E2E_RUN_ID, PV_RUNTIMES, E2E_KEEP_ORG,
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
set -euo pipefail
export PV_RUNTIMES="${PV_RUNTIMES:-hermes claude-code}"
export PV_TOKEN_DIAGNOSTIC_ONLY=1
exec "$(dirname "${BASH_SOURCE[0]}")/test_peer_visibility_mcp_staging.sh"
+2 -4
View File
@@ -188,9 +188,8 @@ import json, os
print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — pass the deleted DefaultModel("claude-code") value.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"model\":\"sonnet\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (claude-code)\",\"runtime\":\"claude-code\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then
fail "create claude-code workspace" "$resp"
@@ -381,9 +380,8 @@ import json, os
print(json.dumps({'GEMINI_API_KEY': os.environ['E2E_GEMINI_API_KEY']}))
")
local resp wsid
# model required (CTO 2026-05-22 SSOT) — gemini-cli routes via the gemini provider.
resp=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"model\":\"gemini-2.0-flash\",\"tier\":1,\"secrets\":$secrets}")
-d "{\"name\":\"Priority E2E (gemini-cli)\",\"runtime\":\"gemini-cli\",\"tier\":1,\"secrets\":$secrets}")
wsid=$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))') || true
if [ -z "$wsid" ]; then fail "create gemini-cli workspace" "$resp"; return 0; fi
CREATED_WSIDS+=("$wsid")
+68 -201
View File
@@ -25,11 +25,6 @@
# Optional env:
# E2E_RUNTIME hermes (default) | claude-code | langgraph
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold EC2 budget)
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS default 3600 (60 min — hermes
# cold-boot worst-case + slack). Raised from
# 1800 (#1646) because flaky tenant-provisioning
# latency (not a code regression) causes
# alternating pass/fail on identical SHAs.
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
# E2E_MODE full (default) | smoke
@@ -37,11 +32,6 @@
# mapped to `smoke` for back-compat with
# any in-flight runner picking up an older
# workflow checkout)
# E2E_AWS_LEAK_CHECK auto (default) | required | off
# required in CI so teardown cannot report
# clean while slug-tagged EC2 remains alive
# E2E_AWS_TERMINATE_LEAKS 1 → terminate slug-tagged leaked EC2 before
# exiting 4
# E2E_INTENTIONAL_FAILURE 1 → poison tenant token mid-run so the
# script fails; the EXIT trap MUST still
# tear down cleanly (and exit 4 on leak).
@@ -61,7 +51,6 @@ CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
RUNTIME="${E2E_RUNTIME:-hermes}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
WORKSPACE_ONLINE_TIMEOUT_SECS="${E2E_WORKSPACE_ONLINE_TIMEOUT_SECS:-3600}"
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
MODE="${E2E_MODE:-full}"
# `canary` is a legacy alias for `smoke` retained for back-compat with
@@ -93,12 +82,8 @@ ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
# Per-runtime model slug dispatch — see lib/model_slug.sh for the rationale.
# Extracted so unit tests (tests/e2e/test_model_slug.sh) can pin every branch
# without booting the full 11-step lifecycle.
# shellcheck disable=SC1091
# shellcheck source=lib/model_slug.sh
source "$(dirname "$0")/lib/model_slug.sh"
# shellcheck disable=SC1091
# shellcheck source=lib/aws_leak_check.sh
source "$(dirname "$0")/lib/aws_leak_check.sh"
CURL_COMMON=(-sS --fail-with-body --max-time 30)
@@ -134,14 +119,12 @@ cleanup_org() {
# DELETE returns 5xx mid-cascade and the cascade finishes anyway,
# and the case where DELETE legitimately exceeds 120s and we want
# eventual-consistency confirmation.
if curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1; then
ok "Teardown request accepted"
else
log "Teardown returned non-2xx (may already be gone)"
fi
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1 \
&& ok "Teardown request accepted" \
|| log "Teardown returned non-2xx (may already be gone)"
local leak_count=1
local elapsed=0
@@ -161,15 +144,7 @@ cleanup_org() {
echo "⚠️ LEAK: org $SLUG still present post-teardown after ${elapsed}s (count=$leak_count)" >&2
exit 4
fi
local aws_leak_rc=0
e2e_verify_no_ec2_leaks_for_slug "$SLUG" || aws_leak_rc=$?
if [ "$aws_leak_rc" != "0" ]; then
case "$aws_leak_rc" in
2) exit 2 ;;
*) exit 4 ;;
esac
fi
ok "Teardown clean — no orphan org or EC2 resources for $SLUG (${elapsed}s)"
ok "Teardown clean — no orphan resources for $SLUG (${elapsed}s)"
# Normalize unexpected upstream exit codes to 1 (generic failure). The
# script's documented contract (header "Exit codes" section) only emits
@@ -356,75 +331,6 @@ tenant_call() {
"$@"
}
sanitize_http_body() {
python3 -c '
import re, sys
s = sys.stdin.read()
s = re.sub(r"(?i)(Authorization:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+", r"\1[redacted]", s)
s = re.sub(r"(?i)(\"(?:auth_token|access_token|refresh_token|token|api_key|secret|password)\"\s*:\s*\")[^\"]+\"", r"\1[redacted]\"", s)
s = re.sub(r"(?i)((?:auth_token|access_token|refresh_token|api_key|secret|password)=)[^&\s]+", r"\1[redacted]", s)
print(s[:4000])
'
}
wait_workspaces_online_routable() {
local label="$1"; shift
local deadline=$(( $(date +%s) + WORKSPACE_ONLINE_TIMEOUT_SECS ))
local wid ws_last_status ws_last_url ws_url_missing_logged ws_failed_logged
local ws_json ws_status ws_url ws_last_err
log "$label"
for wid in "$@"; do
ws_last_status=""
ws_last_url=""
ws_url_missing_logged=0
ws_failed_logged=0
while true; do
if [ "$(date +%s)" -gt "$deadline" ]; then
ws_last_err=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online with a routable URL within ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min) (last status=$ws_last_status, url=$ws_last_url, err=$ws_last_err)"
fi
ws_json=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
ws_status=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status') or '')" 2>/dev/null)
ws_url=$(echo "$ws_json" | python3 -c "import json,sys; print(json.load(sys.stdin).get('url') or '')" 2>/dev/null)
if [ "$ws_status" != "$ws_last_status" ]; then
log " $wid$ws_status"
ws_last_status="$ws_status"
fi
if [ -n "$ws_url" ] && [ "$ws_url" != "$ws_last_url" ]; then
log " $wid url ready: $ws_url"
ws_last_url="$ws_url"
fi
case "$ws_status" in
online)
if [ -n "$ws_url" ]; then
break
fi
if [ "$ws_url_missing_logged" = "0" ]; then
log " $wid online but URL is not assigned yet — waiting for workspace routing readiness"
ws_url_missing_logged=1
fi
sleep 10
;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$ws_failed_logged" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
ws_failed_logged=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online and routable"
done
}
# ─── 5. Provision parent workspace ─────────────────────────────────────
# Inject the LLM provider key so the runtime can authenticate at boot.
# Branch by which secret is set so the script supports multiple paths
@@ -477,9 +383,9 @@ elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
# is still independent of MOLECULE_STAGING_OPENAI_API_KEY, so an OpenAI
# quota collapse doesn't wedge this path. Pinned to the claude-code
# runtime: hermes/langgraph use OpenAI-shaped envs and won't honour
# ANTHROPIC_API_KEY without further wiring. pick_model_slug maps this
# branch to claude-sonnet-4-6 so the claude-code provider registry
# selects anthropic-api instead of the OAuth-only sonnet alias.
# ANTHROPIC_API_KEY without further wiring (out of scope for this
# branch; if you need a hermes/Anthropic path, dispatch with
# E2E_RUNTIME=hermes + E2E_OPENAI_API_KEY pointing at a working key).
SECRETS_JSON=$(python3 -c "
import json, os
k = os.environ['E2E_ANTHROPIC_API_KEY']
@@ -504,7 +410,6 @@ print(json.dumps({
fi
MODEL_SLUG=$(pick_model_slug "$RUNTIME")
log " MODEL_SLUG=$MODEL_SLUG"
log "5/11 Provisioning parent workspace (runtime=$RUNTIME)..."
PARENT_RESP=$(tenant_call POST /workspaces \
@@ -532,16 +437,48 @@ fi
# deadline fires at 5 min and sets status=failed prematurely; heartbeat
# then transitions failed → online after install.sh finishes. So:
#
# - ${WORKSPACE_ONLINE_TIMEOUT_SECS}s (~$((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min)
# deadline (hermes worst-case + slack). Configurable via
# E2E_WORKSPACE_ONLINE_TIMEOUT_SECS (#1646).
# - 20 min deadline (hermes worst-case + slack)
# - 'failed' is a TRANSIENT state we must tolerate — log and keep
# polling, only hard-fail at the deadline. Pre-bootstrap-watcher-fix
# (controlplane#245) this was a flake generator: workspace went
# failed→online inside our window but we bailed at the failed read.
WS_TO_CHECK=("$PARENT_ID")
[ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID")
wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}"
log "7/11 Waiting for workspace(s) to reach status=online (up to 30 min — hermes cold boot)..."
WS_DEADLINE=$(( $(date +%s) + 1800 ))
WS_TO_CHECK="$PARENT_ID"
[ -n "$CHILD_ID" ] && WS_TO_CHECK="$WS_TO_CHECK $CHILD_ID"
for wid in $WS_TO_CHECK; do
WS_LAST_STATUS=""
WS_FAILED_LOGGED=0
while true; do
if [ "$(date +%s)" -gt "$WS_DEADLINE" ]; then
WS_LAST_ERR=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | \
python3 -c "import json,sys; print(json.load(sys.stdin).get('last_sample_error',''))" 2>/dev/null || echo "")
fail "Workspace $wid never reached online within 20 min (last status=$WS_LAST_STATUS, err=$WS_LAST_ERR)"
fi
WS_JSON=$(tenant_call GET "/workspaces/$wid" 2>/dev/null || echo '{}')
WS_STATUS=$(echo "$WS_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
if [ "$WS_STATUS" != "$WS_LAST_STATUS" ]; then
log " $wid$WS_STATUS"
WS_LAST_STATUS="$WS_STATUS"
fi
case "$WS_STATUS" in
online) break ;;
failed)
# Not a hard fail — bootstrap-watcher frequently marks failed at
# 5 min on hermes, then heartbeat recovers to online around 10-13
# min when install.sh finishes. Log once per workspace so the CI
# output isn't spammy.
if [ "$WS_FAILED_LOGGED" = "0" ]; then
log " $wid transiently failed — waiting for heartbeat recovery (bootstrap-watcher deadline, see cp#245)"
WS_FAILED_LOGGED=1
fi
sleep 10
;;
*) sleep 10 ;;
esac
done
ok " $wid online"
done
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
# This step exists because the canvas-terminal failure of 2026-05-03
@@ -553,7 +490,7 @@ wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=o
# - tenantIngressRules / workspaceIngressRules (CP)
# - eicSSHIngressRule helper (CP)
# - AuthorizeIngress source-group support (CP awsapi)
# - MOLECULE_EIC_ENDPOINT_SG_ID Railway env
# - EIC_ENDPOINT_SG_ID Railway env
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
# surfaces within ~20 min of merge instead of waiting for a user report.
#
@@ -567,7 +504,7 @@ wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=o
# probes docker.Ping + container exec; we still expect ok=true there
# since local-docker is the alternative production path.
log "7b/11 Canvas-terminal EIC diagnose probe..."
for wid in "${WS_TO_CHECK[@]}"; do
for wid in $WS_TO_CHECK; do
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
if [ "$DIAG_OK" = "true" ]; then
@@ -575,7 +512,7 @@ for wid in "${WS_TO_CHECK[@]}"; do
else
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; step=s[0] if s else {}; print(' — '.join(x for x in [step.get('error',''), step.get('detail','')] if x))" 2>/dev/null || echo "")
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from the configured EIC endpoint SG, MOLECULE_EIC_ENDPOINT_SG_ID is set in Railway, and EIC endpoint health"
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
fi
done
@@ -603,7 +540,7 @@ CONFIG_PAYLOAD="${CONFIG_MARKER}
name: synth-canary
runtime: ${RUNTIME}
"
for wid in "${WS_TO_CHECK[@]}"; do
for wid in $WS_TO_CHECK; do
PUT_BODY=$(python3 -c "import json,sys; print(json.dumps({'content': sys.stdin.read()}))" <<< "$CONFIG_PAYLOAD")
# Capture body to a tempfile so curl's -w '%{http_code}' is the only
# thing on stdout. The first version used `-w '\n%{http_code}\n'` and
@@ -636,12 +573,6 @@ for wid in "${WS_TO_CHECK[@]}"; do
ok " $wid config.yaml PUT OK (HTTP $PUT_CODE)"
done
# Saving config.yaml follows the same path as Canvas Config Save & Restart.
# The controlplane can briefly put the workspace back into provisioning and
# clear its route while the runtime restarts, so A2A must wait on the same
# externally routable readiness boundary again.
wait_workspaces_online_routable "7d/11 Waiting for workspace(s) to recover routing after config.yaml PUT..." "${WS_TO_CHECK[@]}"
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
log "8/11 Sending A2A message to parent — expecting agent response..."
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
@@ -681,44 +612,10 @@ print(json.dumps({
# 90s gives ~3x headroom over observed cold-call P95 (~25-30s).
# Subsequent A2A turns hit the same workspace and are sub-second, so
# this only widens the window for step 8/11 of the canary's first turn.
A2A_TMP=$(mktemp -t synth_a2a.XXXXXX)
for A2A_ATTEMPT in $(seq 1 12); do
: >"$A2A_TMP"
set +e
A2A_CODE=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
--max-time 90 \
-H "Content-Type: application/json" \
-d "$A2A_PAYLOAD" \
-o "$A2A_TMP" \
-w '%{http_code}' \
2>/dev/null)
A2A_RC=$?
set -e
A2A_CODE=${A2A_CODE:-000}
A2A_RESP=$(cat "$A2A_TMP" 2>/dev/null || echo "")
if [ "$A2A_RC" = "0" ] && [ "$A2A_CODE" -ge 200 ] && [ "$A2A_CODE" -lt 300 ]; then
break
fi
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
if echo "$A2A_CODE" | grep -Eq '^(502|503|504)$' && echo "$A2A_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
log " A2A cold-start probe attempt $A2A_ATTEMPT/12 returned $A2A_CODE: $A2A_SAFE_BODY"
if [ "$A2A_ATTEMPT" -lt 12 ]; then
A2A_SLEEP=10
if echo "$A2A_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
A2A_SLEEP=30
fi
sleep "$A2A_SLEEP"
continue
fi
fi
break
done
rm -f "$A2A_TMP"
if [ "$A2A_RC" != "0" ] || [ "$A2A_CODE" -lt 200 ] || [ "$A2A_CODE" -ge 300 ]; then
A2A_SAFE_BODY=$(printf '%s' "$A2A_RESP" | sanitize_http_body)
fail "A2A POST /workspaces/$PARENT_ID/a2a failed after $A2A_ATTEMPT attempt(s) (curl_rc=$A2A_RC, http=$A2A_CODE): $A2A_SAFE_BODY"
fi
A2A_RESP=$(tenant_call POST "/workspaces/$PARENT_ID/a2a" \
--max-time 90 \
-H "Content-Type: application/json" \
-d "$A2A_PAYLOAD")
AGENT_TEXT=$(echo "$A2A_RESP" | python3 -c "
import json, sys
d = json.load(sys.stdin)
@@ -915,50 +812,20 @@ print(json.dumps({
}
}))
")
DELEG_TMP=$(mktemp -t deleg_a2a.XXXXXX)
for DELEG_ATTEMPT in $(seq 1 12); do
: >"$DELEG_TMP"
set +e
# Raw curl (not tenant_call) because this call carries an extra
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
# or TenantGuard 404s — previously missing, caused section 10 to
# fail rc=22 despite everything upstream being correct (2026-04-21).
DELEG_CODE=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "X-Source-Workspace-Id: $PARENT_ID" \
-H "Content-Type: application/json" \
-d "$DELEG_PAYLOAD" \
-o "$DELEG_TMP" \
-w '%{http_code}' \
2>/dev/null)
DELEG_RC=$?
set -e
DELEG_CODE=${DELEG_CODE:-000}
DELEG_RESP=$(cat "$DELEG_TMP" 2>/dev/null || echo "")
if [ "$DELEG_RC" = "0" ] && [ "$DELEG_CODE" -ge 200 ] && [ "$DELEG_CODE" -lt 300 ]; then
break
fi
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
if echo "$DELEG_CODE" | grep -Eq '^(502|503|504)$' && echo "$DELEG_SAFE_BODY" | grep -Eqi 'Service Unavailable|Bad Gateway|Gateway Timeout|error code: 502|error code: 504|workspace agent unreachable|connection refused|no healthy upstream|workspace agent busy|native_session'; then
log " Delegation A2A cold-start attempt $DELEG_ATTEMPT/12 returned $DELEG_CODE: $DELEG_SAFE_BODY"
if [ "$DELEG_ATTEMPT" -lt 12 ]; then
DELEG_SLEEP=10
if echo "$DELEG_SAFE_BODY" | grep -Eqi 'workspace agent busy|native_session'; then
DELEG_SLEEP=30
fi
sleep "$DELEG_SLEEP"
continue
fi
fi
break
done
rm -f "$DELEG_TMP"
if [ "$DELEG_RC" != "0" ] || [ "$DELEG_CODE" -lt 200 ] || [ "$DELEG_CODE" -ge 300 ]; then
DELEG_SAFE_BODY=$(printf '%s' "$DELEG_RESP" | sanitize_http_body)
fail "Delegation A2A POST failed after $DELEG_ATTEMPT attempt(s) (curl_rc=$DELEG_RC, http=$DELEG_CODE): $DELEG_SAFE_BODY"
fi
set +e
# Raw curl (not tenant_call) because this call carries an extra
# X-Source-Workspace-Id header. Must still send X-Molecule-Org-Id
# or TenantGuard 404s — previously missing, caused section 10 to
# fail rc=22 despite everything upstream being correct (2026-04-21).
DELEG_RESP=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$CHILD_ID/a2a" \
-H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "X-Source-Workspace-Id: $PARENT_ID" \
-H "Content-Type: application/json" \
-d "$DELEG_PAYLOAD")
DELEG_RC=$?
set -e
[ $DELEG_RC -ne 0 ] && fail "Delegation A2A POST failed (rc=$DELEG_RC)"
DELEG_TEXT=$(echo "$DELEG_RESP" | python3 -c "
import json, sys
try:
-193
View File
@@ -1,193 +0,0 @@
"""Tests for `.gitea/scripts/detect-changes.py`."""
from __future__ import annotations
import importlib.util
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py"
def load_module():
spec = importlib.util.spec_from_file_location("detect_changes", SCRIPT)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_ci_profile_classifies_surfaces():
mod = load_module()
assert mod.classify("ci", ["workspace-server/internal/handlers/a2a_proxy.go"]) == {
"platform": True,
"canvas": False,
"python": False,
"scripts": False,
}
assert mod.classify("ci", ["canvas/src/app/page.tsx"]) == {
"platform": False,
"canvas": True,
"python": False,
"scripts": False,
}
assert mod.classify("ci", ["tests/e2e/test_model_slug.sh"]) == {
"platform": False,
"canvas": False,
"python": False,
"scripts": True,
}
assert mod.classify("ci", [".gitea/workflows/ci.yml", "README.md"]) == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
def test_handlers_postgres_profile_is_narrower_than_workspace_server():
mod = load_module()
assert mod.classify("handlers-postgres", ["workspace-server/internal/handlers/a2a_proxy.go"]) == {
"handlers": True,
}
assert mod.classify("handlers-postgres", ["workspace-server/internal/provisioner/provisioner.go"]) == {
"handlers": False,
}
def test_e2e_api_profile_covers_api_inputs():
mod = load_module()
assert mod.classify("e2e-api", ["workspace-server/internal/handlers/workspace.go"]) == {
"api": True,
}
assert mod.classify("e2e-api", ["tests/e2e/test_api.sh"]) == {"api": True}
assert mod.classify("e2e-api", ["canvas/src/app/page.tsx"]) == {"api": False}
def test_fail_open_all_true_for_missing_base():
mod = load_module()
assert mod.all_true("ci") == {
"platform": True,
"canvas": True,
"python": True,
"scripts": True,
}
def test_fetch_base_prefers_advertised_base_ref(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
exists_checks = 0
def fake_base_exists(base: str) -> bool:
nonlocal exists_checks
exists_checks += 1
return exists_checks >= 1
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = ""
stderr = ""
return Result()
monkeypatch.setattr(mod, "base_exists", fake_base_exists)
monkeypatch.setattr(mod, "run_git", fake_run_git)
mod.fetch_base("abc123", "main")
assert calls == [["fetch", "--depth=1", "origin", "main"]]
def test_fetch_base_falls_back_to_sha_when_ref_fetch_does_not_materialize(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
monkeypatch.setattr(mod, "base_exists", lambda _base: False)
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = ""
stderr = ""
return Result()
monkeypatch.setattr(mod, "run_git", fake_run_git)
mod.fetch_base("abc123", "main")
assert calls == [
["fetch", "--depth=1", "origin", "main"],
["fetch", "--depth=1", "origin", "abc123"],
]
def test_changed_paths_uses_merge_base_for_pull_request(monkeypatch):
mod = load_module()
calls: list[list[str]] = []
def fake_run_git(args: list[str], *, timeout: int = 30):
calls.append(args)
class Result:
returncode = 0
stdout = "workspace/agent.py\n"
stderr = ""
if args[0] == "merge-base":
Result.stdout = "merge123\n"
return Result()
monkeypatch.setattr(mod, "run_git", fake_run_git)
assert mod.changed_paths("base123", use_merge_base=True) == ["workspace/agent.py"]
assert calls == [
["merge-base", "base123", "HEAD"],
["diff", "--name-only", "merge123", "HEAD"],
]
def test_detect_deepens_base_ref_when_pr_merge_base_missing(monkeypatch):
mod = load_module()
calls: list[tuple[str, str | None]] = []
merge_base_calls = 0
monkeypatch.setattr(mod, "base_exists", lambda _base: True)
def fake_merge_base(base: str):
nonlocal merge_base_calls
merge_base_calls += 1
if merge_base_calls == 1:
return None
return "merge123"
def fake_deepen_base_ref(base_ref: str):
calls.append(("deepen", base_ref))
def fake_changed_paths(base: str, *, use_merge_base: bool):
calls.append(("changed", str(use_merge_base)))
return [".gitea/workflows/ci.yml"]
monkeypatch.setattr(mod, "merge_base", fake_merge_base)
monkeypatch.setattr(mod, "deepen_base_ref", fake_deepen_base_ref)
monkeypatch.setattr(mod, "changed_paths", fake_changed_paths)
assert mod.detect("ci", "pull_request", "base123", "", "main") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert calls == [("deepen", "main"), ("changed", "True")]
-18
View File
@@ -1,18 +0,0 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_staging_e2e_workflows_use_stable_minimax_default() -> None:
"""Keep cron/push E2E on the same MiniMax model as the smoke-tested script."""
workflow_paths = [
".gitea/workflows/e2e-staging-saas.yml",
".gitea/workflows/staging-smoke.yml",
".gitea/workflows/continuous-synth-e2e.yml",
]
for rel in workflow_paths:
text = (ROOT / rel).read_text()
assert "MiniMax-M2.7-highspeed" not in text
assert "MiniMax-M2" in text
+7 -72
View File
@@ -26,11 +26,9 @@ import re
import subprocess
import sys
import textwrap
import importlib.util
from pathlib import Path
import pytest # noqa: F401 (declares the dep)
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py"
@@ -618,24 +616,16 @@ def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_pa
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
DETECT_CHANGES_SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "detect-changes.py"
def _load_detect_changes():
spec = importlib.util.spec_from_file_location("detect_changes", DETECT_CHANGES_SCRIPT)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
detect_changes = _load_detect_changes()
patterns = {
surface: re.compile(pattern)
for surface, pattern in detect_changes.PROFILES["ci"].items()
}
text = CI_WORKFLOW.read_text(encoding="utf-8")
patterns: dict[str, re.Pattern[str]] = {}
for surface, pattern in re.findall(
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
text,
):
patterns[surface] = re.compile(pattern)
assert set(patterns) == set(CI_SURFACES)
return patterns
@@ -703,58 +693,3 @@ def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
"python": False,
"scripts": False,
}
def test_ci_platform_go_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
platform = doc["jobs"]["platform-build"]
assert platform.get("needs") == "changes"
expensive_steps = [
step
for step in platform["steps"]
if step.get("uses")
or step.get("run", "").startswith("go ")
or "golangci-lint" in step.get("run", "")
]
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "needs.changes.outputs.platform == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_canvas_nextjs_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
canvas = doc["jobs"]["canvas-build"]
assert canvas.get("needs") == "changes"
expensive_steps = [
step
for step in canvas["steps"]
if step.get("uses")
or step.get("run", "").startswith("npm ")
or step.get("run", "").startswith("npx ")
]
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "needs.changes.outputs.canvas == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
def test_ci_shellcheck_steps_are_path_scoped_on_all_events():
doc = yaml.safe_load(CI_WORKFLOW.read_text(encoding="utf-8"))
shellcheck = doc["jobs"]["shellcheck"]
assert shellcheck.get("needs") == "changes"
expensive_steps = [
step
for step in shellcheck["steps"]
if step.get("uses") or step.get("run", "").startswith(("bash ", "find ", "shellcheck "))
]
assert expensive_steps
for step in expensive_steps:
expr = step.get("if", "")
assert "needs.changes.outputs.scripts == 'true'" in expr
assert "github.event_name != 'pull_request'" not in expr
-40
View File
@@ -442,46 +442,6 @@ def test_reap_preserves_real_push(sr_module, monkeypatch):
assert calls == [] # NO POST
def test_reap_compensates_cancelled_real_push_status(sr_module, monkeypatch):
"""Gitea 1.22.6 maps cancelled push runs to failure statuses.
A real push workflow with description exactly "Has been cancelled"
is cancel-cascade noise, not a defect signal. Status-reaper should
compensate it even though the workflow has a push trigger.
"""
calls = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
calls.append((method, path, body))
return (201, {})
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"ci": True}
combined = {
"state": "failure",
"statuses": [
{
"context": "ci / test (push)",
"status": "failure",
"description": "Has been cancelled",
"target_url": "https://example.test/actions/runs/1",
}
],
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 1
assert counters["compensated_cancelled_push"] == 1
assert counters["preserved_real_push"] == 0
assert len(calls) == 1
assert calls[0][0] == "POST"
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
assert calls[0][2]["context"] == "ci / test (push)"
assert calls[0][2]["state"] == "success"
def test_reap_preserves_unknown_workflow(sr_module, monkeypatch, capsys):
"""Workflow not in map → ::notice:: + skip (conservative)."""
monkeypatch.setattr(
+2 -26
View File
@@ -1,26 +1,3 @@
// Package main runs the per-tenant workspace-server.
//
// @title Molecule AI Workspace Server API
// @version 1.0
// @description The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.
// @host api.moleculesai.app
// @BasePath /
// @schemes https
//
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @description Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.
//
// @securityDefinitions.apikey OrgSlugAuth
// @in header
// @name X-Molecule-Org-Slug
// @description Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.
//
// @securityDefinitions.apikey OrgIdAuth
// @in header
// @name X-Molecule-Org-Id
// @description Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
package main
import (
@@ -393,9 +370,8 @@ func main() {
// See molecule-core#7.
bindHost := resolveBindHost()
srv := &http.Server{
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
Addr: fmt.Sprintf("%s:%s", bindHost, port),
Handler: r,
}
// Start server in goroutine
-521
View File
@@ -1,521 +0,0 @@
{
"schemes": [
"https"
],
"swagger": "2.0",
"info": {
"description": "The per-tenant workspace-server HTTP API. Single source of truth for workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas, molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by clients generated from this spec — see RFC #1706.",
"title": "Molecule AI Workspace Server API",
"contact": {},
"version": "1.0"
},
"host": "api.moleculesai.app",
"basePath": "/",
"paths": {
"/workspaces/{id}/schedules": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "List schedules for a workspace",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Create a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Schedule fields",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/handlers.CreateScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}": {
"delete": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Delete a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.StatusResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Update a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
},
{
"description": "Partial schedule fields (only provided keys are updated)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/handlers.UpdateScheduleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.ScheduleResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/history": {
"get": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Get past runs of a schedule",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/handlers.HistoryEntry"
}
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/workspaces/{id}/schedules/{scheduleId}/run": {
"post": {
"security": [
{
"BearerAuth": [],
"OrgSlugAuth": []
}
],
"produces": [
"application/json"
],
"tags": [
"schedules"
],
"summary": "Fire a schedule manually",
"parameters": [
{
"type": "string",
"description": "Workspace ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Schedule ID",
"name": "scheduleId",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.RunNowResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
}
},
"definitions": {
"handlers.CreateScheduleRequest": {
"type": "object",
"required": [
"cron_expr",
"prompt"
],
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
},
"handlers.CreateScheduleResponse": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"handlers.ErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
}
}
},
"handlers.HistoryEntry": {
"type": "object",
"properties": {
"duration_ms": {
"type": "integer"
},
"error_detail": {
"type": "string"
},
"request": {
"type": "object"
},
"status": {
"type": "string"
},
"timestamp": {
"type": "string"
}
}
},
"handlers.RunNowResponse": {
"type": "object",
"properties": {
"prompt": {
"type": "string"
},
"status": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.ScheduleResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"last_error": {
"type": "string"
},
"last_run_at": {
"type": "string"
},
"last_status": {
"type": "string"
},
"name": {
"type": "string"
},
"next_run_at": {
"type": "string"
},
"prompt": {
"type": "string"
},
"run_count": {
"type": "integer"
},
"source": {
"description": "'template' (seeded by org/import) | 'runtime' (created via Canvas/API). Issue #24.",
"type": "string"
},
"timezone": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"workspace_id": {
"type": "string"
}
}
},
"handlers.StatusResponse": {
"type": "object",
"properties": {
"status": {
"type": "string"
}
}
},
"handlers.UpdateScheduleRequest": {
"type": "object",
"properties": {
"cron_expr": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"prompt": {
"type": "string"
},
"timezone": {
"type": "string"
}
}
}
},
"securityDefinitions": {
"BearerAuth": {
"description": "Bearer token issued by Gitea (org-admin or persona PAT) or by the platform's signup/SSO flow.",
"type": "apiKey",
"name": "Authorization",
"in": "header"
},
"OrgIdAuth": {
"description": "Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug. At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.",
"type": "apiKey",
"name": "X-Molecule-Org-Id",
"in": "header"
},
"OrgSlugAuth": {
"description": "Tenant routing header — required on every /workspaces/{id}/* request so the platform edge can route to the correct per-tenant workspace-server. Either X-Molecule-Org-Slug (human-readable, e.g. \"agents-team\") or X-Molecule-Org-Id (UUID) must be sent; slug is preferred for client code.",
"type": "apiKey",
"name": "X-Molecule-Org-Slug",
"in": "header"
}
}
}
-349
View File
@@ -1,349 +0,0 @@
basePath: /
definitions:
handlers.CreateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
required:
- cron_expr
- prompt
type: object
handlers.CreateScheduleResponse:
properties:
id:
type: string
next_run_at:
type: string
status:
type: string
type: object
handlers.ErrorResponse:
properties:
error:
type: string
type: object
handlers.HistoryEntry:
properties:
duration_ms:
type: integer
error_detail:
type: string
request:
type: object
status:
type: string
timestamp:
type: string
type: object
handlers.RunNowResponse:
properties:
prompt:
type: string
status:
type: string
workspace_id:
type: string
type: object
handlers.ScheduleResponse:
properties:
created_at:
type: string
cron_expr:
type: string
enabled:
type: boolean
id:
type: string
last_error:
type: string
last_run_at:
type: string
last_status:
type: string
name:
type: string
next_run_at:
type: string
prompt:
type: string
run_count:
type: integer
source:
description: '''template'' (seeded by org/import) | ''runtime'' (created via
Canvas/API). Issue #24.'
type: string
timezone:
type: string
updated_at:
type: string
workspace_id:
type: string
type: object
handlers.StatusResponse:
properties:
status:
type: string
type: object
handlers.UpdateScheduleRequest:
properties:
cron_expr:
type: string
enabled:
type: boolean
name:
type: string
prompt:
type: string
timezone:
type: string
type: object
host: api.moleculesai.app
info:
contact: {}
description: 'The per-tenant workspace-server HTTP API. Single source of truth for
workspace/schedule/agent/secrets/files/memory CRUD. Hand-written clients (canvas,
molecule-mcp-server, molecule-cli, molecule-sdk-python) should be replaced by
clients generated from this spec — see RFC #1706.'
title: Molecule AI Workspace Server API
version: "1.0"
paths:
/workspaces/{id}/schedules:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.ScheduleResponse'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: List schedules for a workspace
tags:
- schedules
post:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule fields
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.CreateScheduleRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/handlers.CreateScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Create a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}:
delete:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.StatusResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Delete a schedule
tags:
- schedules
patch:
consumes:
- application/json
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
- description: Partial schedule fields (only provided keys are updated)
in: body
name: body
required: true
schema:
$ref: '#/definitions/handlers.UpdateScheduleRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.ScheduleResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Update a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/history:
get:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/handlers.HistoryEntry'
type: array
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Get past runs of a schedule
tags:
- schedules
/workspaces/{id}/schedules/{scheduleId}/run:
post:
parameters:
- description: Workspace ID
in: path
name: id
required: true
type: string
- description: Schedule ID
in: path
name: scheduleId
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.RunNowResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
security:
- BearerAuth: []
OrgSlugAuth: []
summary: Fire a schedule manually
tags:
- schedules
schemes:
- https
securityDefinitions:
BearerAuth:
description: Bearer token issued by Gitea (org-admin or persona PAT) or by the
platform's signup/SSO flow.
in: header
name: Authorization
type: apiKey
OrgIdAuth:
description: Tenant routing header (UUID form). Alternative to X-Molecule-Org-Slug.
At least one of OrgSlugAuth or OrgIdAuth must be sent alongside BearerAuth.
in: header
name: X-Molecule-Org-Id
type: apiKey
OrgSlugAuth:
description: Tenant routing header — required on every /workspaces/{id}/* request
so the platform edge can route to the correct per-tenant workspace-server. Either
X-Molecule-Org-Slug (human-readable, e.g. "agents-team") or X-Molecule-Org-Id
(UUID) must be sent; slug is preferred for client code.
in: header
name: X-Molecule-Org-Slug
type: apiKey
swagger: "2.0"
@@ -116,11 +116,8 @@ func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]inte
// would propagate that token into logs and error responses (#659).
return fmt.Errorf("discord: HTTP request failed")
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("discord: read response body: %w", readErr)
}
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
+1 -4
View File
@@ -119,10 +119,7 @@ func (l *LarkAdapter) SendMessage(ctx context.Context, config map[string]interfa
}
defer func() { _ = resp.Body.Close() }()
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("lark: read response body: %w", readErr)
}
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("lark: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+4 -27
View File
@@ -156,9 +156,6 @@ func (m *Manager) PausePollersForToken(workspaceID, botToken string) func() {
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: pause-pollers rows.Err: %v", err)
}
m.mu.Unlock()
if len(pausedIDs) == 0 {
@@ -207,16 +204,8 @@ func (m *Manager) Reload(ctx context.Context) {
log.Printf("Channels: reload scan error: %v", err)
continue
}
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
log.Printf("Channels: reload config unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
log.Printf("Channels: reload allowed_users unmarshal error for %s: %v", truncID(ch.ID), err)
continue
}
}
_ = json.Unmarshal(configJSON, &ch.Config)
_ = json.Unmarshal(allowedJSON, &ch.AllowedUsers)
// #319: decrypt at the boundary between DB (ciphertext) and the
// in-memory config adapters consume. A decrypt failure logs and
// skips the channel — downstream getUpdates would fail anyway
@@ -227,9 +216,6 @@ func (m *Manager) Reload(ctx context.Context) {
}
desired[ch.ID] = ch
}
if err := rows.Err(); err != nil {
log.Printf("Channels: reload rows.Err: %v", err)
}
m.mu.Lock()
defer m.mu.Unlock()
@@ -487,9 +473,6 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID,
}
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: broadcast rows.Err: %v", err)
}
}
// FetchWorkspaceChannelContext returns recent Slack channel messages formatted
@@ -572,14 +555,8 @@ func (m *Manager) loadChannel(ctx context.Context, channelID string) (ChannelRow
if err != nil {
return ch, fmt.Errorf("channel %s not found: %w", channelID, err)
}
if err := json.Unmarshal(configJSON, &ch.Config); err != nil {
return ch, fmt.Errorf("channel %s config unmarshal: %w", channelID, err)
}
if len(allowedJSON) > 0 {
if err := json.Unmarshal(allowedJSON, &ch.AllowedUsers); err != nil {
return ch, fmt.Errorf("channel %s allowed_users unmarshal: %w", channelID, err)
}
}
json.Unmarshal(configJSON, &ch.Config)
json.Unmarshal(allowedJSON, &ch.AllowedUsers)
// #319: decrypt bot_token / webhook_secret — SendOutbound and adapter
// methods downstream read them as plaintext strings.
if err := DecryptSensitiveFields(ch.Config); err != nil {
+3 -13
View File
@@ -171,11 +171,8 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
if readErr != nil {
return fmt.Errorf("slack: read response body: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
@@ -211,13 +208,9 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("slack: webhook returned %d (read body failed: %v)", resp.StatusCode, readErr)
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
@@ -531,11 +524,8 @@ func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 65536))
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
_ = resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("slack: read history response: %w", readErr)
}
var result struct {
OK bool `json:"ok"`
@@ -686,22 +686,11 @@ func (h *WorkspaceHandler) resolveAgentURL(ctx context.Context, workspaceID stri
_ = db.CacheURL(ctx, workspaceID, agentURL)
}
// When the platform runs inside Docker, a managed workspace's
// 127.0.0.1:{host_port} URL points at the Docker host and must be
// rewritten to the workspace container's Docker-bridge hostname.
// External runtimes are not managed containers; their local test/runtime
// URL is the target and must not be synthesized into ws-<id>:8000.
// When the platform runs inside Docker, 127.0.0.1:{host_port} is
// unreachable (it's the platform container's own localhost, not the
// Docker host). Rewrite to the container's Docker-bridge hostname.
if strings.HasPrefix(agentURL, "http://127.0.0.1:") && h.provisioner != nil && platformInDocker {
var wsRuntime string
if err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&wsRuntime); err != nil {
log.Printf("ProxyA2A: runtime lookup before Docker URL rewrite failed for %s: %v", workspaceID, err)
}
if !isExternalLikeRuntime(wsRuntime) {
agentURL = provisioner.InternalURL(workspaceID)
}
agentURL = provisioner.InternalURL(workspaceID)
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
@@ -71,30 +71,35 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
// with 202 status here was the original cycle 53 bug — callers saw
// proxyErr != nil and logged "delegation failed: proxy a2a error".
if isUpstreamBusyError(err) {
// #1684 / Reno Stars: native_session adapters previously took a
// 503-no-enqueue path here, on the assumption that the SDK owned
// an inbound queue and the platform a2a_queue would double-buffer.
// In practice, the common native_session SDKs (claude-agent-sdk,
// codex app-server, hermes-agent) do NOT have an inbound queue —
// new turns can only be pushed via the same HTTP POST that just
// returned busy. So cron fires (and any A2A retry) bounce 503
// every tick until the SDK voluntarily yields. Reno Stars #1684
// observed 12 consecutive `*/30` cron fires lost over 6h while a
// single native_session held the slot.
// Capability primitive #5 — see project memory
// `project_runtime_native_pluggable.md`. When the target workspace's
// adapter has declared provides_native_session=True, the SDK
// owns its own queue/session state (claude-agent-sdk's streaming
// session, hermes-agent's in-container event log, etc.). Adding
// the platform's a2a_queue layer on top would double-buffer the
// same in-flight state — and worse, the platform queue's drain
// timing has no relationship to the SDK's actual readiness, so
// the queued request might dispatch while the SDK is STILL busy.
//
// The original concern — "drain timing has no relationship to SDK
// readiness" — turns out to be unfounded: heartbeat→drain is
// gated by `payload.ActiveTasks < maxConcurrent` in
// registry.go:Heartbeat, so drain only fires when the workspace
// itself reports spare capacity. That IS the session-ended
// signal. The native_session SDK reports ActiveTasks=1 while in a
// turn, ActiveTasks=0 when idle, and the next heartbeat after
// idle triggers DrainQueueForWorkspace.
//
// So we collapse the two branches: both native_session and
// non-native callers enqueue here. The native_session SDK's own
// in-flight POST stays unaffected; the queued item drains on the
// next post-idle heartbeat.
// For native_session targets, return 503 + Retry-After directly.
// The caller's adapter handles retry on its own schedule, and
// the SDK's own queue absorbs the in-flight request when it does.
// Observability is preserved: logA2AFailure already ran above;
// activity_logs records the busy event; the broadcaster fires.
if runtimeOverrides.HasCapability(workspaceID, "session") {
log.Printf("ProxyA2A: target %s busy and declares native_session — skip enqueue, return 503", workspaceID)
return 0, nil, &proxyA2AError{
Status: http.StatusServiceUnavailable,
Headers: map[string]string{"Retry-After": strconv.Itoa(busyRetryAfterSeconds)},
Response: gin.H{
"error": "workspace agent busy — adapter handles retry (native_session)",
"busy": true,
"retry_after": busyRetryAfterSeconds,
"native_session": true,
},
}
}
idempotencyKey := extractIdempotencyKey(body)
// Honor params.expires_in_seconds when the caller specifies one. Zero
// (the unset default) → expiresAt = nil → infinite TTL preserved by
@@ -1511,35 +1511,6 @@ func TestResolveAgentURL_DockerRewrite(t *testing.T) {
}
}
func TestResolveAgentURL_ExternalRuntimeLoopbackNotRewrittenInDocker(t *testing.T) {
mock := setupTestDB(t)
mr := setupTestRedis(t)
allowLoopbackForTest(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
waitForHandlerAsyncBeforeDBCleanup(t, handler)
handler.provisioner = &stubLocalProv{}
restore := setPlatformInDockerForTest(true)
defer restore()
agentURL := "http://127.0.0.1:55555"
mr.Set("ws:ws-external:url", agentURL)
mock.ExpectQuery("SELECT COALESCE\\(runtime").
WithArgs("ws-external").
WillReturnRows(sqlmock.NewRows([]string{"runtime"}).AddRow("external"))
url, perr := handler.resolveAgentURL(context.Background(), "ws-external")
if perr != nil {
t.Fatalf("unexpected error: %+v", perr)
}
if url != agentURL {
t.Errorf("external runtime loopback URL must not be rewritten; got %q want %q", url, agentURL)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
// --- dispatchA2A direct unit tests ---
func TestDispatchA2A_BuildRequestError(t *testing.T) {
+17 -317
View File
@@ -67,213 +67,7 @@ func NewActivityHandler(b *events.Broadcaster) *ActivityHandler {
return &ActivityHandler{broadcaster: b}
}
// extractAttachmentsFromRequestBody walks a JSON-RPC a2a inbound body to
// surface attachments (file/image/audio/video) as a flat `attachments[]`
// projection so callers don't have to drill into the request_body shape
// themselves.
//
// Two body shapes are walked in order:
//
// 1. a2a-sdk v1 message-part envelope (peer_agent inbound):
//
// {"jsonrpc":"2.0","method":"message/send","params":{
// "message":{"parts":[
// {"kind":"text", "text":"hi"},
// {"kind":"file", "file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}},
// {"kind":"image","file":{"uri":"workspace:bar.png","mime_type":"image/png","name":"bar.png"}},
// ]}}}
//
// 2. canvas chat_upload_receive flat manifest (canvas_user upload):
//
// {"uri":"platform-pending:<ws>/<file>",
// "name":"pasted.png",
// "size":12345,
// "file_id":"<uuid>",
// "mimeType":"image/png"}
//
// The canvas upload pipe writes a single manifest directly at the
// root of request_body (no JSON-RPC envelope) with camelCase
// `mimeType`. We normalize to snake_case `mime_type` on the way out
// so every downstream adaptor (channel / telegram / codex / hermes)
// sees one wire shape regardless of which inbound shape produced it.
//
// Returns nil (omit-from-JSON) when the body has no attachments — the
// `?include=peer_info` envelope projects this as an array iff non-empty.
//
// Defensive on every step: any missing key / wrong-shape value falls
// through to the next arm or returns nil instead of panicking. The
// activity_logs row could carry literally any JSON in request_body
// (legacy formats, future formats); we only commit to the documented
// shapes and silently skip anything else.
func extractAttachmentsFromRequestBody(raw []byte) []map[string]interface{} {
if len(raw) == 0 {
return nil
}
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
if atts := extractAttachmentsFromMessageParts(body); len(atts) > 0 {
return atts
}
if att := extractAttachmentFromFlatUploadManifest(body); att != nil {
return []map[string]interface{}{att}
}
return nil
}
// extractAttachmentsFromMessageParts handles the a2a-sdk v1 shape:
// body.params.message.parts[]. Walks file/image/audio parts; honors v1
// `kind` and v0 `type` discriminators; accepts nested `.file` sub-object
// or inlined uri/mime_type/name on the part itself.
func extractAttachmentsFromMessageParts(body map[string]interface{}) []map[string]interface{} {
params, ok := body["params"].(map[string]interface{})
if !ok {
return nil
}
message, ok := params["message"].(map[string]interface{})
if !ok {
return nil
}
parts, ok := message["parts"].([]interface{})
if !ok {
return nil
}
out := make([]map[string]interface{}, 0)
for _, p := range parts {
part, ok := p.(map[string]interface{})
if !ok {
continue
}
// a2a-sdk v1 uses "kind"; older v0 callers sent "type". Accept
// both for the discriminator — same defensive read pattern as
// the runtime-side extract_text helper.
kind, _ := part["kind"].(string)
if kind == "" {
kind, _ = part["type"].(string)
}
if kind != "file" && kind != "image" && kind != "audio" {
continue
}
// The file sub-object holds uri/mime_type/name. The a2a-sdk v1
// shape nests under "file"; some legacy payloads inlined the
// fields onto the part itself. Support both.
var fileObj map[string]interface{}
if f, ok := part["file"].(map[string]interface{}); ok {
fileObj = f
} else {
fileObj = part
}
uri, _ := fileObj["uri"].(string)
mimeType, _ := fileObj["mime_type"].(string)
name, _ := fileObj["name"].(string)
// At minimum we need either a uri or a name to be useful.
// Empty-part entries are skipped (they're a malformed inbound
// — surface nothing rather than emit a no-info placeholder).
if uri == "" && name == "" {
continue
}
att := map[string]interface{}{"kind": kind}
if uri != "" {
att["uri"] = uri
}
if mimeType != "" {
att["mime_type"] = mimeType
}
if name != "" {
att["name"] = name
}
out = append(out, att)
}
if len(out) == 0 {
return nil
}
return out
}
// extractAttachmentFromFlatUploadManifest handles the canvas
// chat_upload_receive shape: a single upload manifest at the root of
// request_body with no JSON-RPC envelope. Canvas uses camelCase
// `mimeType`; we normalize to snake_case `mime_type` on emit so the
// wire shape matches the message-parts arm. Kind is derived from the
// mime prefix (image/* → "image", audio/* → "audio", video/* → "video",
// anything else → "file") because the canvas upload row doesn't carry
// an explicit discriminator. Returns nil if neither `uri` nor `file_id`
// is present at the root (i.e. not a flat upload manifest).
func extractAttachmentFromFlatUploadManifest(body map[string]interface{}) map[string]interface{} {
uri, _ := body["uri"].(string)
fileID, _ := body["file_id"].(string)
if uri == "" && fileID == "" {
return nil
}
mimeType, _ := body["mimeType"].(string)
if mimeType == "" {
// Defensive: future canvas versions might emit snake_case directly.
mimeType, _ = body["mime_type"].(string)
}
name, _ := body["name"].(string)
// Apply the same minimum-info rule as the message-parts arm: a
// manifest with neither uri nor name is non-actionable; skip.
if uri == "" && name == "" {
return nil
}
att := map[string]interface{}{"kind": kindFromMimeType(mimeType)}
if uri != "" {
att["uri"] = uri
}
if mimeType != "" {
att["mime_type"] = mimeType
}
if name != "" {
att["name"] = name
}
return att
}
// kindFromMimeType derives the attachment `kind` discriminator from a
// MIME type. Used by the flat-upload-manifest arm where the source row
// has no explicit kind field.
func kindFromMimeType(mime string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "image"
case strings.HasPrefix(mime, "audio/"):
return "audio"
case strings.HasPrefix(mime, "video/"):
return "video"
default:
return "file"
}
}
// includeFlagSet returns true iff `flag` appears in the comma-separated
// `?include=` query value. Whitespace around entries is tolerated.
// Empty `include` returns false (existing back-compat shape).
//
// The comma-separable form lets future fields ("attachments_only",
// "tool_trace_expanded", etc.) slot in without further URL-param creep.
func includeFlagSet(includeQuery, flag string) bool {
if includeQuery == "" || flag == "" {
return false
}
for _, raw := range strings.Split(includeQuery, ",") {
if strings.TrimSpace(raw) == flag {
return true
}
}
return false
}
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=&include=
//
// The `include` query param is comma-separable; today the only flag is
// `peer_info`, which enriches a2a_receive rows with `peer_name`,
// `peer_role`, `agent_card_url`, and an `attachments[]` projection (see
// extractAttachmentsFromRequestBody). It's additive + opt-in — existing
// callers that don't pass `?include=peer_info` see the unchanged shape.
// Surface for the layered enrichment that lets Claude Code channel
// pushes carry full sender identity instead of bare UUIDs (sibling
// repos: molecule-ai-workspace-runtime + molecule-mcp-claude-channel).
// List handles GET /workspaces/:id/activity?type=&source=&limit=&since_secs=&since_id=
//
// since_secs filters to activity_logs.created_at >= NOW() - INTERVAL '$N seconds'.
// Optional, additive — callers that don't pass it get today's behavior (the
@@ -308,8 +102,6 @@ func (h *ActivityHandler) List(c *gin.Context) {
sinceSecsStr := c.Query("since_secs")
sinceID := c.Query("since_id")
beforeTSStr := c.Query("before_ts") // optional RFC3339 — return rows strictly older than this timestamp
include := c.Query("include") // comma-separated; today's only flag is "peer_info"
includePeerInfo := includeFlagSet(include, "peer_info")
// Validate peer_id as a UUID at the trust boundary so a malformed
// caller (the agent or a downstream MCP tool) can't smuggle SQL
@@ -400,60 +192,22 @@ func (h *ActivityHandler) List(c *gin.Context) {
usingCursor = true
}
// Build query with optional filters. When ?include=peer_info is set,
// LEFT JOIN workspaces ON activity_logs.source_id = w.id so we can
// surface w.name + w.role on the row. LEFT (not INNER) is required
// for two reasons:
// 1. Canvas rows have source_id IS NULL — those must still appear
// in the result set (with NULL peer_name/peer_role).
// 2. A peer workspace may have been deleted since the row was
// written (no FK constraint on activity_logs.source_id) —
// LEFT JOIN preserves the activity row with NULL peer fields
// rather than silently dropping the row.
//
// agent_card_url is NOT pulled from the workspaces table; it's
// computed server-side from externalPlatformURL + source_id at
// projection time (mirrors molecule-ai-workspace-runtime
// a2a_client._agent_card_url_for which constructs
// {PLATFORM_URL}/registry/discover/{peer_id}).
//
// Column qualification (`activity_logs.<col>`) is added ONLY when
// the JOIN is present — disambiguates `id` / `created_at` which
// exist in both tables. When the JOIN is absent, unqualified
// column references preserve the exact wire-shape existing callers
// + existing test fixtures expect (back-compat).
actCol := ""
if includePeerInfo {
actCol = "activity_logs."
}
selectClause := `SELECT ` + actCol + `id, ` + actCol + `workspace_id, ` + actCol + `activity_type, ` +
actCol + `source_id, ` + actCol + `target_id, ` + actCol + `method, ` +
actCol + `summary, ` + actCol + `request_body, ` + actCol + `response_body, ` +
actCol + `tool_trace, ` + actCol + `duration_ms, ` + actCol + `status, ` +
actCol + `error_detail, ` + actCol + `created_at`
fromClause := ` FROM activity_logs`
if includePeerInfo {
selectClause += `, w.name AS peer_name, w.role AS peer_role`
fromClause += ` LEFT JOIN workspaces w ON w.id = activity_logs.source_id`
}
query := selectClause + fromClause + ` WHERE ` + actCol + `workspace_id = $1`
// Build query with optional filters
query := `SELECT id, workspace_id, activity_type, source_id, target_id, method,
summary, request_body, response_body, tool_trace, duration_ms, status, error_detail, created_at
FROM activity_logs WHERE workspace_id = $1`
args := []interface{}{workspaceID}
argIdx := 2
// WHERE/ORDER column refs use the same `actCol` qualifier prefix
// computed above — empty string when no JOIN (back-compat with
// existing wire shape + sqlmock-regex test fixtures), or
// `activity_logs.` when LEFT JOIN'd (disambiguates `id` /
// `created_at` between the two tables).
if activityType != "" {
query += fmt.Sprintf(" AND "+actCol+"activity_type = $%d", argIdx)
query += fmt.Sprintf(" AND activity_type = $%d", argIdx)
args = append(args, activityType)
argIdx++
}
if source == "canvas" {
query += " AND " + actCol + "source_id IS NULL"
query += " AND source_id IS NULL"
} else if source == "agent" {
query += " AND " + actCol + "source_id IS NOT NULL"
query += " AND source_id IS NOT NULL"
} else if source != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source must be 'canvas' or 'agent'"})
return
@@ -470,7 +224,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
// and avoids duplicate parameter binding (some drivers reject the
// same arg slot reused, ours is fine but the explicit form is
// clearer to read and matches the rest of the builder.)
query += fmt.Sprintf(" AND ("+actCol+"source_id = $%d OR "+actCol+"target_id = $%d)", argIdx, argIdx)
query += fmt.Sprintf(" AND (source_id = $%d OR target_id = $%d)", argIdx, argIdx)
args = append(args, peerID)
argIdx++
}
@@ -478,7 +232,7 @@ func (h *ActivityHandler) List(c *gin.Context) {
// Strictly older — never replay a row with the exact same
// timestamp, mirrors the `created_at > cursorTime` shape
// `since_id` uses for forward paging.
query += fmt.Sprintf(" AND "+actCol+"created_at < $%d", argIdx)
query += fmt.Sprintf(" AND created_at < $%d", argIdx)
args = append(args, beforeTS)
argIdx++
}
@@ -487,13 +241,13 @@ func (h *ActivityHandler) List(c *gin.Context) {
// interpolated into the SQL string. `make_interval(secs => $N)`
// avoids the lib/pq quirk where INTERVAL '$N seconds' won't
// substitute a placeholder inside the literal.
query += fmt.Sprintf(" AND "+actCol+"created_at >= NOW() - make_interval(secs => $%d)", argIdx)
query += fmt.Sprintf(" AND created_at >= NOW() - make_interval(secs => $%d)", argIdx)
args = append(args, sinceSecs)
argIdx++
}
if usingCursor {
// Strictly after — never replay the cursor row itself.
query += fmt.Sprintf(" AND "+actCol+"created_at > $%d", argIdx)
query += fmt.Sprintf(" AND created_at > $%d", argIdx)
args = append(args, cursorTime)
argIdx++
}
@@ -503,9 +257,9 @@ func (h *ActivityHandler) List(c *gin.Context) {
// since_id) keeps DESC — that's the canvas/UI shape and changing it
// would surprise existing callers.
if usingCursor {
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at ASC LIMIT $%d", argIdx)
query += fmt.Sprintf(" ORDER BY created_at ASC LIMIT $%d", argIdx)
} else {
query += fmt.Sprintf(" ORDER BY "+actCol+"created_at DESC LIMIT $%d", argIdx)
query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argIdx)
}
args = append(args, limit)
@@ -518,14 +272,6 @@ func (h *ActivityHandler) List(c *gin.Context) {
}
defer rows.Close()
// agent_card_url base computed once per request so we don't pay the
// header-read cost per row. Only meaningful when includePeerInfo is
// set; the empty string here is harmless when the flag is off.
var platformBase string
if includePeerInfo {
platformBase = externalPlatformURL(c)
}
activities := make([]map[string]interface{}, 0)
for rows.Next() {
var id, wsID, actType, status string
@@ -533,23 +279,10 @@ func (h *ActivityHandler) List(c *gin.Context) {
var reqBody, respBody, toolTrace []byte
var durationMs *int
var createdAt time.Time
// LEFT JOIN'd peer columns — pointer-string so a NULL row
// (canvas message OR deleted peer workspace) decodes as nil
// rather than empty-string. Only scanned when includePeerInfo
// is set (matched against the SELECT clause above).
var peerName, peerRole *string
var scanErr error
if includePeerInfo {
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt,
&peerName, &peerRole)
} else {
scanErr = rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt)
}
if scanErr != nil {
log.Printf("Activity scan error: %v", scanErr)
if err := rows.Scan(&id, &wsID, &actType, &sourceID, &targetID, &method,
&summary, &reqBody, &respBody, &toolTrace, &durationMs, &status, &errorDetail, &createdAt); err != nil {
log.Printf("Activity scan error: %v", err)
continue
}
@@ -575,39 +308,6 @@ func (h *ActivityHandler) List(c *gin.Context) {
if toolTrace != nil {
entry["tool_trace"] = json.RawMessage(toolTrace)
}
// peer_info enrichment (per ?include=peer_info). Only emit the
// new fields when the flag is set — back-compat for callers
// that don't request it.
if includePeerInfo {
// peer_name / peer_role: emit only when present (canvas
// rows have source_id IS NULL → peer_name is NULL by JOIN;
// also a peer workspace may have been deleted since the
// row was written → same NULL outcome). Omit-when-absent
// matches the Layer 3 adaptor's "spread when present"
// pattern; canvas_user rows legitimately have no peer_*.
if peerName != nil && *peerName != "" {
entry["peer_name"] = *peerName
}
if peerRole != nil && *peerRole != "" {
entry["peer_role"] = *peerRole
}
// agent_card_url: constructed server-side from
// externalPlatformURL + source_id. Mirrors the runtime-
// side helper a2a_client._agent_card_url_for which builds
// {PLATFORM_URL}/registry/discover/{peer_id}. Only set
// when source_id is present + non-empty.
if sourceID != nil && *sourceID != "" && platformBase != "" {
entry["agent_card_url"] = platformBase + "/registry/discover/" + *sourceID
}
// attachments: flatten file/image/audio parts from the
// request_body. nil when none — only project when
// non-empty so the omit-when-absent rule holds.
if atts := extractAttachmentsFromRequestBody(reqBody); len(atts) > 0 {
entry["attachments"] = atts
}
}
activities = append(activities, entry)
}
if err := rows.Err(); err != nil {
@@ -1,701 +0,0 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// Tests for the `?include=peer_info` activity-feed enrichment.
//
// The enrichment is additive + opt-in. When the flag is absent, the
// existing tests (TestActivityList_SourceCanvas, etc.) prove the wire
// shape is unchanged. These tests prove:
// - When the flag IS set, the LEFT JOIN is issued and the SELECT
// adds w.name + w.role.
// - peer_name / peer_role surface from the joined row.
// - agent_card_url is composed server-side from
// externalPlatformURL + source_id and appears for non-canvas rows
// (source_id present).
// - attachments[] is projected from request_body.params.message.parts
// for file/image/audio parts.
// - Canvas rows (source_id NULL) do NOT get peer_name / peer_role /
// agent_card_url, but DO still appear in the result set (LEFT JOIN
// preserves them with NULL peer fields).
// - The `include` query param is comma-separable and only recognizes
// known flags.
// ---------- includeFlagSet helper unit tests ----------
func TestIncludeFlagSet(t *testing.T) {
cases := []struct {
query string
flag string
want bool
}{
{"", "peer_info", false},
{"peer_info", "peer_info", true},
{"peer_info,attachments", "peer_info", true},
{"attachments,peer_info", "peer_info", true},
{"attachments , peer_info ", "peer_info", true},
{"peer_infos", "peer_info", false},
{"peerinfo", "peer_info", false},
{"peer_info", "", false},
{",,", "peer_info", false},
}
for _, tc := range cases {
got := includeFlagSet(tc.query, tc.flag)
if got != tc.want {
t.Errorf("includeFlagSet(%q, %q) = %v, want %v", tc.query, tc.flag, got, tc.want)
}
}
}
// ---------- extractAttachmentsFromRequestBody unit tests ----------
func TestExtractAttachmentsFromRequestBody_Empty(t *testing.T) {
if got := extractAttachmentsFromRequestBody(nil); got != nil {
t.Errorf("nil body: want nil, got %v", got)
}
if got := extractAttachmentsFromRequestBody([]byte("")); got != nil {
t.Errorf("empty body: want nil, got %v", got)
}
if got := extractAttachmentsFromRequestBody([]byte("not json")); got != nil {
t.Errorf("non-json body: want nil, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_NoAttachments(t *testing.T) {
// Text-only message: no file/image/audio parts → nil
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`)
if got := extractAttachmentsFromRequestBody(body); got != nil {
t.Errorf("text-only: want nil, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_FileKindV1(t *testing.T) {
// a2a-sdk v1 shape: kind=file, file:{uri,mime_type,name}
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"text","text":"see attached"},
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" {
t.Errorf("kind: want file, got %v", atts[0]["kind"])
}
if atts[0]["uri"] != "workspace:foo.pdf" {
t.Errorf("uri mismatch: %v", atts[0]["uri"])
}
if atts[0]["mime_type"] != "application/pdf" {
t.Errorf("mime_type mismatch: %v", atts[0]["mime_type"])
}
if atts[0]["name"] != "foo.pdf" {
t.Errorf("name mismatch: %v", atts[0]["name"])
}
}
func TestExtractAttachmentsFromRequestBody_ImageAndAudio(t *testing.T) {
// Mixed image + audio parts; both surface
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"image","file":{"uri":"workspace:a.png","mime_type":"image/png","name":"a.png"}},
{"kind":"audio","file":{"uri":"workspace:b.mp3","mime_type":"audio/mpeg","name":"b.mp3"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 2 {
t.Fatalf("want 2 attachments, got %d", len(atts))
}
if atts[0]["kind"] != "image" || atts[1]["kind"] != "audio" {
t.Errorf("kind order: got %v / %v", atts[0]["kind"], atts[1]["kind"])
}
}
func TestExtractAttachmentsFromRequestBody_LegacyV0TypeDiscriminator(t *testing.T) {
// Legacy v0 shape: type=file (not kind), inlined fields (no nested .file)
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"type":"file","uri":"workspace:legacy.txt","mime_type":"text/plain","name":"legacy.txt"}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" || atts[0]["uri"] != "workspace:legacy.txt" || atts[0]["name"] != "legacy.txt" {
t.Errorf("v0 part not surfaced: %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_SkipsEmptyParts(t *testing.T) {
// A "file" part with no uri AND no name is malformed — skip rather
// than emit a no-info entry.
body := []byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"file","file":{}},
{"kind":"file","file":{"name":"only-name.bin"}}
]}}}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment (the named one), got %d", len(atts))
}
if atts[0]["name"] != "only-name.bin" {
t.Errorf("expected only-name.bin, got %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_MalformedShape(t *testing.T) {
// Various malformed shapes return nil (defensive)
for _, b := range []string{
`{}`,
`{"params":{}}`,
`{"params":{"message":{}}}`,
`{"params":{"message":{"parts":"not-a-list"}}}`,
`{"params":{"message":{"parts":[null,42,"string"]}}}`,
} {
if got := extractAttachmentsFromRequestBody([]byte(b)); got != nil {
t.Errorf("body %q: want nil, got %v", b, got)
}
}
}
// ---------- Activity List ?include=peer_info handler tests ----------
func TestActivityList_IncludePeerInfo_IssuesLeftJoin(t *testing.T) {
// When ?include=peer_info is set, the query must:
// 1. SELECT include w.name + w.role aliased as peer_name/peer_role
// 2. FROM contains LEFT JOIN workspaces w ON w.id = activity_logs.source_id
// 3. WHERE uses qualified activity_logs.workspace_id (disambiguates
// from workspaces.id post-JOIN)
//
// Pin all three so a future refactor can't silently drop the JOIN or
// the alias and have the test still pass.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
peerID := "11111111-2222-3333-4444-555555555555"
mock.ExpectQuery(
`SELECT .+w\.name AS peer_name, w\.role AS peer_role FROM activity_logs LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id WHERE activity_logs\.workspace_id = .+`,
).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
AddRow("act-1", "ws-1", "a2a_receive", peerID, "ws-1",
"message/send", "Agent message: hello",
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[{"kind":"text","text":"hello"}]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
"Production Manager", "product manager"))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
c.Request.Host = "platform.test"
c.Request.Header.Set("X-Forwarded-Proto", "https")
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
if r["peer_name"] != "Production Manager" {
t.Errorf("peer_name: got %v", r["peer_name"])
}
if r["peer_role"] != "product manager" {
t.Errorf("peer_role: got %v", r["peer_role"])
}
wantURL := "https://platform.test/registry/discover/" + peerID
if r["agent_card_url"] != wantURL {
t.Errorf("agent_card_url: got %v, want %v", r["agent_card_url"], wantURL)
}
// Text-only message has no attachments → omit from envelope
if _, present := r["attachments"]; present {
t.Errorf("attachments should be omitted on text-only row; got %v", r["attachments"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_CanvasRowHasNoPeerFields(t *testing.T) {
// LEFT JOIN preserves canvas rows (source_id NULL) but their
// peer_name/peer_role come back as NULL — must omit from the
// envelope (not emit empty strings or null literals).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(
`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`,
).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
// source_id NULL = canvas message; peer columns also NULL.
AddRow("act-canvas", "ws-1", "a2a_receive", nil, "ws-1",
"notify", "User said hi",
[]byte(`{"params":{"message":{"parts":[{"kind":"text","text":"hi"}]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
nil, nil))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
if _, present := r[k]; present {
t.Errorf("%s should be absent on canvas row; got %v", k, r[k])
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_AttachmentsSurfaceFromRequestBody(t *testing.T) {
// A peer_agent message with an inline file attachment must have
// attachments[] populated on the envelope.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
peerID := "11111111-2222-3333-4444-555555555555"
mock.ExpectQuery(`LEFT JOIN workspaces`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
AddRow("act-with-file", "ws-1", "a2a_receive", peerID, "ws-1",
"message/send", "Agent message: see attached",
[]byte(`{"jsonrpc":"2.0","method":"message/send","params":{"message":{"parts":[
{"kind":"text","text":"see attached"},
{"kind":"file","file":{"uri":"workspace:foo.pdf","mime_type":"application/pdf","name":"foo.pdf"}}
]}}}`),
nil, nil, nil, "ok", nil, time.Now(),
"Code Reviewer", "code reviewer"))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
r := resp[0]
atts, ok := r["attachments"].([]interface{})
if !ok {
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
}
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
}
att := atts[0].(map[string]interface{})
if att["kind"] != "file" || att["uri"] != "workspace:foo.pdf" || att["name"] != "foo.pdf" {
t.Errorf("attachment shape: %v", att)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_Unset_NoJoinNoExtraFields(t *testing.T) {
// Back-compat — when ?include=peer_info is NOT passed, the SELECT
// uses unqualified column refs (no `activity_logs.` prefix) AND no
// JOIN. Existing tests pass this implicitly; this test pins it
// explicitly so a future refactor that accidentally turns the JOIN
// always-on gets caught.
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
// Regex pinned: "FROM activity_logs WHERE workspace_id" — no JOIN
// keyword between FROM and WHERE; no `activity_logs.` qualifier on
// workspace_id.
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
}).
AddRow("act-1", "ws-1", "a2a_receive", "11111111-2222-3333-4444-555555555555", "ws-1",
"message/send", "Hello",
nil, nil, nil, nil, "ok", nil, time.Now()))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
// Confirm no peer_info enrichment leaks into the default envelope.
for _, k := range []string{"peer_name", "peer_role", "agent_card_url", "attachments"} {
if _, present := resp[0][k]; present {
t.Errorf("%s must NOT appear without ?include=peer_info; got %v", k, resp[0][k])
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestActivityList_IncludePeerInfo_UnknownFlagIgnored(t *testing.T) {
// ?include=bogus must NOT issue the JOIN — only the recognized
// `peer_info` flag triggers enrichment. The unknown flag is silently
// ignored (additive, opt-in convention).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(`SELECT id, workspace_id,.+ FROM activity_logs WHERE workspace_id = .+`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
}))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=bogus", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ---------- flat upload manifest (chat_upload_receive) tests ----------
func TestKindFromMimeType(t *testing.T) {
cases := []struct {
mime string
want string
}{
{"image/png", "image"},
{"image/jpeg", "image"},
{"image/", "image"}, // prefix-only is still image
{"audio/mpeg", "audio"},
{"audio/wav", "audio"},
{"video/mp4", "video"},
{"video/webm", "video"},
{"application/pdf", "file"},
{"text/plain", "file"},
{"", "file"},
{"unknown", "file"},
{"image", "file"}, // no slash → not a prefix match
}
for _, tc := range cases {
if got := kindFromMimeType(tc.mime); got != tc.want {
t.Errorf("kindFromMimeType(%q) = %q, want %q", tc.mime, got, tc.want)
}
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Image(t *testing.T) {
// Canvas chat_upload_receive shape: flat manifest at request_body
// root with camelCase mimeType. The empirical example was a PNG
// pasted into the canvas; surfaces here with kind=image,
// mime_type=image/png (snake-case normalized), uri preserved.
body := []byte(`{
"uri":"platform-pending:091a9180-/26111d48-",
"name":"pasted-2026-05-21T23-12-25-0-0.png",
"size":677133,
"file_id":"26111d48-",
"mimeType":"image/png"
}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d: %v", len(atts), atts)
}
att := atts[0]
if att["kind"] != "image" {
t.Errorf("kind: want image, got %v", att["kind"])
}
if att["uri"] != "platform-pending:091a9180-/26111d48-" {
t.Errorf("uri: %v", att["uri"])
}
if att["mime_type"] != "image/png" {
t.Errorf("mime_type normalization (camelCase→snake_case) failed: %v", att["mime_type"])
}
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
t.Errorf("name: %v", att["name"])
}
// camelCase `mimeType` MUST NOT leak into the projected envelope —
// only snake_case `mime_type` is the wire convention.
if _, present := att["mimeType"]; present {
t.Errorf("camelCase mimeType leaked into envelope: %v", att)
}
if _, present := att["file_id"]; present {
t.Errorf("file_id should not be surfaced on the attachment envelope (it's a canvas-internal id): %v", att)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Audio(t *testing.T) {
body := []byte(`{"uri":"platform-pending:ws/file","name":"voice.mp3","file_id":"abc","mimeType":"audio/mpeg"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "audio" {
t.Fatalf("want audio kind, got %v", atts)
}
if atts[0]["mime_type"] != "audio/mpeg" {
t.Errorf("mime_type: %v", atts[0]["mime_type"])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_Video(t *testing.T) {
body := []byte(`{"uri":"platform-pending:ws/file","name":"clip.mp4","file_id":"abc","mimeType":"video/mp4"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "video" {
t.Fatalf("want video kind, got %v", atts)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_GenericFile(t *testing.T) {
// application/pdf has no image/audio/video prefix → kind=file
body := []byte(`{"uri":"platform-pending:ws/file","name":"doc.pdf","file_id":"abc","mimeType":"application/pdf"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 || atts[0]["kind"] != "file" {
t.Fatalf("want file kind, got %v", atts)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_NoMimeFallsToFile(t *testing.T) {
// No mimeType at all — kind defaults to "file", mime_type omitted.
body := []byte(`{"uri":"platform-pending:ws/file","name":"unknown.bin","file_id":"abc"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["kind"] != "file" {
t.Errorf("kind: want file (default), got %v", atts[0]["kind"])
}
if _, present := atts[0]["mime_type"]; present {
t.Errorf("mime_type should be omitted when source has none, got %v", atts[0]["mime_type"])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_SnakeCaseMimeTypeAccepted(t *testing.T) {
// Defensive: a future canvas version (or non-canvas caller) that
// already emits snake_case mime_type should still be parsed.
body := []byte(`{"uri":"u","name":"n.png","mime_type":"image/png"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["mime_type"] != "image/png" || atts[0]["kind"] != "image" {
t.Errorf("snake_case mime_type not honored: %v", atts[0])
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_FileIDOnlyIsSkipped(t *testing.T) {
// file_id alone (no uri AND no name) is non-actionable — the
// downstream adaptor can't render a discoverable file from just an
// internal canvas id. Skip per the same minimum-info rule the
// message-parts arm applies to empty parts.
body := []byte(`{"file_id":"orphan-uuid","mimeType":"image/png"}`)
if got := extractAttachmentsFromRequestBody(body); got != nil {
t.Errorf("file_id-only manifest must be skipped, got %v", got)
}
}
func TestExtractAttachmentsFromRequestBody_FlatUpload_NameOnlyIsKept(t *testing.T) {
// Symmetric with the message-parts arm: a name without uri is still
// useful (the downstream adaptor can render "user uploaded foo.png").
body := []byte(`{"name":"only-name.bin","file_id":"abc","mimeType":"application/octet-stream"}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment, got %d", len(atts))
}
if atts[0]["name"] != "only-name.bin" {
t.Errorf("name not preserved: %v", atts[0])
}
if _, present := atts[0]["uri"]; present {
t.Errorf("uri should be omitted when absent in source, got %v", atts[0]["uri"])
}
}
func TestExtractAttachmentsFromRequestBody_MessagePartsTakesPrecedenceOverFlat(t *testing.T) {
// If a single request_body somehow has BOTH params.message.parts[]
// AND top-level uri/file_id (a pathological inbound), the
// message-parts arm wins — that's the documented inbound shape and
// it's been the only one historically extracted. The flat arm is a
// fallback for shapes that have NO parts.
body := []byte(`{
"uri":"platform-pending:should-not-win",
"file_id":"x",
"mimeType":"image/png",
"params":{"message":{"parts":[
{"kind":"file","file":{"uri":"workspace:should-win.pdf","mime_type":"application/pdf","name":"win.pdf"}}
]}}
}`)
atts := extractAttachmentsFromRequestBody(body)
if len(atts) != 1 {
t.Fatalf("want 1 attachment (from parts[]), got %d: %v", len(atts), atts)
}
if atts[0]["uri"] != "workspace:should-win.pdf" {
t.Errorf("message-parts arm did not take precedence: %v", atts[0])
}
}
func TestActivityList_IncludePeerInfo_ChatUploadReceiveCanvasRow(t *testing.T) {
// Wire-level integration: a canvas chat_upload_receive row (canvas
// user pasted an image) with source_id NULL (canvas message), flat
// upload manifest at request_body root. The `?include=peer_info`
// projection must surface attachments[] populated from the flat-
// upload-manifest arm while peer_name / peer_role / agent_card_url
// remain absent (canvas row has no peer).
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewActivityHandler(broadcaster)
mock.ExpectQuery(`LEFT JOIN workspaces w ON w\.id = activity_logs\.source_id`).
WithArgs("ws-1", 100).
WillReturnRows(sqlmock.NewRows([]string{
"id", "workspace_id", "activity_type", "source_id", "target_id",
"method", "summary", "request_body", "response_body",
"tool_trace", "duration_ms", "status", "error_detail", "created_at",
"peer_name", "peer_role",
}).
// Empirical shape from 2026-05-21 ~23:12Z agents-team canvas paste.
AddRow("act-upload", "ws-1", "chat_upload_receive", nil, "ws-1",
"chat_upload_receive", "Canvas upload: pasted-2026-05-21T23-12-25-0-0.png",
[]byte(`{
"uri":"platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd",
"name":"pasted-2026-05-21T23-12-25-0-0.png",
"size":677133,
"file_id":"26111d48-aaaa-bbbb-cccc-dddddddddddd",
"mimeType":"image/png"
}`),
nil, nil, nil, "ok", nil, time.Now(),
nil, nil))
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-1/activity?include=peer_info", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("want 1 row, got %d", len(resp))
}
r := resp[0]
// Canvas row → no peer fields.
for _, k := range []string{"peer_name", "peer_role", "agent_card_url"} {
if _, present := r[k]; present {
t.Errorf("%s must NOT appear on canvas upload row; got %v", k, r[k])
}
}
// attachments[] populated from the flat-upload arm.
atts, ok := r["attachments"].([]interface{})
if !ok {
t.Fatalf("attachments missing or wrong type: %T %v", r["attachments"], r["attachments"])
}
if len(atts) != 1 {
t.Fatalf("want 1 attachment from flat manifest, got %d: %v", len(atts), atts)
}
att := atts[0].(map[string]interface{})
if att["kind"] != "image" {
t.Errorf("kind: want image (image/png prefix), got %v", att["kind"])
}
if att["mime_type"] != "image/png" {
t.Errorf("mime_type wire shape: want snake_case image/png, got %v", att["mime_type"])
}
if att["uri"] != "platform-pending:091a9180-b303-4a20-aefe-3a4a675b8aa4/26111d48-aaaa-bbbb-cccc-dddddddddddd" {
t.Errorf("uri preserved verbatim: got %v", att["uri"])
}
if att["name"] != "pasted-2026-05-21T23-12-25-0-0.png" {
t.Errorf("name: %v", att["name"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// Sanity test using the existing test broadcaster setup — verifies the
// extractAttachments helper round-trips through json.Marshal cleanly
// (no map ordering issues, no type-coercion surprises).
func TestExtractAttachmentsFromRequestBody_RoundTripsThroughJSON(t *testing.T) {
body := []byte(`{"params":{"message":{"parts":[{"kind":"file","file":{"uri":"workspace:r.bin","mime_type":"application/octet-stream","name":"r.bin"}}]}}}`)
atts := extractAttachmentsFromRequestBody(body)
b, err := json.Marshal(atts)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded []map[string]interface{}
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(decoded) != 1 || decoded[0]["uri"] != "workspace:r.bin" {
t.Fatalf("round-trip mismatch: %v", decoded)
}
_ = fmt.Sprintf // keep fmt import live if test trimming removes usage
}
@@ -39,7 +39,6 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "production")
t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -59,7 +58,6 @@ func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) {
func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("missing").
@@ -77,7 +75,6 @@ func TestAdminTestToken_WorkspaceNotFound(t *testing.T) {
func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) {
mock := setupTestDB(t)
t.Setenv("MOLECULE_ENV", "development")
t.Setenv("ADMIN_TOKEN", "")
mock.ExpectQuery("SELECT id FROM workspaces WHERE id =").
WithArgs("ws-1").
@@ -1,72 +0,0 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
// AdminWorkspaceTokenHandler lets tenant admins mint the first workspace
// bearer for managed SaaS workspaces whose runtime receives its token later
// through registry registration.
type AdminWorkspaceTokenHandler struct{}
func NewAdminWorkspaceTokenHandler() *AdminWorkspaceTokenHandler {
return &AdminWorkspaceTokenHandler{}
}
// Create handles POST /admin/workspaces/:id/tokens. The route must be mounted
// behind AdminAuth; the plaintext token is returned exactly once.
func (h *AdminWorkspaceTokenHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
if !validWorkspaceID(workspaceID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace id"})
return
}
var existing string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT id FROM workspaces WHERE id = $1 AND status <> 'removed'`,
workspaceID).Scan(&existing)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
log.Printf("admin workspace tokens: workspace lookup failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "workspace lookup failed"})
return
}
var count int
if err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COUNT(*) FROM workspace_auth_tokens WHERE workspace_id = $1 AND revoked_at IS NULL`,
workspaceID).Scan(&count); err != nil {
log.Printf("admin workspace tokens: count failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count tokens"})
return
}
if count >= maxTokensPerWorkspace {
c.JSON(http.StatusTooManyRequests, gin.H{"error": fmt.Sprintf("maximum %d active tokens per workspace", maxTokensPerWorkspace)})
return
}
token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID)
if err != nil {
log.Printf("admin workspace tokens: issue failed for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
return
}
log.Printf("admin workspace tokens: issued token for workspace %s", workspaceID)
c.JSON(http.StatusCreated, gin.H{
"auth_token": token,
"workspace_id": workspaceID,
"message": "Save this token now — it cannot be retrieved again.",
})
}
@@ -1,102 +0,0 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func TestAdminWorkspaceTokenHandler_Create_HappyPath(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WithArgs(wsUUID1, sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var body struct {
AuthToken string `json:"auth_token"`
WorkspaceID string `json:"workspace_id"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.AuthToken == "" || body.WorkspaceID != wsUUID1 {
t.Fatalf("unexpected body: %+v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet: %v", err)
}
}
func TestAdminWorkspaceTokenHandler_Create_MissingWorkspace(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminWorkspaceTokenHandler_Create_RateLimited(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(maxTokensPerWorkspace))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d: %s", w.Code, w.Body.String())
}
}
func TestAdminWorkspaceTokenHandler_Create_IssueFails(t *testing.T) {
mock, cleanup := withMockDB(t)
defer cleanup()
mock.ExpectQuery(`SELECT id FROM workspaces WHERE id = \$1 AND status <> 'removed'`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(wsUUID1))
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
WithArgs(wsUUID1).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(`INSERT INTO workspace_auth_tokens`).
WillReturnError(errors.New("disk full"))
w := makeReq(t, NewAdminWorkspaceTokenHandler().Create, "POST",
"/admin/workspaces/"+wsUUID1+"/tokens", gin.Params{{Key: "id", Value: wsUUID1}})
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
@@ -104,9 +104,6 @@ func (h *ChannelHandler) List(c *gin.Context) {
}
result = append(result, entry)
}
if err := rows.Err(); err != nil {
log.Printf("Channels: list rows.Err: %v", err)
}
c.JSON(http.StatusOK, result)
}
@@ -517,9 +514,6 @@ func (h *ChannelHandler) Webhook(c *gin.Context) {
candidates = append(candidates, row)
}
}
if err := rows.Err(); err != nil {
log.Printf("Channels: telegram webhook rows.Err: %v", err)
}
if targetSlug != "" {
// [slug] routing — match against config username (lowercased)
@@ -393,9 +393,6 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
result = append(result, peer)
}
if err := rows.Err(); err != nil {
log.Printf("queryPeerMaps rows.Err: %v", err)
}
return result, nil
}
@@ -49,9 +49,6 @@ func (h *EventsHandler) List(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("Events list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -90,8 +87,5 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
"created_at": createdAt,
})
}
if err := rows.Err(); err != nil {
log.Printf("WorkspaceEvents list rows error: %v", err)
}
c.JSON(http.StatusOK, events)
}
@@ -216,102 +216,69 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
const externalChannelTemplate = `# Claude Code channel bridges this workspace's A2A traffic into your
# Claude Code session. No tunnel/public URL needed (polling-based).
#
# Prereq: Bun 1.3+ installed (channel plugins are Bun scripts).
# bun --version # must print a version (1.3.x or newer)
# Prereq: Bun installed (channel plugins are Bun scripts).
# bun --version # must print a version number
#
# 1. Inside Claude Code, install the channel plugin. The plugin lives in
# Molecule's own Gitea marketplace (not Anthropic's default), so a
# one-time marketplace-add is needed before install:
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
# The plugin is NOT on Anthropic's default allowlist, so a one-time
# marketplace-add is needed before install:
#
# /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git
# /plugin install molecule@molecule-channel
#
# Then /reload-plugins (or restart Claude Code) so the plugin is
# registered.
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
#
# 2. Create (or extend) the per-host config file. The canonical SSOT
# shape is MOLECULE_WORKSPACES_JSON a JSON array of
# {id, token, platform_url} objects. One plugin instance can watch
# many workspaces across many tenants; append more objects to the
# array (separate them with commas, NOT a newline):
# 2. Create the per-watched-workspace config file:
mkdir -p ~/.claude/channels/molecule
cat > ~/.claude/channels/molecule/.env <<'EOF'
MOLECULE_WORKSPACES_JSON=[{"id":"{{WORKSPACE_ID}}","token":"<paste auth_token from create response>","platform_url":"{{PLATFORM_URL}}"}]
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
MOLECULE_WORKSPACE_IDS={{WORKSPACE_ID}}
MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
EOF
chmod 600 ~/.claude/channels/molecule/.env
# (Legacy single-platform shape MOLECULE_PLATFORM_URL + comma-separated
# MOLECULE_WORKSPACE_IDS + MOLECULE_WORKSPACE_TOKENS is still supported
# for back-compat but does NOT work across multiple tenant URLs. Use
# MOLECULE_WORKSPACES_JSON above unless you have a specific reason.)
# 3. Launch Claude Code with the channel enabled. The channel spec is the
# VALUE of --dangerously-load-development-channels NOT a separate
# --channels flag (that flag does not exist in current Claude Code;
# passing it errors with "entries must be tagged: --channels").
claude --dangerously-load-development-channels plugin:molecule@molecule-channel
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
# allowlisted) channels need the --dangerously-load-development-channels
# flag to opt in without it, you'll see "not on the approved channels
# allowlist" on startup.
claude --dangerously-load-development-channels \
--channels plugin:molecule@molecule-channel
# You should see on stderr:
# molecule channel: connected watching N workspace(s) across M platform(s)
# targets: <platform_url>: <workspace_id>
# molecule channel: connected watching 1 workspace(s) at {{PLATFORM_URL}}
#
# Inbound A2A messages now surface as conversation turns (synthetic
# <channel ...> tags). Claude's replies route back via the
# reply_to_workspace / send_message_to_user MCP tools.
#
# Multi-workspace note: when watching more than one workspace, every
# outbound tool call (send_message_to_user, reply_to_workspace,
# delegate_task, list_peers) MUST pass _as_workspace=<id> so the plugin
# knows which token to authenticate with. The host returns -32603 if you
# forget the synthetic <channel> tag's "watching_as" attribute tells
# you which id to use.
# Inbound A2A messages now surface as conversation turns. Claude's
# replies route back via the reply_to_workspace MCP tool no extra
# wiring on your side.
#
# Common errors:
# "plugin not installed" Step 1 didn't run; run /plugin
# marketplace add + /plugin install
# "plugin not installed" Step 1 didn't run; run /plugin install
# inside Claude Code, then /reload-plugins.
# "entries must be tagged" You passed --channels separately.
# Put plugin:molecule@molecule-channel
# directly after
# --dangerously-load-development-channels.
# "not on approved channels allowlist" Org policy gating. See "managed
# settings" note below.
# "config-missing" ~/.claude/channels/molecule/.env
# not readable; re-run Step 2 and check
# chmod 600.
# "not on approved channels allowlist" Add --dangerously-load-development-channels
# to the launch command (Step 3).
# "config-missing" ~/.claude/channels/molecule/.env not
# readable; re-run Step 2 and check chmod.
#
# Team/Enterprise plans: the channel allowlist is gated by org policy
# AND must be written to the local managed-settings.json file on disk
# (not the claude.ai web admin UI there is no web toggle for this).
# Path per OS:
# macOS: /Library/Application Support/ClaudeCode/managed-settings.json
# Linux: /etc/claude-code/managed-settings.json
# Windows: C:\ProgramData\ClaudeCode\managed-settings.json
# Set channelsEnabled: true and add
# { "plugin": "molecule", "marketplace": "molecule-channel" }
# to allowedChannelPlugins. Restart Claude Code after writing the file.
# A user-level ~/.claude/settings.json does NOT work on Team/Enterprise
# this is the single most common reason a freshly-installed plugin
# appears to do nothing.
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
# blocked by managed settings. Your admin must set channelsEnabled=true and
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
#
# Pro/Max plans skip the channelsEnabled gate but still need the
# allowedChannelPlugins entry in the managed-settings file.
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
# Need help?
# Documentation: https://doc.moleculesai.app/docs/guides/claude-code-channel-plugin
# Full README: https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel
# Common errors:
# "plugin not installed" run /plugin marketplace add then
# /plugin install lines above; /reload-plugins or restart.
# "entries must be tagged: --channels" the launch flag form
# changed; use --dangerously-load-development-channels plugin:molecule@molecule-channel
# (channel spec is the VALUE, not a separate --channels flag).
# "not on the approved channels allowlist" custom channels need
# allowedChannelPlugins in /Library/Application Support/ClaudeCode/managed-settings.json
# (macOS) / equivalent on Linux+Windows. NOT a web setting.
# --dangerously-load-development-channels; team/enterprise orgs
# need admin to set channelsEnabled + allowedChannelPlugins.
# "Inbound messages not arriving" stderr should show
# "molecule channel: connected — watching N workspace(s)";
# verify ~/.claude/channels/molecule/.env shape is MOLECULE_WORKSPACES_JSON.
# verify ~/.claude/channels/molecule/.env has PLATFORM_URL + token.
`
// externalUniversalMcpTemplate — runtime-agnostic standalone path.
@@ -703,15 +670,7 @@ def heartbeat(client, url, ws, tok, start):
r.raise_for_status()
def poll_inbound(client, url, ws, tok, since_id):
# include=peer_info opts into Layer 1's row-level projection so each
# polled activity carries peer_name, peer_role, agent_card_url, and
# attachments[] inline (when source_id resolves to a peer / when the
# message included a file). Pre-Layer-1 platforms ignore unknown query
# params and return the bare row shape, so this is back-compat. Use
# the extra fields in your reply logic e.g. address the sender by
# peer_name rather than UUID, or Read attached files via the workspace:
# URIs in attachments[].
params = {"since_secs": "30", "limit": "50", "include": "peer_info"}
params = {"since_secs": "30", "limit": "50"}
if since_id:
params["since_id"] = since_id
r = client.get(f"{url}/workspaces/{ws}/activity", params=params, headers=hdrs(url, tok))
@@ -778,16 +737,10 @@ python3 ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/kimi_bridge.py
# What the script does:
# Registers the workspace in poll mode (no public URL needed)
# Heartbeats every 20s to keep STATUS = online on the canvas
# Polls /workspaces/:id/activity?include=peer_info every 5s Layer 1
# enrichment surfaces peer_name / peer_role / agent_card_url /
# attachments[] inline on each polled row when applicable
# Polls /workspaces/:id/activity every 5s for new canvas messages
# Echo-replies via POST /workspaces/:id/notify
#
# To change the reply logic, edit the send_reply() call inside the loop.
# Each polled item has top-level peer_name / peer_role / agent_card_url
# fields (peer_agent rows) and attachments[] (any kind) when Layer 1 is
# enabled on the platform use them to disambiguate senders and to Read
# attached files via the workspace: URIs.
# To send a one-off reply from another terminal:
# curl -fsS -X POST "{{PLATFORM_URL}}/workspaces/{{WORKSPACE_ID}}/notify" \
# -H "Authorization: Bearer $(cat ~/.molecule-ai/kimi-{{MCP_SERVER_NAME}}/env | grep TOKEN | cut -d= -f2)" \
@@ -118,86 +118,3 @@ func TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs(t *testing.T) {
}
}
}
// TestExternalChannelTemplate_LaunchFlagShape pins the Claude Code channel
// snippet to the working launch invocation. The channel spec must be the
// VALUE of --dangerously-load-development-channels, NOT a separate
// --channels flag. The two-flag form (`--dangerously-load-development-channels
// --channels plugin:molecule@...`) errors with "entries must be tagged:
// --channels" on current Claude Code builds (2.1.143+) and silently no-ops
// on older ones — either way, new users hit a wall on first launch.
//
// Empirical: hit by a session walking through this exact snippet 2026-05-21;
// the broken form was copy-pasted from this template, ran, errored, and
// confused the operator into believing the plugin install was broken when
// the snippet itself was the bug.
func TestExternalChannelTemplate_LaunchFlagShape(t *testing.T) {
// The broken two-flag form. If this string ever appears in the
// snippet again, the same onboarding pothole returns.
bannedFormBroken := "--dangerously-load-development-channels \\\n --channels plugin:molecule@molecule-channel"
if strings.Contains(externalChannelTemplate, bannedFormBroken) {
t.Errorf("externalChannelTemplate contains the broken two-flag launch form. " +
"Use --dangerously-load-development-channels plugin:molecule@molecule-channel (spec as value, not a separate --channels flag).")
}
// The single-flag form must be present.
requiredFormGood := "--dangerously-load-development-channels plugin:molecule@molecule-channel"
if !strings.Contains(externalChannelTemplate, requiredFormGood) {
t.Errorf("externalChannelTemplate must contain %q so operators see the working launch invocation", requiredFormGood)
}
}
// TestExternalChannelTemplate_CanonicalEnvShape pins the canvas-served
// .env example to the canonical SSOT shape (MOLECULE_WORKSPACES_JSON)
// rather than the legacy single-platform shape. The legacy form
// (MOLECULE_PLATFORM_URL + comma-separated IDs/TOKENS) is still accepted
// by the channel plugin's parseWorkspaceTargets but is single-tenant
// only — it silently fails to onboard users who want to watch multiple
// platforms (e.g. hongming + agents-team from the same plugin instance),
// which is the post-PR#15 expected use case.
func TestExternalChannelTemplate_CanonicalEnvShape(t *testing.T) {
if !strings.Contains(externalChannelTemplate, "MOLECULE_WORKSPACES_JSON=") {
t.Errorf("externalChannelTemplate must use MOLECULE_WORKSPACES_JSON as the canonical .env shape (the post-PR#15 SSOT)")
}
// The JSON example must contain the workspace_id + platform_url placeholders
// so the canvas substitutes them at serve time.
for _, ph := range []string{"{{WORKSPACE_ID}}", "{{PLATFORM_URL}}"} {
if !strings.Contains(externalChannelTemplate, ph) {
t.Errorf("externalChannelTemplate must contain placeholder %q so the canvas substitutes per-workspace values", ph)
}
}
}
// TestPollingTemplates_OptIntoPeerInfo pins the invariant that any template
// which calls /workspaces/:id/activity for inbound delivery requests the
// Layer 1 enrichment via ?include=peer_info. Without this opt-in, the
// platform returns bare activity rows and the operator's bridge / channel
// loses peer_name / peer_role / agent_card_url / attachments[] — they're
// available on the server but not delivered.
//
// Pre-Layer-1 platforms ignore unknown query params (HTTP spec: filters
// not understood are dropped), so this is back-compat across deploys.
//
// The Claude Code channel template doesn't include the poll URL in this
// snippet — its polling lives in the plugin's own server.ts (handled by
// molecule-mcp-claude-channel PR#21). The Kimi template DOES include a
// poll loop in its kimi_bridge.py block, so the invariant applies there.
func TestPollingTemplates_OptIntoPeerInfo(t *testing.T) {
pollingTemplates := map[string]string{
"externalKimiTemplate": externalKimiTemplate,
}
for name, body := range pollingTemplates {
// If the snippet polls /activity, it must opt into peer_info.
// The detection is intentionally loose ("/activity" appears in
// the script) — operators who customize the script keep the
// invariant only if the include hint is in the template.
if !strings.Contains(body, "/activity") {
t.Errorf("%s no longer polls /activity — review whether this test still applies", name)
continue
}
if !strings.Contains(body, `"include": "peer_info"`) && !strings.Contains(body, "include=peer_info") {
t.Errorf("%s polls /activity without ?include=peer_info — operators lose Layer 1 enrichment "+
"(peer_name / peer_role / agent_card_url / attachments[]). Add the param to the poll URL.", name)
}
}
}
@@ -33,7 +33,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Child Agent", nil, 3, "langgraph", sqlmock.AnyArg(), &parentID, nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -44,7 +44,7 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Child Agent","model":"anthropic:claude-opus-4-7","parent_id":"parent-ws-123"}`
body := `{"name":"Child Agent","parent_id":"parent-ws-123"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -69,7 +69,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
mock.ExpectBegin()
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "CC Agent", nil, 2, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -80,7 +80,7 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","model":"sonnet","canvas":{"x":10,"y":20}}`
body := `{"name":"CC Agent","tier":2,"runtime":"claude-code","canvas":{"x":10,"y":20}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -230,7 +230,7 @@ func TestWorkspaceList_WithData(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// 24 cols — compute added after talk_to_user_enabled.
// 23 cols — broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -238,13 +238,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
nil, 3, 1, 0.02, "", 7200, "processing", "langgraph", "", 10.0, 20.0, false, nil, int64(0), false, true).
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true)
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -291,7 +291,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
WithArgs(sqlmock.AnyArg(), "Leader Agent", nil, 3, "claude-code", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), 3, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -301,7 +301,7 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Leader Agent","runtime":"claude-code","model":"sonnet","max_concurrent_tasks":3}`
body := `{"name":"Leader Agent","runtime":"claude-code","max_concurrent_tasks":3}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -777,103 +777,6 @@ func TestCreate_FieldValidation_Returns400(t *testing.T) {
}
}
// TestCreate_ModelRequired_Returns422 pins the CTO 2026-05-22 SSOT
// directive (feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is required user input; the platform must not supply a default,
// the runtime must not fall back. Empirical trigger: Code Reviewer
// 5ba15d7e was created with `{"name":..., "runtime":"codex", ...}` (no
// model). The legacy DefaultModel fallback returned "anthropic:claude-opus-4-7"
// and codex adapter wedged forever — `picks provider='anthropic' but it
// is not in the providers registry`. The gate at the Create boundary
// turns that silent stuck-workspace failure into an immediate 422 the
// caller can react to.
//
// Three shapes covered:
// 1. bare name (no template, no runtime, no model) — formerly defaulted
// to langgraph + anthropic; now 422 because model is unspecified.
// 2. explicit runtime, no model — the Code Reviewer repro shape.
// 3. explicit runtime+template path, but template (when missing on
// disk or unreadable) would leave model empty — exercised here by
// pointing at a non-existent template under /tmp/configs.
func TestCreate_ModelRequired_Returns422(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", "/tmp/configs")
cases := []struct{ label, body string }{
{"bare_name_no_runtime_no_model", `{"name":"x"}`},
{"explicit_codex_no_model", `{"name":"Code Reviewer","role":"code reviewer","runtime":"codex","tier":4,"max_concurrent_tasks":1}`},
{"explicit_hermes_no_model", `{"name":"researcher","runtime":"hermes"}`},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(tc.body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Errorf("Create(%s): want 422 MODEL_REQUIRED, got %d: %s", tc.label, w.Code, w.Body.String())
return
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("Create(%s): want body containing code=MODEL_REQUIRED, got %s", tc.label, w.Body.String())
}
})
}
}
// TestCreate_ExternalRuntime_NoModel_OK pins the external-runtime
// exemption from the MODEL_REQUIRED gate. External workspaces
// intentionally do not spawn a Docker container or run an adapter;
// they delegate to a registered URL (workspace_provision.go:497-498:
// "external is a first-class runtime that intentionally does NOT
// spawn a Docker container"). The model field has no meaning for
// them — the URL is the contract, and the gate would 422 every
// legitimate "register my agent at https://..." flow.
//
// Both spellings count as external:
// 1. payload.External == true (the canonical flag, e.g. with any runtime)
// 2. payload.Runtime == "external" (legacy shape some E2E scripts still use)
//
// The isExternalLikeRuntime() helper catches both "external" and any
// future external-like runtime alias.
func TestCreate_ExternalRuntime_NoModel_OK(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// External=true with explicit runtime — the test_api.sh / Echo Agent shape.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Echo Agent","tier":1,"runtime":"external","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("external workspace without model: want 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
func TestUpdate_FieldValidation_Returns400(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
@@ -364,11 +364,11 @@ func TestWorkspaceCreate(t *testing.T) {
// Expect transaction begin for atomic workspace+secrets creation
mock.ExpectBegin()
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime).
// Expect workspace INSERT (uuid is dynamic, use AnyArg for id, runtime, awareness_namespace).
// Default tier is 3 (Privileged) — see workspace.go create-handler comment.
// delivery_mode defaults to "push" when payload omits it (#2339).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Test Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
// Expect transaction commit (no secrets in this payload)
@@ -386,13 +386,7 @@ func TestWorkspaceCreate(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
// Note: model is now required at the Create boundary (CTO 2026-05-22
// SSOT directive — see feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422). This test happens to take
// the bare-defaults path (no template, no runtime → langgraph), so
// the body must declare an explicit model. Using a langgraph-compatible
// id; the test doesn't exercise model semantics beyond presence.
body := `{"name":"Test Agent","model":"anthropic:claude-opus-4-7","canvas":{"x":100,"y":200}}`
body := `{"name":"Test Agent","canvas":{"x":100,"y":200}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -412,17 +406,24 @@ func TestWorkspaceCreate(t *testing.T) {
if resp["id"] == nil || resp["id"] == "" {
t.Error("expected non-empty id in response")
}
if resp["awareness_namespace"] != "workspace:"+resp["id"].(string) {
t.Errorf("expected awareness namespace derived from workspace id, got %v", resp["awareness_namespace"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
func TestBuildProvisionerConfig_IncludesAwarenessSettings(t *testing.T) {
setupTestDB(t)
// runtime_image_pins reader removed by RFC internal#617 / task #335
// — CP is the SSOT for runtime image pins. No DB lookup here anymore.
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
t.Setenv("AWARENESS_URL", "http://awareness:37800")
t.Setenv("WORKSPACE_DIR", "/tmp/workspace")
cfg := handler.buildProvisionerConfig(
@@ -433,10 +434,17 @@ func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code", WorkspaceDir: "/tmp/workspace", WorkspaceAccess: "read_write"},
map[string]string{"OPENAI_API_KEY": "sk-test"},
"/tmp/plugins",
"workspace:ws-123",
)
if cfg.AwarenessURL != "http://awareness:37800" {
t.Fatalf("expected awareness URL to be injected, got %q", cfg.AwarenessURL)
}
if cfg.AwarenessNamespace != "workspace:ws-123" {
t.Fatalf("expected awareness namespace to be injected, got %q", cfg.AwarenessNamespace)
}
if cfg.WorkspacePath != "/tmp/workspace" {
t.Fatalf("expected workspace path from payload, got %q", cfg.WorkspacePath)
t.Fatalf("expected workspace path from env, got %q", cfg.WorkspacePath)
}
}
@@ -448,7 +456,7 @@ func TestWorkspaceList(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
// 24 cols: compute added after talk_to_user_enabled.
// 23 cols: broadcast_enabled + talk_to_user_enabled added after monthly_spend
// (migration 20260514). Column order must match scanWorkspaceRow exactly.
columns := []string{
"id", "name", "role", "tier", "status", "agent_card", "url",
@@ -456,13 +464,13 @@ func TestWorkspaceList(t *testing.T) {
"last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
rows := sqlmock.NewRows(columns).
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true).
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
nil, 0, 1, 0.0, "", 0, "", "langgraph", "", 50.0, 60.0, false, nil, int64(0), false, true)
mock.ExpectQuery("SELECT w.id, w.name").
WillReturnRows(rows)
@@ -1176,14 +1184,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("dddddddd-0004-0000-0000-000000000000").
WillReturnRows(sqlmock.NewRows(columns).AddRow(
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
nil, 2, 1, 0.0, "", 300, "Analyzing document", "langgraph", "", 10.0, 20.0, false,
nil, int64(0), false, true, []byte(`{}`),
nil, int64(0), false, true,
))
w := httptest.NewRecorder()
@@ -84,7 +84,6 @@ type mcpTool struct {
type MCPHandler struct {
database *sql.DB
broadcaster *events.Broadcaster
a2aProxy func(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error)
// memv2 is the v2 memory plugin wiring (RFC #2728). nil-safe:
// every v2 tool calls memoryV2Available() first and returns a
@@ -99,14 +98,6 @@ func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandle
return &MCPHandler{database: database, broadcaster: broadcaster}
}
func (h *MCPHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
if h.a2aProxy != nil {
return h.a2aProxy(ctx, workspaceID, body, callerID, logActivity)
}
wh := NewWorkspaceHandler(h.broadcaster, nil, "", "")
return wh.ProxyA2ARequest(ctx, workspaceID, body, callerID, logActivity)
}
// ─────────────────────────────────────────────────────────────────────────────
// Tool definitions (mirrors workspace/a2a_mcp_server.py TOOLS list)
// ─────────────────────────────────────────────────────────────────────────────
@@ -53,15 +53,6 @@ func mcpPost(t *testing.T, h *MCPHandler, workspaceID string, body interface{})
return w
}
func expectCanCommunicateSiblings(mock sqlmock.Sqlmock, callerID, targetID, parentID string) {
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(callerID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(callerID, parentID))
mock.ExpectQuery(`SELECT id, parent_id FROM workspaces WHERE id = \$1`).
WithArgs(targetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(targetID, parentID))
}
// ─────────────────────────────────────────────────────────────────────────────
// initialize
// ─────────────────────────────────────────────────────────────────────────────
@@ -187,98 +178,6 @@ func TestMCPHandler_ToolsList_ContainsExpectedTools(t *testing.T) {
}
}
func TestMCPHandler_DelegateTask_RoutesThroughPlatformA2AProxy(t *testing.T) {
h, mock := newMCPHandler(t)
callerID := "11111111-1111-1111-1111-111111111111"
targetID := "22222222-2222-2222-2222-222222222222"
parentID := "33333333-3333-3333-3333-333333333333"
expectCanCommunicateSiblings(mock, callerID, targetID, parentID)
mock.ExpectExec(`(?s)INSERT INTO activity_logs.*'delegation'.*'delegate'`).
WithArgs(callerID, callerID, targetID, "Delegating to "+targetID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE activity_logs`).
WithArgs("dispatched", "", callerID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
var gotTarget, gotCaller string
h.a2aProxy = func(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
gotTarget = workspaceID
gotCaller = callerID
if !logActivity {
t.Fatal("delegate_task should log through platform A2A proxy")
}
if !strings.Contains(string(body), "do work") {
t.Fatalf("A2A body missing task text: %s", string(body))
}
return 200, []byte(`{"result":{"message":{"parts":[{"text":"done"}]}}}`), nil
}
out, err := h.toolDelegateTask(context.Background(), callerID, map[string]interface{}{
"workspace_id": targetID,
"task": "do work",
}, mcpCallTimeout)
if err != nil {
t.Fatalf("delegate_task returned error: %v", err)
}
if out != "done" {
t.Fatalf("delegate_task response = %q, want done", out)
}
if gotTarget != targetID || gotCaller != callerID {
t.Fatalf("proxy called with target=%q caller=%q, want target=%q caller=%q", gotTarget, gotCaller, targetID, callerID)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
func TestMCPHandler_DelegateTaskAsync_RoutesThroughPlatformA2AProxy(t *testing.T) {
h, mock := newMCPHandler(t)
callerID := "11111111-1111-1111-1111-111111111111"
targetID := "22222222-2222-2222-2222-222222222222"
parentID := "33333333-3333-3333-3333-333333333333"
expectCanCommunicateSiblings(mock, callerID, targetID, parentID)
mock.ExpectExec(`(?s)INSERT INTO activity_logs.*'delegation'.*'delegate'`).
WithArgs(callerID, callerID, targetID, "Delegating to "+targetID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE activity_logs`).
WithArgs("dispatched", "", callerID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
called := make(chan struct{}, 1)
h.a2aProxy = func(ctx context.Context, workspaceID string, body []byte, proxyCallerID string, logActivity bool) (int, []byte, error) {
if workspaceID != targetID || proxyCallerID != callerID {
t.Fatalf("unexpected proxy route target=%q caller=%q", workspaceID, proxyCallerID)
}
if !strings.Contains(string(body), "async work") {
t.Fatalf("A2A body missing task text: %s", string(body))
}
called <- struct{}{}
return 200, []byte(`{"result":{"message":{"parts":[{"text":"accepted"}]}}}`), nil
}
out, err := h.toolDelegateTaskAsync(context.Background(), callerID, map[string]interface{}{
"workspace_id": targetID,
"task": "async work",
})
if err != nil {
t.Fatalf("delegate_task_async returned error: %v", err)
}
if !strings.Contains(out, `"status":"dispatched"`) {
t.Fatalf("delegate_task_async response = %s", out)
}
waitGlobalAsyncForTest()
select {
case <-called:
default:
t.Fatal("async delegate did not call platform A2A proxy")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet expectations: %v", err)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// notifications/initialized
// ─────────────────────────────────────────────────────────────────────────────
@@ -1141,8 +1040,6 @@ func TestIsSafeURL_Blocks169_254_Metadata(t *testing.T) {
}
func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://10.0.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 10.x.x.x to be blocked, got nil")
@@ -1150,8 +1047,6 @@ func TestIsSafeURL_Blocks10xPrivate(t *testing.T) {
}
func TestIsSafeURL_Blocks172Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://172.16.0.1/agent")
if err == nil {
t.Errorf("isSafeURL: expected 172.16.0.0/12 to be blocked, got nil")
@@ -1159,8 +1054,6 @@ func TestIsSafeURL_Blocks172Private(t *testing.T) {
}
func TestIsSafeURL_Blocks192_168Private(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
err := isSafeURL("http://192.168.1.100/agent")
if err == nil {
t.Errorf("isSafeURL: expected 192.168.x.x to be blocked, got nil")
@@ -1184,8 +1077,6 @@ func TestIsSafeURL_BlocksInvalidURL(t *testing.T) {
// ==================== SSRF Defence — isPrivateOrMetadataIP ====================
func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"10.0.0.0", "10.255.255.255", "10.1.2.3"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1195,8 +1086,6 @@ func TestIsPrivateOrMetadataIP_10Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"172.16.0.0", "172.31.255.255", "172.20.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
@@ -1206,8 +1095,6 @@ func TestIsPrivateOrMetadataIP_172Range(t *testing.T) {
}
func TestIsPrivateOrMetadataIP_192_168Range(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
tests := []string{"192.168.0.0", "192.168.255.255", "192.168.1.1"}
for _, ip := range tests {
if !isPrivateOrMetadataIP(net.ParseIP(ip)) {
+124 -29
View File
@@ -7,19 +7,24 @@ package handlers
// and A2A response parsing helpers.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/registry"
"github.com/google/uuid"
)
// insertMCPDelegationRow writes a delegation activity row so the canvas
// Agent Comms tab can show the task text for MCP-initiated delegations.
// Mirrors insertDelegationRow (delegation.go) for the MCP tool path.
@@ -95,18 +100,14 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.id != $2 AND w.status != 'removed'`,
parentID.String, workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
} else {
rows, err := h.database.QueryContext(ctx,
cols+` FROM workspaces w WHERE w.parent_id IS NULL AND w.id != $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: sibling scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -116,9 +117,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.parent_id = $1 AND w.status != 'removed'`,
workspaceID)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: children scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -128,9 +127,7 @@ func (h *MCPHandler) toolListPeers(ctx context.Context, workspaceID string) (str
cols+` FROM workspaces w WHERE w.id = $1 AND w.status != 'removed'`,
parentID.String)
if err == nil {
if scanErr := scanPeers(rows); scanErr != nil {
log.Printf("MCP toolListPeers: parent scan error: %v", scanErr)
}
_ = scanPeers(rows)
}
}
@@ -193,6 +190,15 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
// Non-fatal: still make the A2A call even if activity log write fails.
}
agentURL, err := mcpResolveURL(ctx, h.database, targetID)
if err != nil {
return "", err
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
return "", fmt.Errorf("invalid workspace URL: %w", err)
}
a2aBody, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": uuid.New().String(),
@@ -212,17 +218,36 @@ func (h *MCPHandler) toolDelegateTask(ctx context.Context, callerID string, args
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
status, body, err := h.proxyA2ARequest(reqCtx, targetID, a2aBody, callerID, true)
httpReq, err := http.NewRequestWithContext(reqCtx, "POST", agentURL+"/a2a", bytes.NewReader(a2aBody))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// X-Workspace-ID identifies this caller to the A2A proxy. The /workspaces/:id/a2a
// endpoint is intentionally outside WorkspaceAuth (agents do not hold bearer tokens
// to peer workspaces). Access control is enforced by CanCommunicate above, which
// already validated callerID → targetID before this request is constructed.
// callerID was authenticated by WorkspaceAuth on the MCP bridge entry point,
// so this header reflects a verified caller identity, not a spoofable value.
httpReq.Header.Set("X-Workspace-ID", callerID)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", err.Error())
return "", fmt.Errorf("A2A proxy failed: %w", err)
}
if status < 200 || status >= 300 {
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "failed", fmt.Sprintf("A2A proxy returned status %d", status))
return "", fmt.Errorf("A2A proxy returned status %d", status)
return "", fmt.Errorf("A2A call failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// A 200/500 from the peer still means the call was dispatched — only
// network errors are truly "failed". Status 'dispatched' is correct for
// any HTTP response (peer's A2A layer handles the actual processing).
updateMCPDelegationStatus(ctx, h.database, callerID, delegationID, "dispatched", "")
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
return extractA2AText(body), nil
}
@@ -253,13 +278,24 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
// Fire and forget in a detached goroutine. Use a background context so
// the call is not cancelled when the HTTP request completes.
// RFC internal#524 Layer 1: globalGoAsync — the detached call reads db.DB
// through the platform A2A proxy and must be drained by drainTestAsync
// before any t.Cleanup-driven db.DB swap.
// RFC internal#524 Layer 1: globalGoAsync — the detached call reads
// db.DB (mcpResolveURL + updateMCPDelegationStatus) and must be
// drained by drainTestAsync before any t.Cleanup-driven db.DB swap.
globalGoAsync(func() {
bgCtx, cancel := context.WithTimeout(context.Background(), mcpAsyncCallTimeout)
defer cancel()
agentURL, err := mcpResolveURL(bgCtx, h.database, targetID)
if err != nil {
log.Printf("MCPHandler.delegate_task_async: resolve URL for %s: %v", targetID, err)
return
}
// SSRF defence: reject private/metadata URLs before making outbound call.
if err := isSafeURL(agentURL); err != nil {
log.Printf("MCPHandler.delegate_task_async: unsafe URL for %s: %v", targetID, err)
return
}
a2aBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": delegationID,
@@ -273,15 +309,22 @@ func (h *MCPHandler) toolDelegateTaskAsync(ctx context.Context, callerID string,
},
})
status, _, err := h.proxyA2ARequest(bgCtx, targetID, a2aBody, callerID, true)
if err != nil || status < 200 || status >= 300 {
if err != nil {
log.Printf("MCPHandler.delegate_task_async: A2A proxy to %s: %v", targetID, err)
} else {
log.Printf("MCPHandler.delegate_task_async: A2A proxy to %s returned status %d", targetID, status)
}
httpReq, err := http.NewRequestWithContext(bgCtx, "POST", agentURL+"/a2a", bytes.NewReader(a2aBody))
if err != nil {
log.Printf("MCPHandler.delegate_task_async: create request: %v", err)
return
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-Workspace-ID", callerID)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
log.Printf("MCPHandler.delegate_task_async: A2A call to %s: %v", targetID, err)
return
}
defer func() { _ = resp.Body.Close() }()
// Drain response so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
})
return fmt.Sprintf(`{"task_id":%q,"status":"dispatched","target_id":%q}`, delegationID, targetID), nil
@@ -362,6 +405,7 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
return "Message sent.", nil
}
func (h *MCPHandler) toolCommitMemory(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
// PR-6 (RFC #2728) compat shim: when the v2 plugin is wired
// (MEMORY_PLUGIN_URL set), translate legacy scope→namespace and
@@ -490,6 +534,56 @@ func (h *MCPHandler) toolRecallMemory(ctx context.Context, workspaceID string, a
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
// mcpResolveURL returns a routable URL for a workspace's A2A server.
//
// Resolution order:
// 1. Docker-internal URL cache (set by provisioner; correct when platform is in Docker)
// 2. Redis URL cache
// 3. DB `url` column fallback, with 127.0.0.1→Docker bridge rewrite when in Docker
//
// SECURITY (F1083 / #1130): all three paths run the returned URL through
// validateAgentURL to block SSRF targets (private IPs, loopback, cloud metadata).
func mcpResolveURL(ctx context.Context, database *sql.DB, workspaceID string) (string, error) {
if platformInDocker {
if url, err := db.GetCachedInternalURL(ctx, workspaceID); err == nil && url != "" {
if err := validateAgentURL(url); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from internal cache: %w", workspaceID, err)
}
return url, nil
}
}
if url, err := db.GetCachedURL(ctx, workspaceID); err == nil && url != "" {
if platformInDocker && strings.HasPrefix(url, "http://127.0.0.1:") {
return provisioner.InternalURL(workspaceID), nil
}
if err := validateAgentURL(url); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from Redis cache: %w", workspaceID, err)
}
return url, nil
}
var urlStr sql.NullString
var status string
if err := database.QueryRowContext(ctx,
`SELECT url, status FROM workspaces WHERE id = $1`, workspaceID,
).Scan(&urlStr, &status); err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("workspace %s not found", workspaceID)
}
return "", fmt.Errorf("workspace lookup failed: %w", err)
}
if !urlStr.Valid || urlStr.String == "" {
return "", fmt.Errorf("workspace %s has no URL (status: %s)", workspaceID, status)
}
if platformInDocker && strings.HasPrefix(urlStr.String, "http://127.0.0.1:") {
return provisioner.InternalURL(workspaceID), nil
}
if err := validateAgentURL(urlStr.String); err != nil {
return "", fmt.Errorf("workspace %s: forbidden URL from DB: %w", workspaceID, err)
}
return urlStr.String, nil
}
// extractA2AText extracts human-readable text from an A2A JSON-RPC response body.
// Falls back to the raw JSON when no text part can be found.
func extractA2AText(body []byte) string {
@@ -538,3 +632,4 @@ func extractA2AText(body []byte) string {
b, _ := json.Marshal(result)
return string(b)
}
@@ -6,26 +6,18 @@ import (
"testing"
)
// TestHandleA2ADispatchError_NativeSession_NowEnqueues validates the #1684
// fix: native_session adapters used to short-circuit to 503-no-queue here,
// on the assumption that the SDK owned an inbound queue. In practice the
// common native_session SDKs (claude-agent-sdk, codex app-server, hermes)
// don't — new turns arrive only via the same HTTP POST that returns busy.
// So cron fires bounced 503 every tick until the SDK voluntarily yielded;
// Reno Stars #1684 observed 12 consecutive `*/30` cron fires lost over 6h.
// TestHandleA2ADispatchError_NativeSession_SkipsEnqueue validates capability
// primitive #5: when the target workspace has declared
// provides_native_session=True, a busy-shaped dispatch error MUST short-
// circuit straight to 503 + Retry-After. The platform's a2a_queue is
// skipped because the SDK owns its own queue/session state — double-
// buffering would cause spurious dispatches when the SDK is still busy.
//
// Post-fix: native_session and non-native both enqueue. Drain timing is
// gated by registry.go:Heartbeat (`payload.ActiveTasks < maxConcurrent`)
// so the queued item only dispatches when the SDK reports spare capacity
// — i.e. the next heartbeat after the in-flight turn returns.
//
// This test pins the new behavior: native_session capability DOES NOT
// bypass EnqueueA2A. We expect the INSERT INTO a2a_queue query to fire,
// here arranged to fail so we can observe the legacy 503 fallback (and
// thereby confirm the INSERT was attempted; sqlmock fails the test if
// the expected query never runs).
func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
mock := setupTestDB(t)
// Pin via sqlmock: we deliberately do NOT expect any INSERT INTO a2a_queue.
// If a future refactor re-introduces enqueueing under native_session,
// sqlmock fails the test on the unexpected query.
func TestHandleA2ADispatchError_NativeSession_SkipsEnqueue(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
@@ -33,15 +25,10 @@ func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
runtimeOverrides.SetCapabilities("ws-native", map[string]bool{"session": true})
defer runtimeOverrides.Reset()
// We now EXPECT the INSERT to fire even with native_session=true. Make
// it fail so the handler falls through to the legacy 503 path — that
// lets us assert (1) enqueue was attempted, (2) the response on
// queue-failure does NOT carry native_session=true marker (that field
// was removed alongside the gate).
mock.ExpectQuery(`INSERT INTO a2a_queue`).
WithArgs("ws-native", nil, PriorityTask, "{}", "message/send", nil).
WillReturnError(errTestQueueUnavailable)
// DeadlineExceeded triggers isUpstreamBusyError. Without the native
// gate, this would fire EnqueueA2A → INSERT INTO a2a_queue. With
// the gate, it short-circuits to 503. We expect ZERO queue queries;
// sqlmock's ExpectationsWereMet implicitly enforces that on teardown.
_, _, perr := handler.handleA2ADispatchError(
context.Background(), "ws-native", "", []byte("{}"), "message/send",
context.DeadlineExceeded, 1, false,
@@ -50,27 +37,28 @@ func TestHandleA2ADispatchError_NativeSession_NowEnqueues(t *testing.T) {
t.Fatal("expected proxy error, got nil")
}
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503 (enqueue failed → legacy 503 fallback)", perr.Status)
t.Errorf("got status %d, want 503 (native_session bypasses queue but still 503s)", perr.Status)
}
if perr.Headers["Retry-After"] == "" {
t.Error("expected Retry-After header on busy-503")
t.Error("expected Retry-After header on native-session 503")
}
// The native_session marker was removed from the response body — the
// platform queues both kinds now, callers no longer distinguish. Pin
// its absence so a future revert is caught.
if got, ok := perr.Response["native_session"].(bool); ok && got {
t.Errorf("native_session marker should be gone after #1684 fix; got %+v", perr.Response)
// Pin the marker so callers' adapters can distinguish this from a
// queue-failure 503: the body has native_session=true.
if got, _ := perr.Response["native_session"].(bool); !got {
t.Errorf("expected native_session=true in response body; got %+v", perr.Response)
}
// And busy=true stays so existing busy-handling code paths still trigger.
if got, _ := perr.Response["busy"].(bool); !got {
t.Errorf("expected busy=true; got %+v", perr.Response)
t.Errorf("expected busy=true in response body; got %+v", perr.Response)
}
}
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues — non-native
// behavior is unchanged: enqueue is attempted, fail-fallback to 503. This
// negative pin guards against accidentally reverting the unification by
// re-introducing a `if HasCapability(...)` gate that would short-circuit
// the enqueue path.
// TestHandleA2ADispatchError_NoNativeSession_StillEnqueues is the negative
// pin: a workspace WITHOUT the capability flag falls through to the
// existing EnqueueA2A path (and 503 if that fails). Same shape as
// TestHandleA2ADispatchError_ContextDeadline; we duplicate it here so
// the native_session gate change is bracketed by both positive and
// negative tests in the same file.
func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
@@ -91,11 +79,13 @@ func TestHandleA2ADispatchError_NoNativeSession_StillEnqueues(t *testing.T) {
if perr == nil {
t.Fatal("expected proxy error, got nil")
}
// Queue insert failed → falls through to legacy 503 (without
// native_session marker).
if perr.Status != http.StatusServiceUnavailable {
t.Errorf("got status %d, want 503", perr.Status)
}
if got, _ := perr.Response["native_session"].(bool); got {
t.Errorf("non-native workspace should NOT carry native_session=true; got %+v", perr.Response)
t.Errorf("non-native workspace should NOT carry native_session=true in response; got %+v", perr.Response)
}
}
+2 -1
View File
@@ -799,12 +799,13 @@ func (h *OrgHandler) Import(c *gin.Context) {
if len(tmpl.GlobalMemories) > 0 && len(results) > 0 {
rootID, _ := results[0]["id"].(string)
if rootID != "" {
rootNS := workspaceAwarenessNamespace(rootID)
// Force scope to GLOBAL regardless of what the YAML says.
globalSeeds := make([]models.MemorySeed, len(tmpl.GlobalMemories))
for i, gm := range tmpl.GlobalMemories {
globalSeeds[i] = models.MemorySeed{Content: gm.Content, Scope: "GLOBAL"}
}
seedInitialMemories(context.Background(), rootID, globalSeeds)
seedInitialMemories(context.Background(), rootID, globalSeeds, rootNS)
log.Printf("Org import: seeded %d global memories on root workspace %s", len(globalSeeds), rootID)
}
}
@@ -69,15 +69,10 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
model = defaults.Model
}
if model == "" {
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED. The org-import template MUST declare a
// model — either per-workspace (`ws.Model`) or via the org
// defaults block (`defaults.Model`). If neither is present
// the template is malformed and the import must fail-closed
// rather than silently provisioning a workspace with a
// runtime-incompatible default (the prior `anthropic:claude-opus-4-7`
// fallback wedged every codex workspace at adapter init).
return fmt.Errorf("org import: workspace %q has no model and the org defaults block does not provide one (runtime=%s) — model is a required field per the workspace-creation contract; either set `model:` on the workspace or under `defaults:`", ws.Name, runtime)
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Consolidated from a duplicate of the same
// branch in workspace_provision.go.
model = models.DefaultModel(runtime)
}
tier := ws.Tier
if tier == 0 {
@@ -102,6 +97,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
}
id := uuid.New().String()
awarenessNS := workspaceAwarenessNamespace(id)
var role interface{}
if ws.Role != "" {
@@ -167,13 +163,13 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
// EXACTLY for Postgres to consider the index applicable.
var insertedID string
err := db.DB.QueryRowContext(ctx, `
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, max_concurrent_tasks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name)
WHERE status != 'removed'
DO NOTHING
RETURNING id
`, id, ws.Name, role, tier, runtime, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
`, id, ws.Name, role, tier, runtime, awarenessNS, "provisioning", parentID, workspaceDir, workspaceAccess, maxConcurrent).Scan(&insertedID)
if errors.Is(err, sql.ErrNoRows) {
// Skip path — a non-removed row already exists for
// (parent_id, name). Re-select its id; idempotency-friendly
@@ -258,7 +254,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
if len(wsMemories) == 0 {
wsMemories = defaults.InitialMemories
}
seedInitialMemories(ctx, id, wsMemories)
seedInitialMemories(ctx, id, wsMemories, awarenessNS)
// Handle external workspaces
if ws.External {
@@ -112,7 +112,7 @@ func (h *RegistryHandler) SetQueueDrainFunc(f QueueDrainFunc) {
// Go's net.ParseIP.To4() before Contains() runs, so the IPv4 rules above
// catch those without a separate entry.
//
// F1083/#1130 (SSRF on direct A2A URL resolution): in
// F1083/#1130 (SSRF on mcpResolveURL / a2a_proxy resolveAgentURL): in
// addition to blocking IP literals, DNS names are now resolved and each
// returned IP is checked against the blocklist. This closes the gap where
// an attacker could register agent.example.com pointing to 169.254.169.254.
@@ -712,8 +712,6 @@ func TestHeartbeat_SkipsRemovedRows(t *testing.T) {
// ------------------------------------------------------------
func TestValidateAgentURL(t *testing.T) {
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
cases := []struct {
name string
url string
@@ -133,30 +133,24 @@ func loadRestartContextData(ctx context.Context, workspaceID string) restartCont
// message bus.
keySet := map[string]struct{}{}
if rows, err := db.DB.QueryContext(ctx, `SELECT key FROM global_secrets`); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: global_secrets rows.Err: %v", err)
}
rows.Close()
}
if rows, err := db.DB.QueryContext(ctx,
`SELECT key FROM workspace_secrets WHERE workspace_id = $1`, workspaceID,
); err == nil {
defer rows.Close()
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keySet[k] = struct{}{}
}
}
if err := rows.Err(); err != nil {
log.Printf("loadRestartContextData: workspace_secrets rows.Err: %v", err)
}
rows.Close()
}
for k := range keySet {
d.EnvKeys = append(d.EnvKeys, k)
+21 -116
View File
@@ -15,46 +15,13 @@ import (
"github.com/Molecule-AI/molecule-monorepo/platform/internal/scheduler"
)
// ErrorResponse is returned for 4xx/5xx errors. (OpenAPI doc shape — used by swaggo.)
type ErrorResponse struct {
Error string `json:"error"`
}
// StatusResponse is returned by mutating endpoints that only echo a status verb.
type StatusResponse struct {
Status string `json:"status"`
}
// CreateScheduleResponse is returned by POST /workspaces/{id}/schedules.
type CreateScheduleResponse struct {
ID string `json:"id"`
Status string `json:"status"`
NextRunAt time.Time `json:"next_run_at"`
}
// RunNowResponse is returned by POST /workspaces/{id}/schedules/{scheduleId}/run.
type RunNowResponse struct {
Status string `json:"status"`
WorkspaceID string `json:"workspace_id"`
Prompt string `json:"prompt"`
}
// HistoryEntry is one row of /workspaces/{id}/schedules/{scheduleId}/history.
type HistoryEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request" swaggertype:"object"`
}
type ScheduleHandler struct{}
func NewScheduleHandler() *ScheduleHandler {
return &ScheduleHandler{}
}
type ScheduleResponse struct {
type scheduleResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
@@ -73,15 +40,6 @@ type ScheduleResponse struct {
}
// List returns all schedules for a workspace.
//
// @Summary List schedules for a workspace
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Success 200 {array} ScheduleResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) List(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
@@ -100,9 +58,9 @@ func (h *ScheduleHandler) List(c *gin.Context) {
}
defer rows.Close()
schedules := make([]ScheduleResponse, 0)
schedules := make([]scheduleResponse, 0)
for rows.Next() {
var s ScheduleResponse
var s scheduleResponse
if err := rows.Scan(
&s.ID, &s.WorkspaceID, &s.Name, &s.CronExpr, &s.Timezone,
&s.Prompt, &s.Enabled, &s.LastRunAt, &s.NextRunAt, &s.RunCount,
@@ -120,7 +78,7 @@ func (h *ScheduleHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, schedules)
}
type CreateScheduleRequest struct {
type createScheduleRequest struct {
Name string `json:"name"`
CronExpr string `json:"cron_expr" binding:"required"`
Timezone string `json:"timezone"`
@@ -129,23 +87,11 @@ type CreateScheduleRequest struct {
}
// Create adds a new schedule for a workspace.
//
// @Summary Create a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param body body CreateScheduleRequest true "Schedule fields"
// @Success 201 {object} CreateScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Create(c *gin.Context) {
workspaceID := c.Param("id")
ctx := c.Request.Context()
var body CreateScheduleRequest
var body createScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cron_expr and prompt are required"})
return
@@ -199,7 +145,7 @@ func (h *ScheduleHandler) Create(c *gin.Context) {
})
}
type UpdateScheduleRequest struct {
type updateScheduleRequest struct {
Name *string `json:"name"`
CronExpr *string `json:"cron_expr"`
Timezone *string `json:"timezone"`
@@ -209,26 +155,12 @@ type UpdateScheduleRequest struct {
// Update modifies a schedule. Uses a fixed UPDATE with COALESCE so only
// provided fields are changed — no dynamic SQL construction.
//
// @Summary Update a schedule
// @Tags schedules
// @Accept json
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Param body body UpdateScheduleRequest true "Partial schedule fields (only provided keys are updated)"
// @Success 200 {object} ScheduleResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [patch]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Update(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
ctx := c.Request.Context()
var body UpdateScheduleRequest
var body updateScheduleRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
@@ -298,17 +230,6 @@ func (h *ScheduleHandler) Update(c *gin.Context) {
}
// Delete removes a schedule.
//
// @Summary Delete a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} StatusResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId} [delete]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) Delete(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id") // #113: bind to owning workspace to prevent IDOR
@@ -331,17 +252,6 @@ func (h *ScheduleHandler) Delete(c *gin.Context) {
}
// RunNow manually fires a schedule immediately.
//
// @Summary Fire a schedule manually
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {object} RunNowResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/run [post]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) RunNow(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -372,16 +282,6 @@ func (h *ScheduleHandler) RunNow(c *gin.Context) {
}
// History returns recent runs for a schedule from activity_logs.
//
// @Summary Get past runs of a schedule
// @Tags schedules
// @Produce json
// @Param id path string true "Workspace ID"
// @Param scheduleId path string true "Schedule ID"
// @Success 200 {array} HistoryEntry
// @Failure 500 {object} ErrorResponse
// @Router /workspaces/{id}/schedules/{scheduleId}/history [get]
// @Security BearerAuth && OrgSlugAuth
func (h *ScheduleHandler) History(c *gin.Context) {
scheduleID := c.Param("scheduleId")
workspaceID := c.Param("id")
@@ -407,9 +307,17 @@ func (h *ScheduleHandler) History(c *gin.Context) {
}
defer rows.Close()
entries := make([]HistoryEntry, 0)
type historyEntry struct {
Timestamp time.Time `json:"timestamp"`
DurationMs *int `json:"duration_ms"`
Status *string `json:"status"`
ErrorDetail string `json:"error_detail"`
Request json.RawMessage `json:"request"`
}
entries := make([]historyEntry, 0)
for rows.Next() {
var e HistoryEntry
var e historyEntry
var reqStr string
if err := rows.Scan(&e.Timestamp, &e.DurationMs, &e.Status, &e.ErrorDetail, &reqStr); err != nil {
continue
@@ -417,18 +325,15 @@ func (h *ScheduleHandler) History(c *gin.Context) {
e.Request = json.RawMessage(reqStr)
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
log.Printf("ScheduleHistory: rows error: %v", err)
}
c.JSON(http.StatusOK, entries)
}
// ScheduleHealthResponse is the read-only health view of a schedule.
// scheduleHealthResponse is the read-only health view of a schedule.
// It deliberately omits prompt and cron_expr so sensitive task content is
// never exposed to peer workspaces — only execution-state fields needed to
// detect silent cron failures are returned (issue #249).
type ScheduleHealthResponse struct {
type scheduleHealthResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
@@ -497,9 +402,9 @@ func (h *ScheduleHandler) Health(c *gin.Context) {
}
defer rows.Close()
schedules := make([]ScheduleHealthResponse, 0)
schedules := make([]scheduleHealthResponse, 0)
for rows.Next() {
var s ScheduleHealthResponse
var s scheduleHealthResponse
if err := rows.Scan(
&s.ID, &s.Name, &s.Enabled, &s.LastRunAt, &s.NextRunAt,
&s.RunCount, &s.LastStatus, &s.LastError,
@@ -234,7 +234,7 @@ func TestScheduleHealth_SelfCall_Allowed(t *testing.T) {
t.Fatalf("expected 200 for self-call, got %d: %s", w.Code, w.Body.String())
}
var resp []ScheduleHealthResponse
var resp []scheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -284,7 +284,7 @@ func TestScheduleHealth_CanCommunicatePeer_LegacyNoToken(t *testing.T) {
t.Fatalf("expected 200 for peer with no tokens, got %d: %s", w.Code, w.Body.String())
}
var resp []ScheduleHealthResponse
var resp []scheduleHealthResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
@@ -95,7 +95,6 @@ func TestSecurity_GetTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
@@ -153,7 +152,6 @@ func TestSecurity_GetOrgTemplates_NoAuth_Returns401(t *testing.T) {
func TestSecurity_GetOrgTemplates_FreshInstall_FailsOpen(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
t.Setenv("ADMIN_TOKEN", "")
authDB, authMock := newFreshInstallAuthDB(t)
tmpDir := t.TempDir()
+1 -5
View File
@@ -51,10 +51,6 @@ func (h *TracesHandler) List(c *gin.Context) {
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response body"})
return
}
body, _ := io.ReadAll(resp.Body)
c.Data(resp.StatusCode, "application/json", body)
}
+12 -85
View File
@@ -216,6 +216,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
id := uuid.New().String()
awarenessNamespace := workspaceAwarenessNamespace(id)
if h.IsSaaS() {
// SaaS hard gate: every hosted workspace gets its own sibling
// EC2 instance, so T4 is the only meaningful runtime boundary.
@@ -320,51 +321,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Runtime = "langgraph"
}
// SSOT (CTO 2026-05-22, feedback_workspace_model_required_no_platform_default_dynamic_credential_intake):
// model is REQUIRED user input for SPAWNED-runtime workspaces. The
// platform must not provide a default; the runtime must not fall back.
// The decision belongs to the user (or to the agent acting on the
// user's behalf), never to the platform.
//
// Empirical trigger: Code Reviewer 5ba15d7e was created with
// `{"name":"Code Reviewer","role":"...","runtime":"codex",...}` (no
// model). The legacy `DefaultModel(runtime)` fallback in
// provisionWorkspace returned `"anthropic:claude-opus-4-7"`. Codex
// adapter only supports openai-* providers — it wedged forever with
// `codex adapter: workspace config picks provider='anthropic' but
// it is not in the providers registry`. PATCH /workspaces/:id
// explicitly disallows updating model (the comment literally reads
// `model not patchable`), so the only recovery path was SQL UPDATE
// or delete+recreate.
//
// External workspaces are EXEMPT — they intentionally do not spawn
// a Docker container or run an adapter; they delegate to a registered
// URL (see provision.go: "external is a first-class runtime that
// intentionally does NOT spawn a Docker container"). The MODEL_REQUIRED
// gate is meaningful for spawned-runtime workspaces where the model
// id drives provider selection at adapter init. For external workspaces
// the contract is the URL, not the model — requiring it would be
// ceremony with no payoff, and would 422 every legitimate "register
// my agent at https://..." flow. The SSOT directive concerns
// platform-side defaults; an external workspace genuinely has no
// "model decision" for the user to make.
//
// Fail-closed at the Create boundary so the caller learns the
// contract immediately — same shape as the controlplane#188
// runtime-unresolved gate above. Caller fixes the request, no
// EC2 launched, no stuck workspace, no operator paging.
isExternal := payload.External || isExternalLikeRuntime(payload.Runtime)
if payload.Model == "" && !isExternal {
log.Printf("Create: FAIL-CLOSED — model is required (runtime=%q template=%q); refusing the silent DefaultModel fallback per CTO 2026-05-22 SSOT directive", payload.Runtime, payload.Template)
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "model is required and has no platform-side default — pass an explicit \"model\" in the request body, or use a \"template\" whose config.yaml declares one. See feedback_workspace_model_required_no_platform_default_dynamic_credential_intake for the contract.",
"runtime": payload.Runtime,
"template": payload.Template,
"code": "MODEL_REQUIRED",
})
return
}
ctx := c.Request.Context()
// Convert empty role to NULL
@@ -392,10 +348,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace access"})
return
}
if err := validateWorkspaceCompute(payload.Compute); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
@@ -447,10 +399,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// returns the actually-persisted name (which we MUST thread back into
// payload + broadcast so the canvas displays what the DB has).
const insertWorkspaceSQL = `
INSERT INTO workspaces (id, name, role, tier, runtime, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
VALUES ($1, $2, $3, $4, $5, 'provisioning', $6, $7, $8, $9, $10, $11)
INSERT INTO workspaces (id, name, role, tier, runtime, awareness_namespace, status, parent_id, workspace_dir, workspace_access, budget_limit, max_concurrent_tasks, delivery_mode)
VALUES ($1, $2, $3, $4, $5, $6, 'provisioning', $7, $8, $9, $10, $11, $12)
`
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
insertArgs := []any{id, payload.Name, role, payload.Tier, payload.Runtime, awarenessNamespace, payload.ParentID, workspaceDir, workspaceAccess, payload.BudgetLimit, maxConcurrent, deliveryMode}
persistedName, currentTx, err := insertWorkspaceWithNameRetry(
ctx,
tx,
@@ -483,24 +435,6 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
payload.Name = persistedName
}
if !workspaceComputeIsZero(payload.Compute) {
computeJSON, encErr := workspaceComputeJSON(payload.Compute)
if encErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to encode compute config: %v", id, encErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode compute config"})
return
}
if _, dbErr := tx.ExecContext(ctx,
`UPDATE workspaces SET compute = $2::jsonb, updated_at = now() WHERE id = $1`,
id, computeJSON); dbErr != nil {
tx.Rollback() //nolint:errcheck
log.Printf("Create workspace %s: failed to persist compute config: %v", id, dbErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save compute config"})
return
}
}
// Persist initial secrets from the create payload (inside same transaction).
// nil/empty map is a no-op. Any failure rolls back the workspace insert
// so we never have a workspace row without its intended secrets.
@@ -571,7 +505,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// Seed initial memories from the create payload (issue #1050).
// Non-fatal: failures are logged but don't block workspace creation.
seedInitialMemories(ctx, id, payload.InitialMemories)
seedInitialMemories(ctx, id, payload.InitialMemories, awarenessNamespace)
// Broadcast provisioning event. Include `runtime` so the canvas can
// populate the Runtime pill on the side panel immediately — without it
@@ -706,9 +640,10 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
}
c.JSON(http.StatusCreated, gin.H{
"id": id,
"status": "provisioning",
"workspace_access": workspaceAccess,
"id": id,
"status": "provisioning",
"awareness_namespace": awarenessNamespace,
"workspace_access": workspaceAccess,
})
}
@@ -744,7 +679,6 @@ func scanWorkspaceRow(rows interface {
Scan(dest ...interface{}) error
}) (map[string]interface{}, error) {
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
var computeRaw []byte
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
var errorRate, x, y float64
var collapsed, broadcastEnabled, talkToUserEnabled bool
@@ -756,7 +690,7 @@ func scanWorkspaceRow(rows interface {
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
&currentTask, &runtime, &workspaceDir, &x, &y, &collapsed,
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled)
if err != nil {
return nil, err
}
@@ -783,11 +717,6 @@ func scanWorkspaceRow(rows interface {
"broadcast_enabled": broadcastEnabled,
"talk_to_user_enabled": talkToUserEnabled,
}
if len(computeRaw) > 0 && string(computeRaw) != "null" {
ws["compute"] = json.RawMessage(computeRaw)
} else {
ws["compute"] = json.RawMessage(`{}`)
}
// budget_limit: nil when no limit set, int64 otherwise
if budgetLimit.Valid {
@@ -823,8 +752,7 @@ const workspaceListQuery = `
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
w.broadcast_enabled, w.talk_to_user_enabled
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.status != 'removed'
@@ -885,8 +813,7 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
COALESCE(w.workspace_dir, ''),
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
w.budget_limit, COALESCE(w.monthly_spend, 0),
w.broadcast_enabled, w.talk_to_user_enabled,
COALESCE(w.compute, '{}'::jsonb)
w.broadcast_enabled, w.talk_to_user_enabled
FROM workspaces w
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
WHERE w.id = $1
@@ -33,7 +33,7 @@ var wsColumns = []string{
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
// ==================== GET — financial fields stripped from open endpoint ====================
@@ -56,8 +56,7 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
nil, // budget_limit NULL
0, // monthly_spend 0
false, // broadcast_enabled
true, // talk_to_user_enabled
[]byte(`{}`)))
true)) // talk_to_user_enabled
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -101,8 +100,7 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
0.0, 0.0, false,
int64(500), // budget_limit = $5.00 in DB
int64(123), // monthly_spend = $1.23 in DB
false, true, // broadcast_enabled, talk_to_user_enabled
[]byte(`{}`)))
false, true)) // broadcast_enabled, talk_to_user_enabled
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -147,17 +145,18 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
sqlmock.AnyArg(), // id
"Budgeted Agent", // name
nil, // role
3, // tier (default, workspace.go create-handler)
"langgraph", // runtime
sqlmock.AnyArg(), // awareness_namespace
(*string)(nil), // parent_id
nil, // workspace_dir
"none", // workspace_access
&budgetVal, // budget_limit ($10)
models.DefaultMaxConcurrentTasks, // max_concurrent_tasks default
"push", // delivery_mode default (#2339)
"push", // delivery_mode default (#2339)
).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@@ -169,7 +168,7 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Budgeted Agent","model":"anthropic:claude-opus-4-7","budget_limit":1000}`
body := `{"name":"Budgeted Agent","budget_limit":1000}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
@@ -1,208 +0,0 @@
package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
const (
workspaceComputeDiskFloorGB = 30
workspaceComputeDiskCeilingGB = 500
)
type workspaceDisplayResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Mode string `json:"mode,omitempty"`
Protocol string `json:"protocol,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Status string `json:"status,omitempty"`
}
var workspaceComputeInstanceAllowlist = map[string]struct{}{
"t3.medium": {},
"t3.large": {},
"t3.xlarge": {},
"t3.2xlarge": {},
"m6i.large": {},
"m6i.xlarge": {},
"c6i.xlarge": {},
}
func validateWorkspaceCompute(compute models.WorkspaceCompute) error {
if compute.InstanceType != "" {
if _, ok := workspaceComputeInstanceAllowlist[compute.InstanceType]; !ok {
return fmt.Errorf("unsupported compute.instance_type")
}
}
if compute.Volume.RootGB != 0 {
if compute.Volume.RootGB < workspaceComputeDiskFloorGB || compute.Volume.RootGB > workspaceComputeDiskCeilingGB {
return fmt.Errorf("compute.volume.root_gb must be between %d and %d", workspaceComputeDiskFloorGB, workspaceComputeDiskCeilingGB)
}
}
switch compute.Display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch compute.Display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if compute.Display.Width < 0 || compute.Display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func validateWorkspaceDisplayConfig(display models.WorkspaceComputeDisplay) error {
switch display.Mode {
case "", "none", "desktop-control", "gpu-desktop-control":
default:
return fmt.Errorf("unsupported compute.display.mode")
}
switch display.Protocol {
case "", "dcv":
default:
return fmt.Errorf("unsupported compute.display.protocol")
}
if display.Width < 0 || display.Height < 0 {
return fmt.Errorf("compute.display width/height must be non-negative")
}
return nil
}
func workspaceComputeIsZero(compute models.WorkspaceCompute) bool {
return compute.InstanceType == "" &&
compute.Volume.RootGB == 0 &&
compute.Display.Mode == "" &&
compute.Display.Width == 0 &&
compute.Display.Height == 0 &&
compute.Display.Protocol == ""
}
func workspaceComputeJSON(compute models.WorkspaceCompute) (string, error) {
if workspaceComputeIsZero(compute) {
return "{}", nil
}
out := map[string]interface{}{}
if compute.InstanceType != "" {
out["instance_type"] = compute.InstanceType
}
if compute.Volume.RootGB != 0 {
out["volume"] = map[string]interface{}{"root_gb": compute.Volume.RootGB}
}
display := map[string]interface{}{}
if compute.Display.Mode != "" {
display["mode"] = compute.Display.Mode
}
if compute.Display.Width != 0 {
display["width"] = compute.Display.Width
}
if compute.Display.Height != 0 {
display["height"] = compute.Display.Height
}
if compute.Display.Protocol != "" {
display["protocol"] = compute.Display.Protocol
}
if len(display) > 0 {
out["display"] = display
}
b, err := json.Marshal(out)
if err != nil {
return "", err
}
return string(b), nil
}
func withStoredCompute(ctx context.Context, workspaceID string, payload models.CreateWorkspacePayload) models.CreateWorkspacePayload {
if !workspaceComputeIsZero(payload.Compute) || db.DB == nil {
return payload
}
var raw string
err := db.DB.QueryRowContext(ctx,
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("withStoredCompute: load compute for %s failed: %v", workspaceID, err)
}
return payload
}
if raw == "" || raw == "{}" {
return payload
}
var compute models.WorkspaceCompute
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("withStoredCompute: invalid compute JSON for %s: %v", workspaceID, err)
return payload
}
if err := validateWorkspaceCompute(compute); err != nil {
log.Printf("withStoredCompute: stored compute for %s failed validation: %v", workspaceID, err)
return payload
}
payload.Compute = compute
return payload
}
// Display handles GET /workspaces/:id/display.
//
// Phase 1 only exposes the product contract and the non-display unavailable
// state. Future desktop-control work will replace the display-enabled branch
// with short-lived proxied DCV session details.
func (h *WorkspaceHandler) Display(c *gin.Context) {
workspaceID := c.Param("id")
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "workspace not found"})
return
}
log.Printf("Display: load compute for %s failed: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "failed to load display config"})
return
}
var compute models.WorkspaceCompute
if raw != "" && raw != "{}" {
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
log.Printf("Display: invalid compute JSON for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
log.Printf("Display: invalid stored compute for %s: %v", workspaceID, err)
c.JSON(500, gin.H{"error": "invalid display config"})
return
}
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_not_enabled",
})
return
}
c.JSON(200, workspaceDisplayResponse{
Available: false,
Reason: "display_session_unavailable",
Mode: compute.Display.Mode,
Protocol: compute.Display.Protocol,
Width: compute.Display.Width,
Height: compute.Display.Height,
Status: "not_configured",
})
}
@@ -1,318 +0,0 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/gin-gonic/gin"
)
func TestValidateWorkspaceCompute_AcceptsPhase1SizingAndDisplayNone(t *testing.T) {
compute := models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
Display: models.WorkspaceComputeDisplay{Mode: "none"},
}
if err := validateWorkspaceCompute(compute); err != nil {
t.Fatalf("validateWorkspaceCompute returned error for valid compute: %v", err)
}
}
func TestValidateWorkspaceCompute_RejectsUnknownInstanceType(t *testing.T) {
compute := models.WorkspaceCompute{InstanceType: "p4d.24xlarge"}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatal("validateWorkspaceCompute accepted unsupported instance type")
}
}
func TestValidateWorkspaceCompute_RejectsOutOfRangeRootVolume(t *testing.T) {
for _, rootGB := range []int{29, 501} {
compute := models.WorkspaceCompute{Volume: models.WorkspaceComputeVolume{RootGB: rootGB}}
if err := validateWorkspaceCompute(compute); err == nil {
t.Fatalf("validateWorkspaceCompute accepted root_gb=%d", rootGB)
}
}
}
func TestWorkspaceComputeJSON_OmitsEmptyNestedSections(t *testing.T) {
got, err := workspaceComputeJSON(models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
})
if err != nil {
t.Fatalf("workspaceComputeJSON returned error: %v", err)
}
if strings.Contains(got, `"display"`) {
t.Fatalf("workspaceComputeJSON included empty display section: %s", got)
}
if got != `{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}` {
t.Fatalf("workspaceComputeJSON = %s", got)
}
}
func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET compute = \$2::jsonb`).
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Sized Agent",
"external":true,
"runtime":"external",
"compute":{
"instance_type":"m6i.xlarge",
"volume":{"root_gb":100},
"display":{"mode":"none"}
}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceCreate_WithInvalidCompute_ReturnsBadRequest(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{
"name":"Oversized Agent",
"model":"gpt-4",
"compute":{"instance_type":"p4d.24xlarge"}
}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBuildProvisionerConfig_CopiesComputeSizingFromPayload(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(workspace_dir`).
WithArgs("ws-compute").
WillReturnRows(sqlmock.NewRows([]string{"workspace_dir", "workspace_access"}).AddRow("", "none"))
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
cfg := handler.buildProvisionerConfig(
context.Background(),
"ws-compute",
"",
nil,
models.CreateWorkspacePayload{
Tier: 4,
Runtime: "claude-code",
Compute: models.WorkspaceCompute{
InstanceType: "m6i.xlarge",
Volume: models.WorkspaceComputeVolume{RootGB: 100},
},
},
nil,
t.TempDir(),
)
if cfg.InstanceType != "m6i.xlarge" {
t.Errorf("cfg.InstanceType = %q, want m6i.xlarge", cfg.InstanceType)
}
if cfg.DiskGB != 100 {
t.Errorf("cfg.DiskGB = %d, want 100", cfg.DiskGB)
}
}
func TestWithStoredCompute_LoadsComputeForRestartPayloads(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-restart-compute").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"m6i.xlarge","volume":{"root_gb":100}}`))
payload := models.CreateWorkspacePayload{Name: "Restart Me", Tier: 4, Runtime: "claude-code"}
got := withStoredCompute(context.Background(), "ws-restart-compute", payload)
if got.Compute.InstanceType != "m6i.xlarge" {
t.Errorf("stored compute instance_type = %q, want m6i.xlarge", got.Compute.InstanceType)
}
if got.Compute.Volume.RootGB != 100 {
t.Errorf("stored compute root_gb = %d, want 100", got.Compute.Volume.RootGB)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_NonDisplayWorkspaceReturnsUnavailable(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-no-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("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 display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_not_enabled" {
t.Fatalf("reason = %v, want display_not_enabled", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_DisplayConfiguredReturnsSessionUnavailableContract(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("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 display response: %v", err)
}
if resp["available"] != false {
t.Fatalf("available = %v, want false", resp["available"])
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if resp["status"] != "not_configured" {
t.Fatalf("status = %v, want not_configured", resp["status"])
}
if resp["mode"] != "desktop-control" || resp["protocol"] != "dcv" {
t.Fatalf("mode/protocol = %v/%v, want desktop-control/dcv", resp["mode"], resp["protocol"])
}
if resp["width"] != float64(1920) || resp["height"] != float64(1080) {
t.Fatalf("width/height = %v/%v, want 1920/1080", resp["width"], resp["height"])
}
if _, ok := resp["url"]; ok {
t.Fatalf("display response exposed url before session infra exists: %v", resp["url"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_IgnoresUnrelatedStoredComputeSizingDrift(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display-sizing-drift").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"instance_type":"old.large","display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display-sizing-drift"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display-sizing-drift/display", nil)
handler.Display(c)
if w.Code != http.StatusOK {
t.Fatalf("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 display response: %v", err)
}
if resp["reason"] != "display_session_unavailable" {
t.Fatalf("reason = %v, want display_session_unavailable", resp["reason"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplay_InvalidStoredDisplayConfigReturnsServerError(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-invalid-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"vnc"}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-invalid-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-invalid-display/display", nil)
handler.Display(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, 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 display response: %v", err)
}
if resp["error"] != "invalid display config" {
t.Fatalf("error = %v, want invalid display config", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -103,13 +103,13 @@ func cleanupTestRows(t *testing.T, conn *sql.DB, namePrefix string) {
// TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision
// exercises the helper end-to-end against a real Postgres:
//
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
// 1. INSERT a row with name "<prefix>-Repro" — succeeds.
// 2. Run insertWorkspaceWithNameRetry with the same name —
// partial-unique violation fires, helper retries with
// " (2)", that succeeds.
// 3. SELECT the row by id, confirm name = "<prefix>-Repro (2)".
// 4. Run helper AGAIN — second collision, helper retries with
// " (3)".
//
// This is the live-test that proves the partial-index behaviour
// matches the migration's intent — sqlmock cannot reach this depth.
@@ -130,9 +130,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
// targets + the NOT NULL columns required by the schema).
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
`, firstID, baseName); err != nil {
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed first row: %v", err)
}
@@ -145,10 +145,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName}
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
@@ -179,7 +179,7 @@ func TestIntegration_WorkspaceCreate_NameRetry_AutoSuffixesOnCollision(t *testin
t.Fatalf("begin tx3: %v", err)
}
thirdID := uuid.New().String()
args3 := []any{thirdID, baseName}
args3 := []any{thirdID, baseName, "workspace:" + thirdID}
persistedName3, finalTx3, err := insertWorkspaceWithNameRetry(
ctx, tx3, beginTx, baseName, 1, query, args3,
)
@@ -216,9 +216,9 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
// Seed a row, then tombstone it.
firstID := uuid.New().String()
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'removed')
`, firstID, baseName); err != nil {
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'removed')
`, firstID, baseName, "workspace:"+firstID); err != nil {
t.Fatalf("seed tombstoned row: %v", err)
}
@@ -231,10 +231,10 @@ func TestIntegration_WorkspaceCreate_NameRetry_TombstonedRowDoesNotCollide(t *te
}
secondID := uuid.New().String()
query := `
INSERT INTO workspaces (id, name, tier, runtime, status)
VALUES ($1, $2, 2, 'claude-code', 'provisioning')
INSERT INTO workspaces (id, name, tier, runtime, awareness_namespace, status)
VALUES ($1, $2, 2, 'claude-code', $3, 'provisioning')
`
args := []any{secondID, baseName}
args := []any{secondID, baseName, "workspace:" + secondID}
persistedName, finalTx, err := insertWorkspaceWithNameRetry(
ctx, tx, beginTx, baseName, 1, query, args,
)
@@ -435,16 +435,13 @@ func (h *WorkspaceHandler) CascadeDelete(ctx context.Context, id string) ([]stri
if err != nil {
return nil, nil, fmt.Errorf("descendant query: %w", err)
}
defer descRows.Close()
for descRows.Next() {
var descID string
if descRows.Scan(&descID) == nil {
descendantIDs = append(descendantIDs, descID)
}
}
if err := descRows.Err(); err != nil {
return nil, nil, fmt.Errorf("CascadeDelete: failed iterating descendants: %w", err)
}
descRows.Close()
allIDs := append([]string{id}, descendantIDs...)
@@ -503,32 +503,6 @@ func TestCascadeDelete_DescendantQueryError(t *testing.T) {
// sqlmock verifies all expected queries were executed
}
func TestCascadeDelete_DescendantRowsError(t *testing.T) {
mock, _ := setupWorkspaceCrudTest(t)
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
// RowError(0, ...) requires a real row at index 0 to be reachable —
// sqlmock only invokes nextErr[N] when r.pos-1 == N and the row exists.
// AddRow ensures Next() attempts the first row, triggers the error,
// and rows.Err() returns the injected error.
h := &WorkspaceHandler{}
rows := sqlmock.NewRows([]string{"id"}).AddRow("desc-1").RowError(0, sql.ErrConnDone)
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
WithArgs(wsID).
WillReturnRows(rows)
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
if err == nil {
t.Fatal("CascadeDelete returned nil error; want descendant rows error")
}
if deleted != nil {
t.Errorf("deleted = %v; want nil", deleted)
}
if stopErrs != nil {
t.Errorf("stopErrs = %v; want nil", stopErrs)
}
}
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
// and provisioner calls — covered in integration tests. Unit tests here focus on
// the validation and pre-condition paths.
@@ -1,360 +0,0 @@
package handlers
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth"
"github.com/gin-gonic/gin"
)
const (
displayControlDefaultTTLSeconds = 300
displayControlMinTTLSeconds = 30
displayControlMaxTTLSeconds = 3600
)
type workspaceDisplayControlResponse struct {
Controller string `json:"controller"`
ControlledBy string `json:"controlled_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
}
type workspaceDisplayControlNoneResponse struct {
Controller string `json:"controller"`
}
type acquireDisplayControlRequest struct {
Controller string `json:"controller"`
TTLSeconds int `json:"ttl_seconds"`
}
type releaseDisplayControlRequest struct {
Force bool `json:"force"`
}
// DisplayControl handles GET /workspaces/:id/display/control.
func (h *WorkspaceHandler) DisplayControl(c *gin.Context) {
lock, found, err := h.loadActiveDisplayControl(c, c.Param("id"))
if err != nil {
log.Printf("DisplayControl: load lock for %s failed: %v", c.Param("id"), err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
c.JSON(http.StatusOK, lock)
}
// AcquireDisplayControl handles POST /workspaces/:id/display/control/acquire.
func (h *WorkspaceHandler) AcquireDisplayControl(c *gin.Context) {
var req acquireDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control request"})
return
}
}
if req.Controller == "" {
req.Controller = "user"
}
if req.Controller != "user" {
c.JSON(http.StatusBadRequest, gin.H{"error": "browser callers may only acquire user display control"})
return
}
if req.TTLSeconds == 0 {
req.TTLSeconds = displayControlDefaultTTLSeconds
}
if req.TTLSeconds < displayControlMinTTLSeconds || req.TTLSeconds > displayControlMaxTTLSeconds {
c.JSON(http.StatusBadRequest, gin.H{"error": "ttl_seconds must be between 30 and 3600"})
return
}
if ok := h.displayControlEnabled(c, c.Param("id")); !ok {
return
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.started", workspaceID, map[string]any{
"controller": req.Controller,
"controlled_by": controlledBy,
"ttl_seconds": req.TTLSeconds,
})
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(), `
INSERT INTO workspace_display_control_locks
(workspace_id, controller, controlled_by, expires_at)
VALUES
($1, $2, $3, now() + ($4 * interval '1 second'))
ON CONFLICT (workspace_id) DO UPDATE
SET controller = EXCLUDED.controller,
controlled_by = EXCLUDED.controlled_by,
expires_at = EXCLUDED.expires_at,
updated_at = now()
WHERE workspace_display_control_locks.expires_at <= now()
OR workspace_display_control_locks.controlled_by = EXCLUDED.controlled_by
RETURNING controller, controlled_by, expires_at`,
workspaceID, req.Controller, controlledBy, req.TTLSeconds,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.completed", workspaceID, map[string]any{
"controller": lock.Controller,
"controlled_by": lock.ControlledBy,
"ttl_seconds": req.TTLSeconds,
"duration_ms": time.Since(startedAt).Milliseconds(),
})
c.JSON(http.StatusOK, lock)
return
}
if err == sql.ErrNoRows {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("AcquireDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control already held",
})
if !found {
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": workspaceDisplayControlNoneResponse{Controller: "none"},
})
return
}
c.JSON(http.StatusConflict, gin.H{
"error": "display control already held",
"current": current,
})
return
}
log.Printf("AcquireDisplayControl: acquire lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.acquire.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to acquire display control"})
}
// ReleaseDisplayControl handles POST /workspaces/:id/display/control/release.
func (h *WorkspaceHandler) ReleaseDisplayControl(c *gin.Context) {
var req releaseDisplayControlRequest
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid display control release request"})
return
}
}
if req.Force {
if !displayControlIsAdminToken(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "force release requires admin-token auth"})
return
}
}
controlledBy, ok := displayControlActor(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "display control requires admin-token or org-token auth"})
return
}
workspaceID := c.Param("id")
startedAt := time.Now()
emitDisplayControlEvent(c.Request.Context(), "display.control.release.started", workspaceID, map[string]any{
"controlled_by": controlledBy,
"force": req.Force,
})
query := `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1 AND controlled_by = $2`
args := []interface{}{workspaceID, controlledBy}
if req.Force {
query = `DELETE FROM workspace_display_control_locks WHERE workspace_id = $1`
args = []interface{}{workspaceID}
}
result, err := db.DB.ExecContext(c.Request.Context(), query, args...)
if err != nil {
log.Printf("ReleaseDisplayControl: release lock for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("ReleaseDisplayControl: rows affected for %s failed: %v", workspaceID, err)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": err.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to release display control"})
return
}
if rowsAffected == 0 {
current, found, loadErr := h.loadActiveDisplayControl(c, workspaceID)
if loadErr != nil {
log.Printf("ReleaseDisplayControl: load active lock for %s failed: %v", workspaceID, loadErr)
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": loadErr.Error(),
"force": req.Force,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display control"})
return
}
if !found {
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.failed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"error": "display control held by another caller",
"force": req.Force,
})
c.JSON(http.StatusConflict, gin.H{
"error": "display control held by another caller",
"current": current,
})
return
}
emitDisplayControlEvent(c.Request.Context(), "display.control.release.completed", workspaceID, map[string]any{
"controlled_by": controlledBy,
"duration_ms": time.Since(startedAt).Milliseconds(),
"force": req.Force,
"rows_affected": rowsAffected,
})
c.JSON(http.StatusOK, workspaceDisplayControlNoneResponse{Controller: "none"})
}
func (h *WorkspaceHandler) loadActiveDisplayControl(c *gin.Context, workspaceID string) (workspaceDisplayControlResponse, bool, error) {
var lock workspaceDisplayControlResponse
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = $1 AND expires_at > now()`,
workspaceID,
).Scan(&lock.Controller, &lock.ControlledBy, &lock.ExpiresAt)
if err == nil {
return lock, true, nil
}
if err == sql.ErrNoRows {
return workspaceDisplayControlResponse{}, false, nil
}
return workspaceDisplayControlResponse{}, false, err
}
func (h *WorkspaceHandler) displayControlEnabled(c *gin.Context, workspaceID string) bool {
var raw string
err := db.DB.QueryRowContext(c.Request.Context(),
`SELECT COALESCE(compute, '{}'::jsonb) FROM workspaces WHERE id = $1`,
workspaceID,
).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return false
}
log.Printf("displayControlEnabled: load compute for %s failed: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load display config"})
return false
}
compute, err := parseWorkspaceDisplayCompute(workspaceID, raw)
if err != nil {
log.Printf("displayControlEnabled: invalid display config for %s: %v", workspaceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid display config"})
return false
}
if compute.Display.Mode == "" || compute.Display.Mode == "none" {
c.JSON(http.StatusBadRequest, gin.H{"error": "display not enabled"})
return false
}
return true
}
func parseWorkspaceDisplayCompute(workspaceID, raw string) (models.WorkspaceCompute, error) {
var compute models.WorkspaceCompute
if raw == "" || raw == "{}" {
return compute, nil
}
if err := json.Unmarshal([]byte(raw), &compute); err != nil {
return models.WorkspaceCompute{}, fmt.Errorf("invalid compute JSON for %s: %w", workspaceID, err)
}
if err := validateWorkspaceDisplayConfig(compute.Display); err != nil {
return models.WorkspaceCompute{}, err
}
return compute, nil
}
func displayControlActor(c *gin.Context) (string, bool) {
if v, ok := c.Get("org_token_prefix"); ok {
if s, ok := v.(string); ok && s != "" {
return actorOrgTokenPrefix + s, true
}
}
if displayControlIsAdminToken(c) {
return actorAdminToken, true
}
// Browser session auth is intentionally observe-only until AdminAuth
// exposes a stable per-user or per-session identity in gin.Context.
return "", false
}
func displayControlIsAdminToken(c *gin.Context) bool {
adminSecret := os.Getenv("ADMIN_TOKEN")
if adminSecret == "" {
return false
}
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
return subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1
}
func emitDisplayControlEvent(ctx context.Context, eventType string, workspaceID string, payload map[string]any) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("emitDisplayControlEvent: marshal %s payload failed: %v", eventType, err)
return
}
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload, created_at)
VALUES ($1, $2, $3::jsonb, now())
`, eventType, workspaceID, string(payloadJSON)); err != nil {
log.Printf("emitDisplayControlEvent: insert %s failed: %v", eventType, err)
}
}
@@ -1,321 +0,0 @@
package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
func attachDisplayControlAdminToken(t *testing.T, c *gin.Context) {
t.Helper()
t.Setenv("ADMIN_TOKEN", "test-admin-secret")
c.Request.Header.Set("Authorization", "Bearer test-admin-secret")
}
func TestWorkspaceDisplayControl_NoActiveLockReturnsNone(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-display/display/control", nil)
handler.DisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("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 resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ClaimsUnlockedDisplay(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "admin-token", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("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 resp["controller"] != "user" || resp["controlled_by"] != "admin-token" {
t.Fatalf("lock response = %#v, want user/admin-token", resp)
}
if resp["expires_at"] == "" {
t.Fatalf("expires_at missing in response: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_ActiveLockReturnsConflict(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
mock.ExpectQuery(`INSERT INTO workspace_display_control_locks`).
WithArgs("ws-display", "user", "admin-token", 300).
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("agent", "sidecar", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, 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 resp["error"] != "display control already held" {
t.Fatalf("error = %v, want display control already held", resp["error"])
}
current, ok := resp["current"].(map[string]interface{})
if !ok || current["controller"] != "agent" || current["controlled_by"] != "sidecar" {
t.Fatalf("current lock = %#v, want agent/sidecar", resp["current"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsDisplayDisabledWorkspace(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-no-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-no-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-no-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, 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 resp["error"] != "display not enabled" {
t.Fatalf("error = %v, want display not enabled", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlAcquire_RejectsCoarseSessionActor(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectQuery(`SELECT COALESCE\(compute, '\{\}'::jsonb\) FROM workspaces WHERE id = \$1`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"compute"}).AddRow(`{"display":{"mode":"desktop-control","protocol":"dcv","width":1920,"height":1080}}`))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"user","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request.Header.Set("Cookie", "molecule_session=present")
handler.AcquireDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "display control requires admin-token or org-token auth" {
t.Fatalf("error = %v, want display control requires admin-token or org-token auth", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RemovesCallerLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusOK {
t.Fatalf("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 resp["controller"] != "none" {
t.Fatalf("controller = %v, want none", resp["controller"])
}
if _, ok := resp["expires_at"]; ok {
t.Fatalf("none response included expires_at: %#v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_ConflictWhenCallerDoesNotOwnLock(t *testing.T) {
mock := setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
expiresAt := time.Date(2026, 5, 23, 18, 30, 0, 0, time.UTC)
mock.ExpectExec(`DELETE FROM workspace_display_control_locks WHERE workspace_id = \$1 AND controlled_by = \$2`).
WithArgs("ws-display", "admin-token").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT controller, controlled_by, expires_at FROM workspace_display_control_locks WHERE workspace_id = \$1 AND expires_at > now\(\)`).
WithArgs("ws-display").
WillReturnRows(sqlmock.NewRows([]string{"controller", "controlled_by", "expires_at"}).
AddRow("user", "org-token:abcd1234", expiresAt))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", nil)
attachDisplayControlAdminToken(t, c)
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusConflict {
t.Fatalf("expected status 409, 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 resp["error"] != "display control held by another caller" {
t.Fatalf("error = %v, want display control held by another caller", resp["error"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
func TestWorkspaceDisplayControlRelease_RejectsOrgTokenForceRelease(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Set("org_token_prefix", "abcd1234")
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/release", bytes.NewBufferString(`{"force":true}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.ReleaseDisplayControl(c)
if w.Code != http.StatusForbidden {
t.Fatalf("expected status 403, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp["error"] != "force release requires admin-token auth" {
t.Fatalf("error = %v, want force release requires admin-token auth", resp["error"])
}
}
func TestWorkspaceDisplayControlAcquire_RejectsAgentImpersonation(t *testing.T) {
setupTestDB(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-display"}}
c.Request = httptest.NewRequest("POST", "/workspaces/ws-display/display/control/acquire", bytes.NewBufferString(`{"controller":"agent","ttl_seconds":300}`))
c.Request.Header.Set("Content-Type", "application/json")
attachDisplayControlAdminToken(t, c)
handler.AcquireDisplayControl(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, 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 resp["error"] != "browser callers may only acquire user display control" {
t.Fatalf("error = %v, want browser callers may only acquire user display control", resp["error"])
}
}
@@ -128,7 +128,7 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
workspaceID, filepath.Base(runtimeTemplate))
templatePath = runtimeTemplate
// Rebuild cfg with the recovered template path so Start() sees it.
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath)
cfg = h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, prepared.EnvVars, prepared.PluginsPath, prepared.AwarenessNamespace)
cfg.ResetClaudeSession = resetClaudeSession
recovered = true
break
@@ -194,11 +194,10 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
// a ~64k context window worth of text — but small enough to prevent abuse.
const maxMemoryContentLength = 100_000 // ~100 KiB of text
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed) {
func seedInitialMemories(ctx context.Context, workspaceID string, memories []models.MemorySeed, awarenessNamespace string) {
if len(memories) == 0 {
return
}
namespace := workspaceMemoryNamespace(workspaceID)
for _, mem := range memories {
scope := strings.ToUpper(mem.Scope)
if scope == "" {
@@ -224,27 +223,33 @@ func seedInitialMemories(ctx context.Context, workspaceID string, memories []mod
if _, err := db.DB.ExecContext(ctx, `
INSERT INTO agent_memories (workspace_id, content, scope, namespace)
VALUES ($1, $2, $3, $4)
`, workspaceID, redactedContent, scope, namespace); err != nil {
`, workspaceID, redactedContent, scope, awarenessNamespace); err != nil {
log.Printf("seedInitialMemories: failed to insert memory for %s (scope=%s): %v", workspaceID, scope, err)
}
}
log.Printf("seedInitialMemories: seeded %d memories for workspace %s", len(memories), workspaceID)
}
// workspaceMemoryNamespace returns the canonical v2 memory namespace
// string for a workspace. Matches the form produced by
// internal/memory/namespace/resolver.go for self-reads (issue #1735).
func workspaceMemoryNamespace(workspaceID string) string {
func workspaceAwarenessNamespace(workspaceID string) string {
return fmt.Sprintf("workspace:%s", workspaceID)
}
func (h *WorkspaceHandler) loadAwarenessNamespace(ctx context.Context, workspaceID string) string {
var awarenessNamespace string
err := db.DB.QueryRowContext(ctx, `SELECT COALESCE(awareness_namespace, '') FROM workspaces WHERE id = $1`, workspaceID).Scan(&awarenessNamespace)
if err != nil || awarenessNamespace == "" {
return workspaceAwarenessNamespace(workspaceID)
}
return awarenessNamespace
}
func (h *WorkspaceHandler) buildProvisionerConfig(
ctx context.Context,
workspaceID, templatePath string,
configFiles map[string][]byte,
payload models.CreateWorkspacePayload,
envVars map[string]string,
pluginsPath string,
pluginsPath, awarenessNamespace string,
) provisioner.WorkspaceConfig {
// Per-workspace workspace_dir takes priority over global WORKSPACE_DIR env var.
// If neither is set, the provisioner creates an isolated Docker volume.
@@ -283,18 +288,18 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
}
return provisioner.WorkspaceConfig{
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
InstanceType: payload.Compute.InstanceType,
DiskGB: int32(payload.Compute.Volume.RootGB),
EnvVars: envVars,
PlatformURL: h.platformURL,
WorkspaceID: workspaceID,
TemplatePath: templatePath,
ConfigFiles: configFiles,
PluginsPath: pluginsPath,
WorkspacePath: workspacePath,
WorkspaceAccess: workspaceAccess,
Tier: payload.Tier,
Runtime: payload.Runtime,
EnvVars: envVars,
PlatformURL: h.platformURL,
AwarenessURL: os.Getenv("AWARENESS_URL"),
AwarenessNamespace: awarenessNamespace,
// Image left empty — molecule-core's runtime_image_pins table (mig
// 047, dead reader removed by RFC internal#617 / task #335) was an
// aspirational SSOT that never received a writer. CP's
@@ -543,22 +548,13 @@ func (h *WorkspaceHandler) ensureDefaultConfig(workspaceID string, payload model
// via a crafted runtime string (#241).
runtime := sanitizeRuntime(payload.Runtime)
// Generate a minimal config.yaml.
//
// SSOT (CTO 2026-05-22): model is REQUIRED user input. The platform
// must not provide a default; the runtime must not fall back. The
// Create handler is responsible for rejecting empty model BEFORE
// reaching provisionWorkspace; this is a defence-in-depth assertion.
// If we hit here with an empty model the YAML below would still
// render a `model: ""` line — which renders all downstream provider
// derivation undefined. Log loudly and let the workspace boot into
// not_configured rather than masking the contract violation with a
// silently-broken default (the prior `anthropic:claude-opus-4-7`
// fallback was the canonical example — every codex workspace
// created without an explicit model wedged).
// Generate a minimal config.yaml
model := payload.Model
if model == "" {
log.Printf("ensureDefaultConfig: workspace %s reached provisioning with empty model — Create handler should have rejected this; rendering empty model: \"\" in config.yaml (workspace will boot not_configured)", workspaceID)
// SSOT: per-runtime defaults live in models/runtime_defaults.go
// (see RFC #2873). Was previously duplicated here AND in
// org_import.go; consolidating prevents silent drift.
model = models.DefaultModel(runtime)
}
if runtime == "claude-code" {
model = normalizeClaudeCodeModel(model)
@@ -1013,3 +1009,4 @@ func (h *WorkspaceHandler) provisionWorkspaceCP(workspaceID, templatePath string
log.Printf("CPProvisioner: workspace %s started as machine %s via control plane", workspaceID, machineID)
}
@@ -85,9 +85,10 @@ func readOrLazyHealInboundSecret(ctx context.Context, workspaceID, opLabel strin
// prepareProvisionContext when the caller proceeds; nil + non-empty
// abort message when the caller must mark the workspace failed.
type preparedProvisionContext struct {
EnvVars map[string]string
PluginsPath string
Config provisioner.WorkspaceConfig
EnvVars map[string]string
PluginsPath string
AwarenessNamespace string
Config provisioner.WorkspaceConfig
}
// provisionAbort describes why prepareProvisionContext refused to
@@ -169,6 +170,7 @@ func (h *WorkspaceHandler) prepareProvisionContext(
}
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
// Per-agent git identity (#1957) — must run after secret loads so
// a workspace_secret named GIT_AUTHOR_NAME can override.
@@ -229,13 +231,14 @@ func (h *WorkspaceHandler) prepareProvisionContext(
}
}
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath)
cfg := h.buildProvisionerConfig(ctx, workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace)
cfg.ResetClaudeSession = resetClaudeSession
return &preparedProvisionContext{
EnvVars: envVars,
PluginsPath: pluginsPath,
Config: cfg,
EnvVars: envVars,
PluginsPath: pluginsPath,
AwarenessNamespace: awarenessNamespace,
Config: cfg,
}, nil
}
@@ -756,55 +756,47 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
}
}
// TestWorkspaceCreate_FirstDeploy_NoModel_Returns422 inverts the prior
// premise (CTO 2026-05-22 SSOT directive — see
// feedback_workspace_model_required_no_platform_default_dynamic_credential_intake
// and TestCreate_ModelRequired_Returns422 in handlers_extended_test.go).
//
// Pre-2026-05-22 the canvas was allowed to omit `model` and the workspace
// would 201 with no workspace_secrets rows for MODEL/LLM_PROVIDER (the
// thinking being that templates inherit the runtime default later). That
// "soft fallback" was the load-bearing bug magnet — `DefaultModel(runtime)`
// would later return `anthropic:claude-opus-4-7`, and codex workspaces
// wedged forever at adapter init.
//
// New contract: empty model is a 422 MODEL_REQUIRED, with NO DB writes
// at all. The gate fires at the Create boundary before INSERT INTO
// workspaces. The follow-on workspace_secrets gate (which the original
// test pinned) is therefore unreachable on the empty-model path — there
// is no row to mint secrets for.
func TestWorkspaceCreate_FirstDeploy_NoModel_Returns422(t *testing.T) {
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
// written. Important: the canvas can omit `model` (template inherits
// the runtime default later); we must not poison workspace_secrets with
// empty rows in that case.
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// NO mock.ExpectBegin / INSERT INTO workspaces — the Create gate
// MUST fire before any DB write. If the gate fires late, sqlmock
// will surface "call to ExecQuery 'INSERT INTO workspaces' was not
// expected" — which is exactly the failure mode we want to flag.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// NO INSERT INTO workspace_secrets here — the gate is payload.Model != "".
mock.ExpectExec("INSERT INTO canvas_layouts").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE workspaces SET status =`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// Body: hermes runtime WITHOUT external:true (the external-runtime
// exemption — see TestCreate_ExternalRuntime_NoModel_OK — does NOT
// apply here; hermes spawns a real adapter and model selection
// matters at adapter init). This is exactly the shape the old
// "no-model-no-secret-write" test pinned, minus the external flag.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Model Agent","runtime":"hermes"}`
body := `{"name":"No Model Agent","runtime":"hermes","external":true}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 MODEL_REQUIRED for empty model, got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock saw an unexpected DB write — the MODEL_REQUIRED gate fired too late: %v", err)
t.Errorf("sqlmock expectations not met — empty payload.Model should NOT trigger workspace_secrets writes: %v", err)
}
}
@@ -17,9 +17,9 @@ import (
"gopkg.in/yaml.v3"
)
// ==================== workspaceMemoryNamespace ====================
// ==================== workspaceAwarenessNamespace ====================
func TestWorkspaceMemoryNamespace(t *testing.T) {
func TestWorkspaceAwarenessNamespace(t *testing.T) {
tests := []struct {
workspaceID string
expected string
@@ -31,9 +31,9 @@ func TestWorkspaceMemoryNamespace(t *testing.T) {
for _, tt := range tests {
t.Run(tt.workspaceID, func(t *testing.T) {
result := workspaceMemoryNamespace(tt.workspaceID)
result := workspaceAwarenessNamespace(tt.workspaceID)
if result != tt.expected {
t.Errorf("workspaceMemoryNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
t.Errorf("workspaceAwarenessNamespace(%q) = %q, want %q", tt.workspaceID, result, tt.expected)
}
})
}
@@ -193,17 +193,10 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is required user input;
// ensureDefaultConfig no longer fills in a runtime default. The Create
// handler gates on empty model and 422s before reaching here, so this
// test now passes the model explicitly to exercise the YAML rendering
// path — same model value the prior implicit DefaultModel("hermes")
// returned.
payload := models.CreateWorkspacePayload{
Name: "Test Agent",
Tier: 1,
Runtime: "hermes",
Model: "anthropic:claude-opus-4-7",
}
files := handler.ensureDefaultConfig("ws-test-123", payload)
@@ -226,7 +219,7 @@ func TestEnsureDefaultConfig_Hermes(t *testing.T) {
t.Errorf("config.yaml missing tier, got:\n%s", content)
}
if !contains(content, `model: "anthropic:claude-opus-4-7"`) {
t.Errorf("config.yaml should render the supplied model, got:\n%s", content)
t.Errorf("config.yaml should use default non-claude model, got:\n%s", content)
}
}
@@ -234,14 +227,10 @@ func TestEnsureDefaultConfig_ClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): model is supplied explicitly
// instead of relying on the deleted DefaultModel("claude-code") =
// "sonnet" fallback. The Create handler 422s on empty model upstream.
payload := models.CreateWorkspacePayload{
Name: "Code Agent",
Tier: 2,
Runtime: "claude-code",
Model: "sonnet",
}
files := handler.ensureDefaultConfig("ws-code-123", payload)
@@ -418,16 +407,9 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
// Post-CTO-SSOT-directive (2026-05-22): ensureDefaultConfig is no
// longer the source of the model default — it just renders whatever
// the Create handler decided. The "empty runtime → claude-code"
// fallback inside sanitizeRuntime() is still in effect; this test
// continues to pin that behaviour by supplying the explicit
// claude-code model that the Create handler would have required.
payload := models.CreateWorkspacePayload{
Name: "Default Agent",
Tier: 1,
Model: "sonnet",
Name: "Default Agent",
Tier: 1,
}
files := handler.ensureDefaultConfig("ws-empty-rt", payload)
@@ -436,7 +418,7 @@ func TestEnsureDefaultConfig_EmptyRuntimeDefaultsToClaudeCode(t *testing.T) {
t.Errorf("empty runtime should default to claude-code, got:\n%s", configYAML)
}
if !contains(configYAML, `model: "sonnet"`) {
t.Errorf("claude-code workspace should render the supplied model (quoted), got:\n%s", configYAML)
t.Errorf("claude-code default model should be sonnet (quoted), got:\n%s", configYAML)
}
}
@@ -645,7 +627,7 @@ func TestSeedInitialMemories_TruncatesOversizedContent(t *testing.T) {
WillReturnResult(sqlmock.NewResult(1, 1))
}
seedInitialMemories(context.Background(), workspaceID, memories)
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
@@ -674,7 +656,7 @@ func TestSeedInitialMemories_RedactsSecrets(t *testing.T) {
WithArgs(workspaceID, wantRedacted, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
seedInitialMemories(context.Background(), workspaceID, memories)
seedInitialMemories(context.Background(), workspaceID, memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet DB expectations: %v", err)
@@ -691,7 +673,7 @@ func TestSeedInitialMemories_InvalidScopeSkipped(t *testing.T) {
{Content: "this should be skipped", Scope: "NOT_A_REAL_SCOPE"},
}
seedInitialMemories(context.Background(), "ws-bad-scope", memories)
seedInitialMemories(context.Background(), "ws-bad-scope", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB calls for invalid scope: %v", err)
@@ -704,7 +686,7 @@ func TestSeedInitialMemories_EmptyMemoriesNil(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectationsWereMet()
seedInitialMemories(context.Background(), "ws-nil", nil)
seedInitialMemories(context.Background(), "ws-nil", nil, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB calls for nil slice: %v", err)
@@ -733,6 +715,7 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
models.CreateWorkspacePayload{Tier: 1, Runtime: "langgraph"},
map[string]string{"API_KEY": "secret"},
pluginsPath,
"workspace:ws-basic",
)
if cfg.WorkspaceID != "ws-basic" {
@@ -747,6 +730,9 @@ func TestBuildProvisionerConfig_BasicFields(t *testing.T) {
if cfg.PlatformURL != "http://localhost:8080" {
t.Errorf("expected PlatformURL 'http://localhost:8080', got %q", cfg.PlatformURL)
}
if cfg.AwarenessNamespace != "workspace:ws-basic" {
t.Errorf("expected AwarenessNamespace 'workspace:ws-basic', got %q", cfg.AwarenessNamespace)
}
if cfg.PluginsPath != pluginsPath {
t.Errorf("expected PluginsPath %q, got %q", pluginsPath, cfg.PluginsPath)
}
@@ -771,6 +757,7 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
workspaceDir := t.TempDir()
t.Setenv("WORKSPACE_DIR", workspaceDir)
t.Setenv("AWARENESS_URL", "http://awareness:37800")
pluginsPath := t.TempDir()
cfg := handler.buildProvisionerConfig(
@@ -781,11 +768,15 @@ func TestBuildProvisionerConfig_WorkspacePathFromEnv(t *testing.T) {
models.CreateWorkspacePayload{Tier: 2, Runtime: "claude-code"},
nil,
pluginsPath,
"workspace:ws-env",
)
if cfg.WorkspacePath != workspaceDir {
t.Errorf("expected WorkspacePath from env, got %q", cfg.WorkspacePath)
}
if cfg.AwarenessURL != "http://awareness:37800" {
t.Errorf("expected AwarenessURL from env, got %q", cfg.AwarenessURL)
}
}
// ==================== issueAndInjectToken (issue #418) ====================
@@ -797,8 +788,6 @@ func TestIssueAndInjectToken_HappyPath(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace UPDATE (0 rows — no prior tokens, still succeeds)
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -836,8 +825,6 @@ func TestIssueAndInjectToken_RotatesExistingToken(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
// RevokeAllForWorkspace: 1 existing token revoked
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
@@ -904,8 +891,6 @@ func TestIssueAndInjectToken_IssueFailSkipsInjection(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-issue-fail").
@@ -932,8 +917,6 @@ func TestIssueAndInjectToken_NilConfigFilesAllocated(t *testing.T) {
mock := setupTestDB(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
t.Setenv("MOLECULE_ORG_ID", "")
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
mock.ExpectExec(`UPDATE workspace_auth_tokens SET revoked_at`).
WithArgs("ws-418-nil-cfg").
@@ -998,7 +981,7 @@ func TestSeedInitialMemories_Truncation(t *testing.T) {
WithArgs(sqlmock.AnyArg(), expectTruncated, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-1066-test", memories)
seedInitialMemories(context.Background(), "ws-1066-test", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v\n"+
@@ -1018,7 +1001,7 @@ func TestSeedInitialMemories_ContentUnderLimit(t *testing.T) {
WithArgs(sqlmock.AnyArg(), "short content", "TEAM", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-1066-under", memories)
seedInitialMemories(context.Background(), "ws-1066-under", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1043,7 +1026,7 @@ func TestSeedInitialMemories_ExactlyAtLimit(t *testing.T) {
WithArgs(sqlmock.AnyArg(), atLimitContent, "LOCAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-boundary", memories)
seedInitialMemories(context.Background(), "ws-boundary", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1059,7 +1042,7 @@ func TestSeedInitialMemories_EmptyContent(t *testing.T) {
}
// seedInitialMemories skips empty content at line 234 — no DB call expected.
seedInitialMemories(context.Background(), "ws-empty", memories)
seedInitialMemories(context.Background(), "ws-empty", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -1083,7 +1066,7 @@ func TestSeedInitialMemories_OversizedWithSecrets(t *testing.T) {
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "GLOBAL", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
seedInitialMemories(context.Background(), "ws-secrets", memories)
seedInitialMemories(context.Background(), "ws-secrets", memories, "test-ns")
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("DB expectations not met: %v", err)
@@ -164,7 +164,7 @@ func (h *WorkspaceHandler) maybeRestartAfterFileWrite(workspaceID string) {
// isRestarting reports whether a restart cycle is currently in flight for
// the workspace. Callers that have their own "container looks dead" probe
// MUST consult this before triggering a restart, because during the
// 20-30s EC2-pending window the workspace's url= and IsRunning()=false
// 20-30s EC2-pending window the workspace's url='' and IsRunning()=false
// looks identical to a dead container — and any restart-triggering probe
// (maybeMarkContainerDead from canvas /delegations poll, or the trailing
// restart-context probe at the end of runRestartCycle) will set
@@ -337,7 +337,7 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
}
var configFiles map[string][]byte
payload := withStoredCompute(ctx, id, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime})
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: containerRuntime}
log.Printf("Restart: workspace %s (%s) runtime=%q", wsName, id, containerRuntime)
// #12: ?reset=true (or body.Reset) discards the claude-sessions volume
@@ -791,7 +791,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
})
// Runtime from DB — no more config file parsing
payload := withStoredCompute(ctx, workspaceID, models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime})
payload := models.CreateWorkspacePayload{Name: wsName, Tier: tier, Runtime: dbRuntime}
// Snapshot restart-context data before the new session overwrites
// last_heartbeat_at. Issue #19 Layer 1.
@@ -858,9 +858,6 @@ func (h *WorkspaceHandler) Pause(c *gin.Context) {
toPause = append(toPause, struct{ id, name string }{cid, cname})
}
}
if err := rows.Err(); err != nil {
log.Printf("Pause: descendant query rows.Err: %v", err)
}
}
// Stop containers and mark all as paused. StopWorkspaceAuto routes
@@ -942,9 +939,6 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
toResume = append(toResume, ws)
}
}
if err := rows.Err(); err != nil {
log.Printf("Resume: descendant query rows.Err: %v", err)
}
}
// Re-provision all
@@ -954,7 +948,7 @@ func (h *WorkspaceHandler) Resume(c *gin.Context) {
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceProvisioning), ws.id, map[string]interface{}{
"name": ws.name, "tier": ws.tier, "runtime": ws.runtime,
})
payload := withStoredCompute(ctx, ws.id, models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime})
payload := models.CreateWorkspacePayload{Name: ws.name, Tier: ws.tier, Runtime: ws.runtime}
// Resume is provision-only (workspace is paused, no live container
// to stop). provisionWorkspaceAuto handles backend routing and the
// no-backend mark-failed fallback identically to Create. Pre-
@@ -29,7 +29,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0001-0000-0000-000000000000").
@@ -37,7 +37,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "langgraph",
"", 10.0, 20.0, false,
nil, 0, false, true, []byte(`{}`)))
nil, 0, false, true))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -119,7 +119,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -127,7 +127,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true, []byte(`{}`)))
nil, 0, false, true))
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
@@ -183,7 +183,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -191,7 +191,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true, []byte(`{}`)))
nil, 0, false, true))
// Simulate the row vanishing between the two queries.
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
WithArgs(id).
@@ -246,7 +246,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs(id).
@@ -254,7 +254,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
"", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
nil, 0, false, true, []byte(`{}`)))
nil, 0, false, true))
// last_outbound_at follow-up query (existing path)
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
WithArgs(id).
@@ -342,14 +342,14 @@ func TestWorkspaceCreate_DBInsertError(t *testing.T) {
// Transaction begins, workspace INSERT fails, transaction is rolled back.
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Failing Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnError(sql.ErrConnDone)
mock.ExpectRollback()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Failing Agent","model":"anthropic:claude-opus-4-7"}`
body := `{"name":"Failing Agent"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -375,7 +375,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
// Expect workspace INSERT with defaulted tier=3 (Privileged — the
// handler default in workspace.go), runtime="langgraph"
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Default Agent", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
@@ -391,7 +391,7 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Default Agent","model":"anthropic:claude-opus-4-7"}`
body := `{"name":"Default Agent"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -423,7 +423,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "SaaS External Agent", nil, 4, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -438,7 +438,7 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"SaaS External Agent","runtime":"external","model":"external:custom","external":true,"url":"https://example.com/agent","tier":2}`
body := `{"name":"SaaS External Agent","runtime":"external","external":true,"url":"https://example.com/agent","tier":2}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -464,7 +464,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
// Secret inserted inside the same transaction.
mock.ExpectExec("INSERT INTO workspace_secrets").
@@ -479,7 +479,7 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Hermes Agent","runtime":"hermes","model":"anthropic:claude-opus-4-7","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
body := `{"name":"Hermes Agent","runtime":"hermes","external":true,"secrets":{"HERMES_API_KEY":"sk-test-123"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -513,7 +513,7 @@ func TestWorkspaceCreate_SecretPersistFails_RollsBack(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Rollback Agent","model":"anthropic:claude-opus-4-7","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
body := `{"name":"Rollback Agent","secrets":{"OPENAI_API_KEY":"sk-fail"}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -548,7 +548,7 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"No Secrets Agent","model":"anthropic:claude-opus-4-7","external":true,"secrets":{}}`
body := `{"name":"No Secrets Agent","external":true,"secrets":{}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -576,7 +576,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// External URL update (localhost is explicitly allowed by validateAgentURL).
@@ -587,7 +587,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ext Agent","runtime":"external","model":"external:custom","external":true,"url":"http://localhost:8000"}`
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -615,7 +615,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// Pre-register flow: awaiting_agent + runtime preserved as "kimi"
@@ -629,7 +629,7 @@ func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Kimi Agent","runtime":"kimi","model":"kimi-coding/kimi-k2-coding-6","tier":3,"canvas":{"x":100,"y":100}}`
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -659,7 +659,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Agent","runtime":"external","model":"external:custom","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
body := `{"name":"Bad Agent","runtime":"external","external":true,"url":"http://169.254.169.254/latest/meta-data/"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -690,7 +690,7 @@ func TestWorkspaceCreate_ExternalURL_SSRFLoopbackBlocked(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Bad Loopback","runtime":"external","model":"external:custom","external":true,"url":"http://127.0.0.1:9000/a2a"}`
body := `{"name":"Bad Loopback","runtime":"external","external":true,"url":"http://127.0.0.1:9000/a2a"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -718,7 +718,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}))
w := httptest.NewRecorder()
@@ -1422,7 +1422,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
// Populate with non-zero financial values to confirm they are stripped.
mock.ExpectQuery("SELECT w.id, w.name").
@@ -1431,7 +1431,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "langgraph",
"", 0.0, 0.0, false,
int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD
int64(50000), int64(12500), false, true)) // budget_limit=500 USD, spend=125 USD
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1479,7 +1479,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
"budget_limit", "monthly_spend",
"broadcast_enabled", "talk_to_user_enabled", "compute",
"broadcast_enabled", "talk_to_user_enabled",
}
mock.ExpectQuery("SELECT w.id, w.name").
WithArgs("cccccccc-0955-0000-0000-000000000000").
@@ -1492,7 +1492,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
"langgraph",
"/home/user/secret-projects/client-work",
0.0, 0.0, false,
nil, 0, false, true, []byte(`{}`)))
nil, 0, false, true))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -1639,7 +1639,7 @@ runtime_config:
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Hermes Agent", nil, 3, "hermes",
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -1696,7 +1696,7 @@ model: anthropic:claude-sonnet-4-5
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Legacy Agent", nil, 3, "langgraph",
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -1749,7 +1749,7 @@ runtime_config:
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(
sqlmock.AnyArg(), "Custom Hermes", nil, 3, "hermes",
(*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -1844,43 +1844,39 @@ func TestWorkspaceCreate_188_TemplateConfigNoRuntimeKey_FailsClosed(t *testing.T
}
}
// Pre-2026-05-22 this test guarded "bare {name} → langgraph 201" — the
// regression check for controlplane#188 (where an explicit runtime that
// failed to resolve must NOT silently substitute langgraph) had a sibling
// to ensure the LEGITIMATE bare default still landed on langgraph.
//
// Post-CTO-SSOT-directive (2026-05-22) bare body is 422 MODEL_REQUIRED
// before reaching the langgraph branch — the gate runs AFTER the
// langgraph-default assignment so the error body still surfaces
// runtime=langgraph (helps the caller see "ok, langgraph WOULD have
// been the runtime, but you still owe me a model"). The bare-body
// langgraph 201 path no longer exists; what we guard now is the
// 422-shape diagnostic.
//
// Bare-body-with-explicit-model 201 (the new "legitimate default" path)
// is covered by TestWorkspaceCreate in handlers_test.go — no need to
// duplicate the mock dance here.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_NowMODEL_REQUIRED(t *testing.T) {
setupTestDB(t)
// Regression guard: the legitimate default path (no template, no runtime —
// bare {"name":...}) MUST still default to langgraph and return 201. The
// #188 fix must not break this.
func TestWorkspaceCreate_188_NoTemplateNoRuntime_StillDefaultsLanggraph(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Plain Default", nil, 3, "langgraph", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
WithArgs(sqlmock.AnyArg(), float64(0), float64(0)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Plain Default"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
handler.Create(c)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("bare-body create: expected 422 MODEL_REQUIRED, got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 (legitimate default path), got %d: %s", w.Code, w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"code":"MODEL_REQUIRED"`)) {
t.Errorf("bare-body create: expected code=MODEL_REQUIRED in body, got %s", w.Body.String())
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"runtime":"langgraph"`)) {
t.Errorf("bare-body create: expected runtime=\"langgraph\" in 422 body (the gate runs AFTER the langgraph-default assignment so the diagnostic surfaces what runtime WOULD have been used), got %s", w.Body.String())
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
@@ -1894,7 +1890,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WithArgs(sqlmock.AnyArg(), "Explicit Codex", nil, 3, "codex", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectExec("INSERT INTO canvas_layouts").
@@ -1905,7 +1901,7 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Explicit Codex","runtime":"codex","model":"gpt-5.5"}`
body := `{"name":"Explicit Codex","runtime":"codex"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -329,13 +329,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody interf
func decodeError(resp *http.Response) error {
var e contract.Error
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
Message: fmt.Sprintf("status %d (read body failed: %v)", resp.StatusCode, readErr),
}
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
return &contract.Error{
Code: httpStatusToCode(resp.StatusCode),
@@ -3,7 +3,6 @@ package pgplugin
import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"
@@ -247,9 +246,7 @@ func (h *Handler) forget(w http.ResponseWriter, r *http.Request, id string) {
func writeJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
log.Printf("pgplugin: JSON encode error: %v", err)
}
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, code contract.ErrorCode, message string, details map[string]interface{}) {
@@ -256,7 +256,6 @@ func TestWorkspaceAuth_WrongWorkspace_Returns401(t *testing.T) {
// live tokens anywhere) the middleware must let the request through so existing
// deployments keep working during the Phase-30 rollout.
func TestAdminAuth_FailOpen_NoTokensGlobally(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -376,7 +375,6 @@ func TestAdminAuth_C11_DeleteNoBearer_Returns401(t *testing.T) {
// TestAdminAuth_ValidBearer_Passes — a valid bearer token (from any workspace)
// must be accepted for admin routes.
func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -420,7 +418,6 @@ func TestAdminAuth_ValidBearer_Passes(t *testing.T) {
// TestAdminAuth_InvalidBearer_Returns401 — wrong token must not grant admin access.
func TestAdminAuth_InvalidBearer_Returns401(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -703,7 +700,6 @@ func TestAdminAuth_Issue180_ApprovalsListing_NoBearer_Returns401(t *testing.T) {
// fail-open contract: on a fresh install (no tokens anywhere), the middleware
// must not block the canvas from polling /approvals/pending.
func TestAdminAuth_Issue180_ApprovalsListing_FailOpen_NoTokens(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -1102,7 +1098,6 @@ func TestCanvasOrBearer_TokensExist_CanvasOrigin_Passes(t *testing.T) {
// issuing workspace has status='removed' must not grant admin access.
// The JOIN in ValidateAnyToken filters the row out, resulting in ErrNoRows.
func TestAdminAuth_RemovedWorkspaceToken_Returns401(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New: %v", err)
@@ -1256,7 +1251,6 @@ func TestAdminAuth_623_ForgedCORSOrigin_Returns401(t *testing.T) {
// TestAdminAuth_623_ValidBearer_WithOrigin_Passes — bearer + matching Origin
// should still work (the Origin is irrelevant once the bearer validates).
func TestAdminAuth_623_ValidBearer_WithOrigin_Passes(t *testing.T) {
t.Setenv("ADMIN_TOKEN", "")
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)

Some files were not shown because too many files have changed in this diff Show More