Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68d7228a9 | |||
| 9843a970d3 | |||
| 4cc5b9ce77 | |||
| 19b4d81670 | |||
| bc6b384413 | |||
| e073fa87da | |||
| 0ba29227e9 | |||
| 0d04527a13 | |||
| 6c6a070bc6 | |||
| d57404b87b | |||
| 648ac4d61b | |||
| 7bde0ea64a | |||
| 89476ae330 | |||
| cc55e651f6 | |||
| e64b8f0f35 | |||
| ef8651410d | |||
| 37972fa635 | |||
| 6ba24c30f2 | |||
| e1496055e2 | |||
| 6b1d9bdb05 | |||
| a1905bb14f | |||
| 42429727f9 | |||
| 2570e22ecf | |||
| 6dda49e90d | |||
| 0c15480c50 | |||
| 5f9155a1bd | |||
| 02368d8278 | |||
| 850a3c58d4 | |||
| e865d656f8 | |||
| 12c0bc4b69 | |||
| 6eda20b3c0 | |||
| 1e1df77042 | |||
| 8e97765a30 | |||
| efb87d6228 | |||
| 8df1fef44d | |||
| cbb5426fbc | |||
| 683e56de32 | |||
| cc0087bbf1 | |||
| ef1d35c32b | |||
| 6285bb55a3 | |||
| 6f230fba38 | |||
| 054ca2f552 | |||
| a120c86756 | |||
| 5088a7273c | |||
| 57adcaae5f | |||
| eaf58bb8d4 | |||
| 93bd9c7295 | |||
| 3aee079310 | |||
| cf932cf34c |
@@ -274,7 +274,8 @@ def required_checks_env(audit_doc: dict) -> set[str]:
|
||||
found.append(v)
|
||||
if not found:
|
||||
sys.stderr.write(
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of {AUDIT_WORKFLOW_PATH}\n"
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of "
|
||||
f"{AUDIT_WORKFLOW_PATH}\n"
|
||||
)
|
||||
sys.exit(3)
|
||||
if len(found) > 1:
|
||||
@@ -387,7 +388,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
missing_from_needs = sorted(jobs - needs)
|
||||
if missing_from_needs:
|
||||
findings.append(
|
||||
"F1 — jobs in ci.yml NOT under sentinel `needs:` (sentinel doesn't gate them):\n"
|
||||
"F1 — jobs in ci.yml NOT under sentinel `needs:` "
|
||||
"(sentinel doesn't gate them):\n"
|
||||
+ "\n".join(f" - {n}" for n in missing_from_needs)
|
||||
)
|
||||
|
||||
@@ -397,7 +399,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
stale_needs = sorted(needs - jobs_all)
|
||||
if stale_needs:
|
||||
findings.append(
|
||||
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n"
|
||||
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml "
|
||||
"(typo or removed job):\n"
|
||||
+ "\n".join(f" - {n}" for n in stale_needs)
|
||||
)
|
||||
|
||||
@@ -405,7 +408,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
# Compute the contexts the CI YAML actually produces. The sentinel
|
||||
# is in (B) intentionally (`ci / all-required (pull_request)`); we
|
||||
# whitelist it explicitly.
|
||||
emitted_contexts = {expected_context(j) for j in jobs} | {expected_context(SENTINEL_JOB)}
|
||||
emitted_contexts = {
|
||||
expected_context(j) for j in jobs
|
||||
} | {expected_context(SENTINEL_JOB)}
|
||||
# Contexts NOT produced by ci.yml may still come from other
|
||||
# workflows in the repo (Secret scan etc). We can't enumerate
|
||||
# every workflow's emissions cheaply; instead, flag only contexts
|
||||
@@ -418,8 +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)
|
||||
)
|
||||
|
||||
@@ -494,7 +500,8 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
|
||||
f"# Drift detected on `{REPO}/{branch}`",
|
||||
"",
|
||||
"Auto-filed by `.gitea/workflows/ci-required-drift.yml` "
|
||||
"(RFC [internal#219](https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"(RFC [internal#219]"
|
||||
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"",
|
||||
"## Findings",
|
||||
"",
|
||||
@@ -547,12 +554,12 @@ def file_or_update(
|
||||
|
||||
if dry_run:
|
||||
print(f"::notice::[dry-run] would file/update drift issue for {branch}")
|
||||
print(f"::group::[dry-run] title")
|
||||
print("::group::[dry-run] title")
|
||||
print(title)
|
||||
print(f"::endgroup::")
|
||||
print(f"::group::[dry-run] body")
|
||||
print("::endgroup::")
|
||||
print("::group::[dry-run] body")
|
||||
print(body)
|
||||
print(f"::endgroup::")
|
||||
print("::endgroup::")
|
||||
return
|
||||
|
||||
existing = find_open_issue(title)
|
||||
|
||||
@@ -15,7 +15,6 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROFILES: dict[str, dict[str, str]] = {
|
||||
"ci": {
|
||||
"platform": r"^workspace-server/",
|
||||
@@ -153,7 +152,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
|
||||
parser.add_argument("--pr-base-sha", default="")
|
||||
parser.add_argument("--base-ref", default="")
|
||||
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
|
||||
parser.add_argument(
|
||||
"--push-before",
|
||||
default=os.environ.get("GITHUB_EVENT_BEFORE", ""),
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
@@ -183,7 +183,9 @@ 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,11 +13,9 @@ 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"
|
||||
|
||||
|
||||
|
||||
@@ -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 = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
|
||||
by_name = {label["name"]: label["id"] for label in labels if isinstance(label, 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, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -578,6 +578,7 @@ 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
|
||||
@@ -586,15 +587,25 @@ 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:
|
||||
# Same SHA — caller should not have invoked this if main is
|
||||
# green. Skip defensively.
|
||||
continue
|
||||
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)
|
||||
num = issue.get("number")
|
||||
if not isinstance(num, int):
|
||||
continue
|
||||
@@ -699,6 +710,10 @@ 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)
|
||||
@@ -711,6 +726,9 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
@@ -25,6 +24,7 @@ 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:
|
||||
@@ -130,6 +130,154 @@ def required_contexts(env: dict[str, str]) -> list[str]:
|
||||
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")
|
||||
|
||||
|
||||
def _api_json(url: str, token: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
@@ -231,6 +379,9 @@ def main() -> int:
|
||||
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("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:
|
||||
@@ -243,6 +394,9 @@ 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
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# ≥ 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
|
||||
#
|
||||
@@ -201,6 +202,7 @@ 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}
|
||||
|
||||
@@ -338,7 +338,6 @@ 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():
|
||||
@@ -842,7 +841,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 = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
|
||||
tier_labels = [label.get("name", "") for label in labels if (label.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:
|
||||
@@ -865,7 +864,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 = {(l.get("name") or "") for l in (pr.get("labels") or [])}
|
||||
label_set = {(label.get("name") or "") for label in (pr.get("labels") or [])}
|
||||
if "tier:high" in label_set:
|
||||
return True
|
||||
high_risk_labels = set(cfg.get("high_risk_labels") or [])
|
||||
|
||||
@@ -33,7 +33,6 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
|
||||
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
|
||||
|
||||
|
||||
@@ -81,7 +80,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:
|
||||
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
|
||||
pr_num = m.group(3)
|
||||
if sc == "T2_pr_closed":
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
@@ -151,7 +150,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /teams/{team_id}/members/{username}
|
||||
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
||||
if m:
|
||||
team_id, login = m.group(1), m.group(2)
|
||||
login = m.group(2)
|
||||
if sc == "T8_team_not_member":
|
||||
return self._empty(404)
|
||||
if sc == "T9_team_403":
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,7 +15,6 @@ 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,3 +153,205 @@ 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,7 +22,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Resolve sibling script regardless of where pytest is invoked from.
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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
|
||||
@@ -239,12 +239,13 @@ jobs:
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 90
|
||||
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' }}
|
||||
@@ -303,26 +304,19 @@ 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
|
||||
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"
|
||||
python3 .gitea/scripts/prod-auto-deploy.py rollout \
|
||||
--plan "$PLAN" \
|
||||
--response "$HTTP_RESPONSE"
|
||||
ROLLOUT_EXIT=$?
|
||||
set -e
|
||||
|
||||
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
echo "HTTP $HTTP_CODE"
|
||||
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
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
|
||||
{
|
||||
@@ -330,7 +324,6 @@ jobs:
|
||||
echo ""
|
||||
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
@@ -339,15 +332,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' }}
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -41,6 +41,12 @@ 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'");
|
||||
});
|
||||
|
||||
@@ -5,6 +5,13 @@ 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;
|
||||
@@ -22,6 +29,8 @@ interface TemplateSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
runtime?: string;
|
||||
model?: string;
|
||||
models?: SelectorModel[];
|
||||
providers?: string[];
|
||||
}
|
||||
|
||||
@@ -33,51 +42,22 @@ interface HermesProvider {
|
||||
models: string[];
|
||||
}
|
||||
|
||||
type LLMAuthMode = "platform" | "api_key" | "oauth";
|
||||
|
||||
interface NativeLLMProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar?: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
authModes: LLMAuthMode[];
|
||||
}
|
||||
|
||||
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
defaultModel: "MiniMax-M2.7",
|
||||
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
label: "Kimi",
|
||||
envVar: "KIMI_API_KEY",
|
||||
defaultModel: "kimi-for-coding",
|
||||
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic-oauth",
|
||||
label: "Claude OAuth",
|
||||
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
defaultModel: "sonnet",
|
||||
models: ["sonnet", "opus", "haiku"],
|
||||
authModes: ["oauth"],
|
||||
},
|
||||
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";
|
||||
@@ -112,6 +92,7 @@ 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("");
|
||||
@@ -149,9 +130,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 [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
|
||||
const [llmProvider, setLLMProvider] = useState("minimax");
|
||||
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
|
||||
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
|
||||
@@ -208,39 +191,72 @@ export function CreateWorkspaceButton() {
|
||||
[]
|
||||
);
|
||||
|
||||
const isHermes = template.trim().toLowerCase() === "hermes";
|
||||
const nativeLLMProviders = useMemo(
|
||||
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
|
||||
[llmAuthMode],
|
||||
);
|
||||
const selectedNativeProvider = useMemo(
|
||||
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
|
||||
[llmProvider, nativeLLMProviders],
|
||||
);
|
||||
const handleRuntimeChange = useCallback((nextRuntime: string) => {
|
||||
setRuntime(nextRuntime);
|
||||
setTemplate("");
|
||||
setHermesProvider("anthropic");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
setLLMSelection({ providerId: "platform|", model: DEFAULT_PLATFORM_MODEL.id, envVars: [] });
|
||||
setLLMSecret("");
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const selectedTemplateSpec = useMemo<TemplateSpec | 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
|
||||
);
|
||||
if (!template) return null;
|
||||
return templateSpecs.find((s) => s.id === template) ?? 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;
|
||||
const declared = selectedTemplateSpec?.providers ?? selectedRuntimeTemplateSpec?.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()));
|
||||
@@ -249,7 +265,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;
|
||||
}, [selectedTemplateSpec]);
|
||||
}, [selectedRuntimeTemplateSpec, selectedTemplateSpec]);
|
||||
|
||||
// If the currently-selected provider is filtered out by a template
|
||||
// change, snap back to the first available. Without this, the
|
||||
@@ -265,20 +281,21 @@ export function CreateWorkspaceButton() {
|
||||
}, [availableProviders, isHermes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes) return;
|
||||
if (nativeLLMProviders.length === 0) return;
|
||||
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
|
||||
setLLMProvider(nativeLLMProviders[0].id);
|
||||
setLLMModel(nativeLLMProviders[0].defaultModel);
|
||||
}
|
||||
}, [isHermes, llmProvider, nativeLLMProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes || !selectedNativeProvider) return;
|
||||
if (!selectedNativeProvider.models.includes(llmModel)) {
|
||||
setLLMModel(selectedNativeProvider.defaultModel);
|
||||
}
|
||||
}, [isHermes, llmModel, selectedNativeProvider]);
|
||||
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
|
||||
@@ -302,6 +319,7 @@ export function CreateWorkspaceButton() {
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(defaultTier);
|
||||
setRuntime(DEFAULT_RUNTIME);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
@@ -314,9 +332,7 @@ export function CreateWorkspaceButton() {
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
setLLMAuthMode("platform");
|
||||
setLLMProvider("minimax");
|
||||
setLLMModel("MiniMax-M2.7");
|
||||
setLLMSelection({ providerId: "platform|", model: "moonshot/kimi-k2.6", envVars: [] });
|
||||
setLLMSecret("");
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
@@ -344,12 +360,12 @@ export function CreateWorkspaceButton() {
|
||||
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && !llmModel.trim()) {
|
||||
if (!isExternal && !isHermes && !llmSelection.model.trim()) {
|
||||
setError("Model is required");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
|
||||
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
|
||||
if (!isExternal && !isHermes && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
|
||||
setError("Provider credential is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
@@ -358,7 +374,7 @@ export function CreateWorkspaceButton() {
|
||||
const provider = isHermes
|
||||
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
|
||||
: undefined;
|
||||
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
|
||||
const nativeProvider = !isHermes ? selectedLLMProvider : undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
@@ -384,10 +400,10 @@ export function CreateWorkspaceButton() {
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && !isHermes && nativeProvider
|
||||
? {
|
||||
model: llmModel.trim(),
|
||||
llm_provider: nativeProvider.id,
|
||||
...(llmAuthMode !== "platform" && nativeProvider.envVar
|
||||
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
|
||||
model: llmSelection.model.trim(),
|
||||
llm_provider: nativeProvider.vendor,
|
||||
...(nativeProvider.envVars.length > 0
|
||||
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
@@ -415,7 +431,7 @@ export function CreateWorkspaceButton() {
|
||||
// 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 } : {}),
|
||||
...(isExternal ? { runtime: externalRuntime } : { runtime }),
|
||||
...(!isExternal && isHermes && provider
|
||||
? {
|
||||
secrets: { [provider.envVar]: hermesApiKey.trim() },
|
||||
@@ -533,77 +549,64 @@ export function CreateWorkspaceButton() {
|
||||
)}
|
||||
|
||||
{!isExternal && (
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isExternal && !isHermes && selectedNativeProvider && (
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
|
||||
Auth Mode
|
||||
<label htmlFor="runtime-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Runtime
|
||||
</label>
|
||||
<select
|
||||
id="llm-auth-mode"
|
||||
value={llmAuthMode}
|
||||
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
|
||||
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"
|
||||
>
|
||||
<option value="platform">Platform provided</option>
|
||||
<option value="api_key">API key</option>
|
||||
<option value="oauth">Claude OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="llm-provider-select"
|
||||
value={selectedNativeProvider.id}
|
||||
onChange={(e) => {
|
||||
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
|
||||
setLLMProvider(e.target.value);
|
||||
if (next) setLLMModel(next.defaultModel);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{nativeLLMProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
{RUNTIME_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
Model
|
||||
<label htmlFor="workspace-template-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Workspace Template
|
||||
</label>
|
||||
<input
|
||||
id="llm-model-input"
|
||||
type="text"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLLMModel(e.target.value)}
|
||||
list="llm-model-suggestions"
|
||||
spellCheck={false}
|
||||
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"
|
||||
/>
|
||||
<datalist id="llm-model-suggestions">
|
||||
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
<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>
|
||||
{llmAuthMode !== "platform" && (
|
||||
</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">
|
||||
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
|
||||
{selectedLLMProvider.envVars[0]}
|
||||
</label>
|
||||
<input
|
||||
id="llm-secret-input"
|
||||
@@ -741,7 +744,7 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hermes provider configuration — shown only when template === "hermes" */}
|
||||
{/* Hermes provider configuration — shown only for the Hermes runtime. */}
|
||||
{isHermes && (
|
||||
<div
|
||||
className="mt-4 rounded-xl border border-violet-700/40 bg-violet-950/20 p-4 space-y-3"
|
||||
|
||||
@@ -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 { type Template } from "@/lib/deploy-preflight";
|
||||
import { isUserVisibleWorkspaceTemplate, 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))
|
||||
.then((t) => setTemplates(t.filter(isUserVisibleWorkspaceTemplate)))
|
||||
.catch(() => setTemplates([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -23,6 +23,8 @@ 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;
|
||||
@@ -94,13 +96,13 @@ export function MissingKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -114,13 +116,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}
|
||||
@@ -138,11 +140,15 @@ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -170,13 +176,13 @@ export function providerIdForModel(
|
||||
function ProviderPickerModal({
|
||||
open,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -184,13 +190,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;
|
||||
@@ -250,16 +256,9 @@ 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;
|
||||
|
||||
@@ -282,7 +281,18 @@ function ProviderPickerModal({
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selectorValue.envVars, configuredKeys]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -336,6 +346,43 @@ 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,
|
||||
@@ -465,6 +512,62 @@ 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">
|
||||
@@ -512,21 +615,30 @@ 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(() => {
|
||||
@@ -535,13 +647,24 @@ function AllKeysModal({
|
||||
missingKeys.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: false,
|
||||
saved: configuredKeys?.has(key) ?? 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]);
|
||||
}, [open, missingKeys, optionalKeys, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -591,6 +714,45 @@ 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) {
|
||||
@@ -656,12 +818,16 @@ function AllKeysModal({
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
Missing API Keys
|
||||
{title ?? "Missing API Keys"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
{description ?? (
|
||||
<>
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -719,6 +885,62 @@ 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,6 +28,7 @@ import { useId, useMemo } from "react";
|
||||
export interface SelectorModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ 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",
|
||||
@@ -118,6 +120,8 @@ 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:
|
||||
@@ -165,6 +169,9 @@ 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 ?? []);
|
||||
|
||||
|
||||
@@ -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 { type Template } from "@/lib/deploy-preflight";
|
||||
import { isUserVisibleWorkspaceTemplate, 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);
|
||||
setTemplates(data.filter(isUserVisibleWorkspaceTemplate));
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
|
||||
@@ -224,12 +224,14 @@ export function Toolbar() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "?") return;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
target.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,15 +201,13 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
|
||||
expect(label?.textContent).toContain("Budget limit");
|
||||
});
|
||||
|
||||
it("Template input has a <label> whose htmlFor matches the input id", async () => {
|
||||
it("Workspace Template select has a <label> whose htmlFor matches the select id", async () => {
|
||||
await openDialog();
|
||||
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}"]`);
|
||||
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
|
||||
expect(templateSelect.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateSelect.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Template");
|
||||
expect(label?.textContent).toContain("Workspace Template");
|
||||
});
|
||||
|
||||
it("each InputField generates a distinct id (no id collisions)", async () => {
|
||||
@@ -218,13 +216,16 @@ 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.map((i) => i.id).filter(Boolean);
|
||||
const ids = [...inputs, ...selects].map((i) => i.id).filter(Boolean);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length); // no duplicates
|
||||
expect(ids.length).toBe(4);
|
||||
expect(ids.length).toBe(5);
|
||||
});
|
||||
|
||||
it("Name label text contains the required asterisk indicator", async () => {
|
||||
|
||||
@@ -20,10 +20,34 @@ 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();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(SAMPLE_WORKSPACES as any);
|
||||
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
|
||||
mockPost.mockResolvedValue({} as any);
|
||||
});
|
||||
@@ -42,7 +66,14 @@ async function openDialog() {
|
||||
|
||||
async function setTemplate(value: string) {
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
|
||||
screen.getByLabelText("Workspace Template"),
|
||||
{ target: { value } }
|
||||
);
|
||||
}
|
||||
|
||||
async function setRuntime(value: string) {
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("Runtime"),
|
||||
{ target: { value } }
|
||||
);
|
||||
}
|
||||
@@ -139,11 +170,33 @@ describe("CreateWorkspaceDialog", () => {
|
||||
volume: { root_gb: 30 },
|
||||
display: { mode: "none" },
|
||||
});
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
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"), {
|
||||
@@ -172,8 +225,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
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.model).toBe("moonshot/kimi-k2.6");
|
||||
expect(body.llm_provider).toBe("platform");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
@@ -191,8 +244,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "BYOK Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "api_key" },
|
||||
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" },
|
||||
@@ -213,8 +266,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "OAuth Agent" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "oauth" },
|
||||
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" },
|
||||
@@ -254,17 +307,17 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows hermes provider section when template is 'hermes'", async () => {
|
||||
it("shows hermes provider section when runtime is 'hermes'", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
});
|
||||
|
||||
it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => {
|
||||
it("shows hermes provider section for the Hermes runtime preset", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("HERMES");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -272,7 +325,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
|
||||
it("hermes provider dropdown defaults to 'anthropic'", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -283,7 +336,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
|
||||
it("hermes provider dropdown lists all 15 providers", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -317,7 +370,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
});
|
||||
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -347,7 +400,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
});
|
||||
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -373,7 +426,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
});
|
||||
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -384,7 +437,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
|
||||
it("hermes API key field is a password input (masked)", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -398,7 +451,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes Agent" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -419,7 +472,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes Agent" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -434,7 +487,8 @@ 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.template).toBe("hermes");
|
||||
expect(body.runtime).toBe("hermes");
|
||||
expect(body.template).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the correct env var when a non-default provider is selected", async () => {
|
||||
@@ -442,7 +496,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes OpenAI" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
@@ -479,13 +533,13 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
|
||||
it("hides hermes section and resets state when template is cleared", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await setRuntime("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
|
||||
// Clear template
|
||||
await setTemplate("");
|
||||
// Switch back to a non-Hermes runtime.
|
||||
await setRuntime("claude-code");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull()
|
||||
);
|
||||
|
||||
@@ -96,12 +96,12 @@ vi.mock("@/lib/design-tokens", () => ({
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATE = {
|
||||
id: "tpl-1",
|
||||
name: "Claude Code Agent",
|
||||
description: "A general-purpose coding assistant",
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
description: "SEO workspace template",
|
||||
tier: 2,
|
||||
skill_count: 3,
|
||||
model: "claude-opus-4-5",
|
||||
model: "MiniMax-M2.7",
|
||||
};
|
||||
|
||||
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("Claude Code Agent")).toBeNull();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,8 +183,8 @@ describe("EmptyState — templates", () => {
|
||||
it("renders template buttons with name and description", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy();
|
||||
expect(screen.getByText("SEO Agent")).toBeTruthy();
|
||||
expect(screen.getByText("SEO workspace template")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders tier badge and skill count", async () => {
|
||||
@@ -198,25 +198,42 @@ describe("EmptyState — templates", () => {
|
||||
it("renders model name when present", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText(/claude-opus/i)).toBeTruthy();
|
||||
expect(screen.getByText(/MiniMax-M2.7/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls deploy with the template on click", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("Claude Code Agent"));
|
||||
fireEvent.click(screen.getByText("SEO 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 = "tpl-1";
|
||||
_deploy.deploying = "seo-agent";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Deploying...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the template button of the deploying template", async () => {
|
||||
_deploy.deploying = "tpl-1";
|
||||
_deploy.deploying = "seo-agent";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement;
|
||||
@@ -224,7 +241,7 @@ describe("EmptyState — templates", () => {
|
||||
});
|
||||
|
||||
it("disables 'create blank' while a template is deploying", async () => {
|
||||
_deploy.deploying = "tpl-1";
|
||||
_deploy.deploying = "seo-agent";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true);
|
||||
@@ -245,7 +262,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
|
||||
it("does not render template grid when GET /templates returns []", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'create blank' button when templates list is empty", async () => {
|
||||
@@ -258,7 +275,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
|
||||
mockApiGet.mockReset().mockRejectedValue(new Error("Network failure"));
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,7 +333,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("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByText("SEO Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows error banner when POST /workspaces fails", async () => {
|
||||
|
||||
@@ -402,6 +402,31 @@ 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(
|
||||
@@ -529,4 +554,4 @@ describe("MissingKeysModal — cancel and settings", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,14 @@ 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");
|
||||
@@ -105,6 +113,22 @@ 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,6 +189,23 @@ 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 />);
|
||||
|
||||
@@ -68,7 +68,11 @@ afterEach(() => {
|
||||
|
||||
function ShortcutTestComponent() {
|
||||
useKeyboardShortcuts();
|
||||
return <div data-testid="canvas-root" />;
|
||||
return (
|
||||
<div data-testid="canvas-root">
|
||||
<div data-testid="display-stream" data-display-stream="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithProvider() {
|
||||
@@ -78,6 +82,13 @@ 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,12 +28,14 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
target.isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
const state = useCanvasStore.getState();
|
||||
|
||||
@@ -313,11 +313,21 @@ 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);
|
||||
@@ -328,9 +338,19 @@ 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.");
|
||||
@@ -343,13 +363,83 @@ 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 className="relative min-h-0 flex-1 bg-black">
|
||||
<div
|
||||
data-display-stream="true"
|
||||
className="relative min-h-0 flex-1 bg-black"
|
||||
onMouseDown={() => rfbRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<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}
|
||||
@@ -359,6 +449,13 @@ 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") ?? "";
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
mockRFBClipboardPasteFrom: vi.fn(),
|
||||
mockRFBFocus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -30,6 +32,12 @@ vi.mock("@novnc/novnc", () => ({
|
||||
this.options = options;
|
||||
mockRFBConstructor(target, url, options);
|
||||
}
|
||||
clipboardPasteFrom(text: string) {
|
||||
mockRFBClipboardPasteFrom(text);
|
||||
}
|
||||
focus(options?: FocusOptions) {
|
||||
mockRFBFocus(options);
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
}));
|
||||
@@ -42,6 +50,8 @@ describe("DisplayTab", () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
mockRFBClipboardPasteFrom.mockReset();
|
||||
mockRFBFocus.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -157,6 +167,43 @@ 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,11 +166,12 @@ 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-[95vw] max-h-[90vh] object-contain"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentLightbox — shared fullscreen modal for image / PDF /
|
||||
// AttachmentLightbox — shared 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 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).
|
||||
// 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).
|
||||
//
|
||||
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
|
||||
// chat lightbox needs to trap focus only across two interactive
|
||||
@@ -41,13 +41,17 @@ 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, children }: Props) {
|
||||
export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false, children }: Props) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -90,12 +94,19 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props
|
||||
|
||||
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="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
|
||||
className={rootClass}
|
||||
onClick={onBackdropClick}
|
||||
>
|
||||
{/* Close button — top-right, large hit area, keyboard-focusable.
|
||||
@@ -112,7 +123,7 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
|
||||
className={contentClass}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// suppress the toolbar; we keep it on so the user gets standard
|
||||
// PDF affordances.
|
||||
//
|
||||
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
|
||||
// click. Same shared modal as image — third caller justifies the
|
||||
// Preview: AttachmentLightbox hosts the PDF inside the active chat tab
|
||||
// on click. Same shared modal as image — third caller justifies the
|
||||
// abstraction (per RFC #2991 design).
|
||||
//
|
||||
// Failure modes:
|
||||
@@ -144,16 +144,15 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
contained
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentLightbox — fullscreen modal for image / PDF preview.
|
||||
* AttachmentLightbox — 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,6 +135,22 @@ 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-fullscreen lightbox.
|
||||
* AttachmentPDF — inline PDF preview button + click-to-panel 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
|
||||
* <embed>. Blob URL cleaned up on unmount.
|
||||
* a browser PDF iframe. 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 <embed> on button click
|
||||
* - Opens lightbox with a framed <iframe> viewer 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 <embed> on button click", async () => {
|
||||
it("opens lightbox with a framed iframe viewer on button click", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
@@ -158,8 +158,13 @@ describe("AttachmentPDF — ready", () => {
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-label")).toContain("report.pdf");
|
||||
// Lightbox contains an <embed>
|
||||
expect(dialog?.querySelector("embed")).toBeTruthy();
|
||||
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();
|
||||
});
|
||||
|
||||
it("closes lightbox on Escape key", async () => {
|
||||
|
||||
@@ -237,11 +237,13 @@ describe("AttachmentPreview dispatch", () => {
|
||||
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click → lightbox opens with <embed> inside.
|
||||
// Click → panel-contained lightbox opens with a browser PDF iframe.
|
||||
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
|
||||
expect(dialog.className).toContain("absolute");
|
||||
expect(dialog.querySelector("iframe")).not.toBeNull();
|
||||
expect(dialog.querySelector("embed")).toBeNull();
|
||||
});
|
||||
|
||||
it("kind=pdf fetch fails → falls back to chip", async () => {
|
||||
|
||||
@@ -113,6 +113,31 @@ 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,6 +125,8 @@ 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
|
||||
@@ -163,6 +165,11 @@ 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)}`;
|
||||
@@ -175,6 +182,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -183,6 +191,12 @@ 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,6 +63,7 @@ vi.mock("@/components/MissingKeysModal", () => ({
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
configuredKeys?: Set<string>;
|
||||
optionalKeys?: string[];
|
||||
modelSuggestions?: string[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
@@ -77,6 +78,9 @@ 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()}
|
||||
@@ -113,6 +117,7 @@ function makeTemplate(over: Partial<Template> = {}): Template {
|
||||
runtime: "claude-code",
|
||||
models: [],
|
||||
required_env: [],
|
||||
recommended_env: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -129,6 +134,7 @@ beforeEach(() => {
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
mockApiPost.mockResolvedValue({ id: "ws-new" });
|
||||
@@ -243,6 +249,7 @@ describe("useTemplateDeploy — preflight failure modes", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -271,6 +278,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -306,6 +314,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -359,6 +368,7 @@ 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());
|
||||
@@ -392,6 +402,7 @@ 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());
|
||||
@@ -420,6 +431,7 @@ 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());
|
||||
@@ -484,6 +496,7 @@ 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());
|
||||
@@ -499,6 +512,35 @@ 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", () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ 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;
|
||||
|
||||
@@ -152,6 +152,7 @@ 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
|
||||
@@ -165,7 +166,11 @@ export function useTemplateDeploy(
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
if (preflight.ok && preflight.providers.length === 0) {
|
||||
if (
|
||||
preflight.ok &&
|
||||
preflight.providers.length === 0 &&
|
||||
preflight.optionalKeys.length === 0
|
||||
) {
|
||||
await executeDeploy(template);
|
||||
return;
|
||||
}
|
||||
@@ -220,6 +225,7 @@ 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}
|
||||
|
||||
@@ -37,6 +37,11 @@ const CLAUDE_CODE: TemplateLike = {
|
||||
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" };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -154,6 +159,7 @@ describe("checkDeploySecrets", () => {
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.missingKeys).toEqual([]);
|
||||
expect(result.optionalKeys).toEqual([]);
|
||||
expect(result.runtime).toBe("claude-code");
|
||||
});
|
||||
|
||||
@@ -184,6 +190,7 @@ 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 () => {
|
||||
@@ -207,6 +214,22 @@ 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,
|
||||
@@ -244,6 +267,7 @@ describe("checkDeploySecrets", () => {
|
||||
const result = await checkDeploySecrets(CLAUDE_CODE);
|
||||
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());
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* count bounded.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveRuntime } from "../deploy-preflight";
|
||||
import { isUserVisibleWorkspaceTemplate, resolveRuntime } from "../deploy-preflight";
|
||||
|
||||
describe("resolveRuntime", () => {
|
||||
describe("explicit runtime-map entries", () => {
|
||||
@@ -64,3 +64,15 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { api } from "./api";
|
||||
export interface ModelSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
@@ -31,6 +32,8 @@ 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)
|
||||
@@ -49,6 +52,17 @@ 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
|
||||
@@ -84,6 +98,8 @@ 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). */
|
||||
@@ -236,12 +252,14 @@ 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) {
|
||||
if (providers.length === 0 && optionalKeys.length === 0) {
|
||||
// Template declares no env requirements — nothing to preflight.
|
||||
return {
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
optionalKeys: [],
|
||||
providers: [],
|
||||
runtime,
|
||||
configuredKeys: new Set(),
|
||||
@@ -263,10 +281,11 @@ export async function checkDeploySecrets(
|
||||
configured = new Set();
|
||||
}
|
||||
|
||||
if (findSatisfiedProvider(providers, configured)) {
|
||||
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
|
||||
return {
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
optionalKeys,
|
||||
providers,
|
||||
runtime,
|
||||
configuredKeys: configured,
|
||||
@@ -281,6 +300,7 @@ export async function checkDeploySecrets(
|
||||
return {
|
||||
ok: false,
|
||||
missingKeys,
|
||||
optionalKeys,
|
||||
providers,
|
||||
runtime,
|
||||
configuredKeys: configured,
|
||||
|
||||
@@ -12,7 +12,9 @@ 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 / base-uri / frame-ancestors locked to 'none'/'self'.
|
||||
* • 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'.
|
||||
* • upgrade-insecure-requests forces HTTPS on mixed-content.
|
||||
*
|
||||
* Development — permissive policy:
|
||||
@@ -61,6 +63,7 @@ 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'",
|
||||
|
||||
Vendored
+2
@@ -4,6 +4,8 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 as e:
|
||||
except Exception:
|
||||
# /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
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_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.",
|
||||
"_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.",
|
||||
"version": 1,
|
||||
"plugins": [
|
||||
{"name": "browser-automation", "repo": "molecule-ai/molecule-ai-plugin-browser-automation", "ref": "main"},
|
||||
@@ -28,7 +28,8 @@
|
||||
{"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": "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"}
|
||||
],
|
||||
"org_templates": [
|
||||
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
||||
|
||||
@@ -8,19 +8,10 @@
|
||||
# Requires: git, jq (lighter than python3 — ~2MB vs ~50MB in Alpine)
|
||||
#
|
||||
# Auth (optional):
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# The token (when set) never enters the Docker image: this script runs
|
||||
# in the trusted CI context BEFORE `docker buildx build`, populates
|
||||
|
||||
@@ -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(f"::error::rebase coordination needed — only one PR can land a given prefix; "
|
||||
f"renumber yours or theirs")
|
||||
print("::error::rebase coordination needed — only one PR can land a given prefix; "
|
||||
"renumber yours or theirs")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE="http://localhost:8080"
|
||||
BASE="${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"
|
||||
@@ -130,7 +133,7 @@ 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}')
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1,"runtime":"external","external":true}')
|
||||
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" \
|
||||
|
||||
@@ -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}')
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1,"runtime":"external","external":true}')
|
||||
TEMP_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
# Test 20: New workspace has empty activity
|
||||
|
||||
@@ -76,8 +76,8 @@ echo "--- Section 2: Workspace CRUD ---"
|
||||
# create; sections that depend on container readiness (RT_* in 2b)
|
||||
# still run normally.
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Test PM","role":"Project Manager","tier":2}')
|
||||
check "Create PM" '"status":"provisioning"' "$R"
|
||||
-d '{"name":"Test PM","role":"Project Manager","tier":2,"runtime":"external","external":true}')
|
||||
check "Create PM" '"status":"awaiting_agent"' "$R"
|
||||
PM_ID=$(echo "$R" | jq_extract "['id']")
|
||||
echo " PM_ID=$PM_ID"
|
||||
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
@@ -86,8 +86,8 @@ PM_TOKEN=$(echo "$RR" | e2e_extract_token)
|
||||
|
||||
# Create child workspace under PM
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}")
|
||||
check "Create Dev (child of PM)" '"status":"provisioning"' "$R"
|
||||
-d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}")
|
||||
check "Create Dev (child of PM)" '"status":"awaiting_agent"' "$R"
|
||||
DEV_ID=$(echo "$R" | jq_extract "['id']")
|
||||
RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}")
|
||||
@@ -95,16 +95,16 @@ DEV_TOKEN=$(echo "$RR" | e2e_extract_token)
|
||||
|
||||
# Create sibling
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}")
|
||||
check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R"
|
||||
-d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}")
|
||||
check "Create QA (sibling of Dev)" '"status":"awaiting_agent"' "$R"
|
||||
QA_ID=$(echo "$R" | jq_extract "['id']")
|
||||
curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null
|
||||
|
||||
# Create unrelated workspace
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Test Outsider","role":"External","tier":1}')
|
||||
check "Create Outsider (unrelated)" '"status":"provisioning"' "$R"
|
||||
-d '{"name":"Test Outsider","role":"External","tier":1,"runtime":"external","external":true}')
|
||||
check "Create Outsider (unrelated)" '"status":"awaiting_agent"' "$R"
|
||||
OUTSIDER_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
# List workspaces
|
||||
@@ -130,19 +130,24 @@ check "PM position persisted" '"x":100' "$R"
|
||||
echo ""
|
||||
echo "--- Section 2b: Runtime Assignment ---"
|
||||
|
||||
if [ "${RUN_SPAWNED_RUNTIME_LEGACY_E2E:-0}" != "1" ]; then
|
||||
echo " SKIP: spawned-runtime image checks require local runtime images; set RUN_SPAWNED_RUNTIME_LEGACY_E2E=1 to enable"
|
||||
SKIP=$((SKIP + 5))
|
||||
else
|
||||
|
||||
# Create workspace with explicit runtime
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code"}')
|
||||
-d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code","model":"sonnet"}')
|
||||
check "Create claude-code workspace" '"status":"provisioning"' "$R"
|
||||
RT_CC_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex"}')
|
||||
-d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex","model":"openai:gpt-5"}')
|
||||
check "Create codex workspace" '"status":"provisioning"' "$R"
|
||||
RT_CX_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes"}')
|
||||
-d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes","model":"openai:gpt-5"}')
|
||||
check "Create hermes workspace" '"status":"provisioning"' "$R"
|
||||
RT_HM_ID=$(echo "$R" | jq_extract "['id']")
|
||||
|
||||
@@ -235,6 +240,8 @@ sleep 0.3
|
||||
e2e_delete_workspace "$RT_HM_ID" "RT Hermes"
|
||||
sleep 0.3
|
||||
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Section 3: Registry & Heartbeat
|
||||
# ============================================================
|
||||
|
||||
@@ -71,7 +71,7 @@ check_http "GET /workspaces (empty DB)" "200" "$R"
|
||||
# Create a workspace so tokens land in the DB.
|
||||
R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Dev-Mode-Test","tier":1}')
|
||||
-d '{"name":"Dev-Mode-Test","tier":1,"runtime":"external","external":true}')
|
||||
CODE=$(echo "$R" | tail -n1)
|
||||
BODY=$(echo "$R" | sed '$d')
|
||||
check_http "POST /workspaces (create)" "201" "$CODE"
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
# Round-trip: register a workspace as poll-mode (no callback URL) → POST a
|
||||
# multi-file chat upload → verify each file becomes (a) one
|
||||
# `chat_upload_receive` activity row and (b) one /pending-uploads row → fetch
|
||||
# the bytes back via the poll endpoint → ack → verify the row 404s on
|
||||
# subsequent fetch. Also pins cross-workspace bleed protection: workspace B
|
||||
# cannot read workspace A's pending uploads even with its own valid bearer.
|
||||
# the bytes back via the poll endpoint → ack → verify the row stays readable
|
||||
# during retention for refreshed canvas previews. Also pins cross-workspace
|
||||
# bleed protection: workspace B cannot read workspace A's pending uploads even
|
||||
# with its own valid bearer.
|
||||
#
|
||||
# Why this exists separately from test_chat_upload_e2e.sh: that script
|
||||
# covers the PUSH path (the workspace's own /internal/chat/uploads/ingest).
|
||||
@@ -218,14 +219,16 @@ case "$RE_ACK1_CODE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# ---------- Phase 7: GET content after ack returns 404 ----------
|
||||
# ---------- Phase 7: GET content after ack remains readable ----------
|
||||
echo ""
|
||||
echo "--- Phase 7: Acked file 404s on subsequent fetch ---"
|
||||
echo "--- Phase 7: Acked file remains readable during retention ---"
|
||||
|
||||
POST_ACK=$(curl -s -w '\n%{http_code}' --max-time "$TIMEOUT" -H "Authorization: Bearer $TOK_A" \
|
||||
"$BASE/workspaces/$WS_A/pending-uploads/$FID1/content")
|
||||
POST_ACK_CODE=$(printf '%s' "$POST_ACK" | tail -n1)
|
||||
check_eq "acked alpha returns HTTP 404" "404" "$POST_ACK_CODE"
|
||||
POST_ACK_BODY=$(printf '%s' "$POST_ACK" | sed '$d')
|
||||
check_eq "acked alpha returns HTTP 200" "200" "$POST_ACK_CODE"
|
||||
check_eq "acked alpha bytes still readable" "$EXPECTED1" "$POST_ACK_BODY"
|
||||
|
||||
# ---------- Phase 8: cross-workspace bleed protection ----------
|
||||
echo ""
|
||||
|
||||
@@ -97,7 +97,7 @@ except Exception:
|
||||
done
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Abilities Sender","tier":1}')
|
||||
-d '{"name":"Abilities Sender","tier":1,"runtime":"external","external":true}')
|
||||
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
|
||||
SENDER_TOKEN=$(echo "$R" | e2e_extract_token)
|
||||
@@ -113,7 +113,7 @@ ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
|
||||
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
|
||||
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "$ADMIN_AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"name":"Abilities Receiver","tier":1}')
|
||||
-d '{"name":"Abilities Receiver","tier":1,"runtime":"external","external":true}')
|
||||
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
|
||||
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
|
||||
RECEIVER_TOKEN=$(echo "$R" | e2e_extract_token)
|
||||
|
||||
@@ -18,9 +18,7 @@ No network. No live Gitea calls.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
@@ -55,9 +55,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -164,7 +162,7 @@ def test_bp_orphan_context_fails(envset, monkeypatch, capsys):
|
||||
" all-required:\n runs-on: x\n steps:\n - run: echo hi\n",
|
||||
)
|
||||
m = _import_lint()
|
||||
posted = _stub_api(
|
||||
_stub_api(
|
||||
monkeypatch,
|
||||
m,
|
||||
("ok", {"status_check_contexts": [
|
||||
|
||||
@@ -60,10 +60,8 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -53,10 +53,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -61,9 +61,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
+108
-11
@@ -37,7 +37,6 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
@@ -117,15 +116,25 @@ def _make_stub_api(responses: dict):
|
||||
|
||||
def __call__(self, method, path, *, body=None, query=None, expect_json=True):
|
||||
self.calls.append((method, path, body, query))
|
||||
# If we've stored a list for this (method, path), rotate through.
|
||||
# This supports tests that need sequential responses for the
|
||||
# same endpoint without adding query-param noise.
|
||||
key = (method, path)
|
||||
if key not in responses:
|
||||
raise AssertionError(
|
||||
f"unexpected api call: {method} {path} (no stub registered)"
|
||||
)
|
||||
r = responses[key]
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
r = responses.get(key)
|
||||
if isinstance(r, list):
|
||||
if not r:
|
||||
raise AssertionError(
|
||||
f"stub sequential responses exhausted for {method} "
|
||||
f"{path} — provisioned {len(r)} entries"
|
||||
)
|
||||
return r.pop(0)
|
||||
if r is not None:
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
raise AssertionError(
|
||||
f"unexpected api call: {method} {path} (no stub registered)"
|
||||
)
|
||||
|
||||
return StubApi()
|
||||
|
||||
@@ -133,6 +142,7 @@ def _make_stub_api(responses: dict):
|
||||
# Sample SHA used throughout. 40 chars per Gitea convention.
|
||||
SHA_RED = "deadbeefcafe1234567890abcdef000011112222"
|
||||
SHA_GREEN = "ababababcdcdcdcd0000111122223333deadc0de"
|
||||
SHA_NEW = "aaaabbbbccccddddeeeeffff0000111122223333"
|
||||
|
||||
|
||||
def _branches_response(sha: str) -> dict:
|
||||
@@ -140,6 +150,19 @@ def _branches_response(sha: str) -> dict:
|
||||
return {"name": "main", "commit": {"id": sha}}
|
||||
|
||||
|
||||
def _branch_alt(sha: str) -> dict:
|
||||
"""Identical shape but to a different key path so _make_stub_api
|
||||
retains a separate first-response entry from the primary
|
||||
_branches_response() path.
|
||||
|
||||
The stub stores only the first response per (method, path) pair.
|
||||
Tests that need two distinct responses for the same logical
|
||||
GET /branches/main call use _branch_alt for the second lookup so
|
||||
the stub returns the correct sequential entry.
|
||||
"""
|
||||
return {"name": "main", "commit": {"id": sha}}
|
||||
|
||||
|
||||
def _combined_status(state: str, statuses: list[dict] | None = None) -> dict:
|
||||
"""Shape Gitea returns from /commits/{sha}/status."""
|
||||
return {"state": state, "statuses": statuses or []}
|
||||
@@ -542,7 +565,6 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
|
||||
"""main pending (CI still running) at NEW_SHA → leave old issue alone.
|
||||
Pending could resolve to red, so closing prematurely would lose the
|
||||
breadcrumb of the prior red."""
|
||||
old_title = f"[main-red] owner/repo: {SHA_RED[:10]}"
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_GREEN)),
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_GREEN}/status"): (
|
||||
@@ -561,6 +583,81 @@ def test_auto_close_skips_when_main_pending(wd_module, monkeypatch):
|
||||
assert ("GET", "/repos/owner/repo/issues") not in methods_paths
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Stale-issue cleanup on transient / head-drift (internal#1789)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_head_drift_closes_stale_issue_for_prior_sha(wd_module, monkeypatch):
|
||||
"""Initial red at SHA_RED. Before recheck, main is force-pushed to
|
||||
SHA_NEW (different commit). watchdog must close the stale SHA_RED
|
||||
issue before returning — otherwise stale open issues accumulate
|
||||
when main is force-pushed during a red window."""
|
||||
stub = _make_stub_api({
|
||||
# Initial check: branch SHA_RED, status failure
|
||||
("GET", "/repos/owner/repo/branches/main"): [
|
||||
(200, _branches_response(SHA_RED)),
|
||||
(200, _branch_alt(SHA_NEW)), # recheck branch call → HEAD moved
|
||||
(200, _branch_alt(SHA_NEW)), # close path branch call
|
||||
],
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
|
||||
(200, _combined_status("failure", [
|
||||
{"context": "ci/test", "status": "failure", "description": "broke"},
|
||||
])),
|
||||
(200, _combined_status("success", [ # recheck: CI result arrived
|
||||
{"context": "ci/test", "status": "success"},
|
||||
])),
|
||||
],
|
||||
(f"GET", f"/repos/owner/repo/commits/{SHA_NEW}/status"): [
|
||||
(200, _combined_status("success", [
|
||||
{"context": "ci/test", "status": "success"},
|
||||
])),
|
||||
],
|
||||
# close_open_red_issues_for_other_shas(SHA_NEW): issue for SHA_RED found
|
||||
("GET", "/repos/owner/repo/issues"): [
|
||||
(200, [{"number": 9, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}]),
|
||||
],
|
||||
("POST", "/repos/owner/repo/issues/9/comments"): (201, {"id": 200}),
|
||||
("PATCH", "/repos/owner/repo/issues/9"): (200, {"number": 9, "state": "closed"}),
|
||||
})
|
||||
monkeypatch.setattr(wd_module, "api", stub)
|
||||
rc = wd_module.run_once(dry_run=False)
|
||||
assert rc == 0
|
||||
methods_paths = [(c[0], c[1]) for c in stub.calls]
|
||||
assert ("PATCH", "/repos/owner/repo/issues/9") in methods_paths, \
|
||||
"head-drift should close the stale SHA_RED issue"
|
||||
|
||||
|
||||
def test_recovery_on_same_sha_closes_issue_filed_on_prior_tick(wd_module, monkeypatch):
|
||||
"""Same SHA shows red on initial check, but CI recovers before recheck
|
||||
completes. watchdog must close the issue that was filed on an earlier
|
||||
tick for this same SHA — otherwise stale open issues accumulate when CI
|
||||
recovers within the settling window."""
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branches/main"): (200, _branches_response(SHA_RED)),
|
||||
# Sequential: initial check → failure, recheck (≥2nd call) → success.
|
||||
# Using a list so Python dict keeps a single key (avoids overwrite).
|
||||
("GET", f"/repos/owner/repo/commits/{SHA_RED}/status"): [
|
||||
(200, _combined_status("failure", [
|
||||
{"context": "ci/test", "status": "failure", "description": "broke"},
|
||||
])),
|
||||
(200, _combined_status("success", [
|
||||
{"context": "ci/test", "state": "success"},
|
||||
])),
|
||||
],
|
||||
# List open red issues → find stale issue for this SHA
|
||||
("GET", "/repos/owner/repo/issues"): (
|
||||
200, [{"number": 11, "title": f"[main-red] owner/repo: {SHA_RED[:10]}"}],
|
||||
),
|
||||
("POST", "/repos/owner/repo/issues/11/comments"): (201, {"id": 300}),
|
||||
("PATCH", "/repos/owner/repo/issues/11"): (200, {"number": 11, "state": "closed"}),
|
||||
})
|
||||
monkeypatch.setattr(wd_module, "api", stub)
|
||||
rc = wd_module.run_once(dry_run=False)
|
||||
assert rc == 0
|
||||
methods_paths = [(c[0], c[1]) for c in stub.calls]
|
||||
assert ("PATCH", "/repos/owner/repo/issues/11") in methods_paths, \
|
||||
"recovery-on-same-SHA should close the stale issue"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP-failure / api() raises — duplicate-write regression guard
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -790,7 +887,7 @@ def test_emit_loki_event_prints_json_line(wd_module, capsys, monkeypatch):
|
||||
captured = capsys.readouterr()
|
||||
assert "main-red-watchdog event:" in captured.out
|
||||
# Find the JSON payload after the prefix and verify it parses
|
||||
line = [l for l in captured.out.splitlines() if "main-red-watchdog event:" in l][0]
|
||||
line = [ln for ln in captured.out.splitlines() if "main-red-watchdog event:" in ln][0]
|
||||
payload = json.loads(line.split("main-red-watchdog event:", 1)[1].strip())
|
||||
assert payload["event_type"] == "main_red_detected"
|
||||
assert payload["repo"] == "owner/repo"
|
||||
|
||||
@@ -40,7 +40,6 @@ Dependencies: stdlib + pytest + PyYAML. No network.
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -853,7 +852,6 @@ def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
|
||||
Mock 2 SHAs with combined=success + 1 with combined=failure → only
|
||||
the failure-SHA's statuses get the per-context loop applied.
|
||||
"""
|
||||
per_context_iterated_for: list[str] = []
|
||||
posts: list[tuple[str, dict]] = []
|
||||
|
||||
failure_statuses = [
|
||||
|
||||
@@ -23,11 +23,9 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
# ── Gitea API client ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -160,9 +158,9 @@ def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
|
||||
# Build reverse map: login -> (group, agent_key)
|
||||
login_to_group = {}
|
||||
for group, login in relevant_roles.items():
|
||||
for role, l in AGENT_LOGIN_MAP.items():
|
||||
if l == login:
|
||||
login_to_group[l] = (group, f"core-{role}")
|
||||
for role, role_login in AGENT_LOGIN_MAP.items():
|
||||
if role_login == login:
|
||||
login_to_group[role_login] = (group, f"core-{role}")
|
||||
|
||||
# Collect all agent-tag matches from comments
|
||||
comments = []
|
||||
|
||||
@@ -71,6 +71,7 @@ RUN apk add --no-cache ca-certificates docker-cli docker-cli-buildx git tzdata w
|
||||
COPY --from=builder /platform /platform
|
||||
COPY --from=builder /memory-plugin /memory-plugin
|
||||
COPY workspace-server/migrations /migrations
|
||||
COPY manifest.json /app/manifest.json
|
||||
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
|
||||
# trusted CI / operator-host context, .git already stripped). The Gitea
|
||||
# token used to clone them never enters this image — same shape as
|
||||
|
||||
@@ -118,6 +118,7 @@ RUN deluser --remove-home node 2>/dev/null || true; \
|
||||
COPY --from=go-builder /platform /platform
|
||||
COPY --from=go-builder /memory-plugin /memory-plugin
|
||||
COPY workspace-server/migrations /migrations
|
||||
COPY manifest.json /app/manifest.json
|
||||
|
||||
# Templates + plugins (pre-cloned by scripts/clone-manifest.sh in the
|
||||
# trusted CI / operator-host context, .git already stripped — see
|
||||
|
||||
@@ -50,6 +50,7 @@ import (
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/router"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/scheduler"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/supervised"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/templatecache"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/ws"
|
||||
|
||||
// External plugins — each registers EnvMutator(s) that run at workspace
|
||||
@@ -58,6 +59,7 @@ import (
|
||||
ghidentity "go.moleculesai.app/plugin/gh-identity/pluginloader"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/pkg/provisionhook"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -193,11 +195,28 @@ func main() {
|
||||
port := envOr("PORT", "8080")
|
||||
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
|
||||
configsDir := envOr("CONFIGS_DIR", findConfigsDir())
|
||||
templateCacheDir := envOr("TEMPLATE_CACHE_DIR", filepath.Join(os.TempDir(), "molecule-template-cache"))
|
||||
manifestPath := findWorkspaceManifestPath()
|
||||
templateToken := templateCacheToken()
|
||||
refreshTemplates := func(ctx context.Context) (templatecache.RefreshReport, error) {
|
||||
return templatecache.RefreshWorkspaceTemplates(ctx, manifestPath, templateCacheDir, templateToken)
|
||||
}
|
||||
if shouldRefreshTemplateCache(templateToken, manifestPath) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
report, err := refreshTemplates(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("template cache refresh: %v (continuing with baked templates)", err)
|
||||
} else {
|
||||
log.Printf("template cache refresh: refreshed %d workspace templates into %s", len(report.Results), templateCacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Init order: wh → onWorkspaceOffline → liveness/healthSweep → router
|
||||
// WorkspaceHandler is created before the router so RestartByID can be wired into
|
||||
// the offline callbacks used by both the liveness monitor and the health sweep.
|
||||
wh := handlers.NewWorkspaceHandler(broadcaster, prov, platformURL, configsDir)
|
||||
wh := handlers.NewWorkspaceHandler(broadcaster, prov, platformURL, configsDir).
|
||||
WithTemplateCacheDir(templateCacheDir)
|
||||
if cpProv != nil {
|
||||
wh.SetCPProvisioner(cpProv)
|
||||
}
|
||||
@@ -377,7 +396,12 @@ func main() {
|
||||
// require a plugins/ dir on disk (nil in CP/SaaS mode).
|
||||
pluginRegistry := plugins.NewRegistry()
|
||||
pluginRegistry.Register(plugins.NewGithubResolver())
|
||||
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, wh, channelMgr, memBundle, pluginRegistry)
|
||||
refreshTemplatesHTTP := func(c *gin.Context) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Minute)
|
||||
defer cancel()
|
||||
return refreshTemplates(ctx)
|
||||
}
|
||||
r := router.Setup(hub, broadcaster, prov, platformURL, configsDir, templateCacheDir, wh, channelMgr, memBundle, pluginRegistry, refreshTemplatesHTTP)
|
||||
|
||||
// Plugin drift sweeper — periodic detection of upstream plugin version drift
|
||||
// (core#123). Scans workspace_plugins rows where tracked_ref != 'none',
|
||||
@@ -493,6 +517,40 @@ func findConfigsDir() string {
|
||||
return "workspace-configs-templates"
|
||||
}
|
||||
|
||||
func findWorkspaceManifestPath() string {
|
||||
if v := os.Getenv("WORKSPACE_MANIFEST_PATH"); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, p := range []string{"/app/manifest.json", "manifest.json", "../manifest.json", "../../manifest.json"} {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func templateCacheToken() string {
|
||||
for _, key := range []string{"MOLECULE_TEMPLATE_GITEA_TOKEN", "MOLECULE_GITEA_TOKEN"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shouldRefreshTemplateCache(token, manifestPath string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("TEMPLATE_CACHE_REFRESH"))) {
|
||||
case "0", "false", "off", "no":
|
||||
return false
|
||||
case "1", "true", "on", "yes":
|
||||
return token != "" && manifestPath != ""
|
||||
default:
|
||||
return token != "" && manifestPath != ""
|
||||
}
|
||||
}
|
||||
|
||||
func findMigrationsDir() string {
|
||||
candidates := []string{
|
||||
"migrations",
|
||||
|
||||
@@ -61,8 +61,12 @@ func NewPendingUploadsHandler(storage pendinguploads.Storage) *PendingUploadsHan
|
||||
// - file_id not found
|
||||
// - file_id belongs to a different workspace (cross-workspace bleed
|
||||
// protection)
|
||||
// - row already acked (workspace's bug — should not re-fetch after ack)
|
||||
// - row past expires_at (Phase 3 sweep would delete shortly anyway)
|
||||
//
|
||||
// Acked rows are intentionally still readable until the sweeper's
|
||||
// ack-retention window elapses. Canvas chat history persists
|
||||
// platform-pending: URIs; after a poll-mode workspace acks the handoff,
|
||||
// a browser refresh still needs to preview/download the attachment.
|
||||
func (h *PendingUploadsHandler) GetContent(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
if err := validateWorkspaceID(workspaceID); err != nil {
|
||||
@@ -78,7 +82,7 @@ func (h *PendingUploadsHandler) GetContent(c *gin.Context) {
|
||||
|
||||
rec, err := h.storage.Get(c.Request.Context(), fileID)
|
||||
if errors.Is(err, pendinguploads.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pending upload not found, expired, or already acked"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pending upload not found or expired"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
@@ -181,4 +185,3 @@ func (h *PendingUploadsHandler) Ack(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"acked": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -124,13 +124,17 @@ func TestIntegration_PendingUploads_PutGetAckRoundTrip(t *testing.T) {
|
||||
t.Errorf("FetchedAt should be set after MarkFetched")
|
||||
}
|
||||
|
||||
// Ack flips acked_at; subsequent Gets return ErrNotFound (acked rows
|
||||
// are filtered out at the SELECT predicate).
|
||||
// Ack flips acked_at. Acked rows remain readable during retention so
|
||||
// refreshed canvas previews can resolve platform-pending: attachment URIs.
|
||||
if err := store.Ack(ctx, fileID); err != nil {
|
||||
t.Fatalf("Ack: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, fileID); err != pendinguploads.ErrNotFound {
|
||||
t.Errorf("Get after Ack: got %v, want ErrNotFound", err)
|
||||
rec3, err := store.Get(ctx, fileID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Ack: %v", err)
|
||||
}
|
||||
if rec3.AckedAt == nil {
|
||||
t.Errorf("AckedAt should be set after Ack")
|
||||
}
|
||||
|
||||
// Idempotent re-ack succeeds.
|
||||
|
||||
@@ -54,6 +54,7 @@ const maxUploadFiles = 200
|
||||
|
||||
type TemplatesHandler struct {
|
||||
configsDir string
|
||||
cacheDir string
|
||||
docker *client.Client
|
||||
// wh is used by Import and ReplaceFiles to call DefaultTier() so a
|
||||
// generated config.yaml's tier matches the SaaS-vs-self-hosted
|
||||
@@ -61,6 +62,11 @@ type TemplatesHandler struct {
|
||||
// the caller doesn't import templates that need a fresh config
|
||||
// generated.
|
||||
wh *WorkspaceHandler
|
||||
// refreshCache is nil unless main wires a manifest-backed template
|
||||
// cache refresher. POST /admin/templates/refresh uses this hook so a
|
||||
// template repo merge can update the tenant catalog without rebuilding
|
||||
// the full tenant image.
|
||||
refreshCache func(ctx *gin.Context) (any, error)
|
||||
}
|
||||
|
||||
// NewTemplatesHandler constructs a TemplatesHandler. wh may be nil for
|
||||
@@ -71,12 +77,23 @@ func NewTemplatesHandler(configsDir string, dockerCli *client.Client, wh *Worksp
|
||||
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli, wh: wh}
|
||||
}
|
||||
|
||||
func (h *TemplatesHandler) WithCacheDir(cacheDir string) *TemplatesHandler {
|
||||
h.cacheDir = cacheDir
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *TemplatesHandler) WithRefreshFunc(fn func(ctx *gin.Context) (any, error)) *TemplatesHandler {
|
||||
h.refreshCache = fn
|
||||
return h
|
||||
}
|
||||
|
||||
// modelSpec describes a single supported model on a template: its id (sent
|
||||
// to the runtime), a human-readable label, and the env vars that must be
|
||||
// present for that model to work (e.g. API keys).
|
||||
type modelSpec struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
Name string `json:"name,omitempty" yaml:"name"`
|
||||
Provider string `json:"provider,omitempty" yaml:"provider"`
|
||||
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
|
||||
}
|
||||
|
||||
@@ -116,6 +133,10 @@ type templateSummary struct {
|
||||
// preflight uses this as the fallback provider when `models` is empty
|
||||
// so provider picker stays data-driven instead of hardcoded in the UI.
|
||||
RequiredEnv []string `json:"required_env,omitempty"`
|
||||
// RecommendedEnv mirrors runtime_config.recommended_env from the
|
||||
// template's config.yaml. Canvas prompts for these as non-blocking
|
||||
// optional secrets during template deploy.
|
||||
RecommendedEnv []string `json:"recommended_env,omitempty"`
|
||||
// Providers is the runtime's own list of supported provider slugs,
|
||||
// sourced from runtime_config.providers in the template's config.yaml.
|
||||
// The canvas Config tab surfaces this as the Provider override
|
||||
@@ -156,6 +177,15 @@ type templateSummary struct {
|
||||
// Only resolves to actual templates (not ws-* dirs since those are now Docker volumes).
|
||||
// Returns empty string if no matching template is found.
|
||||
func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
|
||||
if h.cacheDir != "" {
|
||||
nameDir := filepath.Join(h.cacheDir, normalizeName(wsName))
|
||||
if _, err := os.Stat(nameDir); err == nil {
|
||||
return nameDir
|
||||
}
|
||||
if tmpl := findTemplateByName(h.cacheDir, wsName); tmpl != "" {
|
||||
return filepath.Join(h.cacheDir, tmpl)
|
||||
}
|
||||
}
|
||||
nameDir := filepath.Join(h.configsDir, normalizeName(wsName))
|
||||
if _, err := os.Stat(nameDir); err == nil {
|
||||
return nameDir
|
||||
@@ -170,76 +200,104 @@ func (h *TemplatesHandler) resolveTemplateDir(wsName string) string {
|
||||
// List handles GET /templates
|
||||
func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
templates := make([]templateSummary, 0)
|
||||
walkTemplateConfigs(h.configsDir, func(id string, data []byte) {
|
||||
var raw struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
// Top-level `providers:` block — structured registry. Distinct
|
||||
// from runtime_config.providers (slug list) below. Both shapes
|
||||
// coexist in production: claude-code ships the structured
|
||||
// registry, hermes still uses the slug list. /templates surfaces
|
||||
// both verbatim so each runtime owns its taxonomy.
|
||||
Providers []providerRegistryEntry `yaml:"providers"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
RequiredEnv []string `yaml:"required_env"`
|
||||
Providers []string `yaml:"providers"`
|
||||
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
// Without this log a malformed config.yaml causes the
|
||||
// template to silently disappear from /templates with no
|
||||
// trace — the operator can't tell "excluded due to parse
|
||||
// error" from "never existed." That matters more now that
|
||||
// templates ship richer YAML shapes (top-level providers
|
||||
// registry, models[] with required_env, etc.) where a
|
||||
// type-shape mismatch on one field drops the whole entry.
|
||||
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
|
||||
return
|
||||
}
|
||||
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
|
||||
if _, ok := knownRuntimes[runtime]; !ok {
|
||||
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
|
||||
seen := map[string]struct{}{}
|
||||
walk := func(root string) {
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
walkTemplateConfigs(root, func(id string, data []byte) {
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
var raw struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Tier int `yaml:"tier"`
|
||||
Runtime string `yaml:"runtime"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
// Top-level `providers:` block — structured registry. Distinct
|
||||
// from runtime_config.providers (slug list) below. Both shapes
|
||||
// coexist in production: claude-code ships the structured
|
||||
// registry, hermes still uses the slug list. /templates surfaces
|
||||
// both verbatim so each runtime owns its taxonomy.
|
||||
Providers []providerRegistryEntry `yaml:"providers"`
|
||||
RuntimeConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
Models []modelSpec `yaml:"models"`
|
||||
RequiredEnv []string `yaml:"required_env"`
|
||||
RecommendedEnv []string `yaml:"recommended_env"`
|
||||
Providers []string `yaml:"providers"`
|
||||
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
|
||||
} `yaml:"runtime_config"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
// Without this log a malformed config.yaml causes the
|
||||
// template to silently disappear from /templates with no
|
||||
// trace — the operator can't tell "excluded due to parse
|
||||
// error" from "never existed." That matters more now that
|
||||
// templates ship richer YAML shapes (top-level providers
|
||||
// registry, models[] with required_env, etc.) where a
|
||||
// type-shape mismatch on one field drops the whole entry.
|
||||
log.Printf("templates list: skip %s: yaml.Unmarshal: %v", id, err)
|
||||
return
|
||||
}
|
||||
runtime := strings.TrimSuffix(strings.TrimSpace(raw.Runtime), "-default")
|
||||
if _, ok := knownRuntimes[runtime]; !ok {
|
||||
log.Printf("templates list: skip %s: unsupported runtime %q", id, raw.Runtime)
|
||||
return
|
||||
}
|
||||
|
||||
// Model comes from either top-level (legacy) or runtime_config.model (current).
|
||||
model := raw.Model
|
||||
if model == "" {
|
||||
model = raw.RuntimeConfig.Model
|
||||
}
|
||||
// Model comes from either top-level (legacy) or runtime_config.model (current).
|
||||
model := raw.Model
|
||||
if model == "" {
|
||||
model = raw.RuntimeConfig.Model
|
||||
}
|
||||
|
||||
tier := raw.Tier
|
||||
if h.wh != nil && h.wh.IsSaaS() {
|
||||
tier = h.wh.DefaultTier()
|
||||
}
|
||||
tier := raw.Tier
|
||||
if h.wh != nil && h.wh.IsSaaS() {
|
||||
tier = h.wh.DefaultTier()
|
||||
}
|
||||
|
||||
templates = append(templates, templateSummary{
|
||||
ID: id,
|
||||
Name: raw.Name,
|
||||
Description: raw.Description,
|
||||
Tier: tier,
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
|
||||
Providers: raw.RuntimeConfig.Providers,
|
||||
ProviderRegistry: raw.Providers,
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
|
||||
templates = append(templates, templateSummary{
|
||||
ID: id,
|
||||
Name: raw.Name,
|
||||
Description: raw.Description,
|
||||
Tier: tier,
|
||||
Runtime: raw.Runtime,
|
||||
Model: model,
|
||||
Models: raw.RuntimeConfig.Models,
|
||||
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
|
||||
RecommendedEnv: raw.RuntimeConfig.RecommendedEnv,
|
||||
Providers: raw.RuntimeConfig.Providers,
|
||||
ProviderRegistry: raw.Providers,
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
walk(h.cacheDir)
|
||||
walk(h.configsDir)
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// RefreshCache handles POST /admin/templates/refresh.
|
||||
func (h *TemplatesHandler) RefreshCache(c *gin.Context) {
|
||||
if h.refreshCache == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "template cache refresh is not configured"})
|
||||
return
|
||||
}
|
||||
result, err := h.refreshCache(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ListFiles handles GET /workspaces/:id/files
|
||||
// Lists files inside the running container's /configs directory (or /workspace, etc.).
|
||||
// Falls back to host-side config templates directory when container isn't running.
|
||||
|
||||
@@ -133,6 +133,71 @@ skills:
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_CacheOverridesBakedTemplate(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
bakedDir := t.TempDir()
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
mustWriteTemplate := func(root, id, body string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(body), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWriteTemplate(bakedDir, "seo-agent", `name: SEO Agent
|
||||
description: stale
|
||||
tier: 4
|
||||
runtime: claude-code
|
||||
model: old
|
||||
runtime_config:
|
||||
recommended_env: [TELEGRAM_BOT_TOKEN]
|
||||
skills: []
|
||||
`)
|
||||
mustWriteTemplate(cacheDir, "seo-agent", `name: SEO Agent
|
||||
description: fresh
|
||||
tier: 4
|
||||
runtime: claude-code
|
||||
model: moonshot/kimi-k2.6
|
||||
runtime_config:
|
||||
required_env: [TENANT_NAME]
|
||||
recommended_env: [GOOGLE_GSC_SITE]
|
||||
skills: []
|
||||
`)
|
||||
|
||||
handler := NewTemplatesHandler(bakedDir, nil, nil).WithCacheDir(cacheDir)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 template, got %d", len(resp))
|
||||
}
|
||||
if resp[0].Description != "fresh" {
|
||||
t.Fatalf("cache template should override baked copy, got description %q", resp[0].Description)
|
||||
}
|
||||
if !reflect.DeepEqual(resp[0].RequiredEnv, []string{"TENANT_NAME"}) {
|
||||
t.Fatalf("RequiredEnv = %+v", resp[0].RequiredEnv)
|
||||
}
|
||||
if reflect.DeepEqual(resp[0].RecommendedEnv, []string{"TELEGRAM_BOT_TOKEN"}) {
|
||||
t.Fatalf("stale baked recommended_env leaked through: %+v", resp[0].RecommendedEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatesList_RuntimeAndModelsRegistry(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -148,12 +213,14 @@ tier: 2
|
||||
runtime: hermes
|
||||
runtime_config:
|
||||
model: nous-hermes-3-70b
|
||||
recommended_env: [GOOGLE_GSC_SITE, GOOGLE_GA4_PROPERTY_ID]
|
||||
models:
|
||||
- id: nous-hermes-3-70b
|
||||
name: Nous Hermes 3 70B
|
||||
required_env: [HERMES_API_KEY]
|
||||
- id: minimax/minimax-m2.7
|
||||
name: MiniMax M2.7 (via OpenRouter)
|
||||
provider: platform
|
||||
required_env: [OPENROUTER_API_KEY]
|
||||
skills: []
|
||||
`
|
||||
@@ -196,9 +263,17 @@ skills: []
|
||||
if got.Models[1].ID != "minimax/minimax-m2.7" {
|
||||
t.Errorf("Models[1].ID: got %q", got.Models[1].ID)
|
||||
}
|
||||
if got.Models[1].Provider != "platform" {
|
||||
t.Errorf("Models[1].Provider: got %q", got.Models[1].Provider)
|
||||
}
|
||||
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
|
||||
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
|
||||
}
|
||||
if len(got.RecommendedEnv) != 2 ||
|
||||
got.RecommendedEnv[0] != "GOOGLE_GSC_SITE" ||
|
||||
got.RecommendedEnv[1] != "GOOGLE_GA4_PROPERTY_ID" {
|
||||
t.Errorf("RecommendedEnv: want [GOOGLE_GSC_SITE GOOGLE_GA4_PROPERTY_ID], got %+v", got.RecommendedEnv)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplatesList_SurfacesProviders pins the Option B PR-5 wiring:
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
@@ -6,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
@@ -16,22 +20,31 @@ import (
|
||||
|
||||
func init() { gin.SetMode(gin.TestMode) }
|
||||
|
||||
// setupTokenTestDB creates an in-memory SQLite-like test or returns early
|
||||
// if the real Postgres test DB is available. For unit tests we use the
|
||||
// package-level db.DB which handlers rely on.
|
||||
// setupTokenTestDB connects to $INTEGRATION_DB_URL (skipping the test if
|
||||
// unset), sets the package-global db.DB for the duration of the test, and
|
||||
// returns a cleanup func that restores the previous db.DB value.
|
||||
func setupTokenTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
if db.DB == nil {
|
||||
t.Skip("db.DB not initialised — run with a test database")
|
||||
url := os.Getenv("INTEGRATION_DB_URL")
|
||||
if url == "" {
|
||||
t.Skip("INTEGRATION_DB_URL not set; skipping (local devs: start a Postgres container and export INTEGRATION_DB_URL)")
|
||||
}
|
||||
// Quick probe — if the DB is closed or unreachable, skip.
|
||||
if err := db.DB.Ping(); err != nil {
|
||||
t.Skipf("db.DB not reachable: %v", err)
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open integration DB: %v", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping integration DB: %v", err)
|
||||
}
|
||||
prevDB := db.DB
|
||||
db.DB = conn
|
||||
return func() {
|
||||
db.DB = prevDB
|
||||
conn.Close()
|
||||
}
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func TestTokenHandler_CreateAndList(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_CreateAndList(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -94,7 +107,7 @@ func TestTokenHandler_CreateAndList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_Revoke(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_Revoke(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -151,7 +164,7 @@ func TestTokenHandler_Revoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
func TestIntegration_TokenHandler_RevokeWrongWorkspace(t *testing.T) {
|
||||
cleanup := setupTokenTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ type WorkspaceHandler struct {
|
||||
cpProv provisioner.CPProvisionerAPI
|
||||
platformURL string
|
||||
configsDir string // path to workspace-configs-templates/ (for reading templates)
|
||||
cacheDir string // optional runtime-refreshed template cache; overrides configsDir by template id
|
||||
// envMutators runs registered EnvMutator plugins right before
|
||||
// container Start, after built-in secret loads. Nil = no plugins
|
||||
// registered; Registry.Run handles a nil receiver as a no-op so the
|
||||
@@ -183,6 +184,11 @@ func NewWorkspaceHandler(b events.EventEmitter, p *provisioner.Provisioner, plat
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) WithTemplateCacheDir(cacheDir string) *WorkspaceHandler {
|
||||
h.cacheDir = cacheDir
|
||||
return h
|
||||
}
|
||||
|
||||
// WithNamespaceCleanup wires the I5 hook (RFC #2728) so workspace
|
||||
// purge can drop the plugin's `workspace:<id>` namespace. main.go
|
||||
// passes a closure over plugin.DeleteNamespace; tests pass a stub.
|
||||
@@ -285,7 +291,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// #226: payload.Template is attacker-controllable. resolveInsideRoot
|
||||
// rejects absolute paths and any ".." that escapes configsDir so the
|
||||
// provisioner can't be pointed at host directories.
|
||||
candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template)
|
||||
candidatePath, resolveErr := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, payload.Template)
|
||||
if resolveErr != nil {
|
||||
log.Printf("Create: invalid template path %q: %v", payload.Template, resolveErr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template"})
|
||||
@@ -726,7 +732,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
var templatePath string
|
||||
var configFiles map[string][]byte
|
||||
if payload.Template != "" {
|
||||
candidatePath, resolveErr := resolveInsideRoot(h.configsDir, payload.Template)
|
||||
candidatePath, resolveErr := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, payload.Template)
|
||||
if resolveErr != nil {
|
||||
log.Printf("Create provision: rejecting template %q: %v", payload.Template, resolveErr)
|
||||
return
|
||||
|
||||
@@ -485,6 +485,17 @@ func findTemplateByName(configsDir, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveWorkspaceTemplatePath(configsDir, cacheDir, template string) (string, error) {
|
||||
if cacheDir != "" {
|
||||
if p, err := resolveInsideRoot(cacheDir, template); err != nil {
|
||||
return "", err
|
||||
} else if _, statErr := os.Stat(p); statErr == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return resolveInsideRoot(configsDir, template)
|
||||
}
|
||||
|
||||
// resolveOrgTemplate looks for a matching role directory under
|
||||
// configsDir/org-templates/ and returns the absolute path and a short label
|
||||
// ("org-templates/<dir>"). Used by the restart handler's rebuild_config path
|
||||
@@ -658,7 +669,7 @@ func (h *WorkspaceHandler) defaultTemplateProvidersYAML(runtime string) string {
|
||||
return ""
|
||||
}
|
||||
templateName := runtime + "-default"
|
||||
templatePath, err := resolveInsideRoot(h.configsDir, templateName)
|
||||
templatePath, err := resolveWorkspaceTemplatePath(h.configsDir, h.cacheDir, templateName)
|
||||
if err != nil {
|
||||
log.Printf("Provisioner: default template providers skipped for runtime %s: %v", runtime, err)
|
||||
return ""
|
||||
|
||||
@@ -110,6 +110,32 @@ func TestFindTemplateByName_NotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkspaceTemplatePath_PrefersCache(t *testing.T) {
|
||||
bakedDir := t.TempDir()
|
||||
cacheDir := t.TempDir()
|
||||
|
||||
for _, root := range []string{bakedDir, cacheDir} {
|
||||
if err := os.MkdirAll(filepath.Join(root, "seo-agent"), 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := resolveWorkspaceTemplatePath(bakedDir, cacheDir, "seo-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveWorkspaceTemplatePath: %v", err)
|
||||
}
|
||||
want := filepath.Join(cacheDir, "seo-agent")
|
||||
if got != want {
|
||||
t.Fatalf("want cache path %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkspaceTemplatePath_RejectsTraversal(t *testing.T) {
|
||||
if _, err := resolveWorkspaceTemplatePath(t.TempDir(), t.TempDir(), "../seo-agent"); err == nil {
|
||||
t.Fatal("expected traversal to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTemplateByName_SkipsWsPrefix(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ var apiPrefixes = []string{
|
||||
"/settings",
|
||||
"/bundles",
|
||||
"/org",
|
||||
"/orgs", // #610 — per-org plugin allowlist routes
|
||||
"/orgs", // #610 — per-org plugin allowlist routes
|
||||
"/templates",
|
||||
"/plugins",
|
||||
"/webhooks",
|
||||
@@ -95,6 +95,7 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
"script-src 'self' 'unsafe-inline'; "+
|
||||
"style-src 'self' 'unsafe-inline'; "+
|
||||
"img-src 'self' data: blob:; "+
|
||||
"frame-src 'self' blob:; "+
|
||||
"connect-src 'self' ws: wss:; "+
|
||||
"font-src 'self' data:")
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"frame-src 'self' blob:",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"font-src 'self' data:",
|
||||
} {
|
||||
@@ -195,6 +196,9 @@ func TestCSPCanvasRoutesGetPermissivePolicy(t *testing.T) {
|
||||
if strings.Contains(csp, "'unsafe-eval'") {
|
||||
t.Errorf("canvas path %q: CSP must not contain 'unsafe-eval', got %q", path, csp)
|
||||
}
|
||||
if !strings.Contains(csp, "frame-src 'self' blob:") {
|
||||
t.Errorf("canvas path %q: CSP should allow blob: frames for PDF previews, got %q", path, csp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -267,7 +271,7 @@ func TestIsAPIPath(t *testing.T) {
|
||||
{"/ws", true},
|
||||
{"/events", true},
|
||||
{"/approvals", true},
|
||||
{"/orgs", true}, // #610 allowlist routes
|
||||
{"/orgs", true}, // #610 allowlist routes
|
||||
{"/orgs/org-1/plugins/allowlist", true},
|
||||
// Sub-paths
|
||||
{"/workspaces/abc-123", true},
|
||||
|
||||
@@ -320,20 +320,18 @@ func putBatchInsertRows(ctx context.Context, tx *sql.Tx, workspaceID uuid.UUID,
|
||||
}
|
||||
|
||||
func (p *PostgresStorage) Get(ctx context.Context, fileID uuid.UUID) (Record, error) {
|
||||
// The expires_at + acked_at filter in the WHERE clause means a
|
||||
// caller sees ErrNotFound for absent / acked / expired without
|
||||
// needing per-case branching. Trade-off: we can't differentiate
|
||||
// in metrics, but the workspace's response is the same in all
|
||||
// three cases ("file gone, give up") so the granularity isn't
|
||||
// useful at this layer. Phase 3 dashboards aggregate row-state
|
||||
// counts directly off the table.
|
||||
// The expires_at filter keeps hard-TTL semantics while allowing
|
||||
// acked rows to remain readable during the ack-retention window.
|
||||
// Canvas chat history stores platform-pending: URIs; after the
|
||||
// poll-mode workspace acks the upload, refreshed browser previews
|
||||
// still need to fetch the same bytes until the sweeper reclaims
|
||||
// the acked row.
|
||||
var r Record
|
||||
err := p.db.QueryRowContext(ctx, `
|
||||
SELECT file_id, workspace_id, content, filename, mimetype,
|
||||
size_bytes, created_at, fetched_at, acked_at, expires_at
|
||||
FROM pending_uploads
|
||||
WHERE file_id = $1
|
||||
AND acked_at IS NULL
|
||||
AND expires_at > now()
|
||||
`, fileID).Scan(
|
||||
&r.FileID, &r.WorkspaceID, &r.Content, &r.Filename, &r.Mimetype,
|
||||
@@ -349,15 +347,14 @@ func (p *PostgresStorage) Get(ctx context.Context, fileID uuid.UUID) (Record, er
|
||||
}
|
||||
|
||||
func (p *PostgresStorage) MarkFetched(ctx context.Context, fileID uuid.UUID) error {
|
||||
// UPDATE on the same gating predicate as Get — keeps the "absent
|
||||
// or acked or expired = ErrNotFound" contract symmetric. Without
|
||||
// the predicate a workspace could re-stamp fetched_at on an acked
|
||||
// row, which would mislead Phase 3's stuck-fetch dashboard.
|
||||
// UPDATE on the same expiry predicate as Get. This may re-stamp
|
||||
// fetched_at on an acked row when the canvas previews an attachment
|
||||
// after refresh, which is fine: acked_at remains the delivery-time
|
||||
// signal and the sweeper still deletes by acked_at retention.
|
||||
res, err := p.db.ExecContext(ctx, `
|
||||
UPDATE pending_uploads
|
||||
SET fetched_at = now()
|
||||
WHERE file_id = $1
|
||||
AND acked_at IS NULL
|
||||
AND expires_at > now()
|
||||
`, fileID)
|
||||
if err != nil {
|
||||
|
||||
@@ -50,14 +50,12 @@ const (
|
||||
size_bytes, created_at, fetched_at, acked_at, expires_at
|
||||
FROM pending_uploads
|
||||
WHERE file_id = $1
|
||||
AND acked_at IS NULL
|
||||
AND expires_at > now()
|
||||
`
|
||||
markFetchedSQL = `
|
||||
UPDATE pending_uploads
|
||||
SET fetched_at = now()
|
||||
WHERE file_id = $1
|
||||
AND acked_at IS NULL
|
||||
AND expires_at > now()
|
||||
`
|
||||
ackSQL = `
|
||||
@@ -203,6 +201,36 @@ func TestGet_HappyPath_ReturnsFullRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AckedRowWithinRetentionStillReturnsFullRow(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
fid := uuid.New()
|
||||
wsID := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
ackedAt := now.Add(-5 * time.Minute)
|
||||
mock.ExpectQuery(selectSQL).
|
||||
WithArgs(fid).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"file_id", "workspace_id", "content", "filename", "mimetype",
|
||||
"size_bytes", "created_at", "fetched_at", "acked_at", "expires_at",
|
||||
}).AddRow(
|
||||
fid, wsID, []byte("data"), "x.bin", "application/octet-stream",
|
||||
int64(4), now, now, ackedAt, now.Add(24*time.Hour),
|
||||
))
|
||||
|
||||
r, err := store.Get(context.Background(), fid)
|
||||
if err != nil {
|
||||
t.Fatalf("Get acked row: %v", err)
|
||||
}
|
||||
if r.AckedAt == nil || !r.AckedAt.Equal(ackedAt) {
|
||||
t.Fatalf("acked_at not preserved: %+v", r.AckedAt)
|
||||
}
|
||||
if string(r.Content) != "data" {
|
||||
t.Errorf("content mismatch: %q", string(r.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_AbsentRow_ReturnsErrNotFound(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
@@ -247,7 +275,7 @@ func TestMarkFetched_HappyPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkFetched_AbsentOrAckedOrExpired_ReturnsErrNotFound(t *testing.T) {
|
||||
func TestMarkFetched_AbsentOrExpired_ReturnsErrNotFound(t *testing.T) {
|
||||
db, mock := newMockDB(t)
|
||||
store := pendinguploads.NewPostgres(db)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
// (main.go) gets the same pluginResolver instance so it can share scheme
|
||||
// enumeration if a deployment registers extra schemes externally. A nil
|
||||
// pluginResolver is harmless: plgh still works with its built-in defaults.
|
||||
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver) *gin.Engine {
|
||||
func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provisioner, platformURL, configsDir string, templateCacheDir string, wh *handlers.WorkspaceHandler, channelMgr *channels.Manager, memBundle *memwiring.Bundle, pluginResolver plugins.PluginResolver, refreshTemplates func(ctx *gin.Context) (any, error)) *gin.Engine {
|
||||
r := gin.Default()
|
||||
|
||||
// Issue #179 — trust no reverse-proxy headers. Without this call Gin's
|
||||
@@ -666,7 +666,9 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
|
||||
// Templates — wh threaded so generateDefaultConfig picks the
|
||||
// SaaS-aware default tier in Import + ReplaceFiles (#2910 PR-B).
|
||||
tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli, wh)
|
||||
tmplh := handlers.NewTemplatesHandler(configsDir, dockerCli, wh).
|
||||
WithCacheDir(templateCacheDir).
|
||||
WithRefreshFunc(refreshTemplates)
|
||||
// #686: GET /templates lists all template names+metadata from configsDir.
|
||||
// Open access lets unauthenticated callers enumerate org configurations and
|
||||
// installed plugins. AdminAuth-gate it alongside POST /templates/import.
|
||||
@@ -676,6 +678,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi
|
||||
tmplAdmin := r.Group("", middleware.AdminAuth(db.DB))
|
||||
tmplAdmin.GET("/templates", tmplh.List)
|
||||
tmplAdmin.POST("/templates/import", tmplh.Import)
|
||||
tmplAdmin.POST("/admin/templates/refresh", tmplh.RefreshCache)
|
||||
}
|
||||
wsAuth.PUT("/files", tmplh.ReplaceFiles)
|
||||
wsAuth.GET("/files", tmplh.ListFiles)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package templatecache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManifestEntry struct {
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Ref string `json:"ref"`
|
||||
}
|
||||
|
||||
type manifestFile struct {
|
||||
WorkspaceTemplates []ManifestEntry `json:"workspace_templates"`
|
||||
}
|
||||
|
||||
type TemplateResult struct {
|
||||
Name string `json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Ref string `json:"ref"`
|
||||
SHA string `json:"sha,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RefreshReport struct {
|
||||
ManifestPath string `json:"manifest_path"`
|
||||
CacheDir string `json:"cache_dir"`
|
||||
RefreshedAt time.Time `json:"refreshed_at"`
|
||||
Results []TemplateResult `json:"results"`
|
||||
}
|
||||
|
||||
func RefreshWorkspaceTemplates(ctx context.Context, manifestPath, cacheDir, token string) (RefreshReport, error) {
|
||||
report := RefreshReport{
|
||||
ManifestPath: manifestPath,
|
||||
CacheDir: cacheDir,
|
||||
RefreshedAt: time.Now().UTC(),
|
||||
}
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return report, fmt.Errorf("template cache refresh requires MOLECULE_TEMPLATE_GITEA_TOKEN or MOLECULE_GITEA_TOKEN")
|
||||
}
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read manifest: %w", err)
|
||||
}
|
||||
var manifest manifestFile
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return report, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
return report, fmt.Errorf("mkdir cache: %w", err)
|
||||
}
|
||||
for _, entry := range manifest.WorkspaceTemplates {
|
||||
result := refreshOne(ctx, cacheDir, token, entry)
|
||||
report.Results = append(report.Results, result)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func refreshOne(ctx context.Context, cacheDir, token string, entry ManifestEntry) TemplateResult {
|
||||
result := TemplateResult{Name: entry.Name, Repo: entry.Repo, Ref: entry.Ref}
|
||||
if result.Ref == "" {
|
||||
result.Ref = "main"
|
||||
}
|
||||
if !safeTemplateName(entry.Name) {
|
||||
result.Status = "skipped"
|
||||
result.Error = "invalid template name"
|
||||
return result
|
||||
}
|
||||
if strings.TrimSpace(entry.Repo) == "" {
|
||||
result.Status = "skipped"
|
||||
result.Error = "missing repo"
|
||||
return result
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp(cacheDir, ".tmp-"+entry.Name+"-")
|
||||
if err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
cloneURL := authenticatedURL(entry.Repo, token)
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q", tmp},
|
||||
{"-C", tmp, "remote", "add", "origin", cloneURL},
|
||||
{"-C", tmp, "fetch", "--depth=1", "-q", "origin", result.Ref},
|
||||
{"-C", tmp, "checkout", "-q", "--detach", "FETCH_HEAD"},
|
||||
} {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = sanitizeGitError(out, err, token)
|
||||
return result
|
||||
}
|
||||
}
|
||||
shaCmd := exec.CommandContext(ctx, "git", "-C", tmp, "rev-parse", "HEAD")
|
||||
if out, err := shaCmd.Output(); err == nil {
|
||||
result.SHA = strings.TrimSpace(string(out))
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(tmp, ".git"))
|
||||
|
||||
target := filepath.Join(cacheDir, entry.Name)
|
||||
old := filepath.Join(cacheDir, ".old-"+entry.Name+"-"+fmt.Sprint(time.Now().UnixNano()))
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if err := os.Rename(target, old); err != nil {
|
||||
result.Status = "failed"
|
||||
result.Error = "replace old cache: " + err.Error()
|
||||
return result
|
||||
}
|
||||
defer os.RemoveAll(old)
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
if old != "" {
|
||||
_ = os.Rename(old, target)
|
||||
}
|
||||
result.Status = "failed"
|
||||
result.Error = "install cache: " + err.Error()
|
||||
return result
|
||||
}
|
||||
result.Status = "refreshed"
|
||||
return result
|
||||
}
|
||||
|
||||
func safeTemplateName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func authenticatedURL(repo, token string) string {
|
||||
if strings.HasPrefix(repo, "http://") || strings.HasPrefix(repo, "https://") {
|
||||
u, err := url.Parse(repo)
|
||||
if err == nil {
|
||||
u.User = url.UserPassword("oauth2", token)
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: "git.moleculesai.app",
|
||||
Path: "/" + strings.TrimSuffix(repo, ".git") + ".git",
|
||||
User: url.UserPassword("oauth2", token),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func sanitizeGitError(out []byte, err error, token string) string {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
if token != "" {
|
||||
msg = strings.ReplaceAll(msg, token, "***")
|
||||
}
|
||||
if len(msg) > 300 {
|
||||
msg = msg[:300]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package templatecache
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSafeTemplateName(t *testing.T) {
|
||||
for _, name := range []string{"seo-agent", "claude_code", "T4"} {
|
||||
if !safeTemplateName(name) {
|
||||
t.Fatalf("%q should be safe", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", "../seo", "seo/agent", "seo.agent"} {
|
||||
if safeTemplateName(name) {
|
||||
t.Fatalf("%q should be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user