Compare commits

..

1 Commits

Author SHA1 Message Date
Molecule AI Dev Engineer A (Kimi) 120ca0d2a8 style(scripts): fix ruff E501 line-too-long in 6 production scripts
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 9s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 15s
CI / all-required (pull_request) Successful in 1m0s
E2E API Smoke Test / detect-changes (pull_request) Successful in 20s
E2E Chat / detect-changes (pull_request) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m3s
qa-review / approved (pull_request) Failing after 6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m19s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m17s
security-review / approved (pull_request) Failing after 12s
CI / Platform (Go) (pull_request) Successful in 4s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 13s
E2E Chat / E2E Chat (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 5s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
gate-check-v3 / gate-check (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 8s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m26s
audit-force-merge / audit (pull_request) Has been skipped
Manual line wrapping to ≤88 chars in:
- ci-required-drift.py (7 lines)
- detect-changes.py (1 line)
- lint-workflow-yaml.py (2 lines)
- main-red-watchdog.py (1 line)
- prod-auto-deploy.py (8 lines)
- status-reaper.py (2 lines)

Also auto-fixes F541 (f-strings without placeholders) and I001 (import sorting)
in ci-required-drift.py, detect-changes.py, prod-auto-deploy.py.

All 148 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:12:11 +00:00
338 changed files with 2551 additions and 8852 deletions
+5 -4
View File
@@ -423,9 +423,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
)
if stale_protection:
findings.append(
"F2 — protection `status_check_contexts` entries with `ci / ` "
"prefix that NO job in ci.yml emits "
"(stale name → silent advisory gate):\n"
"F2 — protection `status_check_contexts` entries with "
"`ci / ` prefix that NO "
"job in ci.yml emits (stale name → silent advisory gate):\n"
+ "\n".join(f" - {c}" for c in stale_protection)
)
@@ -501,7 +501,8 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
"",
"Auto-filed by `.gitea/workflows/ci-required-drift.yml` "
"(RFC [internal#219]"
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) "
"§4 + §6).",
"",
"## Findings",
"",
+1 -3
View File
@@ -183,9 +183,7 @@ def required_contexts_green(
status = latest_statuses.get(context)
state = status_state(status or {})
if state != "success":
if pr_labels and _is_tier_low_pending_ok(
latest_statuses, context, pr_labels
):
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
continue # tier:low soft-fail: accept pending sop-checklist
missing_or_bad.append(f"{context}={state or 'missing'}")
return not missing_or_bad, missing_or_bad
@@ -13,9 +13,11 @@ from __future__ import annotations
import argparse
import glob
import re
import sys
from pathlib import Path
from typing import NamedTuple
SELF = ".gitea/workflows/lint-curl-status-capture.yml"
+5 -2
View File
@@ -71,7 +71,10 @@ from typing import Any, Iterable
try:
import yaml
except ImportError:
print("::error::PyYAML is required. Install with: pip install PyYAML", file=sys.stderr)
print(
"::error::PyYAML is required. Install with: pip install PyYAML",
file=sys.stderr,
)
sys.exit(2)
@@ -198,7 +201,7 @@ USES_CROSS_REPO_RE = re.compile(
(?P<owner>[A-Za-z0-9_.\-]+)
/
(?P<repo>[A-Za-z0-9_.\-]+)
/ # mandatory subpath separator => cross-repo composite/reusable
/ # mandatory subpath separator => cross-repo composite/reusable
(?P<path>[^@\s]+)
@
(?P<ref>\S+)
+1 -1
View File
@@ -283,7 +283,7 @@ def _ensure_labels(repo: str, names: list[str]) -> list[int]:
if status != "ok" or not isinstance(labels, list):
return []
out: list[int] = []
by_name = {label["name"]: label["id"] for label in labels if isinstance(label, dict)}
by_name = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
for n in names:
if n in by_name:
out.append(by_name[n])
@@ -82,7 +82,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
+7 -22
View File
@@ -578,7 +578,6 @@ def close_open_red_issues_for_other_shas(
current_sha: str,
*,
dry_run: bool = False,
close_same_sha: bool = False,
) -> int:
"""When main is green at current_sha, close any open `[main-red]`
issues whose title references a different SHA. Returns the number
@@ -587,25 +586,15 @@ def close_open_red_issues_for_other_shas(
Lineage note: we only close issues whose title prefix matches; if
a human renamed the issue or added a suffix this won't touch it.
That's intentional — manual editorial state takes precedence.
Args:
close_same_sha: set True when the caller already knows main is
green at current_sha (e.g. recovery block) and wants to close
the open issue for THIS SHA too. Defaults False so the
green-path callers never accidentally close an issue they just
filed on the same tick.
"""
target_title = title_for(current_sha)
open_red = list_open_red_issues()
closed = 0
for issue in open_red:
if issue.get("title") == target_title:
if not close_same_sha:
# Same SHA — caller should not have invoked this if main is
# green. Skip defensively (guards against green-path callers
# that accidentally pass the SHA they just filed for).
continue
# close_same_sha=True: close even this SHA's issue (recovery path)
# Same SHA — caller should not have invoked this if main is
# green. Skip defensively.
continue
num = issue.get("number")
if not isinstance(num, int):
continue
@@ -618,7 +607,10 @@ def close_open_red_issues_for_other_shas(
"`feedback_no_such_thing_as_flakes`."
)
if dry_run:
print(f"::notice::[dry-run] would close issue #{num} ({issue.get('title')})")
title = issue.get("title", "")
print(
f"::notice::[dry-run] would close issue #{num} ({title})"
)
closed += 1
continue
# Comment first, then close. Order matters: a closed issue can
@@ -710,10 +702,6 @@ def run_once(*, dry_run: bool = False) -> int:
f"{sha[:10]} but HEAD is now {recheck_sha[:10]} on "
f"{WATCH_BRANCH}; next cron tick will re-evaluate."
)
# HEAD drifted — close any stale main-red issue for the prior SHA
# before returning, so we don't leave stale open issues when main
# is no longer pointing at the red commit.
close_open_red_issues_for_other_shas(recheck_sha, dry_run=dry_run)
return 0
recheck_status = get_combined_status(sha)
@@ -726,9 +714,6 @@ def run_once(*, dry_run: bool = False) -> int:
f"{recheck_status.get('state')!r} on recheck; "
f"initial red was a transient cancel-cascade."
)
# CI recovered on the same SHA — close any stale main-red issue
# that was filed on a prior tick for this SHA.
close_open_red_issues_for_other_shas(sha, dry_run=dry_run, close_same_sha=True)
return 0
# Still red after settling — file/update. Use the recheck data
+30 -163
View File
@@ -24,7 +24,6 @@ DEFAULT_REQUIRED_CONTEXTS = [
"Secret scan / Scan diff for credential-shaped strings (push)",
]
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
REDEPLOY_PATH = "/cp/admin/tenants/redeploy-fleet"
def truthy_flag(value: str | None) -> bool:
@@ -60,7 +59,10 @@ def build_plan(env: dict[str, str]) -> dict:
}
short_sha = sha[:7]
target_tag = env.get("PROD_AUTO_DEPLOY_TARGET_TAG", "").strip() or f"staging-{short_sha}"
target_tag = (
env.get("PROD_AUTO_DEPLOY_TARGET_TAG", "").strip()
or f"staging-{short_sha}"
)
canary_slug = env.get("PROD_AUTO_DEPLOY_CANARY_SLUG", "hongming").strip()
body = {
"target_tag": target_tag,
@@ -78,7 +80,9 @@ def build_plan(env: dict[str, str]) -> dict:
body["canary_slug"] = canary_slug
cp_url = env.get("CP_URL", "").strip() or PROD_CP_URL
if cp_url != PROD_CP_URL and not truthy_flag(env.get("PROD_ALLOW_NON_PROD_CP_URL", "")):
if cp_url != PROD_CP_URL and not truthy_flag(
env.get("PROD_ALLOW_NON_PROD_CP_URL", "")
):
raise ValueError(
f"Refusing production deploy to CP_URL={cp_url!r}; "
f"set PROD_ALLOW_NON_PROD_CP_URL=true for an explicit non-prod drill"
@@ -127,155 +131,11 @@ def required_contexts(env: dict[str, str]) -> list[str]:
raw = env.get("PROD_AUTO_DEPLOY_REQUIRED_CONTEXTS", "")
if not raw.strip():
return DEFAULT_REQUIRED_CONTEXTS
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
def chunks(items: list[str], size: int) -> list[list[str]]:
return [items[i : i + size] for i in range(0, len(items), size)]
class RolloutFailed(RuntimeError):
def __init__(self, message: str, response: dict):
super().__init__(message)
self.response = response
def slugs_from_redeploy_response(body: dict) -> list[str]:
slugs: list[str] = []
for row in body.get("results") or []:
slug = str(row.get("slug") or "").strip()
if slug:
slugs.append(slug)
return slugs
def scoped_redeploy_body(base: dict, slugs: list[str]) -> dict:
body = dict(base)
body.pop("canary_slug", None)
body["only_slugs"] = slugs
body["soak_seconds"] = 0
body["batch_size"] = max(1, len(slugs))
return body
def cp_api_json(method: str, url: str, token: str, body: dict | None = None) -> tuple[int, dict]:
data = None
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = {"error": raw[:500]}
return exc.code, parsed
def plan_rollout_slugs(cp_url: str, token: str, body: dict, redeploy=None) -> list[str]:
if redeploy is None:
redeploy = redeploy_scoped
dry_run_body = dict(body)
dry_run_body["dry_run"] = True
status, resp = redeploy(cp_url, token, dry_run_body)
if status != 200:
raise RuntimeError(f"dry-run redeploy-fleet returned HTTP {status}: {resp.get('error', '')}")
if resp.get("ok") is not True:
raise RuntimeError(f"dry-run redeploy-fleet reported ok={resp.get('ok')}: {resp.get('error', '')}")
slugs = slugs_from_redeploy_response(resp)
if not slugs:
raise RuntimeError("dry-run redeploy-fleet returned no rollout candidates")
return slugs
def redeploy_scoped(cp_url: str, token: str, body: dict) -> tuple[int, dict]:
return cp_api_json("POST", f"{cp_url}{REDEPLOY_PATH}", token, body)
def _raise_for_redeploy_result(status: int, body: dict, slugs: list[str]) -> None:
if status != 200 or body.get("ok") is not True:
raise RuntimeError(
"redeploy scoped call failed for "
f"{','.join(slugs)}: HTTP {status}, ok={body.get('ok')}"
)
def execute_scoped_rollout(
plan: dict,
token: str,
list_slugs=plan_rollout_slugs,
redeploy=redeploy_scoped,
sleep=time.sleep,
) -> dict:
cp_url = plan["cp_url"]
base_body = plan["body"]
all_slugs = list_slugs(cp_url, token, base_body)
batch_size = int(base_body.get("batch_size") or 1)
canary_slug = str(base_body.get("canary_slug") or "").strip()
dry_run = bool(base_body.get("dry_run"))
aggregate = {"ok": True, "results": []}
if canary_slug:
if canary_slug not in all_slugs:
raise RuntimeError(f"configured canary slug {canary_slug!r} is not a running tenant")
body = scoped_redeploy_body(base_body, [canary_slug])
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(body['only_slugs'])}")
status, resp = redeploy(cp_url, token, body)
aggregate["results"].extend(resp.get("results") or [])
try:
_raise_for_redeploy_result(status, resp, [canary_slug])
except RuntimeError as exc:
aggregate["ok"] = False
aggregate["error"] = str(exc)
raise RolloutFailed(str(exc), aggregate) from exc
soak_seconds = int(base_body.get("soak_seconds") or 0)
if soak_seconds > 0 and not dry_run:
print(f"Canary passed; soaking locally for {soak_seconds}s")
sleep(soak_seconds)
remaining = [slug for slug in all_slugs if slug != canary_slug]
for group in chunks(remaining, batch_size):
body = scoped_redeploy_body(base_body, group)
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(group)}")
status, resp = redeploy(cp_url, token, body)
aggregate["results"].extend(resp.get("results") or [])
try:
_raise_for_redeploy_result(status, resp, group)
except RuntimeError as exc:
aggregate["ok"] = False
aggregate["error"] = str(exc)
raise RolloutFailed(str(exc), aggregate) from exc
return aggregate
def rollout_from_plan_file(plan_path: str, response_path: str, env: dict[str, str]) -> None:
token = env.get("CP_ADMIN_API_TOKEN", "").strip()
if not token:
raise ValueError("CP_ADMIN_API_TOKEN is required for production auto-deploy")
with open(plan_path, "r", encoding="utf-8") as fh:
plan = json.load(fh)
if not plan.get("enabled"):
raise RuntimeError("production auto-deploy plan is disabled")
try:
response = execute_scoped_rollout(plan, token)
except RolloutFailed as exc:
response = exc.response
with open(response_path, "w", encoding="utf-8") as fh:
json.dump(response, fh, sort_keys=True)
fh.write("\n")
raise
with open(response_path, "w", encoding="utf-8") as fh:
json.dump(response, fh, sort_keys=True)
fh.write("\n")
return [
line.strip()
for line in raw.replace(",", "\n").splitlines()
if line.strip()
]
def _api_json(url: str, token: str) -> dict:
@@ -324,10 +184,14 @@ def live_disable_flag(env: dict[str, str]) -> str:
def assert_not_disabled(env: dict[str, str]) -> None:
plan = build_plan(env)
if not plan.get("enabled"):
raise RuntimeError(plan.get("disabled_reason", "production auto-deploy disabled"))
raise RuntimeError(
plan.get("disabled_reason", "production auto-deploy disabled")
)
live_value = live_disable_flag(env)
if truthy_flag(live_value):
raise RuntimeError(f"PROD_AUTO_DEPLOY_DISABLED={live_value} (live Gitea variable)")
raise RuntimeError(
f"PROD_AUTO_DEPLOY_DISABLED={live_value} (live Gitea variable)"
)
def wait_for_ci_context(env: dict[str, str]) -> str:
@@ -369,19 +233,25 @@ def wait_for_ci_context(env: dict[str, str]) -> str:
if all(context_is_satisfied(state) for state in states.values()):
return "success"
time.sleep(interval)
last = ", ".join(f"{context}={state}" for context, state in last_states.items()) or "none"
raise TimeoutError(f"Timed out waiting {timeout}s for required CI contexts; last_states={last}")
last = ", ".join(
f"{context}={state}"
for context, state in last_states.items()
) or "none"
raise TimeoutError(
f"Timed out waiting {timeout}s for required CI contexts; "
f"last_states={last}"
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("plan", help="print production deploy plan as JSON")
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
sub.add_parser(
"assert-enabled",
help="fail if production deploy is currently disabled",
)
sub.add_parser("wait-ci", help="block until required CI context is green")
rollout_parser = sub.add_parser("rollout", help="execute canary-first scoped production rollout")
rollout_parser.add_argument("--plan", required=True, help="path to prod-auto-deploy plan JSON")
rollout_parser.add_argument("--response", required=True, help="path to write aggregate response JSON")
args = parser.parse_args()
try:
@@ -394,9 +264,6 @@ def main() -> int:
if args.command == "wait-ci":
wait_for_ci_context(dict(os.environ))
return 0
if args.command == "rollout":
rollout_from_plan_file(args.plan, args.response, dict(os.environ))
return 0
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
print(f"::error::{exc}", file=sys.stderr)
return 1
-2
View File
@@ -12,7 +12,6 @@
# ≥ 1 review on the PR where:
# • state == APPROVED
# • review.dismissed == false
# • review.official != false (excludes draft/mis-filed APPROVED reviews)
# • review.user.login != PR.user.login (non-author)
# • review.user.login ∈ team-members
#
@@ -202,7 +201,6 @@ fi
JQ_FILTER='.[]
| select(.state == "APPROVED")
| select(.dismissed != true)
| select(.official != false)
| select(.user.login != $author)'
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
JQ_FILTER="${JQ_FILTER}
+3 -2
View File
@@ -338,6 +338,7 @@ def compute_ack_state(
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
@@ -841,7 +842,7 @@ def render_status(
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
labels = pr.get("labels") or []
tier_labels = [label.get("name", "") for label in labels if (label.get("name", "") or "").startswith("tier:")]
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
mode_map = cfg.get("tier_failure_mode") or {}
default_mode = cfg.get("default_mode", "hard")
for tl in tier_labels:
@@ -864,7 +865,7 @@ def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
Governance fix for internal#442 — closes the inconsistency between
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
"""
label_set = {(label.get("name") or "") for label in (pr.get("labels") or [])}
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
if "tier:high" in label_set:
return True
high_risk_labels = set(cfg.get("high_risk_labels") or [])
+6 -2
View File
@@ -125,7 +125,9 @@ OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30")
API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3")
API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2")
API_RETRY_SLEEP_SEC = float(
_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2"
)
# Compensating-status description prefix. Used as the marker so a human
# auditing commit statuses can tell at a glance that the green was
@@ -506,7 +508,9 @@ def reap(
successful_contexts = {
(s.get("context") or "")
for s in statuses
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success"
if isinstance(s, dict) and (
s.get("status") or s.get("state") or ""
) == "success"
}
for s in statuses:
if not isinstance(s, dict):
@@ -33,6 +33,7 @@ import re
import sys
import urllib.parse
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
@@ -80,7 +81,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /repos/{owner}/{name}/pulls/{pr_number}
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
if m:
pr_num = m.group(3)
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
if sc == "T2_pr_closed":
return self._json(200, {
"number": int(pr_num),
@@ -150,7 +151,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
# GET /teams/{team_id}/members/{username}
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
if m:
login = m.group(2)
team_id, login = m.group(1), m.group(2)
if sc == "T8_team_not_member":
return self._empty(404)
if sc == "T9_team_403":
@@ -2,6 +2,7 @@ import importlib.util
import sys
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
mq = importlib.util.module_from_spec(spec)
@@ -15,6 +15,7 @@ Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
from __future__ import annotations
import importlib.util
import os
import sys
import unittest
from pathlib import Path
@@ -153,205 +153,3 @@ def test_default_required_contexts_delegate_path_gating_to_all_required():
"CI / all-required (push)",
"Secret scan / Scan diff for credential-shaped strings (push)",
]
def test_slugs_from_redeploy_response_uses_controlplane_plan_rows():
body = {
"results": [
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
{"slug": "", "phase": "batch-1", "ssm_status": "DryRun"},
{"phase": "batch-1", "ssm_status": "DryRun"},
]
}
assert prod.slugs_from_redeploy_response(body) == ["hongming", "tenant-a"]
def test_plan_rollout_slugs_asks_controlplane_for_dry_run_plan():
calls = []
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {
"ok": True,
"results": [
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
],
}
slugs = prod.plan_rollout_slugs(
"https://api.moleculesai.app",
"secret",
{
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": False,
"confirm": True,
},
redeploy=fake_redeploy,
)
assert slugs == ["hongming", "tenant-a"]
assert calls == [
{
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": True,
"confirm": True,
}
]
def test_scoped_redeploy_body_removes_canary_and_local_soak():
base = {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": False,
"confirm": True,
}
scoped = prod.scoped_redeploy_body(base, ["tenant-a", "tenant-b"])
assert scoped == {
"target_tag": "staging-abcdef1",
"soak_seconds": 0,
"batch_size": 2,
"dry_run": False,
"confirm": True,
"only_slugs": ["tenant-a", "tenant-b"],
}
def test_plan_scoped_rollout_preserves_canary_then_batches():
calls, sleeps = [], []
def fake_list(_cp_url, _token, _body):
return ["tenant-a", "hongming", "tenant-b", "tenant-c"]
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {
"ok": True,
"results": [{"slug": slug, "healthz_ok": True} for slug in body["only_slugs"]],
}
aggregate = prod.execute_scoped_rollout(
{
"cp_url": "https://api.moleculesai.app",
"body": {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 2,
"dry_run": False,
"confirm": True,
},
},
token="secret",
list_slugs=fake_list,
redeploy=fake_redeploy,
sleep=sleeps.append,
)
assert [call["only_slugs"] for call in calls] == [
["hongming"],
["tenant-a", "tenant-b"],
["tenant-c"],
]
assert sleeps == [60]
assert aggregate["ok"] is True
assert [result["slug"] for result in aggregate["results"]] == [
"hongming",
"tenant-a",
"tenant-b",
"tenant-c",
]
def test_scoped_rollout_halts_after_failed_canary():
calls = []
def fake_redeploy(_cp_url, _token, body):
calls.append(body)
return 200, {"ok": False, "results": [{"slug": body["only_slugs"][0], "error": "bad"}]}
try:
prod.execute_scoped_rollout(
{
"cp_url": "https://api.moleculesai.app",
"body": {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 2,
"dry_run": False,
"confirm": True,
},
},
token="secret",
list_slugs=lambda _cp_url, _token, _body: ["hongming", "tenant-a"],
redeploy=fake_redeploy,
sleep=lambda _seconds: None,
)
except prod.RolloutFailed as exc:
assert "redeploy scoped call failed" in str(exc)
assert exc.response["ok"] is False
assert exc.response["results"] == [{"slug": "hongming", "error": "bad"}]
else:
raise AssertionError("expected failed canary to halt rollout")
assert [call["only_slugs"] for call in calls] == [["hongming"]]
def test_rollout_from_plan_file_writes_partial_response_on_failure(tmp_path):
plan_path = tmp_path / "plan.json"
response_path = tmp_path / "response.json"
plan_path.write_text(
"""
{
"enabled": true,
"cp_url": "https://api.moleculesai.app",
"body": {"target_tag": "staging-abcdef1", "confirm": true}
}
""",
encoding="utf-8",
)
original = prod.execute_scoped_rollout
def fake_execute(_plan, _token):
raise prod.RolloutFailed(
"redeploy scoped call failed for hongming: HTTP 500, ok=false",
{
"ok": False,
"error": "redeploy scoped call failed for hongming: HTTP 500, ok=false",
"results": [{"slug": "hongming", "error": "bad"}],
},
)
prod.execute_scoped_rollout = fake_execute
try:
try:
prod.rollout_from_plan_file(
str(plan_path),
str(response_path),
{"CP_ADMIN_API_TOKEN": "secret"},
)
except prod.RolloutFailed:
pass
else:
raise AssertionError("expected rollout failure")
finally:
prod.execute_scoped_rollout = original
assert response_path.read_text(encoding="utf-8").strip()
assert '"ok": false' in response_path.read_text(encoding="utf-8")
assert '"slug": "hongming"' in response_path.read_text(encoding="utf-8")
@@ -22,6 +22,7 @@ from __future__ import annotations
import os
import sys
import tempfile
import unittest
# Resolve sibling script regardless of where pytest is invoked from.
+1 -1
View File
@@ -239,7 +239,7 @@ jobs:
# Strip the package-import prefix so we can match .coverage-allowlist.txt
# entries written as paths relative to workspace-server/.
# Handle both module paths: platform/workspace-server/... and platform/...
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
-242
View File
@@ -1,242 +0,0 @@
name: E2E Legacy Advisory
# Advisory lane for older/manual E2E scripts that are too broad or
# environment-dependent for required PR CI. This intentionally does not run on
# pull_request or push so it cannot block merges/deploys; scheduled/manual reds
# still surface drift in scripts that would otherwise only be shellchecked.
#
# Gitea 1.22.6 rejects workflow_dispatch.inputs, so keep dispatch input-free.
on:
schedule:
# Stagger after the staging smoke/canvas morning lanes.
- cron: '15 9 * * *'
workflow_dispatch:
concurrency:
group: e2e-legacy-advisory
cancel-in-progress: false
permissions:
contents: read
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
legacy-local-platform:
name: Legacy local-platform E2E
runs-on: docker-host
timeout-minutes: 45
env:
PG_CONTAINER: pg-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
MOLECULE_ENV: development
BIND_ADDR: 127.0.0.1
MOLECULE_IN_DOCKER: "false"
A2A_TIMEOUT: "30"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- name: Prepare local platform dependencies
run: |
set -euo pipefail
docker pull postgres:16 >/dev/null
docker pull redis:7 >/dev/null
docker pull alpine:latest >/dev/null
docker network create molecule-core-net >/dev/null 2>&1 || true
- name: Start Postgres
run: |
set -euo pipefail
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker run -d --name "$PG_CONTAINER" \
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
-p 0:5432 postgres:16 >/dev/null
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
docker port "$PG_CONTAINER" 5432/tcp || true
docker logs "$PG_CONTAINER" || true
exit 1
fi
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && exit 0
sleep 1
done
docker logs "$PG_CONTAINER" || true
exit 1
- name: Start Redis
run: |
set -euo pipefail
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
docker port "$REDIS_CONTAINER" 6379/tcp || true
docker logs "$REDIS_CONTAINER" || true
exit 1
fi
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && exit 0
sleep 1
done
docker logs "$REDIS_CONTAINER" || true
exit 1
- name: Pick platform port
run: |
set -euo pipefail
PLATFORM_PORT=$(python3 - <<'PY'
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
PY
)
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
- name: Build platform
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Populate template manifests for dev-mode E2E
run: |
set -euo pipefail
if command -v jq >/dev/null 2>&1; then
bash scripts/clone-manifest.sh manifest.json workspace-configs-templates org-templates plugins
else
echo "::warning::jq unavailable; dev-mode template assertion may fail if templates are absent"
fi
- name: Start platform
run: |
set -euo pipefail
./workspace-server/platform-server > workspace-server/platform.log 2>&1 &
echo $! > workspace-server/platform.pid
for i in $(seq 1 30); do
curl -sf "$BASE/health" >/dev/null && exit 0
sleep 1
done
cat workspace-server/platform.log || true
exit 1
- name: Run comprehensive E2E
run: bash tests/e2e/test_comprehensive_e2e.sh
- name: Run workspace abilities E2E
run: bash tests/e2e/test_workspace_abilities_e2e.sh
- name: Run dev-mode E2E
run: bash tests/e2e/test_dev_mode.sh
- name: Start stub A2A agents
run: |
set -euo pipefail
cat > /tmp/molecule-stub-a2a.py <<'PY'
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("content-length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
req = json.loads(raw)
except Exception:
req = {}
method = req.get("method")
if method not in ("message/send", None):
body = {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32601, "message": "method not found"}}
else:
body = {
"jsonrpc": "2.0",
"id": req.get("id", "stub"),
"result": {
"role": "agent",
"parts": [{"kind": "text", "type": "text", "text": "stub agent response"}],
},
}
data = json.dumps(body, separators=(",", ":")).encode()
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *_):
return
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
PY
python3 /tmp/molecule-stub-a2a.py > /tmp/molecule-stub-a2a.log 2>&1 &
echo $! > /tmp/molecule-stub-a2a.pid
- name: Seed external agents for legacy A2A/activity scripts
run: |
set -euo pipefail
create_agent() {
local name="$1" role="$2"
curl -sS -X POST "$BASE/workspaces" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${name}\",\"role\":\"${role}\",\"tier\":1,\"runtime\":\"external\",\"external\":true,\"url\":\"http://127.0.0.1:18080\"}" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
}
ECHO_ID=$(create_agent "Echo Agent" "Echo")
SEO_ID=$(create_agent "SEO Agent" "SEO")
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$ECHO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}" >/dev/null
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
-d "{\"id\":\"$SEO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"SEO Agent\",\"skills\":[{\"id\":\"seo\",\"name\":\"SEO\"}]}}" >/dev/null
- name: Run activity E2E
run: bash tests/e2e/test_activity_e2e.sh
- name: Run A2A E2E
run: bash tests/e2e/test_a2a_e2e.sh
- name: Runtime-dependent legacy E2E preflight
run: |
set -euo pipefail
if [ -f workspace-configs-templates/claude-code-default/.auth-token ] && docker image inspect workspace:latest >/dev/null 2>&1; then
bash tests/e2e/test_claude_code_e2e.sh
bash tests/e2e/test_chat_upload_e2e.sh
else
echo "::notice::Skipping test_claude_code_e2e.sh and test_chat_upload_e2e.sh: require workspace:latest plus workspace-configs-templates/claude-code-default/.auth-token"
fi
- name: Dump platform log on failure
if: failure()
run: cat workspace-server/platform.log || true
- name: Stop platform and stub agents
if: always()
run: |
if [ -f workspace-server/platform.pid ]; then
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
fi
if [ -f /tmp/molecule-stub-a2a.pid ]; then
kill "$(cat /tmp/molecule-stub-a2a.pid)" 2>/dev/null || true
fi
- name: Stop service containers
if: always()
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
+4 -4
View File
@@ -152,7 +152,7 @@ jobs:
# block). See #2578 PR comment for the rationale.
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
# OpenAI fallback — kept wired so an operator-dispatched run with
# E2E_RUNTIME=hermes or =codex via workflow_dispatch can still
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
# exercise the OpenAI path.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
@@ -161,7 +161,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 == 'codex' && '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' }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
@@ -185,7 +185,7 @@ jobs:
- name: Verify LLM key present
run: |
# Per-runtime key check — claude-code uses MiniMax; hermes /
# codex (operator-dispatched only) use OpenAI. Hard-fail
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
# rather than soft-skip per #2578's lesson — empty key
# silently falls through to the wrong SECRETS_JSON branch and
# produces a confusing auth error 5 min later instead of the
@@ -206,7 +206,7 @@ jobs:
required_secret_value=""
fi
;;
codex|hermes)
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_API_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
@@ -239,13 +239,12 @@ jobs:
# Publish/release lane (internal#462) — production deploy of a merged
# fix; reserved capacity, never queued behind PR-CI.
runs-on: publish
timeout-minutes: 90
timeout-minutes: 75
env:
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
GITEA_HOST: git.moleculesai.app
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
CI_STATUS_TIMEOUT_SECONDS: "3600"
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
@@ -304,19 +303,26 @@ jobs:
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
BODY="$(jq -c '.body' "$PLAN")"
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
set +e
python3 .gitea/scripts/prod-auto-deploy.py rollout \
--plan "$PLAN" \
--response "$HTTP_RESPONSE"
ROLLOUT_EXIT=$?
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" > "$HTTP_CODE_FILE"
set -e
if [ ! -s "$HTTP_RESPONSE" ]; then
jq -nc --arg error "rollout command exited $ROLLOUT_EXIT before writing a response" \
'{ok:false, results:[], error:$error}' > "$HTTP_RESPONSE"
fi
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
{
@@ -324,6 +330,7 @@ jobs:
echo ""
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
echo "**Target tag:** \`$TARGET_TAG\`"
echo "**HTTP:** $HTTP_CODE"
echo ""
echo "### Per-tenant result"
echo ""
@@ -332,15 +339,15 @@ jobs:
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
exit 1
fi
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
if [ "$OK" != "true" ]; then
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
exit 1
fi
if [ "$ROLLOUT_EXIT" -ne 0 ]; then
echo "::error::redeploy-fleet rollout failed with exit code $ROLLOUT_EXIT."
exit "$ROLLOUT_EXIT"
fi
- name: Verify reachable tenants report this SHA
if: ${{ steps.plan.outputs.enabled == 'true' }}
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
[[ "$file" == *_test.go ]] && continue
[[ "$file" == *"$path"* ]] || continue
awk "BEGIN{exit !(\$pct < 10)}" || continue
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
continue
fi
-12
View File
@@ -46,18 +46,6 @@
---
## Quick Start
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
cd molecule-monorepo
./scripts/dev-start.sh
```
Then open [http://localhost:3000](http://localhost:3000), add your model API key in **Config → Secrets & API Keys → Global**, and create a workspace from a template.
See the full [Quickstart Guide](./docs/quickstart.md) for prerequisites, manual setup, and troubleshooting.
## The Pitch
Molecule AI is the most powerful way to govern an AI agent organization in production.
+3 -7
View File
@@ -15,11 +15,9 @@ test("FilesTab renders after split", async ({ page, request }) => {
// Clean slate
const { workspaces } = await request
.get("http://localhost:8080/workspaces")
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string; name: string }> }));
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string }> }));
for (const w of workspaces) {
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`, {
headers: { "X-Confirm-Name": w.name },
});
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`);
}
// Create a workspace
@@ -82,7 +80,5 @@ test("FilesTab renders after split", async ({ page, request }) => {
await expect(editorEmpty.first()).toBeVisible({ timeout: 5_000 });
// Cleanup
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`, {
headers: { "X-Confirm-Name": "FilesTab Smoke" },
});
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`);
});
-35
View File
@@ -1,35 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: [
".next/**",
"coverage/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-require-imports": "warn",
"prefer-const": "warn",
"react-hooks/rules-of-hooks": "warn",
"react/display-name": "warn",
"react/no-unescaped-entities": "warn",
},
},
];
export default eslintConfig;
+1 -4330
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -6,7 +6,7 @@
"dev": "next dev --turbopack -p 3000",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint": "next lint",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
@@ -31,7 +31,6 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.6.0",
@@ -39,8 +38,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.15",
"@tailwindcss/postcss": "^4.0.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
-6
View File
@@ -41,12 +41,6 @@ describe("buildCsp — production", () => {
expect(csp).toContain("object-src 'none'");
});
it("allows blob: in frame-src for authenticated PDF previews", () => {
const frameSrc = csp.match(/frame-src[^;]*/)?.[0] ?? "";
expect(frameSrc).toContain("'self'");
expect(frameSrc).toContain("blob:");
});
it("locks base-uri to 'self' (prevents base-tag injection)", () => {
expect(csp).toContain("base-uri 'self'");
});
+1 -4
View File
@@ -232,10 +232,7 @@ function CanvasInner() {
}
state.beginDelete(subtree);
try {
const workspaceName = state.nodes.find((n) => n.id === id)?.data.name ?? "";
await api.del(`/workspaces/${id}?confirm=true`, {
headers: { "X-Confirm-Name": workspaceName },
});
await api.del(`/workspaces/${id}?confirm=true`);
// Mirror the server-side cascade locally — drop the parent AND
// every descendant in one atomic update. The per-descendant
// WORKSPACE_REMOVED WS events still arrive (and are no-ops
+1 -1
View File
@@ -128,7 +128,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div role="status" aria-live="polite" className="text-[12px] text-ink-mid" data-testid="console-loading">
<div className="text-[12px] text-ink-mid" data-testid="console-loading">
Loading console output
</div>
)}
+46 -230
View File
@@ -5,13 +5,6 @@ import * as Dialog from "@radix-ui/react-dialog";
import { api } from "@/lib/api";
import { isSaaSTenant } from "@/lib/tenant";
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
import {
ProviderModelSelector,
buildProviderCatalog,
findProviderForModel,
type SelectorModel,
type SelectorValue,
} from "./ProviderModelSelector";
interface WorkspaceOption {
id: string;
@@ -29,8 +22,6 @@ interface TemplateSpec {
id: string;
name?: string;
runtime?: string;
model?: string;
models?: SelectorModel[];
providers?: string[];
}
@@ -42,26 +33,7 @@ interface HermesProvider {
models: string[];
}
const DEFAULT_LLM_MODELS: SelectorModel[] = [
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
];
const DEFAULT_PLATFORM_MODEL = DEFAULT_LLM_MODELS[0];
const DEFAULT_RUNTIME = "claude-code";
const RUNTIME_OPTIONS = [
{ value: "claude-code", label: "Claude Code" },
{ value: "codex", label: "OpenAI Codex CLI" },
{ value: "hermes", label: "Hermes" },
{ value: "openclaw", label: "OpenClaw" },
];
const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "hermes", "openclaw"]);
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
const DEFAULT_DISPLAY_ROOT_GB = 80;
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
// All providers supported by Hermes runtime via providers.resolve_provider().
// `defaultModel` is the slug injected into the workspace provision request
@@ -92,7 +64,6 @@ export function CreateWorkspaceButton() {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState("");
const [runtime, setRuntime] = useState(DEFAULT_RUNTIME);
const [template, setTemplate] = useState("");
const [parentId, setParentId] = useState("");
const [budgetLimit, setBudgetLimit] = useState("");
@@ -100,8 +71,8 @@ export function CreateWorkspaceButton() {
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
const [displayEnabled, setDisplayEnabled] = useState(false);
const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE);
const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB));
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
const [displayRootGB, setDisplayRootGB] = useState("80");
const [displayResolution, setDisplayResolution] = useState("1920x1080");
// Templates fetched from /api/templates — drives the dynamic provider
// filter below. Same data source ConfigTab uses (PR #2454). When the
@@ -130,18 +101,11 @@ export function CreateWorkspaceButton() {
// (Anthropic), which 401s if the user's key is for a different
// provider. Hence: require model when template=hermes.
const [hermesModel, setHermesModel] = useState("");
const [llmSelection, setLLMSelection] = useState<SelectorValue>({
providerId: "platform|",
model: "moonshot/kimi-k2.6",
envVars: [],
});
const [llmSecret, setLLMSecret] = useState("");
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
// lock to T4 — the full-host access tier. The EC2 size is controlled by
// the compute profile below. On self-hosted we still offer T1/T2/T3
// because the Docker-
// lock to T4 — the full-host access tier, which maps to t3.large at the
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
// sandbox distinction is a real choice there; T4 is available too for
// operators who want the full-host tier.
//
@@ -191,72 +155,31 @@ export function CreateWorkspaceButton() {
[]
);
const handleRuntimeChange = useCallback((nextRuntime: string) => {
setRuntime(nextRuntime);
setTemplate("");
setHermesProvider("anthropic");
setHermesApiKey("");
setHermesModel("");
setLLMSelection({ providerId: "platform|", model: DEFAULT_PLATFORM_MODEL.id, envVars: [] });
setLLMSecret("");
}, []);
const isHermes = template.trim().toLowerCase() === "hermes";
// Resolve the selected workspace template from /templates. Runtime is
// deliberately separate: "SEO Agent" is a workspace template, not a
// runtime, so it must never appear in the runtime selector.
// Resolve the selected template's spec from the /templates response.
// The `template` input is free-text; templates can be matched by id,
// name, or runtime so any of those work. Lower-cased compare keeps
// "Hermes" / "hermes" / "HERMES" interchangeable.
const selectedTemplateSpec = useMemo<TemplateSpec | null>(() => {
if (!template) return null;
return templateSpecs.find((s) => s.id === template) ?? null;
const t = template.trim().toLowerCase();
if (!t) return null;
return (
templateSpecs.find(
(s) =>
(s.id || "").toLowerCase() === t ||
(s.name || "").toLowerCase() === t ||
(s.runtime || "").toLowerCase() === t,
) ?? null
);
}, [template, templateSpecs]);
const selectedRuntimeTemplateSpec = useMemo<TemplateSpec | null>(() => (
templateSpecs.find((s) => s.id === runtime && BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) ?? null
), [runtime, templateSpecs]);
const isHermes = runtime === "hermes";
const visibleTemplateSpecs = useMemo(
() => templateSpecs.filter((spec) => {
if (BASE_RUNTIME_TEMPLATE_IDS.has(spec.id)) return false;
const specRuntime = (spec.runtime ?? DEFAULT_RUNTIME).trim().toLowerCase();
return specRuntime === runtime;
}),
[runtime, templateSpecs],
);
const llmModels = useMemo(
() => {
if (!selectedTemplateSpec?.models?.length) return DEFAULT_LLM_MODELS;
if (isHermes) {
return selectedTemplateSpec.models;
}
if (selectedTemplateSpec.models.some((model) => model.provider === "platform")) {
return selectedTemplateSpec.models;
}
const templateDefault = selectedTemplateSpec.model?.trim();
const defaultModelSpec = templateDefault
? selectedTemplateSpec.models.find((model) => model.id === templateDefault)
: undefined;
return [
{
id: templateDefault || DEFAULT_PLATFORM_MODEL.id,
name: defaultModelSpec?.name ?? DEFAULT_PLATFORM_MODEL.name,
provider: "platform",
required_env: [],
},
...selectedTemplateSpec.models,
];
},
[isHermes, selectedTemplateSpec],
);
const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]);
const selectedLLMProvider = useMemo(
() => llmCatalog.find((p) => p.id === llmSelection.providerId) ?? llmCatalog[0],
[llmCatalog, llmSelection.providerId],
);
// Filter HERMES_PROVIDERS by what the template declares it supports.
// Empty/missing declared list → fall back to the full catalog so
// templates that haven't migrated to the explicit `providers:` field
// (and self-hosted setups without /templates) keep working unchanged.
const availableProviders = useMemo<HermesProvider[]>(() => {
const declared = selectedTemplateSpec?.providers ?? selectedRuntimeTemplateSpec?.providers;
const declared = selectedTemplateSpec?.providers;
if (!declared || declared.length === 0) return HERMES_PROVIDERS;
const allowed = new Set(declared.map((p) => p.toLowerCase()));
const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase()));
@@ -265,7 +188,7 @@ export function CreateWorkspaceButton() {
// metadata for yet), fall back to the full list rather than render
// an empty <select>. Better to over-show than to lock the user out.
return filtered.length > 0 ? filtered : HERMES_PROVIDERS;
}, [selectedRuntimeTemplateSpec, selectedTemplateSpec]);
}, [selectedTemplateSpec]);
// If the currently-selected provider is filtered out by a template
// change, snap back to the first available. Without this, the
@@ -280,23 +203,6 @@ export function CreateWorkspaceButton() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availableProviders, isHermes]);
useEffect(() => {
if (isHermes || llmCatalog.length === 0) return;
const templateDefault = selectedTemplateSpec?.model?.trim();
const matched = templateDefault ? findProviderForModel(llmCatalog, templateDefault) : null;
const next = matched ?? llmCatalog[0];
setLLMSelection({
providerId: next.id,
model: matched && templateDefault
? templateDefault
: next.wildcard
? ""
: next.models[0]?.id ?? "",
envVars: next.envVars,
});
setLLMSecret("");
}, [isHermes, llmCatalog, selectedTemplateSpec?.model]);
// Auto-fill hermesModel with the provider's defaultModel whenever the
// provider changes, but only if the user hasn't already typed their own
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
@@ -319,21 +225,18 @@ export function CreateWorkspaceButton() {
setName("");
setRole("");
setTier(defaultTier);
setRuntime(DEFAULT_RUNTIME);
setTemplate("");
setParentId("");
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
setDisplayInstanceType("t3.xlarge");
setDisplayRootGB("80");
setDisplayResolution("1920x1080");
setHermesProvider("anthropic");
setExternalRuntime("external");
setHermesApiKey("");
setHermesModel("");
setLLMSelection({ providerId: "platform|", model: "moonshot/kimi-k2.6", envVars: [] });
setLLMSecret("");
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
@@ -360,21 +263,12 @@ export function CreateWorkspaceButton() {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
if (!isExternal && !isHermes && !llmSelection.model.trim()) {
setError("Model is required");
return;
}
if (!isExternal && !isHermes && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
setError("Provider credential is required");
return;
}
setCreating(true);
setError(null);
const provider = isHermes
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
: undefined;
const nativeProvider = !isHermes ? selectedLLMProvider : undefined;
try {
const parsedBudget = budgetLimit.trim()
@@ -398,40 +292,26 @@ export function CreateWorkspaceButton() {
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
...(!isExternal && !isHermes && nativeProvider
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
...(displayEnabled
? {
model: llmSelection.model.trim(),
llm_provider: nativeProvider.vendor,
...(nativeProvider.envVars.length > 0
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
: {}),
}
: {}),
...(!isExternal
? {
compute: displayEnabled
? {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : DEFAULT_DISPLAY_ROOT_GB },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
}
: {
instance_type: DEFAULT_HEADLESS_INSTANCE_TYPE,
volume: { root_gb: DEFAULT_HEADLESS_ROOT_GB },
display: { mode: "none" },
},
compute: {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
},
}
: {}),
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
// returned in the response for the modal below.
...(isExternal ? { runtime: externalRuntime } : { runtime }),
...(isExternal ? { runtime: externalRuntime } : {}),
...(!isExternal && isHermes && provider
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
@@ -549,76 +429,13 @@ export function CreateWorkspaceButton() {
)}
{!isExternal && (
<div className="space-y-3">
<div>
<label htmlFor="runtime-select" className="text-[11px] text-ink-mid block mb-1">
Runtime
</label>
<select
id="runtime-select"
value={runtime}
onChange={(e) => handleRuntimeChange(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
{RUNTIME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="workspace-template-select" className="text-[11px] text-ink-mid block mb-1">
Workspace Template
</label>
<select
id="workspace-template-select"
value={template}
onChange={(e) => setTemplate(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="">Blank workspace</option>
{visibleTemplateSpecs.map((spec) => (
<option key={spec.id} value={spec.id}>
{spec.name || spec.id}
</option>
))}
</select>
</div>
</div>
)}
{!isExternal && !isHermes && selectedLLMProvider && (
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
<div className="text-[11px] font-medium text-ink-mid">
LLM
</div>
<ProviderModelSelector
models={llmModels}
value={llmSelection}
onChange={(next) => {
setLLMSelection(next);
setLLMSecret("");
}}
idPrefix="create-workspace-llm"
variant="stack"
/>
{selectedLLMProvider.envVars.length > 0 && (
<div>
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
{selectedLLMProvider.envVars[0]}
</label>
<input
id="llm-secret-input"
type="password"
value={llmSecret}
onChange={(e) => setLLMSecret(e.target.value)}
autoComplete="off"
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
/>
</div>
)}
</div>
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
)}
<div>
@@ -725,11 +542,10 @@ export function CreateWorkspaceButton() {
)}
<div>
<label htmlFor="parent-workspace-select" className="text-[11px] text-ink-mid block mb-1">
<label className="text-[11px] text-ink-mid block mb-1">
Parent Workspace
</label>
<select
id="parent-workspace-select"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
@@ -744,7 +560,7 @@ export function CreateWorkspaceButton() {
</div>
</div>
{/* Hermes provider configuration — shown only for the Hermes runtime. */}
{/* Hermes provider configuration — shown only when template === "hermes" */}
{isHermes && (
<div
className="mt-4 rounded-xl border border-violet-700/40 bg-violet-950/20 p-4 space-y-3"
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { OrgTemplatesSection } from "./TemplatePalette";
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
import { type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import { Spinner } from "./Spinner";
import { TIER_CONFIG } from "@/lib/design-tokens";
@@ -18,7 +18,7 @@ export function EmptyState() {
useEffect(() => {
api
.get<Template[]>("/templates")
.then((t) => setTemplates(t.filter(isUserVisibleWorkspaceTemplate)))
.then((t) => setTemplates(t))
.catch(() => setTemplates([]))
.finally(() => setLoading(false));
}, []);
+18 -240
View File
@@ -23,8 +23,6 @@ interface Props {
/** Grouped provider options derived from the template's models[] /
* required_env. When length 2 the modal shows a radio picker. */
providers?: ProviderChoice[];
/** Optional keys to offer in the deploy modal without blocking Deploy. */
optionalKeys?: string[];
/** Runtime slug used only for the "The <runtime> runtime "
* headline; behavior is driven by providers/missingKeys. */
runtime: string;
@@ -96,13 +94,13 @@ export function MissingKeysModal({
open,
missingKeys,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -116,13 +114,13 @@ export function MissingKeysModal({
<ProviderPickerModal
open={open}
providers={pickerProviders}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
modelSuggestions={modelSuggestions}
models={models}
initialModel={initialModel}
title={title}
@@ -140,15 +138,11 @@ export function MissingKeysModal({
<AllKeysModal
open={open}
missingKeys={keys}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
title={title}
description={description}
/>
);
}
@@ -176,13 +170,13 @@ export function providerIdForModel(
function ProviderPickerModal({
open,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -190,13 +184,13 @@ function ProviderPickerModal({
}: {
open: boolean;
providers: ProviderChoice[];
optionalKeys: string[];
runtime: string;
onKeysAdded: (model?: string) => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
modelSuggestions?: string[];
models?: ModelSpec[];
initialModel?: string;
title?: string;
@@ -256,9 +250,16 @@ function ProviderPickerModal({
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(null);
// Legacy compat: map the selector value back into the old `selected`/
// `model` shape for the rest of the modal body (footer copy, etc.).
const selected = useMemo(
() =>
providers.find((p) => p.id === selectorValue.providerId) ??
providers[0],
[providers, selectorValue.providerId],
);
const model = selectorValue.model;
const showModelInput = catalog.length > 0;
@@ -281,18 +282,7 @@ function ProviderPickerModal({
error: null,
})),
);
setOptionalEntries(
optionalKeys
.filter((key) => !selectorValue.envVars.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys, optionalKeys]);
}, [open, selectorValue.envVars, configuredKeys]);
useEffect(() => {
if (!open) return;
@@ -346,43 +336,6 @@ function ProviderPickerModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(index, { saving: true, error: null });
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
value: entry.value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: entry.key,
value: entry.value.trim(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
if (!open) return null;
// Portal to document.body for the same reason as
// OrgImportPreflightModal — several callers (TemplatePalette,
@@ -512,62 +465,6 @@ function ProviderPickerModal({
</div>
))}
</div>
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
))}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
@@ -615,30 +512,21 @@ function ProviderPickerModal({
function AllKeysModal({
open,
missingKeys,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
title,
description,
}: {
open: boolean;
missingKeys: string[];
optionalKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
title?: string;
description?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
useEffect(() => {
@@ -647,24 +535,13 @@ function AllKeysModal({
missingKeys.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saved: false,
saving: false,
error: null,
})),
);
setOptionalEntries(
optionalKeys
.filter((key) => !missingKeys.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
setGlobalError(null);
}, [open, missingKeys, optionalKeys, configuredKeys]);
}, [open, missingKeys]);
useEffect(() => {
if (!open) return;
@@ -714,45 +591,6 @@ function AllKeysModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(index, { saving: true, error: null });
try {
if (workspaceId) {
await api.put(`/workspaces/${workspaceId}/secrets`, {
key: entry.key,
value: entry.value.trim(),
});
} else {
await api.put("/settings/secrets", {
key: entry.key,
value: entry.value.trim(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
const handleAddKeysAndDeploy = useCallback(() => {
const anySaving = entries.some((e) => e.saving);
if (anySaving) {
@@ -818,16 +656,12 @@ function AllKeysModal({
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
{title ?? "Missing API Keys"}
Missing API Keys
</h3>
</div>
<p className="text-[12px] text-ink-mid leading-relaxed">
{description ?? (
<>
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</>
)}
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</p>
</div>
@@ -885,62 +719,6 @@ function AllKeysModal({
</div>
))}
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
</div>
))}
</div>
)}
{globalError && (
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
{globalError}
@@ -28,7 +28,6 @@ import { useId, useMemo } from "react";
export interface SelectorModel {
id: string;
name?: string;
provider?: string;
required_env?: string[];
}
@@ -89,7 +88,6 @@ interface Props {
/** Vendor keys human label. Add new vendors here when templates pick
* up new model families. */
const VENDOR_LABELS: Record<string, string> = {
"platform": "Platform",
"anthropic-oauth": "Claude Code subscription",
anthropic: "Anthropic API",
minimax: "MiniMax",
@@ -120,8 +118,6 @@ const VENDOR_LABELS: Record<string, string> = {
/** Optional per-vendor tooltip shown on hover. */
const VENDOR_TOOLTIPS: Record<string, string> = {
"platform":
"Use the Molecule platform-managed LLM proxy. No vendor API key is required.",
"anthropic-oauth":
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
anthropic:
@@ -169,9 +165,6 @@ const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: strin
/** Infer a vendor key from a model spec. Combines id-prefix and env
* signals. Exported for tests. */
export function inferVendor(model: SelectorModel): string {
const explicitProvider = model.provider?.trim().toLowerCase();
if (explicitProvider) return explicitProvider;
const id = model.id || "";
const envSet = new Set(model.required_env ?? []);
@@ -242,13 +242,10 @@ export function ProvisioningTimeout({
const handleCancelConfirm = useCallback(async () => {
if (!confirmingCancel) return;
const workspaceId = confirmingCancel;
const workspaceName = timedOut.find((e) => e.workspaceId === workspaceId)?.workspaceName ?? "";
setConfirmingCancel(null);
setCancelling((prev) => new Set(prev).add(workspaceId));
try {
await api.del(`/workspaces/${workspaceId}`, {
headers: { "X-Confirm-Name": workspaceName },
});
await api.del(`/workspaces/${workspaceId}`);
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
trackingRef.current.delete(workspaceId);
showToast("Deployment cancelled", "info");
+2 -2
View File
@@ -5,7 +5,7 @@ import { flushSync } from "react-dom";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import type { WorkspaceData } from "@/store/socket";
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
import { type Template } from "@/lib/deploy-preflight";
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
import {
OrgImportPreflightModal,
@@ -446,7 +446,7 @@ export function TemplatePalette() {
setLoading(true);
try {
const data = await api.get<Template[]>("/templates");
setTemplates(data.filter(isUserVisibleWorkspaceTemplate));
setTemplates(data);
} catch {
setTemplates([]);
} finally {
+2 -4
View File
@@ -224,14 +224,12 @@ export function Toolbar() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "?") return;
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
const tag = target.tagName;
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target.isContentEditable;
(e.target as HTMLElement).isContentEditable;
if (inInput) return;
// Don't fire when a modal/dialog is already mounted (canvas modals,
// side panel, etc. use z-50 or above).
@@ -201,13 +201,15 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
expect(label?.textContent).toContain("Budget limit");
});
it("Workspace Template select has a <label> whose htmlFor matches the select id", async () => {
it("Template input has a <label> whose htmlFor matches the input id", async () => {
await openDialog();
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
expect(templateSelect.id).toBeTruthy();
const label = document.querySelector(`label[for="${templateSelect.id}"]`);
const templateInput = screen.getByPlaceholderText(
"e.g. seo-agent (from workspace-configs-templates/)"
) as HTMLInputElement;
expect(templateInput.id).toBeTruthy();
const label = document.querySelector(`label[for="${templateInput.id}"]`);
expect(label).toBeTruthy();
expect(label?.textContent).toContain("Workspace Template");
expect(label?.textContent).toContain("Template");
});
it("each InputField generates a distinct id (no id collisions)", async () => {
@@ -216,16 +218,13 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
screen.getByPlaceholderText("e.g. SEO Agent"),
screen.getByPlaceholderText("e.g. SEO Specialist"),
screen.getByPlaceholderText("e.g. 100"),
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
] as HTMLInputElement[];
const selects = [
screen.getByLabelText("Runtime"),
screen.getByLabelText("Workspace Template"),
] as HTMLSelectElement[];
const ids = [...inputs, ...selects].map((i) => i.id).filter(Boolean);
const ids = inputs.map((i) => i.id).filter(Boolean);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length); // no duplicates
expect(ids.length).toBe(5);
expect(ids.length).toBe(4);
});
it("Name label text contains the required asterisk indicator", async () => {
@@ -20,34 +20,10 @@ const SAMPLE_WORKSPACES = [
{ id: "ws-2", name: "Research Agent", tier: 2 },
];
const SAMPLE_TEMPLATES = [
{
id: "seo-agent",
name: "SEO Agent",
runtime: "claude-code",
model: "moonshot/kimi-k2.6",
providers: ["platform", "minimax", "kimi-coding", "anthropic", "anthropic-oauth"],
models: [
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
],
},
{ id: "hermes", name: "Hermes", runtime: "hermes" },
];
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockImplementation(async (url: string) => {
if (url === "/templates") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_TEMPLATES as any;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return SAMPLE_WORKSPACES as any;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGet.mockResolvedValue(SAMPLE_WORKSPACES as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockPost.mockResolvedValue({} as any);
});
@@ -66,14 +42,7 @@ async function openDialog() {
async function setTemplate(value: string) {
fireEvent.change(
screen.getByLabelText("Workspace Template"),
{ target: { value } }
);
}
async function setRuntime(value: string) {
fireEvent.change(
screen.getByLabelText("Runtime"),
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
{ target: { value } }
);
}
@@ -94,7 +63,7 @@ describe("CreateWorkspaceDialog", () => {
it('first option is "None (root level)" with empty value', async () => {
await openDialog();
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select).toBeTruthy();
const firstOption = select.options[0];
expect(firstOption.value).toBe("");
@@ -104,12 +73,12 @@ describe("CreateWorkspaceDialog", () => {
it("populates select with workspace names from GET /workspaces", async () => {
await openDialog();
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
const optionValues = Array.from(select.options).map((o) => o.value);
expect(optionValues).toContain("ws-1");
expect(optionValues).toContain("ws-2");
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text.trim());
expect(optionTexts.some((t) => t.includes("Platform Team"))).toBe(true);
expect(optionTexts.some((t) => t.includes("Research Agent"))).toBe(true);
@@ -118,7 +87,7 @@ describe("CreateWorkspaceDialog", () => {
it("sends parent_id in POST body when a workspace is selected", async () => {
await openDialog();
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
@@ -126,7 +95,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "My Agent" },
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "ws-1" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -143,7 +112,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "Root Agent" },
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -154,7 +123,7 @@ describe("CreateWorkspaceDialog", () => {
expect(body.parent_id).toBeUndefined();
});
it("sends the cost-efficient headless compute profile by default", async () => {
it("omits compute config by default", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Plain Agent" },
@@ -163,54 +132,10 @@ describe("CreateWorkspaceDialog", () => {
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toEqual({
instance_type: "t3.medium",
volume: { root_gb: 30 },
display: { mode: "none" },
});
expect(body.model).toBe("moonshot/kimi-k2.6");
expect(body.llm_provider).toBe("platform");
expect(body.runtime).toBe("claude-code");
expect(body.secrets).toBeUndefined();
});
it("keeps runtime and workspace template as separate selectors", async () => {
await openDialog();
const runtimeSelect = screen.getByLabelText("Runtime") as HTMLSelectElement;
const runtimeTexts = Array.from(runtimeSelect.options).map((o) => o.text.trim());
expect(runtimeTexts).toEqual([
"Claude Code",
"OpenAI Codex CLI",
"Hermes",
"OpenClaw",
]);
expect(runtimeTexts).not.toContain("SEO Agent");
await waitFor(() => {
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
const templateTexts = Array.from(templateSelect.options).map((o) => o.text.trim());
expect(templateTexts).toContain("SEO Agent");
expect(templateTexts).not.toContain("Hermes");
});
});
it("does not send managed compute for external agents", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "External Agent" },
});
fireEvent.click(screen.getByLabelText(/External agent/));
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.compute).toBeUndefined();
expect(body.runtime).toBe("external");
expect(body.model).toBe("anthropic:claude-opus-4-7");
});
it("sends display compute profile when desktop display is enabled", async () => {
@@ -225,8 +150,7 @@ describe("CreateWorkspaceDialog", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("moonshot/kimi-k2.6");
expect(body.llm_provider).toBe("platform");
expect(body.model).toBe("anthropic:claude-opus-4-7");
expect(body.compute).toEqual({
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
@@ -239,57 +163,13 @@ describe("CreateWorkspaceDialog", () => {
});
});
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "BYOK Agent" },
});
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
target: { value: "minimax|MINIMAX_API_KEY" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "sk-minimax-test" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" });
});
it("sends Claude OAuth token separately from platform-managed mode", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "OAuth Agent" },
});
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" },
});
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
target: { value: "oauth-token" },
});
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
fireEvent.click(createBtn!);
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.model).toBe("sonnet");
expect(body.llm_provider).toBe("anthropic-oauth");
expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" });
});
it("renders gracefully when GET /workspaces fails", async () => {
mockGet.mockRejectedValueOnce(new Error("Network error"));
await openDialog();
// Dialog still renders; select exists with only the root option
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select.options.length).toBe(1);
expect(select.options[0].value).toBe("");
});
@@ -307,17 +187,17 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull();
});
it("shows hermes provider section when runtime is 'hermes'", async () => {
it("shows hermes provider section when template is 'hermes'", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
});
it("shows hermes provider section for the Hermes runtime preset", async () => {
it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("HERMES");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -325,7 +205,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes provider dropdown defaults to 'anthropic'", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -336,7 +216,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes provider dropdown lists all 15 providers", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -370,7 +250,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -400,7 +280,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -426,7 +306,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
});
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -437,7 +317,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hermes API key field is a password input (masked)", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -451,7 +331,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes Agent" },
});
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -472,7 +352,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes Agent" },
});
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -487,8 +367,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
await waitFor(() => expect(mockPost).toHaveBeenCalled());
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
expect(body.secrets).toEqual({ ANTHROPIC_API_KEY: "sk-test-anthropic-key" });
expect(body.runtime).toBe("hermes");
expect(body.template).toBeUndefined();
expect(body.template).toBe("hermes");
});
it("uses the correct env var when a non-default provider is selected", async () => {
@@ -496,7 +375,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Hermes OpenAI" },
});
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
@@ -533,13 +412,13 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
it("hides hermes section and resets state when template is cleared", async () => {
await openDialog();
await setRuntime("hermes");
await setTemplate("hermes");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
);
// Switch back to a non-Hermes runtime.
await setRuntime("claude-code");
// Clear template
await setTemplate("");
await waitFor(() =>
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull()
);
@@ -96,12 +96,12 @@ vi.mock("@/lib/design-tokens", () => ({
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const TEMPLATE = {
id: "seo-agent",
name: "SEO Agent",
description: "SEO workspace template",
id: "tpl-1",
name: "Claude Code Agent",
description: "A general-purpose coding assistant",
tier: 2,
skill_count: 3,
model: "MiniMax-M2.7",
model: "claude-opus-4-5",
};
function template(overrides: Partial<typeof TEMPLATE> = {}): typeof TEMPLATE {
@@ -159,7 +159,7 @@ describe("EmptyState — loading", () => {
it("does not render template buttons while loading", async () => {
renderEmpty();
await flush();
expect(screen.queryByText("SEO Agent")).toBeNull();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
});
});
@@ -183,8 +183,8 @@ describe("EmptyState — templates", () => {
it("renders template buttons with name and description", async () => {
renderEmpty();
await flush();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.getByText("SEO workspace template")).toBeTruthy();
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy();
});
it("renders tier badge and skill count", async () => {
@@ -198,42 +198,25 @@ describe("EmptyState — templates", () => {
it("renders model name when present", async () => {
renderEmpty();
await flush();
expect(screen.getByText(/MiniMax-M2.7/i)).toBeTruthy();
expect(screen.getByText(/claude-opus/i)).toBeTruthy();
});
it("calls deploy with the template on click", async () => {
renderEmpty();
await flush();
fireEvent.click(screen.getByText("SEO Agent"));
fireEvent.click(screen.getByText("Claude Code Agent"));
expect(_deploy.deployFn).toHaveBeenCalledWith(template());
});
it("hides runtime-default templates from the product template grid", async () => {
mockApiGet.mockResolvedValue([
template({ id: "claude-code-default", name: "Claude Code Agent" }),
template({ id: "codex", name: "OpenAI Codex CLI" }),
template({ id: "hermes", name: "Hermes Agent" }),
template({ id: "openclaw", name: "OpenClaw Agent" }),
template(),
]);
renderEmpty();
await flush();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
expect(screen.queryByText("Hermes Agent")).toBeNull();
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
});
it("shows 'Deploying...' on the button of the template being deployed", async () => {
_deploy.deploying = "seo-agent";
_deploy.deploying = "tpl-1";
renderEmpty();
await flush();
expect(screen.getByText("Deploying...")).toBeTruthy();
});
it("disables the template button of the deploying template", async () => {
_deploy.deploying = "seo-agent";
_deploy.deploying = "tpl-1";
renderEmpty();
await flush();
const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement;
@@ -241,7 +224,7 @@ describe("EmptyState — templates", () => {
});
it("disables 'create blank' while a template is deploying", async () => {
_deploy.deploying = "seo-agent";
_deploy.deploying = "tpl-1";
renderEmpty();
await flush();
expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true);
@@ -262,7 +245,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
it("does not render template grid when GET /templates returns []", async () => {
renderEmpty();
await flush();
expect(screen.queryByText("SEO Agent")).toBeNull();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
});
it("renders 'create blank' button when templates list is empty", async () => {
@@ -275,7 +258,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
mockApiGet.mockReset().mockRejectedValue(new Error("Network failure"));
renderEmpty();
await flush();
expect(screen.queryByText("SEO Agent")).toBeNull();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
});
});
@@ -333,7 +316,7 @@ describe("EmptyState — create blank", () => {
await flush();
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
await act(async () => { await Promise.resolve(); });
expect((screen.getByText("SEO Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByText("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
});
it("shows error banner when POST /workspaces fails", async () => {
@@ -402,31 +402,6 @@ describe("MissingKeysModal — add keys and deploy", () => {
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows optional keys without blocking deploy", () => {
const onKeysAdded = vi.fn();
render(
<MissingKeysModal
open={true}
missingKeys={[]}
optionalKeys={["GOOGLE_GSC_SITE"]}
runtime="claude-code"
title="Configure Workspace"
onKeysAdded={onKeysAdded}
onCancel={vi.fn()}
/>
);
expect(screen.getByText("Optional")).toBeTruthy();
expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0);
const deployBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Deploy",
);
expect(deployBtn).toBeTruthy();
expect(deployBtn!.disabled).toBe(false);
act(() => { fireEvent.click(deployBtn!); });
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows global error when not all keys saved", async () => {
const onKeysAdded = vi.fn();
render(
@@ -554,4 +529,4 @@ describe("MissingKeysModal — cancel and settings", () => {
);
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
});
});
});
@@ -272,9 +272,7 @@ describe("OrgCancelButton — API interactions", () => {
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true", {
headers: { "X-Confirm-Name": "Test Org" },
});
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
});
it("shows success toast on DELETE success", async () => {
@@ -44,14 +44,6 @@ const HERMES_MODELS: SelectorModel[] = [
];
describe("inferVendor", () => {
it("uses explicit provider metadata before slug heuristics", () => {
expect(inferVendor({
id: "moonshot/kimi-k2.6",
provider: "platform",
required_env: [],
})).toBe("platform");
});
it("uses slash prefix when present", () => {
expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }))
.toBe("nousresearch");
@@ -113,22 +105,6 @@ describe("buildProviderCatalog", () => {
expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]);
});
it("labels explicit platform-managed providers", () => {
const catalog = buildProviderCatalog([
{
id: "moonshot/kimi-k2.6",
name: "Kimi K2.6",
provider: "platform",
required_env: [],
},
]);
expect(catalog[0]).toMatchObject({
vendor: "platform",
label: "Platform",
envVars: [],
});
});
it("flags wildcard providers", () => {
const catalog = buildProviderCatalog(HERMES_MODELS);
const hf = catalog.find((p) => p.vendor === "huggingface");
@@ -189,23 +189,6 @@ describe("TemplatePalette — sidebar", () => {
expect(screen.getByText("Researcher")).toBeTruthy();
});
it("hides runtime-default templates from the deployable product template list", async () => {
mockGet.mockResolvedValue([
{ id: "claude-code-default", name: "Claude Code Agent", description: "", tier: 4, skills: [] },
{ id: "codex", name: "OpenAI Codex CLI", description: "", tier: 4, skills: [] },
{ id: "hermes", name: "Hermes Agent", description: "", tier: 4, skills: [] },
{ id: "openclaw", name: "OpenClaw Agent", description: "", tier: 4, skills: [] },
{ id: "seo-agent", name: "SEO Agent", description: "SEO workspace template", tier: 4, skills: ["seo"] },
]);
render(<TemplatePalette />);
await openSidebar();
expect(screen.getByText("SEO Agent")).toBeTruthy();
expect(screen.queryByText("Claude Code Agent")).toBeNull();
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
expect(screen.queryByText("Hermes Agent")).toBeNull();
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
});
it("shows template description", async () => {
mockGet.mockResolvedValue(MOCK_TEMPLATES);
render(<TemplatePalette />);
@@ -57,7 +57,6 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
try {
await api.del<{ status: string }>(
`/workspaces/${rootId}?confirm=true`,
{ headers: { "X-Confirm-Name": rootName } },
);
showToast(`Cancelled deployment of "${rootName}"`, "success");
// Optimistic local removal — workspace-server broadcasts
@@ -199,9 +199,7 @@ describe("OrgCancelButton — Yes / cascade delete", () => {
});
// 1) API call hit the cascade-delete endpoint with confirm=true
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true", {
headers: { "X-Confirm-Name": "My Org" },
});
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true");
// 2) beginDelete locked the WHOLE subtree (root + 2 children) — NOT the unrelated node
expect(mockState.beginDelete).toHaveBeenCalledTimes(1);
@@ -68,11 +68,7 @@ afterEach(() => {
function ShortcutTestComponent() {
useKeyboardShortcuts();
return (
<div data-testid="canvas-root">
<div data-testid="display-stream" data-display-stream="true" />
</div>
);
return <div data-testid="canvas-root" />;
}
function renderWithProvider() {
@@ -82,13 +78,6 @@ function renderWithProvider() {
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Esc — deselect / close context menu", () => {
it("does not handle keys targeted at the display stream", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
const { getByTestId } = renderWithProvider();
fireEvent.keyDown(getByTestId("display-stream"), { key: "Escape" });
expect(mockStoreState.closeContextMenu).not.toHaveBeenCalled();
});
it("closes the context menu when one is open", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
renderWithProvider();
@@ -28,14 +28,12 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
const tag = target.tagName;
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target.isContentEditable;
(e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") {
const state = useCanvasStore.getState();
@@ -7,10 +7,8 @@ import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import type { WorkspaceCompute } from "@/store/socket";
const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"];
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"];
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "langgraph", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
type Props = {
workspaceId: string;
@@ -32,17 +30,15 @@ type FormState = {
};
export function ContainerConfigTab({ workspaceId, data }: Props) {
const runtime = data.runtime;
const instanceType = data.compute?.instance_type;
const rootGB = data.compute?.volume?.root_gb;
const displayMode = data.compute?.display?.mode;
const displayProtocol = data.compute?.display?.protocol;
const displayWidth = data.compute?.display?.width;
const displayHeight = data.compute?.display?.height;
const initial = useMemo(
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight }),
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight],
);
const initial = useMemo(() => formFromData(data), [
data.runtime,
data.compute?.instance_type,
data.compute?.volume?.root_gb,
data.compute?.display?.mode,
data.compute?.display?.protocol,
data.compute?.display?.width,
data.compute?.display?.height,
]);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -223,25 +219,18 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
);
}
function formFromData(data: {
runtime?: string;
instanceType?: string;
rootGB?: number;
displayMode?: string;
displayProtocol?: string;
displayWidth?: number;
displayHeight?: number;
}): FormState {
const width = data.displayWidth ?? 1920;
const height = data.displayHeight ?? 1080;
function formFromData(data: Props["data"]): FormState {
const display = data.compute?.display;
const width = display?.width ?? 1920;
const height = display?.height ?? 1080;
const resolution = `${width}x${height}`;
return {
runtime: data.runtime || "claude-code",
instanceType: data.instanceType || DEFAULT_HEADLESS_INSTANCE_TYPE,
rootGB: String(data.rootGB || DEFAULT_HEADLESS_ROOT_GB),
displayEnabled: !!data.displayMode && data.displayMode !== "none",
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
displayProtocol: data.displayProtocol || "novnc",
instanceType: data.compute?.instance_type || "t3.large",
rootGB: String(data.compute?.volume?.root_gb || 50),
displayEnabled: !!display?.mode && display.mode !== "none",
displayMode: display?.mode && display.mode !== "none" ? display.mode : "desktop-control",
displayProtocol: display?.protocol || "novnc",
resolution,
};
}
+1 -3
View File
@@ -93,9 +93,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
const handleDelete = async () => {
setDeleteError(null);
try {
await api.del(`/workspaces/${workspaceId}?confirm=true`, {
headers: { "X-Confirm-Name": name },
});
await api.del(`/workspaces/${workspaceId}?confirm=true`);
// Mirror the server-side cascade — drop the row + every
// descendant locally so the canvas reflects the deletion
// immediately, even when the WS is dead and the per-descendant
+4 -105
View File
@@ -265,11 +265,6 @@ function DisplayControlBar({
onAcquire: () => void;
onRelease: () => void;
}) {
const userControl = control?.controller === "user";
const adminControl = userControl && control?.controlled_by === "admin-token";
const canAcquireUserControl = control?.controller === "none" || (userControl && !hasSession);
const canReleaseUserControl = adminControl || (userControl && hasSession);
return (
<div className="flex min-w-0 items-center gap-3">
{control && (
@@ -287,7 +282,8 @@ function DisplayControlBar({
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
</div>
)}
{canAcquireUserControl && (
{(control?.controller === "none" ||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
<button
type="button"
onClick={onAcquire}
@@ -297,7 +293,7 @@ function DisplayControlBar({
Take control
</button>
)}
{canReleaseUserControl && (
{control?.controller === "user" && control.controlled_by === "admin-token" && (
<button
type="button"
onClick={onRelease}
@@ -313,21 +309,11 @@ function DisplayControlBar({
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const rfbRef = useRef<RFB | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
const [remoteClipboardText, setRemoteClipboardText] = useState("");
useEffect(() => {
let cancelled = false;
let rfb: RFB | null = null;
let clipboardTimer: ReturnType<typeof setTimeout> | null = null;
const setTemporaryClipboardStatus = (message: string) => {
setClipboardStatus(message);
if (clipboardTimer) clearTimeout(clipboardTimer);
clipboardTimer = setTimeout(() => setClipboardStatus(null), 2500);
};
async function connect() {
setStreamError(null);
@@ -338,19 +324,9 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
rfb = new mod.default(containerRef.current, stream.url, {
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
});
rfbRef.current = rfb;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.focusOnClick = true;
rfb.focus({ preventScroll: true });
rfb.addEventListener("clipboard", (event: Event) => {
const text = (event as CustomEvent<{ text?: string }>).detail?.text ?? "";
if (!text) return;
setRemoteClipboardText(text);
void navigator.clipboard?.writeText(text)
.then(() => setTemporaryClipboardStatus("Copied remote clipboard"))
.catch(() => setTemporaryClipboardStatus("Remote clipboard ready"));
});
rfb.addEventListener("disconnect", (event: Event) => {
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
@@ -363,83 +339,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
connect();
return () => {
cancelled = true;
if (clipboardTimer) clearTimeout(clipboardTimer);
rfbRef.current = null;
rfb?.disconnect();
};
}, [sessionUrl]);
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
if (!isDisplayEventTarget(containerRef.current, event.target)) return;
const text = event.clipboardData?.getData("text/plain") ?? "";
if (!text) return;
event.preventDefault();
rfbRef.current?.clipboardPasteFrom(text);
rfbRef.current?.focus({ preventScroll: true });
setClipboardStatus("Pasted to desktop");
};
window.addEventListener("paste", onPaste, true);
return () => window.removeEventListener("paste", onPaste, true);
}, []);
const pasteLocalClipboard = async () => {
try {
const text = await navigator.clipboard?.readText();
if (!text) {
setClipboardStatus("Clipboard is empty");
return;
}
rfbRef.current?.clipboardPasteFrom(text);
rfbRef.current?.focus({ preventScroll: true });
setClipboardStatus("Pasted to desktop");
} catch {
setClipboardStatus("Press Ctrl/Cmd+V while the desktop is focused");
}
};
const copyRemoteClipboard = async () => {
if (!remoteClipboardText) {
setClipboardStatus("No remote clipboard yet");
return;
}
try {
await navigator.clipboard.writeText(remoteClipboardText);
setClipboardStatus("Copied remote clipboard");
} catch {
setClipboardStatus("Browser blocked clipboard copy");
}
};
return (
<div
data-display-stream="true"
className="relative min-h-0 flex-1 bg-black"
onMouseDown={() => rfbRef.current?.focus({ preventScroll: true })}
>
<div className="relative min-h-0 flex-1 bg-black">
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
<div className="absolute right-3 top-3 flex items-center gap-2">
{clipboardStatus && (
<span className="rounded border border-line/50 bg-black/80 px-2 py-1 text-[10px] text-white">
{clipboardStatus}
</span>
)}
<button
type="button"
onClick={pasteLocalClipboard}
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black"
>
Paste
</button>
<button
type="button"
onClick={copyRemoteClipboard}
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
disabled={!remoteClipboardText}
>
Copy
</button>
</div>
{streamError && (
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
{streamError}
@@ -449,13 +355,6 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
);
}
function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean {
if (!container) return false;
if (target instanceof Node && container.contains(target)) return true;
const active = document.activeElement;
return active instanceof Node && container.contains(active);
}
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
const url = new URL(sessionUrl, window.location.href);
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
@@ -67,7 +67,7 @@ export function FileEditor({
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div aria-hidden="true" className="text-2xl opacity-20 mb-2">📄</div>
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-ink-mid">Select a file to edit</p>
</div>
</div>
@@ -79,16 +79,16 @@ export function FileEditor({
{/* File header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/20">
<div className="flex items-center gap-1.5 min-w-0">
<span aria-hidden="true" className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] font-mono text-ink-mid truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-warm">modified</span>}
</div>
<div className="flex items-center gap-2">
{success && <span role="status" aria-live="polite" className="text-[9px] text-good">{success}</span>}
{success && <span className="text-[9px] text-good">{success}</span>}
<button
onClick={onDownload}
aria-label="Download file"
className="text-[10px] text-ink-mid hover:text-ink-mid focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-ink-mid hover:text-ink-mid"
>
</button>
@@ -96,7 +96,7 @@ export function FileEditor({
<button
onClick={onSave}
disabled={!isDirty || saving}
className="text-[10px] text-accent hover:text-accent disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
>
{saving ? "Saving..." : "Save"}
</button>
@@ -166,7 +166,6 @@ export function FileEditor({
}
}}
spellCheck={false}
aria-label="File content editor"
className="flex-1 w-full bg-surface p-3 text-[11px] font-mono text-ink leading-relaxed resize-none focus:outline-none"
style={{ tabSize: 2 }}
/>
@@ -29,8 +29,8 @@ afterEach(() => {
const defaultProps = {
selectedFile: "/configs/agent.yaml",
fileContent: "name: test\nruntime: claude-code",
editContent: "name: test\nruntime: claude-code",
fileContent: "name: test\nruntime: langgraph",
editContent: "name: test\nruntime: langgraph",
setEditContent: vi.fn(),
loadingFile: false,
saving: false,
@@ -197,12 +197,12 @@ describe("FileEditor — textarea", () => {
render(
<FileEditor
{...defaultProps}
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
expect(ta).toBeTruthy();
expect(ta?.value).toBe("runtime: claude-code");
expect(ta?.value).toBe("runtime: langgraph");
});
it("textarea is readOnly when root is not /configs", () => {
@@ -210,7 +210,7 @@ describe("FileEditor — textarea", () => {
<FileEditor
{...defaultProps}
root="/workspace"
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
@@ -222,7 +222,7 @@ describe("FileEditor — textarea", () => {
<FileEditor
{...defaultProps}
root="/configs"
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
@@ -78,11 +78,11 @@ describe("walkEntry — file entry", () => {
});
it("populates the File object with correct content", async () => {
const { entry, file } = makeFile("config.yaml", "runtime: claude-code");
const { entry, file } = makeFile("config.yaml", "runtime: langgraph");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out[0]!.file).toBe(file);
expect(await out[0]!.file.text()).toBe("runtime: claude-code");
expect(await out[0]!.file.text()).toBe("runtime: langgraph");
});
it("appends to existing entries array (non-destructive)", async () => {
+1 -1
View File
@@ -32,7 +32,7 @@ interface PluginInfo {
author: string;
tags: string[];
skills: string[];
// Declared supported runtimes (e.g. ["claude_code", "hermes"]).
// Declared supported runtimes (e.g. ["claude_code", "deepagents"]).
// Empty / absent = "unspecified, try it".
runtimes?: string[];
// Only present on /workspaces/:id/plugins responses — true if the
@@ -3,10 +3,10 @@
// Regression tests for ConfigTab hermes-workspace UX (#1894 + #1900).
//
// All four bugs this suite pins hit the same workspace on 2026-04-23:
// a hermes-runtime workspace whose Config tab showed "Claude Code
// a hermes-runtime workspace whose Config tab showed "LangGraph
// (default)" in the runtime dropdown, an empty Model field, and a
// scary red "No config.yaml found" banner. Clicking Save would
// silently PATCH runtime back to Claude Code, breaking the workspace.
// silently PATCH runtime back to LangGraph, breaking the workspace.
//
// Each test pins one invariant. If any fails, the bug is back.
@@ -91,7 +91,7 @@ describe("ConfigTab — hermes workspace", () => {
it("loads runtime from workspace metadata when config.yaml is missing (#1894 bug 1)", async () => {
// This is the hermes case: no platform config.yaml, so the form must
// fall back to GET /workspaces/:id's runtime field. Before the fix, the
// runtime dropdown showed "Claude Code (default)" because the fallback
// runtime dropdown showed "LangGraph (default)" because the fallback
// didn't exist.
wireApi({
workspaceRuntime: "hermes",
@@ -150,9 +150,9 @@ describe("ConfigTab — hermes workspace", () => {
expect(screen.queryByText(/Hermes manages its own config/i)).toBeNull();
});
it("DOES show 'No config.yaml found' error for claude-code workspace (default runtime)", async () => {
it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
// Regression guard the other way — the gray info banner is hermes-
// specific. A claude-code workspace with no config.yaml SHOULD still
// specific. A langgraph workspace with no config.yaml SHOULD still
// see the red error so the user knows to provide a template config.
wireApi({
workspaceRuntime: "",
@@ -302,21 +302,21 @@ describe("ConfigTab — config.yaml on disk", () => {
// MCP server list, etc.) but runtime/model/tier come from the
// workspace row so the node badge matches the form.
//
// Scenario: DB says "hermes", config.yaml says "openclaw". The form
// Scenario: DB says "hermes", config.yaml says "crewai". The form
// must show hermes (DB wins).
//
// We pick hermes (not claude-code) on the DB side because "claude-code"
// is collapsed to the empty-string "Claude Code (default)" option in
// the runtime dropdown — so a "claude-code" DB value would render as
// We pick hermes (not langgraph) on the DB side because "langgraph"
// is collapsed to the empty-string "LangGraph (default)" option in
// the runtime dropdown — so a "langgraph" DB value would render as
// the empty-valued option and obscure whether the DB-wins logic
// actually fired. Hermes has its own non-empty option value and
// gives the assertion a clean signal.
wireApi({
workspaceRuntime: "hermes", // DB — authoritative
configYamlContent: 'runtime: openclaw\nmodel: "claude-opus"\n',
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
templates: [
{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] },
{ id: "t-openclaw", name: "OpenClaw", runtime: "openclaw", models: [] },
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
],
});
@@ -36,27 +36,6 @@ beforeEach(() => {
});
describe("ContainerConfigTab", () => {
it("defaults missing compute to the cost-efficient headless profile", () => {
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: undefined,
}}
/>,
);
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.medium");
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "30");
});
it("renders persisted compute and status settings", () => {
render(
<ContainerConfigTab
@@ -290,9 +290,7 @@ describe("DetailsTab — delete workflow", () => {
) as HTMLButtonElement;
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
await flush();
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true", {
headers: { "X-Confirm-Name": "Test Workspace" },
});
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
@@ -2,12 +2,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockRFBConstructor: vi.fn(),
mockRFBClipboardPasteFrom: vi.fn(),
mockRFBFocus: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
@@ -32,12 +30,6 @@ vi.mock("@novnc/novnc", () => ({
this.options = options;
mockRFBConstructor(target, url, options);
}
clipboardPasteFrom(text: string) {
mockRFBClipboardPasteFrom(text);
}
focus(options?: FocusOptions) {
mockRFBFocus(options);
}
disconnect() {}
},
}));
@@ -50,8 +42,6 @@ describe("DisplayTab", () => {
mockGet.mockReset();
mockPost.mockReset();
mockRFBConstructor.mockReset();
mockRFBClipboardPasteFrom.mockReset();
mockRFBFocus.mockReset();
});
it("renders unavailable state for non-display workspaces", async () => {
@@ -167,43 +157,6 @@ describe("DisplayTab", () => {
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
});
it("forwards browser paste events into the noVNC clipboard", async () => {
mockGet
.mockResolvedValueOnce({
available: true,
mode: "desktop-control",
protocol: "novnc",
width: 1920,
height: 1080,
})
.mockResolvedValueOnce({
controller: "none",
});
mockPost.mockResolvedValueOnce({
controller: "user",
controlled_by: "admin-token",
expires_at: "2026-05-23T08:48:27Z",
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
});
render(<DisplayTab workspaceId="ws-display" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
const desktop = await screen.findByTitle("Workspace desktop");
fireEvent.paste(desktop, {
clipboardData: {
getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
},
});
expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me");
expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true });
});
it("releases user display control", async () => {
mockGet
.mockResolvedValueOnce({
@@ -166,12 +166,11 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
contained
>
<img
src={state.blobUrl}
alt={attachment.name}
className="max-w-full max-h-full object-contain"
className="max-w-[95vw] max-h-[90vh] object-contain"
/>
</AttachmentLightbox>
</>
@@ -1,6 +1,6 @@
"use client";
// AttachmentLightbox — shared modal for image / PDF /
// AttachmentLightbox — shared fullscreen modal for image / PDF /
// (future) any-fullscreen-renderable kind. Owns:
// - Backdrop + centered viewport
// - Esc to close
@@ -14,11 +14,11 @@
//
// Design choices:
//
// 1. Portals — we don't use ReactDOM.createPortal because the chat tab
// already gives us a positioned container and the preview should stay
// inside that panel. Saves a portal mount in the common case + avoids
// the SSR warning (canvas is "use client" but the parent shell is
// server-rendered).
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
// chat surface already renders at a high z-index and the modal's
// fixed-position layout reaches the viewport regardless. Saves a
// portal mount in the common case + avoids the SSR warning (canvas
// is "use client" but the parent shell is server-rendered).
//
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
// chat lightbox needs to trap focus only across two interactive
@@ -41,17 +41,13 @@ interface Props {
* the dialog opens. The caller knows what's inside (image alt
* text, PDF filename) and supplies it. */
ariaLabel: string;
/** Constrain the preview to the nearest positioned ancestor instead
* of the whole browser viewport. ChatTab passes this so previews
* stay inside the active side-panel tab. */
contained?: boolean;
/** The thing being shown in fullscreen <img>, <embed>, etc.
* Caller is responsible for sizing it to fit the viewport (we
* give it max-w-full max-h-full via CSS). */
children: ReactNode;
}
export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false, children }: Props) {
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -94,19 +90,12 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false
if (!open) return null;
const rootClass = contained
? "absolute inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
: "fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity";
const contentClass = contained
? "h-full w-full p-3 flex items-center justify-center"
: "max-w-[95vw] max-h-[90vh] flex items-center justify-center";
return (
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className={rootClass}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
onClick={onBackdropClick}
>
{/* Close button top-right, large hit area, keyboard-focusable.
@@ -123,7 +112,7 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false
</svg>
</button>
<div
className={contentClass}
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
{children}
@@ -19,8 +19,8 @@
// suppress the toolbar; we keep it on so the user gets standard
// PDF affordances.
//
// Preview: AttachmentLightbox hosts the PDF inside the active chat tab
// on click. Same shared modal as image — third caller justifies the
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
// click. Same shared modal as image — third caller justifies the
// abstraction (per RFC #2991 design).
//
// Failure modes:
@@ -144,15 +144,16 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
open={open}
onClose={() => setOpen(false)}
ariaLabel={`Preview of ${attachment.name}`}
contained
>
<div className="h-full w-full overflow-hidden rounded-lg border border-white/20 bg-white shadow-2xl">
<iframe
src={`${state.blobUrl}#view=FitH`}
title={attachment.name}
className="h-full w-full bg-white"
/>
</div>
<embed
src={state.blobUrl}
type="application/pdf"
// The lightbox's content slot caps at 95vw / 90vh, so size
// 100% within that and let the user scroll inside the PDF
// viewer.
style={{ width: "95vw", height: "90vh" }}
aria-label={attachment.name}
/>
</AttachmentLightbox>
</>
);
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/**
* AttachmentLightbox modal for image / PDF preview.
* AttachmentLightbox fullscreen modal for image / PDF preview.
*
* Owns: backdrop + viewport, Esc to close, click-outside to close,
* focus trap (close button focus on open, restore on close),
@@ -135,22 +135,6 @@ describe("AttachmentLightbox — render", () => {
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
expect(closeBtn).toBeTruthy();
});
it("uses absolute positioning when contained=true", () => {
render(
<AttachmentLightbox
open={true}
onClose={vi.fn()}
ariaLabel="Preview"
contained
>
<MockContent />
</AttachmentLightbox>,
);
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.className).toContain("absolute");
expect(dialog?.className).not.toContain("fixed");
});
});
// ─── Focus management ─────────────────────────────────────────────────────────
@@ -1,19 +1,19 @@
// @vitest-environment jsdom
/**
* AttachmentPDF inline PDF preview button + click-to-panel lightbox.
* AttachmentPDF inline PDF preview button + click-to-fullscreen lightbox.
*
* Per RFC #2991 PR-3: platform-auth URIs fetch bytes Blob ObjectURL;
* external URIs use the raw URL directly. State machine: idle loading
* ready/error. Loading skeleton shown while fetching. Error falls back to
* AttachmentChip. Clicking the preview button opens AttachmentLightbox with
* a browser PDF iframe. Blob URL cleaned up on unmount.
* <embed>. Blob URL cleaned up on unmount.
*
* NOTE: No @testing-library/jest-dom import use DOM APIs for assertions.
*
* Covers:
* - Renders loading skeleton with PdfGlyph + filename text
* - Renders preview button with PDF glyph, filename, and "PDF" label
* - Opens lightbox with a framed <iframe> viewer on button click
* - Opens lightbox with <embed> on button click
* - Lightbox closes on Escape
* - tone=user applies blue/accent classes on button
* - tone=agent applies neutral border on button
@@ -136,7 +136,7 @@ describe("AttachmentPDF — ready", () => {
expect(btn?.textContent).toContain("PDF");
});
it("opens lightbox with a framed iframe viewer on button click", async () => {
it("opens lightbox with <embed> on button click", async () => {
mockFetchOk("data");
const att = makeAttachment("report.pdf");
render(
@@ -158,13 +158,8 @@ describe("AttachmentPDF — ready", () => {
});
const dialog = document.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-label")).toContain("report.pdf");
expect(dialog?.className).toContain("absolute");
const frame = dialog?.querySelector("iframe") as HTMLIFrameElement | null;
expect(frame).toBeTruthy();
expect(frame?.getAttribute("title")).toBe("report.pdf");
expect(frame?.className).toContain("bg-white");
expect(frame?.parentElement?.className).toContain("w-full");
expect(dialog?.querySelector("embed")).toBeNull();
// Lightbox contains an <embed>
expect(dialog?.querySelector("embed")).toBeTruthy();
});
it("closes lightbox on Escape key", async () => {
@@ -237,13 +237,11 @@ describe("AttachmentPreview dispatch", () => {
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
});
// Click → panel-contained lightbox opens with a browser PDF iframe.
// Click → lightbox opens with <embed> inside.
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.className).toContain("absolute");
expect(dialog.querySelector("iframe")).not.toBeNull();
expect(dialog.querySelector("embed")).toBeNull();
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
});
it("kind=pdf fetch fails → falls back to chip", async () => {
@@ -113,31 +113,6 @@ describe("resolveAttachmentHref — platform-pending: scheme (poll-mode uploads)
});
});
describe("resolveAttachmentHref — legacy platform content URLs", () => {
const chatWs = "chat-ws-aaaaaaaa";
const sourceWs = "d76977b1-d620-4f42-a57e-111111111111";
const fileID = "e2dfaf2e-1111-4abc-9999-222222222222";
it("rewrites /workspaces/<ws>/content/<file>/content to the authenticated pending-upload endpoint", () => {
const url = resolveAttachmentHref(
chatWs,
`/workspaces/${sourceWs}/content/${fileID}/content`,
);
expect(url).toContain(`/workspaces/${sourceWs}/pending-uploads/${fileID}/content`);
expect(url).not.toContain(`/workspaces/${chatWs}/`);
});
it("treats legacy content URLs as platform attachments so previews fetch with auth headers", () => {
expect(isPlatformAttachment(`/workspaces/${sourceWs}/content/${fileID}/content`)).toBe(true);
});
it("passes malformed legacy content URLs through unchanged", () => {
const malformed = `/workspaces/${sourceWs}/content//content`;
expect(resolveAttachmentHref(chatWs, malformed)).toBe(malformed);
expect(isPlatformAttachment(malformed)).toBe(false);
});
});
describe("isPlatformAttachment", () => {
it("returns true for platform-pending: URIs", () => {
expect(isPlatformAttachment("platform-pending:abc/file")).toBe(true);
@@ -125,8 +125,6 @@ export async function uploadChatFiles(
* - `/workspace/...` (bare absolute path inside the container)
* - `platform-pending:<wsid>/<file_id>` (poll-mode upload, staged
* on platform side; resolves to /pending-uploads/<file_id>/content)
* - `/workspaces/<wsid>/content/<file_id>/content` (legacy platform
* content URL; normalizes to the same pending-upload endpoint)
* Everything that looks like an allowed-root container path is
* rewritten to the authenticated /chat/download endpoint. HTTP(S)
* URIs pass through unchanged so we can also render links to
@@ -165,11 +163,6 @@ export function resolveAttachmentHref(
}
return uri;
}
const legacy = parseLegacyPlatformContentUri(uri);
if (legacy) {
const [wsid, fileID] = legacy;
return `${PLATFORM_URL}/workspaces/${encodeURIComponent(wsid)}/pending-uploads/${encodeURIComponent(fileID)}/content`;
}
const containerPath = normalizeWorkspaceUri(uri);
if (containerPath) {
return `${PLATFORM_URL}/workspaces/${workspaceId}/chat/download?path=${encodeURIComponent(containerPath)}`;
@@ -182,7 +175,6 @@ export function resolveAttachmentHref(
* downloadChatFile rather than letting the browser navigate. */
export function isPlatformAttachment(uri: string): boolean {
if (uri.startsWith("platform-pending:")) return true;
if (parseLegacyPlatformContentUri(uri)) return true;
return normalizeWorkspaceUri(uri) !== null;
}
@@ -191,12 +183,6 @@ export function isPlatformAttachment(uri: string): boolean {
* mirror the server's `allowedRoots` allowlist. */
const ALLOWED_CONTAINER_ROOTS = ["/configs", "/workspace", "/home", "/plugins"];
function parseLegacyPlatformContentUri(uri: string): [string, string] | null {
const m = uri.match(/^\/workspaces\/([^/]+)\/content\/([^/]+)\/content(?:[?#].*)?$/);
if (!m || !m[1] || !m[2]) return null;
return [m[1], m[2]];
}
function normalizeWorkspaceUri(uri: string): string | null {
let path: string | null = null;
if (uri.startsWith("workspace:")) {
@@ -63,7 +63,6 @@ vi.mock("@/components/MissingKeysModal", () => ({
onKeysAdded: (model?: string) => void;
onCancel: () => void;
configuredKeys?: Set<string>;
optionalKeys?: string[];
modelSuggestions?: string[];
initialModel?: string;
title?: string;
@@ -78,9 +77,6 @@ vi.mock("@/components/MissingKeysModal", () => ({
</span>
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
<span data-testid="modal-title">{props.title ?? ""}</span>
<span data-testid="modal-optional-keys">
{(props.optionalKeys ?? []).join(",")}
</span>
<button
data-testid="modal-keys-added"
onClick={() => props.onKeysAdded()}
@@ -117,7 +113,6 @@ function makeTemplate(over: Partial<Template> = {}): Template {
runtime: "claude-code",
models: [],
required_env: [],
recommended_env: [],
...over,
};
}
@@ -134,7 +129,6 @@ beforeEach(() => {
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
mockApiPost.mockResolvedValue({ id: "ws-new" });
@@ -149,30 +143,46 @@ afterEach(() => {
// ── Tests ────────────────────────────────────────────────────────────────────
/**
* Drive the always-show-picker flow to completion: deploy() opens the
* modal, then we click "keys added" to fire the actual POST. Centralised
* here because as of the always-prompt change, every happy-path test
* must click through the modal before asserting on POST.
*/
async function deployThroughPicker<T>(
result: { current: ReturnType<typeof useTemplateDeploy> },
rerender: () => void,
template: Template,
): Promise<void> {
await act(async () => {
await result.current.deploy(template);
});
rerender();
render(<>{result.current.modal}</>);
await act(async () => {
fireEvent.click(screen.getByTestId("modal-keys-added"));
// Let the fire-and-forget executeDeploy resolve.
await Promise.resolve();
await Promise.resolve();
});
}
describe("useTemplateDeploy — happy path", () => {
it("preflight ok with no key requirements → POST /workspaces directly → onDeployed fires", async () => {
it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
model: "MiniMax-M2.7",
}));
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
name: "SEO Agent",
template: "seo-agent",
name: "Claude Code",
template: "claude-code-default",
tier: 1,
model: "MiniMax-M2.7",
llm_provider: "minimax",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
@@ -182,13 +192,11 @@ describe("useTemplateDeploy — happy path", () => {
it("uses caller-supplied canvasCoords when provided", async () => {
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ canvasCoords }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(canvasCoords).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
@@ -198,11 +206,9 @@ describe("useTemplateDeploy — happy path", () => {
});
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
const { result } = renderHook(() => useTemplateDeploy());
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
canvas: { x: number; y: number };
@@ -249,7 +255,6 @@ describe("useTemplateDeploy — preflight failure modes", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -278,7 +283,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -314,7 +318,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -368,7 +371,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -402,7 +404,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -431,7 +432,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -458,9 +458,16 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
);
});
it("template with no provider requirements deploys directly on platform-managed defaults", async () => {
it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
// Default preflight mock: ok=true, providers=[]. claude-code is
// single-provider, but the always-prompt rule means the user must
// still click through the picker to confirm provider+model — even
// when keys are saved and the runtime has only one provider option.
// Reason: the user needs an explicit chance to override the
// template's default model (e.g. opus vs sonnet vs haiku) before
// an EC2 boots and burns billing on the wrong tier.
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
@@ -468,18 +475,13 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
await result.current.deploy(makeTemplate());
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
template: "claude-code-default",
model: "claude-sonnet-4-5",
llm_provider: "anthropic",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
// POST does NOT fire until the user confirms in the picker.
expect(mockApiPost).not.toHaveBeenCalled();
expect(onDeployed).not.toHaveBeenCalled();
expect(result.current.deploying).toBeNull();
});
@@ -496,7 +498,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -512,48 +513,17 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
expect(mockApiPost).not.toHaveBeenCalled();
});
it("opens configure modal for optional env prompts even when no required provider key is missing", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: true,
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
}));
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
expect(screen.getByTestId("modal-optional-keys").textContent).toBe(
"GOOGLE_GSC_SITE,GOOGLE_GA4_PROPERTY_ID",
);
expect(mockApiPost).not.toHaveBeenCalled();
});
});
describe("useTemplateDeploy — POST failure", () => {
it("POST rejection sets error and clears deploying", async () => {
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(result.current.error).toBe("server 500");
expect(result.current.deploying).toBeNull();
@@ -562,11 +532,9 @@ describe("useTemplateDeploy — POST failure", () => {
it("non-Error rejection still surfaces a message (defensive)", async () => {
mockApiPost.mockRejectedValueOnce("plain string");
const { result } = renderHook(() => useTemplateDeploy());
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(result.current.error).toBe("Deploy failed");
expect(result.current.deploying).toBeNull();
@@ -15,8 +15,6 @@ export function useKeyboardShortcut(
if (!enabled) return;
function handler(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.closest?.('[data-display-stream="true"]')) return;
if (e.key !== key) return;
if (meta && !e.metaKey) return;
if (ctrl && !e.ctrlKey) return;
+4 -36
View File
@@ -55,22 +55,6 @@ interface MissingKeysInfo {
preflight: PreflightResult;
}
function nativeProviderForClaudeCodeModel(model: string): string | undefined {
const trimmed = model.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return undefined;
if (lower.startsWith("minimax")) return "minimax";
if (lower.startsWith("kimi")) return "kimi-coding";
if (lower.startsWith("claude")) return "anthropic";
if (/^(sonnet|opus|haiku)$/.test(lower)) return "anthropic-oauth";
return undefined;
}
function isNativeClaudeCodeRuntime(template: Template): boolean {
const runtime = template.runtime ?? resolveRuntime(template.id);
return runtime === "claude-code";
}
export interface UseTemplateDeployResult {
/** Template id currently being deployed (incl. the preflight
* network call), or null when idle. Callers pass this to disable
@@ -113,10 +97,6 @@ export function useTemplateDeploy(
setDeploying(template.id);
setError(null);
try {
const selectedModel = model?.trim() || template.model?.trim();
const nativeProvider = isNativeClaudeCodeRuntime(template) && selectedModel
? nativeProviderForClaudeCodeModel(selectedModel)
: undefined;
const coords = canvasCoords
? canvasCoords()
: {
@@ -128,8 +108,7 @@ export function useTemplateDeploy(
template: template.id,
tier: isSaaSTenant() ? 4 : template.tier,
canvas: coords,
...(selectedModel ? { model: selectedModel } : {}),
...(nativeProvider ? { llm_provider: nativeProvider } : {}),
...(model ? { model } : {}),
});
onDeployed?.(ws.id);
} catch (e) {
@@ -152,7 +131,6 @@ export function useTemplateDeploy(
runtime,
models: template.models,
required_env: template.required_env,
recommended_env: template.recommended_env,
});
} catch (e) {
// Preflight network failure used to strand `deploying` — the
@@ -166,17 +144,8 @@ export function useTemplateDeploy(
setDeploying(null);
return;
}
if (
preflight.ok &&
preflight.providers.length === 0 &&
preflight.optionalKeys.length === 0
) {
await executeDeploy(template);
return;
}
// Open the picker whenever a template declares provider/key choices.
// Templates with no provider requirements deploy directly on the
// platform-managed default above. Reasons to keep the picker here:
// Always open the picker — every deploy goes through an
// explicit confirm-provider/model step. Reasons:
// 1. Multi-provider templates (e.g. hermes) need a per-
// workspace pick or the adapter falls back to its
// compiled-in default and 500s with "No LLM provider
@@ -195,7 +164,7 @@ export function useTemplateDeploy(
setMissingKeysInfo({ template, preflight });
setDeploying(null);
},
[executeDeploy],
[],
);
// No useCallback here — consumers call this on every render anyway
@@ -225,7 +194,6 @@ export function useTemplateDeploy(
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
optionalKeys={missingKeysInfo?.preflight.optionalKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
configuredKeys={missingKeysInfo?.preflight.configuredKeys}
@@ -32,16 +32,11 @@ const hermesModels: ModelSpec[] = [
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
const CLAUDE_CODE: TemplateLike = {
runtime: "claude-code",
const LANGGRAPH: TemplateLike = {
runtime: "langgraph",
required_env: ["OPENAI_API_KEY"],
};
const OPTIONAL_ONLY: TemplateLike = {
runtime: "claude-code",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
@@ -74,7 +69,7 @@ describe("providersFromTemplate", () => {
});
it("falls back to top-level required_env when no models[] are declared", () => {
const providers = providersFromTemplate(CLAUDE_CODE);
const providers = providersFromTemplate(LANGGRAPH);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
});
@@ -156,11 +151,10 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([]);
expect(result.runtime).toBe("claude-code");
expect(result.runtime).toBe("langgraph");
});
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
@@ -190,7 +184,6 @@ describe("checkDeploySecrets", () => {
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
expect(result.optionalKeys).toEqual([]);
});
it("treats has_value=false as not-configured", async () => {
@@ -202,7 +195,7 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
@@ -214,22 +207,6 @@ describe("checkDeploySecrets", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("prompts optional-only env without treating it as missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(OPTIONAL_ONLY);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([
"GOOGLE_GSC_SITE",
"GOOGLE_GA4_PROPERTY_ID",
]);
expect(global.fetch).toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
@@ -239,7 +216,7 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
await checkDeploySecrets(CLAUDE_CODE, "ws-123");
await checkDeploySecrets(LANGGRAPH, "ws-123");
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/workspaces/ws-123/secrets"),
expect.any(Object),
@@ -252,7 +229,7 @@ describe("checkDeploySecrets", () => {
json: () => Promise.resolve([]),
} as Response);
await checkDeploySecrets(CLAUDE_CODE);
await checkDeploySecrets(LANGGRAPH);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/settings/secrets"),
expect.any(Object),
@@ -264,10 +241,9 @@ describe("checkDeploySecrets", () => {
new Error("Network error"),
);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.optionalKeys).toEqual([]);
// Empty Set on fetch failure — useTemplateDeploy relies on this
// so the picker still opens with every entry rendered as input.
expect(result.configuredKeys).toEqual(new Set());
@@ -28,8 +28,8 @@ describe("isExternalLikeRuntime", () => {
"docker",
"local",
"agent",
"legacy-runtime",
"codex",
"crewai",
"langgraph",
"openclaw",
"custom-runtime",
])("%q returns false", (runtime) => {
@@ -8,7 +8,7 @@
* count bounded.
*/
import { describe, it, expect } from "vitest";
import { isUserVisibleWorkspaceTemplate, resolveRuntime } from "../deploy-preflight";
import { resolveRuntime } from "../deploy-preflight";
describe("resolveRuntime", () => {
describe("explicit runtime-map entries", () => {
@@ -64,15 +64,3 @@ describe("resolveRuntime", () => {
});
});
});
describe("isUserVisibleWorkspaceTemplate", () => {
it("hides runtime-default templates from product template surfaces", () => {
for (const id of ["claude-code-default", "codex", "hermes", "openclaw"]) {
expect(isUserVisibleWorkspaceTemplate({ id })).toBe(false);
}
});
it("keeps product templates visible", () => {
expect(isUserVisibleWorkspaceTemplate({ id: "seo-agent" })).toBe(true);
});
});
@@ -68,7 +68,8 @@ describe("provisionTimeoutForRuntime", () => {
});
it("returns 120_000 for any unknown runtime", () => {
expect(provisionTimeoutForRuntime("legacy-runtime")).toBe(120_000);
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
});
@@ -76,7 +77,7 @@ describe("provisionTimeoutForRuntime", () => {
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
[undefined, undefined],
["claude-code", undefined],
["claude-code", { provisionTimeoutMs: 500_000 }],
["langgraph", { provisionTimeoutMs: 500_000 }],
[undefined, { provisionTimeoutMs: 45_000 }],
];
for (const [runtime, overrides] of cases) {
-2
View File
@@ -23,7 +23,6 @@ const DEFAULT_TIMEOUT_MS = 35_000;
export interface RequestOptions {
timeoutMs?: number;
headers?: Record<string, string>;
}
/**
@@ -77,7 +76,6 @@ async function request<T>(
const headers: Record<string, string> = {
"Content-Type": "application/json",
...platformAuthHeaders(),
...(options?.headers ?? {}),
};
// Re-read slug locally for the 401 handler below — `headers` already
// has it, but the 401 branch needs the bare value to gate the
+1 -1
View File
@@ -44,7 +44,7 @@ export const plans: Plan[] = [
price: "$0",
features: [
"3 workspaces",
"Claude Code, Codex, Hermes, OpenClaw runtimes",
"Claude Code, LangGraph, OpenClaw runtimes",
"Shared Redis + bounded storage",
"Community support",
],
+2 -22
View File
@@ -21,7 +21,6 @@ import { api } from "./api";
export interface ModelSpec {
id: string;
name?: string;
provider?: string;
required_env?: string[];
}
@@ -32,8 +31,6 @@ export interface TemplateLike {
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
/** Optional env vars declared at runtime_config level. */
recommended_env?: string[];
}
/** Full /templates response shape shared by TemplatePalette (sidebar)
@@ -52,17 +49,6 @@ export interface Template extends TemplateLike {
skill_count: number;
}
const RUNTIME_DEFAULT_TEMPLATE_IDS = new Set([
"claude-code-default",
"codex",
"hermes",
"openclaw",
]);
export function isUserVisibleWorkspaceTemplate(template: Pick<Template, "id">): boolean {
return !RUNTIME_DEFAULT_TEMPLATE_IDS.has(template.id);
}
/** Map from a template id to the runtime name the per-workspace
* preflight expects. Used only when the server's `/templates`
* response predates the `runtime` field on the summary (legacy
@@ -98,8 +84,6 @@ export interface PreflightResult {
/** Flat list of env var names needed for the legacy modal path and
* for callers that want a single display of "what's missing". */
missingKeys: string[];
/** Optional env vars to offer in the modal without blocking deploy. */
optionalKeys: string[];
/** Grouped provider options derived from the template. When length 2
* the modal renders a picker; length 1 means exactly one provider is
* required (AllKeysModal renders the N envVars inline). */
@@ -252,14 +236,12 @@ export async function checkDeploySecrets(
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
const optionalKeys = Array.from(new Set(template.recommended_env ?? []));
if (providers.length === 0 && optionalKeys.length === 0) {
if (providers.length === 0) {
// Template declares no env requirements — nothing to preflight.
return {
ok: true,
missingKeys: [],
optionalKeys: [],
providers: [],
runtime,
configuredKeys: new Set(),
@@ -281,11 +263,10 @@ export async function checkDeploySecrets(
configured = new Set();
}
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
if (findSatisfiedProvider(providers, configured)) {
return {
ok: true,
missingKeys: [],
optionalKeys,
providers,
runtime,
configuredKeys: configured,
@@ -300,7 +281,6 @@ export async function checkDeploySecrets(
return {
ok: false,
missingKeys,
optionalKeys,
providers,
runtime,
configuredKeys: configured,
+1 -4
View File
@@ -12,9 +12,7 @@ import type { NextRequest } from "next/server";
* style-src retains 'unsafe-inline': React Flow positions nodes via
* element-level style="" attributes which cannot be nonce'd; CSS injection
* is significantly lower risk than script injection and is acceptable here.
* object-src locked to 'none'; frame-src allows self + blob: for
* browser-native PDF previews backed by authenticated Blob URLs.
* base-uri / frame-ancestors locked to 'self'/'none'.
* object-src / base-uri / frame-ancestors locked to 'none'/'self'.
* upgrade-insecure-requests forces HTTPS on mixed-content.
*
* Development permissive policy:
@@ -63,7 +61,6 @@ export function buildCsp(nonce: string, isDev: boolean): string {
"img-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"frame-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
+1 -4
View File
@@ -337,11 +337,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
batchDelete: async () => {
const ids = Array.from(get().selectedNodeIds);
const names = new Map(get().nodes.map((node) => [node.id, node.data.name]));
const results = await Promise.allSettled(
ids.map((id) => api.del(`/workspaces/${id}`, {
headers: { "X-Confirm-Name": names.get(id) ?? "" },
}))
ids.map((id) => api.del(`/workspaces/${id}`))
);
const failed: string[] = [];
results.forEach((r, i) => {
-2
View File
@@ -4,8 +4,6 @@ declare module "@novnc/novnc" {
resizeSession: boolean;
focusOnClick: boolean;
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
clipboardPasteFrom(text: string): void;
disconnect(): void;
focus(options?: FocusOptions): void;
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
|--------|------|---------|
| GET | /health | inline |
| GET | /metrics | metrics.Handler() — Prometheus text format; no auth, scrape-safe |
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `DELETE /workspaces/:id` also requires `X-Confirm-Name: <workspace name>`; cascading deletes still require `?confirm=true`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
| GET/PATCH | /workspaces/:id/config | workspace.go |
| GET/POST | /workspaces/:id/memory | workspace.go |
| DELETE | /workspaces/:id/memory/:key | workspace.go |
-2
View File
@@ -6,8 +6,6 @@ Molecule AI's memory model is built around one principle:
That is the purpose of **HMA: Hierarchical Memory Architecture**.
The organizational boundary is enforced **physically**, not at the application layer: each org runs as its own tenant on its own EC2, with its own memory plugin sidecar and its own Postgres. Memory writes are loopback-only — never cross-tenant. See [`workspace-placement.md`](workspace-placement.md) for the architecture contract that makes HMA tenant-isolated by construction.
## The Three Scopes
| Scope | Meaning | Intended use |
@@ -84,8 +84,6 @@ Six runtime adapters ship production-ready on `main`: LangGraph, DeepAgents, Cla
## 3. System Architecture
> **Workspace placement contract:** every Molecule org runs as a fully isolated tenant on its own EC2, with workspace-server, memory plugin, Postgres, and Redis all co-located. The platform (controlplane on Railway) handles provisioning, billing, and DNS only — it never holds tenant data. See [`workspace-placement.md`](workspace-placement.md) for the formal RFC.
### System Boundary Diagram
```
-194
View File
@@ -1,194 +0,0 @@
# Workspace placement — org-per-EC2 architecture
Status: Accepted (implicit since 2026-05; formalized 2026-05-24)
Owners: hongming (CTO), cui (CEO)
Tracking: #1793
This RFC formalizes the architecture decision that has been implicit in the system since the post-suspension rebuild: **each Molecule AI org is one isolated tenant on its own EC2 instance**, with every functional surface (workspace-server, memory plugin, Postgres, Redis, canvas) co-located on that instance. The platform's role is provisioning, billing, and the cross-tenant control plane — never the data path.
The implementation already follows this pattern in every direction we look (provisioner, memory v2 cutover, tenant entrypoint, controlplane user-data, even the OSS deploy story). Writing it down so it stays that way.
## TL;DR
```
┌──────────────────────────────────┐
│ Platform (controlplane) │
│ Railway-hosted │
│ api.moleculesai.app │
│ │
│ - org provisioning │
│ - billing + Stripe integration │
│ - DNS + tunnel orchestration │
│ - auth / org-token issuance │
│ - fleet redeploy orchestration │
│ │
│ NEVER holds tenant data │
└──────────────────────────────────┘
│ │
provision │ │ provision
+ billing │ │ + billing
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Tenant: agents-team │ │ Tenant: <other-org> │
│ Own EC2 (us-east-2) │ │ Own EC2 (us-east-2) │
│ agents-team.molecule.. │ │ <slug>.moleculesai.app │
│ │ │ │
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
│ │ molecule-tenant │ │ │ │ molecule-tenant │ │
│ │ (workspace-server │ │ │ │ (workspace-server │ │
│ │ + canvas + go) │ │ │ │ + canvas + go) │ │
│ └───────────────────┘ │ │ └───────────────────┘ │
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
│ │ memory-plugin │ │ │ │ memory-plugin │ │
│ │ (loopback :9100) │ │ │ │ (loopback :9100) │ │
│ └───────────────────┘ │ │ └───────────────────┘ │
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
│ │ postgres pgvector │ │ │ │ postgres pgvector │ │
│ │ (172.17.0.1:5432) │ │ │ │ (172.17.0.1:5432) │ │
│ └───────────────────┘ │ │ └───────────────────┘ │
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
│ │ redis │ │ │ │ redis │ │
│ └───────────────────┘ │ │ └───────────────────┘ │
│ ┌───────────────────┐ │ │ ┌───────────────────┐ │
│ │ workspace runtime │ │ │ │ workspace runtime │ │
│ │ containers (ws-*) │ │ │ │ containers (ws-*) │ │
│ └───────────────────┘ │ │ └───────────────────┘ │
└─────────────────────────┘ └─────────────────────────┘
```
Every tenant is a self-contained molecule-core instance. The platform is a thin coordinator above them.
## What crosses the platform/tenant boundary
What the platform sends down to the tenant:
- Initial EC2 provisioning (user-data script via SSM) — see `molecule-controlplane/internal/provisioner/ec2.go`
- Per-tenant secrets (DB password, `SECRETS_ENCRYPTION_KEY`, `MOLECULE_CP_SHARED_SECRET`) injected as env at boot
- Image redeploys via `POST /cp/admin/tenants/:slug/redeploy` → SSM → `docker pull && docker stop && docker run`
- DNS records (Cloudflare) and tunnel registration (cloudflared)
- Billing-state changes (subscription status, plan upgrades)
What the tenant sends up to the platform:
- Boot-stage telemetry (`report_stage` calls during EC2 user-data execution)
- LLM usage events (for billing aggregation; documented in `controlplane/migrations/037_llm_usage_billing.up.sql`)
- Workspace lifecycle events for cross-tenant analytics — read-only, no remote control implied
What does NOT cross the boundary:
- Memory contents (HMA scopes, agent_memories before A3, memory_plugin records after)
- Workspace state, files, canvas layouts
- Workspace runtime container state
- Per-org user authentication state (tenant issues its own session tokens via `wsauth`)
If a feature design wants to put any of those on the platform side, that's a violation of this RFC and needs explicit justification.
## SSOT rationale
The single-source-of-truth boundary is **the tenant EC2**.
This decision was the implicit basis for the memory v1→v2 migration that ran 2026-05-24 (issues #1747#1791#1792). The v2 memory plugin runs as a sidecar on each tenant EC2, sharing the tenant's Postgres under a dedicated `memory_plugin` schema. There is no platform-side memory aggregation, no central index, no cross-tenant memory federation. Memory writes are loopback-only (workspace-server → memory-plugin on `127.0.0.1:9100`).
Why this is correct:
1. **Organizational isolation is the product.** A tenant's memory, workspaces, secrets, and conversation history must not be readable by another org, ever. The simplest enforcement is physical: different EC2, different DB, different network. Application-level multi-tenancy adds a class of cross-tenant data leak bugs that can't happen here.
2. **The platform must remain horizontally scalable independent of tenant data volume.** If memory aggregation lived on the platform, billing/provisioning/auth would scale with the volume of memory across all tenants. With per-tenant storage, the platform's scaling envelope depends only on the number of orgs.
3. **OSS-deployability requires it.** molecule-core is open-source; anyone can deploy it. If functional state lived on a centralized platform, OSS deployers would either have to run their own platform (high barrier) or call ours (privacy concern + scale concern). Per-tenant SSOT means the OSS molecule-core instance is functionally complete — it just talks to a platform for billing.
## OSS-deployment shape
A workspace inside any tenant reaches its parent tenant by injecting two env vars at container start:
- `MOLECULE_ORG_ID` — the UUID of the org this workspace belongs to
- `MOLECULE_PLATFORM_URL` — the tenant's HTTPS URL (e.g. `https://agents-team.moleculesai.app`)
These are baked into the workspace runtime's docker run by the workspace-server when it provisions a workspace. The workspace's agent runtime uses them to:
- Register itself in the tenant's `workspaces` table
- Send heartbeats (Redis TTL key on the tenant)
- Subscribe to A2A messages via the tenant's WebSocket hub
- Commit memories via the tenant's MCP bridge or HTTP `/memories` endpoints
An OSS deployer running their own molecule-core instance gets the same shape: their workspaces inject the deployer's tenant URL and org ID. The agent runtime is **agnostic** to whether it's talking to our hosted platform or a self-hosted one.
The only thing tying a tenant to **our** platform is the billing/auth path:
- `MOLECULE_CP_URL` env on the tenant container points at `api.moleculesai.app`
- `MOLECULE_CP_SHARED_SECRET` env authenticates the tenant→platform direction
- LLM usage events POST to `cp_url/cp/llm-usage-events` for billing aggregation
An OSS deployer can leave `MOLECULE_CP_URL` unset (or point at their own platform). The workspace-server's `wiring.go` and `cp_provisioner.go` already handle the absent-CP case gracefully — the tenant is fully functional without it.
## Scaling envelope
Per-tenant resource shape (current):
| Layer | Sizing |
|---|---|
| EC2 | t3.medium (2 vCPU, 4 GiB) for default-tier orgs |
| Postgres | Single container, pgvector pre-installed, ~1-10 GiB per org expected |
| Memory plugin | Loopback only, ~50 MB resident, scales with memory record count |
| Workspace runtime containers (ws-\*) | One per workspace; sized by template tier |
The platform's scaling envelope:
| Layer | Sizing |
|---|---|
| controlplane | Single Railway service, scales horizontally |
| Postgres | One Railway-hosted Postgres for billing + org registry + auth tokens |
| DNS | Cloudflare zone with one CNAME per tenant |
| Tunnels | One Cloudflare tunnel per tenant |
Order-of-magnitude:
- 100 orgs: trivial (100 EC2s, controlplane unchanged)
- 10K orgs: needs an EC2 placement strategy (region pinning, dedicated-tier hosts), but the platform is still a single service
- 1M orgs: this design starts to strain — Cloudflare tunnel-per-tenant becomes expensive, EC2-per-tenant becomes resource-wasteful, and we'd want a denser tenant-on-shared-infra mode
The current architecture is sized for the 10010K range. The 1M-org variant is explicitly out of scope for this RFC.
## Decision points for new feature design
When proposing a new feature, the design must answer "where does the data live?" Pick one:
1. **On the tenant.** Default choice for anything functional. Tenant DB, tenant memory plugin, tenant filesystem. The feature ships in `molecule-core` and is deployed via the tenant image.
2. **On the platform.** ONLY for billing, cross-org analytics (anonymized), org registry, auth tokens, DNS/tunnel state. The feature ships in `molecule-controlplane`.
3. **Both, with one as SSOT.** Rare. The tenant is the SSOT; the platform may cache for cross-tenant queries but must be willing to re-read from the tenant on miss. Document the cache invalidation contract.
When in doubt, default to #1. If you find yourself wanting to put HMA memory, workspace state, or session history on the platform, stop — you're re-introducing the SSOT violation the v1→v2 memory migration was designed to remove.
## Migration path for non-conforming code
The implementation already conforms. There is no migration backlog as of 2026-05-24:
- Memory: v1→v2 migration complete (#1747#1791#1792). v2 plugin per-tenant is SSOT.
- Workspace state: always per-tenant (the `workspaces` table lives in the tenant Postgres).
- Activity logs: per-tenant `activity_logs` table.
- Files: per-tenant (Docker volumes attached to ws-\* containers).
- Secrets: per-tenant (`workspace_secrets` + `global_secrets` tables in tenant DB).
- LLM usage events: tenant emits, platform aggregates for billing — correct shape.
If a future PR proposes platform-side aggregation of something functional, link this RFC in the review.
## What this RFC does NOT cover
Out of scope for this document; tracked separately if needed:
- **Multi-region tenant placement** — current design is single-region (us-east-2). Multi-region needs its own RFC because it changes the EC2 placement contract.
- **BYO-compute / customer-managed VPC** — adjacent design; the org-per-EC2 boundary holds but the EC2 ownership shifts to the customer.
- **Workspace runtime selection** — separately documented in `docs/architecture/workspace-tiers.md`.
- **Tenant image upgrade strategy** — separately documented in `docs/architecture/tenant-image-upgrades.md`.
- **OSS billing alternatives** — how OSS deployers handle billing without our controlplane is a separate go-to-market decision.
## References
- `docs/architecture/memory.md` — HMA scopes + v2 plugin
- `docs/architecture/saas-prod-migration-2026-04-19.md` — provisioning pipeline reference
- `docs/architecture/molecule-technical-doc.md` §3 (System Architecture) — top-level picture
- `molecule-controlplane/internal/provisioner/ec2.go` — the canonical user-data + docker run for tenants
- `workspace-server/entrypoint-tenant.sh` — the canonical tenant boot script
- Memory system migration: #1747 (kill v1 fallback), #1791 (Phase A2 backfill), #1792 (Phase A3 drop table)
+2 -2
View File
@@ -19,8 +19,8 @@ import (
"context"
"testing"
mclient "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/client"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/memory/contract"
mclient "github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/client"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/memory/contract"
)
func TestMyPlugin_FullRoundTrip(t *testing.T) {
@@ -70,7 +70,7 @@ def test_diag_memory_root_writable_in_canary_mode(sim: CPSim) -> None:
key = f"canary-probe-{uuid.uuid4().hex[:8]}"
try:
val = sim.probe_memory(key)
except Exception:
except Exception as e:
# /mcp may not be exposed on this template — canary 4 will
# surface the real defect if memory is actually broken.
if os.environ.get("CANARY_STRICT_MCP") == "1":
+3 -3
View File
@@ -1,5 +1,5 @@
{
"_comment": "Platform template registry. Repos may be public or platform-private; CI and runtime template-cache refresh clone them with the SSOT-managed template read token, then strip .git metadata before use. Customer/private tenant templates remain outside this platform manifest. 'main' refs are pinned to tags before broad rollout.",
"_comment": "OSS surface registry — every repo listed here MUST be public on git.moleculesai.app. Layer-3 customer/private templates are NOT registered here; they are handled at provision-time via the per-tenant credential resolver (see internal#102 RFC). 'main' refs are pinned to tags before broad rollout.",
"version": 1,
"plugins": [
{"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"},
@@ -28,8 +28,7 @@
{"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"},
{"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"},
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
{"name": "seo-agent", "repo": "molecule-ai/molecule-ai-workspace-template-seo-agent", "ref": "main"}
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
@@ -39,3 +38,4 @@
{"name": "ux-ab-lab", "repo": "molecule-ai/molecule-ai-org-template-ux-ab-lab", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+1 -1
View File
@@ -9,7 +9,7 @@ There are three related scripts; pick the right one:
| Script | Purpose | Targets |
|---|---|---|
| `measure-coordinator-task-bounds.sh` | **Canonical** v1 harness for the RFC #2251 / Issue 4 reproduction. Provisions a PM coordinator + Researcher child via `claude-code-default` + `claude-code` templates, sends a synthesis-heavy A2A kickoff, observes elapsed time + activity trace. | OSS-shape platform — localhost or any `/workspaces`-shaped endpoint. Has tenant/admin-token guards for non-localhost runs. |
| `measure-coordinator-task-bounds.sh` | **Canonical** v1 harness for the RFC #2251 / Issue 4 reproduction. Provisions a PM coordinator + Researcher child via `claude-code-default` + `langgraph` templates, sends a synthesis-heavy A2A kickoff, observes elapsed time + activity trace. | OSS-shape platform — localhost or any `/workspaces`-shaped endpoint. Has tenant/admin-token guards for non-localhost runs. |
| `measure-coordinator-task-bounds-runner.sh` | Generalised runner for the same measurement contract but with **arbitrary template + secret + model combinations** (Hermes/MiniMax, etc.). Useful for cross-runtime variants without modifying the canonical harness. | Same as above (local or SaaS via `MODE=saas`). |
| `measure-coordinator-task-bounds.sh` (in [molecule-controlplane](https://git.moleculesai.app/molecule-ai/molecule-controlplane)) | **Production-shape** variant that bootstraps a real staging tenant via `POST /cp/admin/orgs`, then runs the same measurement against `<slug>.staging.moleculesai.app`. | Staging controlplane only — refuses to run against production. |
+13 -4
View File
@@ -8,10 +8,19 @@
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
#
# Auth (optional):
# Repos in manifest.json may be public or platform-private. CI and
# operator refresh jobs should set MOLECULE_GITEA_TOKEN to the
# SSOT-managed template read token. Anonymous clone still works for
# public entries, but private platform templates depend on the token.
# Post-2026-05-08 (#192): every repo in manifest.json is public on
# git.moleculesai.app. Anonymous clone works for the entire registered
# set. The OSS-surface contract is recorded in manifest.json's _comment
# — Layer-3 customer/private templates (e.g. reno-stars) are NOT in the
# manifest; they are handled at provision-time via the per-tenant
# credential resolver (internal#102 RFC).
#
# MOLECULE_GITEA_TOKEN is therefore optional today. Kept supported for
# two reasons: (a) historical CI configs that still inject
# AUTO_SYNC_TOKEN remain harmless, (b) reserved for the case where a
# private internal-only template is later registered via a ci-readonly
# team grant — review must explicitly sign off on that, since it
# violates the public-OSS-surface contract.
#
# The token (when set) never enters the Docker image: this script runs
# in the trusted CI context BEFORE `docker buildx build`, populates
+1 -1
View File
@@ -91,7 +91,7 @@ Cold-start times on workspace-template images:
|---|---|
| claude-code | ~30-60s |
| openclaw | ~1-2 min |
| claude-code | ~1 min |
| langgraph | ~1 min |
| hermes | **~7 min** (large image) |
If the demo will use `hermes`, provision the demo workspace at least
+5 -1
View File
@@ -86,9 +86,13 @@ esac
# RuntimeImages — keep this list in sync if a runtime is added.
TEMPLATES=(
"claude-code"
"codex"
"hermes"
"openclaw"
"langgraph"
"deepagents"
"crewai"
"autogen"
"gemini-cli"
)
# Pre-flight: required tooling.
@@ -2,7 +2,7 @@
# Standalone runner for Issue 4 reproduction (RFC #2251) — exists alongside
# `measure-coordinator-task-bounds.sh` to support arbitrary template + secret
# combinations without modifying the canonical harness. The canonical harness
# stays focused on its v1 contract (claude-code-default + claude-code + OpenRouter);
# stays focused on its v1 contract (claude-code-default + langgraph + OpenRouter);
# this runner wraps the same workspace-server API calls but takes everything as
# env-var inputs so a Hermes/MiniMax run can share the measurement code path.
#
+2 -2
View File
@@ -196,7 +196,7 @@ Auth: $([ -n "$ADMIN_TOKEN" ] && echo "Bearer ***${ADMIN_TOKEN: -4}" ||
Would provision:
PM (coordinator, tier=2, template=claude-code-default)
Researcher (child, tier=2, template=claude-code-default)
Researcher (child, tier=2, template=langgraph)
Would send synthesis-heavy task: $SYNTHESIS_DEPTH delegations + 600w
synthesis. Coordinator A2A timeout: ${A2A_TIMEOUT}s.
@@ -220,7 +220,7 @@ emit "pm_provisioned" "{\"workspace_id\":\"$PM_ID\"}"
emit "provisioning_child" null
R=$(api -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"Researcher","role":"Returns short research findings","tier":2,"template":"claude-code-default"}')
-d '{"name":"Researcher","role":"Returns short research findings","tier":2,"template":"langgraph"}')
CHILD_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
[ -n "$CHILD_ID" ] || { echo "ERROR: child create failed: $R" >&2; exit 1; }
emit "child_provisioned" "{\"workspace_id\":\"$CHILD_ID\"}"
+2 -2
View File
@@ -281,8 +281,8 @@ def main() -> int:
for prefix, peers in sorted(open_pr_collisions.items()):
peer_str = ", ".join(f"#{p['number']} ({p['headRefName']})" for p in peers)
print(f"::error::migration prefix {prefix:03d} also claimed by open PR(s): {peer_str}")
print("::error::rebase coordination needed — only one PR can land a given prefix; "
"renumber yours or theirs")
print(f"::error::rebase coordination needed — only one PR can land a given prefix; "
f"renumber yours or theirs")
return 1
+8 -8
View File
@@ -47,23 +47,23 @@ echo " Cross-Agent Chat: Agents Talk to Each Other"
echo "============================================"
echo ""
# --- Create 3 agents: PM (Claude Code), Developer (OpenClaw), Researcher (Codex) ---
# --- Create 3 agents: PM (LangGraph), Developer (CrewAI), Researcher (AutoGen) ---
echo "--- Creating 3 agents ---"
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"PM","role":"Project Manager","tier":2,"template":"claude-code-default"}')
-d '{"name":"PM","role":"Project Manager","tier":2,"template":"langgraph"}')
PM=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "PM (Claude Code): $PM"
echo "PM (LangGraph): $PM"
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"Developer","role":"Code implementation","tier":2,"template":"openclaw"}')
-d '{"name":"Developer","role":"Code implementation","tier":2,"template":"crewai"}')
DEV=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Developer (OpenClaw): $DEV"
echo "Developer (CrewAI): $DEV"
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"Researcher","role":"Research and analysis","tier":2,"template":"codex"}')
-d '{"name":"Researcher","role":"Research and analysis","tier":2,"template":"autogen"}')
RES=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Researcher (Codex): $RES"
echo "Researcher (AutoGen): $RES"
# --- Set hierarchy: PM -> Developer, Researcher ---
echo ""
@@ -136,7 +136,7 @@ check "Researcher responds directly" "agent" "$RESP"
echo ""
echo "--- Test 2: PM delegates to Researcher (cross-runtime A2A) ---"
echo " Asking PM to research something (should delegate to Researcher)..."
RESP=$(a2a_send "$PM" "Please ask the Researcher to briefly explain what Claude Code is.")
RESP=$(a2a_send "$PM" "Please ask the Researcher to briefly explain what LangGraph is.")
echo " PM says: $RESP"
# The response should contain info from the Researcher
check "PM got Researcher's response" "graph\|agent\|lang\|workflow" "$RESP"
+6 -6
View File
@@ -49,11 +49,11 @@ R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
PM_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create PM (claude-code)" "provisioning" "$R"
# Research Agent — Claude Code + Gemini Flash
# Research Agent — LangGraph + Gemini Flash
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"Researcher","role":"Deep research and analysis","tier":2,"template":"claude-code-default"}')
-d '{"name":"Researcher","role":"Deep research and analysis","tier":2,"template":"langgraph"}')
RES_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create Researcher (claude-code)" "provisioning" "$R"
check "Create Researcher (langgraph)" "provisioning" "$R"
# Dev Agent — OpenClaw + Gemini Flash
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
@@ -61,11 +61,11 @@ R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
DEV_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create Developer (openclaw)" "provisioning" "$R"
# Analyst — Hermes + Gemini Flash
# Analyst — DeepAgents + Gemini Flash
R=$(curl -s -X POST "$PLATFORM/workspaces" -H 'Content-Type: application/json' \
-d '{"name":"Analyst","role":"Data analysis and reporting","tier":2,"template":"hermes"}')
-d '{"name":"Analyst","role":"Data analysis and reporting","tier":2,"template":"deepagents"}')
ANA_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
check "Create Analyst (hermes)" "provisioning" "$R"
check "Create Analyst (deepagents)" "provisioning" "$R"
echo ""
echo " PM: $PM_ID"
+4 -23
View File
@@ -45,31 +45,12 @@ e2e_mint_workspace_token() {
printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])"
}
e2e_delete_workspace() {
local wid="$1"
local name="${2:-}"
shift 2 || true
local curl_args=("$@")
if [ -z "$wid" ]; then
return 0
fi
if [ -z "$name" ]; then
name=$(curl -s "$BASE/workspaces/$wid" "${curl_args[@]}" | python3 -c "import json,sys
try:
print(json.load(sys.stdin).get('name',''))
except Exception:
pass" 2>/dev/null || true)
fi
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" \
-H "X-Confirm-Name: $name" "${curl_args[@]}" > /dev/null || true
}
e2e_cleanup_all_workspaces() {
curl -s "$BASE/workspaces" | python3 -c "import json,sys
for _wid in $(curl -s "$BASE/workspaces" | python3 -c "import json,sys
try:
[print(f\"{w.get('id','')}\\t{w.get('name','')}\") for w in json.load(sys.stdin)]
[print(w['id']) for w in json.load(sys.stdin)]
except Exception:
pass" 2>/dev/null | while IFS=$'\t' read -r _wid _name; do
e2e_delete_workspace "$_wid" "$_name"
pass" 2>/dev/null); do
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
}
+3 -6
View File
@@ -1,14 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
BASE="${BASE:-http://localhost:8080}"
BASE="http://localhost:8080"
PASS=0
FAIL=0
TIMEOUT="${A2A_TIMEOUT:-120}" # seconds per A2A call (override via A2A_TIMEOUT env var)
# shellcheck source=_lib.sh
source "$(dirname "$0")/_lib.sh"
check() {
local desc="$1"
local expected="$2"
@@ -133,14 +130,14 @@ echo ""
# ========================================
echo "--- Test 6: Offline workspace ---"
# Create a workspace but don't provision it
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1,"runtime":"external","external":true}')
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1}')
OFFLINE_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$OFFLINE_ID/a2a" \
-H "Content-Type: application/json" \
-d '{"method":"message/send","params":{"message":{"role":"user","parts":[{"type":"text","text":"test"}]}}}')
check "Offline workspace returns error" '"error"' "$R"
# Clean up
e2e_delete_workspace "$OFFLINE_ID" "Offline Test"
curl -s -X DELETE "$BASE/workspaces/$OFFLINE_ID" >/dev/null
echo ""
# ========================================
+2 -2
View File
@@ -215,7 +215,7 @@ echo ""
echo "--- Activity Isolation ---"
# Test 19: Create a second workspace to verify isolation
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1,"runtime":"external","external":true}')
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1}')
TEMP_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
# Test 20: New workspace has empty activity
@@ -235,7 +235,7 @@ R=$(curl -s "$BASE/workspaces/$TEMP_ID/activity")
check "Activity in correct workspace" 'Temp workspace log' "$R"
# Cleanup
e2e_delete_workspace "$TEMP_ID" "Activity Test Workspace"
curl -s -X DELETE "$BASE/workspaces/$TEMP_ID" > /dev/null
# ---------- Edge Cases ----------
echo ""
+3 -7
View File
@@ -289,9 +289,7 @@ R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $ECHO_TOKEN")
check "current_task in list response" '"current_task"' "$R"
# Test 21: Delete
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID?confirm=true" \
-H "Authorization: Bearer $ECHO_TOKEN" \
-H "X-Confirm-Name: Echo Agent v2")
R=$(acurl -X DELETE "$BASE/workspaces/$ECHO_ID" -H "Authorization: Bearer $ECHO_TOKEN")
check "DELETE /workspaces/:id" '"status":"removed"' "$R"
R=$(curl -s "$BASE/workspaces" -H "Authorization: Bearer $SUM_TOKEN")
@@ -312,9 +310,7 @@ ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.st
# Delete the workspace — use SUM_TOKEN (per-workspace) for WorkspaceAuth
# and ADMIN_TOKEN for the AdminAuth layer.
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID?confirm=true" \
-H "Authorization: Bearer $SUM_TOKEN" \
-H "X-Confirm-Name: Summarizer Agent")
R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN")
check "Delete before re-import" '"status":"removed"' "$R"
# After deleting both workspaces, all per-workspace tokens are revoked.
@@ -385,7 +381,7 @@ REBUNDLE=$(curl -s "$BASE/bundles/export/$NEW_ID" -H "Authorization: Bearer $NEW
check "Re-exported bundle has agent_card" '"agent_card"' "$REBUNDLE"
# Clean up — use the token just issued to the re-imported workspace
e2e_delete_workspace "$NEW_ID" "$ORIG_NAME" -H "Authorization: Bearer $NEW_TOKEN"
curl -s -X DELETE "$BASE/workspaces/$NEW_ID" -H "Authorization: Bearer $NEW_TOKEN" > /dev/null
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
-1
View File
@@ -39,7 +39,6 @@ cleanup() {
set +e
if [ -n "$PARENT" ]; then
curl -sS -X DELETE "$BASE/workspaces/$PARENT?confirm=true&purge=true" \
-H "X-Confirm-Name: e2e-chat-upload" \
${PARENT_TOK:+-H "Authorization: Bearer $PARENT_TOK"} >/dev/null 2>&1
fi
exit $rc

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