Compare commits

...

26 Commits

Author SHA1 Message Date
core-be d88a320f0c fix: resolve SourceResolver naming conflict, SSRF guard placement, and multiple test regressions
- plugins/drift_sweeper.go: rename SourceResolver→PluginResolver to avoid
  redeclaring the interface already defined in source.go (core#228)

- handlers/workspace.go: move SSRF guard before BeginTx so URL rejection
  never touches the DB (core#212 fix — same pattern as registry.go:324)

- handlers/restart_signals.go: convert rewriteForDocker standalone function
  to a method on *WorkspaceHandler; fix two call sites to use h.rewriteForDocker

- handlers/plugins.go: change Sources() return type from plugins.SourceResolver
  to pluginSources (the narrow interface satisfied by *Registry)

- handlers/admin_plugin_drift.go: remove unused "context" import

- handlers/delegation_test.go: remove stray closing brace

- handlers/restart_signals_test.go: rewrite with correct miniredis v2 API
  (mr.Get takes context, mr.Set requires TTL), resolveURLTestWrapper embedding
  pattern, and corrected Redis key handling

- handlers/workspace_test.go: use http://localhost:8000 for SSRF-safe test
  (no DNS required); remove spurious mock.ExpectExec for Redis CacheURL call

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 06:05:11 +00:00
core-lead 08a929c740 Merge pull request 'test(canvas): structural tests for TIER_CONFIG and COMM_TYPE_LABELS' (#245) from test/canvas-design-tokens-config into main
publish-workspace-server-image / build-and-push (push) Failing after 9s
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:58:28 +00:00
Molecule AI Core Platform Lead 64c7af2968 trigger
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-10 05:58:09 +00:00
core-fe 814c7cc460 test(canvas): add structural tests for TIER_CONFIG and COMM_TYPE_LABELS
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
Both are data constants exported from design-tokens.ts — TIER_CONFIG
maps tier levels 1-4 to label/color/border CSS classes, and
COMM_TYPE_LABELS maps a2a_send/a2a_receive/task_update to display
labels. No logic to test; structural shape coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 05:51:40 +00:00
core-lead 2b1c51d837 Merge pull request 'feat(canvas): document all keyboard shortcuts in Toolbar help dialog' (#244) from feat/canvas-keyboard-shortcuts-help into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Failing after 9s
2026-05-10 05:33:52 +00:00
Molecule AI Core Platform Lead 5327866847 trigger
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
2026-05-10 05:33:34 +00:00
core-fe 3c934dfce0 feat(canvas): document all keyboard shortcuts and interactions in the help dialog
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
Issue: MEDIUM priority from canvas accessibility audit (2026-05-09).
The existing Quick Start help dialog in Toolbar omitted most keyboard shortcuts
from useKeyboardShortcuts.ts — users couldn't discover them visually.

Changes:
- Toolbar.tsx: enhance the help dialog (role="dialog") to include all
  documented shortcuts: Esc, Enter, Shift+Enter, Cmd+], Cmd+[, Z, plus
  mouse interaction tips for Palette, Right-click, Dbl-click, Shift+click.
  Renamed from "Quick start" to "Shortcuts & tips".
- canvas-audit-items.md: update Keyboard Shortcuts section from PARTIAL
  to complete; mark help dialog item as done.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 05:26:06 +00:00
claude-ceo-assistant 6153d47d8f Merge pull request 'test(canvas): add cssVar unit tests for ColorToken → CSS variable mapping' (#239) from test/canvas-cssvar-tests into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
publish-workspace-server-image / build-and-push (push) Failing after 9s
2026-05-10 05:23:13 +00:00
claude-ceo-assistant 71abd72e70 Merge pull request 'fix(sop-tier-check): clause splitter strips newlines — every tier:low PR fails (#229)' (#243) from fix/internal-229-sop-tier-check-tier-low-relaxation into main
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:23:11 +00:00
core-fe 3884580aaa test(canvas): add cssVar unit tests for theme token → CSS variable mapping
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 5s
audit-force-merge / audit (pull_request) Successful in 4s
Covers all ColorToken variants (surface, ink, accent, good, bad, warm,
bg, warn, plasma), pure-function property (deterministic output).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 05:06:42 +00:00
claude-ceo-assistant 02a1de75aa Merge pull request 'test(canvas): add pure-function tests for deriveWsBaseUrl, statusDotClass, and readThemeCookie' (#238) from test/canvas-utility-pure-tests into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Failing after 11s
2026-05-10 05:03:53 +00:00
claude-ceo-assistant 8fff99c525 Merge pull request 'test(canvas): add pure-function tests for resolveRuntime and canvas-topology utilities' (#236) from test/canvas-preflight-utils-tests into main
publish-workspace-server-image / build-and-push (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:03:50 +00:00
claude-ceo-assistant e5da324a53 Merge pull request 'test(canvas): add pure-function tests for runtimeProfiles, getIcon, and createMessage' (#235) from test/canvas-runtimeprofiles-tests into main
publish-workspace-server-image / build-and-push (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:03:47 +00:00
claude-ceo-assistant b4591a1bff Merge pull request 'fix(ci): port publish-workspace-server-image.yml from .github/ to .gitea/workflows/ (issue #228)' (#237) from fix/ci-port-publish-workspace-server-image-228 into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
publish-workspace-server-image / build-and-push (push) Failing after 10s
2026-05-10 05:03:30 +00:00
claude-ceo-assistant f72a5ecc2c Merge pull request 'test(canvas/config): add pure-function tests for parseYaml and toYaml' (#233) from test/canvas-yaml-utils-tests into main
publish-workspace-server-image / build-and-push (push) Has been cancelled
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:03:29 +00:00
claude-ceo-assistant 0ac19da699 Merge pull request 'test(canvas): add pure-function tests for extractMessageText and providerIdForModel' (#227) from test/canvas-pure-function-tests into main
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-10 05:03:28 +00:00
dev-lead b75187d11c fix(sop-tier-check): clause splitter strips newlines, OR-set collapses to one token (#229)
sop-tier-check / tier-check (pull_request) Failing after 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 4s
PR #225 introduced the AND-composition clause evaluator. PR #231
patched the per-team case-pattern matching but did NOT fix the
underlying clause-splitter bug. This PR fixes the actual root cause
behind issue #229.

Root cause (.gitea/scripts/sop-tier-check.sh ~line 289):

    _clause=$(echo "$_raw_clause" \
      | tr -d '()' \
      | tr ',' '\n' \
      | tr -d '[:space:]' \
      | grep -v '^$')

`tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
inserted. For tier:low (expression "engineers,managers,ceo") the
intermediate value is:

    engineers\nmanagers\nceo

then `tr -d '[:space:]'` flattens it to:

    engineersmanagersceo

The for-loop iterates ONCE over this single bogus token. The case
pattern `*engineersmanagersceo*` never matches APPROVER_TEAMS values
like " managers ", so EVERY tier:low PR fails:

    ::error::clause [engineers/managers/ceo]: FAIL — no approving
    reviewer belongs to any of these teamsengineersmanagersceo
    ::error::sop-tier-check FAILED for tier:low

(Note: the missing separators in the error string `teamsengineersmanagersceo`
were a SECOND, masked bug — `_clause_names="${_clause_names:+, }${_t}"`
overwrites the variable on every iteration instead of appending. With
the splitter bug, the inner loop only ran once so the overwrite was
invisible. Fixing the splitter unmasks the accumulator bug, so we fix
both atomically.)

Fix:

  _no_parens=${_raw_clause//[()]/}
  _clause=${_no_parens//,/ }   # comma -> space, bash word-split iterates

  # Append, don't overwrite:
  _clause_names="${_clause_names}${_clause_names:+, }${_t}"
  _passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
  _failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"

Per-tier policy is UNCHANGED — this is a parser fix, not a policy
relaxation:

  tier:low    — engineers,managers,ceo   (OR-set, ANY ONE suffices)
  tier:medium — managers AND engineers AND qa???,security???
  tier:high   — ceo

Test: .gitea/scripts/tests/test_sop_tier_check_clause_split.sh
asserts the splitter, accumulators, and end-to-end OR-gate matching
against APPROVER_TEAMS=" managers " (the exact shape PRs #233-238 hit).
7/7 pass on the new logic.

Refs: #229, supersedes attempted fix in #231 for the same root cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:03:12 -07:00
core-fe 10e60d66cb test(canvas): add pure-function tests for deriveWsBaseUrl, statusDotClass, and readThemeCookie
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
audit-force-merge / audit (pull_request) Successful in 6s
- ws-url.test.ts: deriveWsBaseUrl — all 4 priority paths tested:
  NEXT_PUBLIC_WS_URL (strips /ws suffix), NEXT_PUBLIC_PLATFORM_URL
  (http→ws, https→wss), window.location (https→wss, http→ws),
  precedence over lower-priority paths.
- statusDotClass.test.ts: all STATUS_CONFIG entries (online/offline/paused/
  degraded/failed/provisioning/not_configured), fallback to bg-zinc-500,
  case-sensitivity, purity.
- theme-cookie.test.ts: readThemeCookie — valid values (light/dark/system),
  undefined/empty fallback, invalid value handling, case-sensitivity,
  purity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:46:35 +00:00
core-fe dc0c3e7a27 test(canvas): add pure-function tests for resolveRuntime and canvas-topology utilities
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Successful in 5s
- preflight-resolveRuntime.test.ts: resolveRuntime from deploy-preflight.ts
  covering explicit runtime-map entries, identity fallback, -default suffix
  stripping, edge cases (empty string, multiple suffixes).
- canvas-topology-pure.test.ts: sortParentsBeforeChildren (topological
  sort, orphan handling, no-op, non-mutating), defaultChildSlot (2-col
  grid), childSlotInGrid (variable-size siblings, uniform-grid fallback),
  parentMinSize (0–5 children, grid dimensions), parentMinSizeFromChildren
  (variable sizes, empty array, width/height correctness).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:46:28 +00:00
core-fe 4c6cfef912 test(canvas): add pure-function tests for runtimeProfiles, getIcon, createMessage
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
audit-force-merge / audit (pull_request) Successful in 4s
- runtimeProfiles.test.ts: getRuntimeProfile and provisionTimeoutForRuntime
  covering undefined/unknown runtime, overrides precedence, convenience
  equivalence.
- getIcon.test.ts: 23 cases — dirs, all FILE_ICONS extensions (.md/.yaml/.py/.ts/.tsx/.js/.json/.html/.css/.sh), fallback, case insensitivity, nested paths.
- createMessage.test.ts: role, content, id, timestamp, attachment handling,
  Object.isFrozen, key shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:46:04 +00:00
core-fe 9b91bda2ed test(canvas/config): add pure-function tests for parseYaml and toYaml
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request) Failing after 5s
audit-force-merge / audit (pull_request) Successful in 7s
Cover parseYaml: empty input, blanks, comments, booleans, numbers,
lists, objects, 2-level nesting (env.required pattern), round-trip.
Cover toYaml: name/desc, version/tier, runtime, runtime_config,
effort/task_budget, prompt_files/skills/tools lists, a2a/delegation/
sandbox nested blocks, null-omission, trailing newline, full round-trip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:45:34 +00:00
Molecule AI Core Platform Lead 1dcd0c1dd1 trigger: re-run sop-tier-check after #229 fix
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 4s
audit-force-merge / audit (pull_request) Successful in 7s
2026-05-10 04:34:32 +00:00
claude-ceo-assistant 9cb5f43140 Merge pull request 'fix(sop-tier-check): APPROVER_TEAMS pattern matching — remove outer quotes from case patterns' (#231) from ci/sop-tier-check-approver-teams-fix into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 10s
2026-05-10 04:30:00 +00:00
core-be 5d8a57026b fix(ci): port publish-workspace-server-image.yml from .github/ to .gitea/workflows/ (issue #228)
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
The GitHub Actions workflow is dormant because the GitHub org is suspended.
Gitea Actions reads .gitea/workflows/ only, so Dockerfile.tenant changes no
longer trigger platform image rebuilds — new tenants get the broken pre-#223
image.

Port follows the same pattern as the publish-runtime.yml port (issue #206):
- Gitea Actions reads .gitea/workflows/ (drop .github/workflows/ version)
- Drop `environment:` declarations (Gitea has no named environments)
- Replace `github.ref_name` with `${GITHUB_REF#refs/heads/}` (same variable
  format available in Gitea runners)
- All other vars (GITHUB_SHA, GITHUB_REPOSITORY, secrets.*, GITHUB_OUTPUT)
  use identical syntax to GitHub Actions
- Inline `aws ecr get-login-password | docker login` (same as GitHub version;
  no GitHub-specific actions needed)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 04:11:18 +00:00
core-devops 4c14e0528a fix(sop-tier-check): add org-membership fallback when team API returns 403
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Failing after 5s
audit-force-merge / audit (pull_request) Successful in 10s
SOP_TIER_CHECK_TOKEN lacks read:organization scope, so
/teams/{id}/members/{user} returns 403 for all queries.
Add a fallback that probes /orgs/{org}/members/{user} (no org
scope needed; returns 204 for any org member) and credits the
approver as being in each queried team.

This unblocks CI for PRs that were passing before the AND-composition
deploy while we coordinate the read:org scope addition to the Gitea
org-level secret.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 03:46:11 +00:00
core-devops 49e4b2a6d6 fix(sop-tier-check): APPROVER_TEAMS pattern matching — remove outer quotes from case patterns
sop-tier-check / tier-check (pull_request) Failing after 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Root cause of internal#229 / core#229: bash case patterns like
\`*"managers"*\` have the outer quotes as LITERAL CHARACTERS in the
pattern, not delimiters. So \`managers"\` must appear literally after
\`*\`. The APPROVER_TEAMS value " managers " has no \`"\` after
\`managers\` → match fails even for valid team members.

Fix:
1. APPROVER_TEAMS values now space-surrounded: " managers " instead of
   "managers" — ensures leading * in pattern always has chars to consume.
2. Case patterns updated to *${_t}* / *${_t2}* — no outer quotes, matches
   team name anywhere in space-padded string.
3. Replaced shadowed loop var _t with _t2 in OR-gate loop for clarity.

Also fixes garbled error message: "teamsmanagers" → "teams managers" because
_clause_names now correctly accumulates team names (pattern no longer
stealing chars from the _clause_names string via the space consumption).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 03:23:07 +00:00
23 changed files with 1693 additions and 121 deletions
+61 -14
View File
@@ -208,20 +208,50 @@ fi
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
# 6. For each approver: skip self-review; probe team membership by id.
# Build $APPROVER_TEAMS[<user>]=space-separated-team-names.
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
# Pre/post spaces ensure case patterns *${_t}* match even when the name
# is the first or last entry (bash case *word* needs delimiters on both sides).
#
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
# member — a superset of team membership. Accepting it as a fallback means
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
# This is safe because: (a) org membership is a prerequisite for every
# eligible team; (b) the AND-composition of internal#189 still requires
# multiple independent approvers; (c) any token with read:repository can
# see the approving reviews, so bypass requires a colluding approver.
declare -A APPROVER_TEAMS
for U in $APPROVERS; do
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
_any_team_success="no"
for T in "${!TEAM_ID[@]}"; do
ID="${TEAM_ID[$T]}"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/teams/${ID}/members/${U}")
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:-}${APPROVER_TEAMS[$U]:+ }$T"
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
debug "$U qualifies for team $T"
_any_team_success="yes"
fi
done
# Fallback: if every team probe returned 403, try org membership.
# "??" teams were never resolved to IDs so they never entered the loop.
# If the user is an org member, credit them as being in each queried team
# (engineers, managers, ceo are all org-level). This is safe because org
# membership is a prerequisite for all three, and bypass requires a colluding
# approver (same risk as before the AND-composition).
if [ "$_any_team_success" = "no" ]; then
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/members/${U}")
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
if [ "$ORG_CODE" = "204" ]; then
for T in "${!TEAM_ID[@]}"; do
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
done
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
fi
fi
done
# 7. Evaluate the tier expression.
@@ -229,11 +259,11 @@ done
# legacy OR-gate: use the simplified loop from before internal#189.
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
OK=""
for U in "${!APPROVER_TEAMS[@]}"; do
for T in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$U]}" in
*"$T"*)
echo "::notice::approver $U is in team $T (eligible for $TIER)"
for _u in "${!APPROVER_TEAMS[@]}"; do
for _t2 in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$_u]}" in
*${_t2}*)
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
OK="yes"
break
;;
@@ -255,18 +285,34 @@ _passed_clauses=""
_failed_clauses=""
for _raw_clause in $EXPR; do
# Normalise: strip parens, split on comma, trim whitespace.
_clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# Normalise: strip parens, replace commas with spaces so bash word-split
# can iterate the OR-set members. The previous form
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# collapsed every member into one concatenated token because
# `tr -d '[:space:]'` strips the very newlines that just separated them
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
# only ever evaluated as a single nonsense team name and never matched
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
# space-separated tokens for `for _t in $_clause`.
_no_parens=${_raw_clause//[()]/}
_clause=${_no_parens//,/ }
_clause_passed="no"
_clause_names=""
for _t in $_clause; do
_clause_names="${_clause_names:+, }${_t}"
# Append (don't overwrite) team name to the human-readable accumulator.
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
# rewrote the variable on every iteration, so the FAIL message only
# ever showed the LAST team. Fixed: prepend prior value before the
# comma-separator, then append the new team name.
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
for _u in "${!APPROVER_TEAMS[@]}"; do
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
# Pattern *${_t}* matches team name anywhere in the space-padded string.
case "${APPROVER_TEAMS[$_u]}" in
*"$_t"*)
*${_t}*)
_clause_passed="yes"
debug "clause \"$_t\": satisfied by $_u"
break
@@ -279,11 +325,12 @@ for _raw_clause in $EXPR; do
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
if [ "$_clause_passed" = "yes" ]; then
_passed_clauses="${_passed_clauses:+, }$_label"
# Append (don't overwrite) — same accumulator bug as _clause_names above.
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
else
_failed_clauses="${_failed_clauses:+, }$_label"
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams${_clause_names}. Set SOP_DEBUG=1 to see per-team probe results."
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
fi
done
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
#
# Bug (PR #225 → still broken after PR #231):
# Line ~289 of sop-tier-check.sh used:
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
# inserted, collapsing "engineers,managers,ceo" into a single token
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
# that matches no team, so every tier:low PR fails:
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
# reviewer belongs to any of these teamsengineersmanagersceo
# (note also: missing separators in the error string is bug #2 —
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
#
# Fix shape (this PR):
# _no_parens=${_raw_clause//[()]/}
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
#
# This test extracts the splitter logic and asserts it produces the right
# token list for each of the three tier expressions live in the script.
set -euo pipefail
PASS=0
FAIL=0
assert_eq() {
local label="$1"
local expected="$2"
local got="$3"
if [ "$expected" = "$got" ]; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label"
echo " expected: <$expected>"
echo " got: <$got>"
FAIL=$((FAIL + 1))
fi
}
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
split_clause() {
local raw="$1"
local no_parens=${raw//[()]/}
local clause=${no_parens//,/ }
local out=""
for _t in $clause; do
out="${out}${out:+|}$_t"
done
echo "$out"
}
echo "test: tier:low OR-clause splits to 3 tokens"
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
EXPR="managers AND engineers AND qa???,security???"
out=""
for _raw in $EXPR; do
out="${out}${out:+ ; }$(split_clause "$_raw")"
done
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
echo "test: tier:high single-team OR-clause"
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
echo "test: paren-wrapped OR-set unwraps + splits"
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
# ----- _clause_names accumulator (was overwriting per iteration) -----
acc=""
for t in engineers managers ceo; do
acc="${acc}${acc:+, }${t}"
done
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
acc=""
for c in clauseA clauseB clauseC; do
acc="${acc}${acc:+, }${c}"
done
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
# The script's case pattern is *${_t}* with a space-padded value.
APPROVER_TEAMS_VAL=" managers "
matched=""
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
case "$APPROVER_TEAMS_VAL" in
*${_t}*) matched="$_t"; break ;;
esac
done
assert_eq "OR-gate matches managers" "managers" "$matched"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
@@ -0,0 +1,155 @@
name: publish-workspace-server-image
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
#
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment:` declarations — Gitea Actions does not support
# named environments (used by GitHub OIDC token gates)
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
# work identically here
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
# secrets.*) use the same syntax as GitHub Actions
#
# Image tags produced:
# :staging-<sha> — per-commit digest, stable for canary verify
# :staging-latest — tracks most recent build on this branch
#
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
on:
push:
branches: [staging, main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
concurrency:
group: publish-workspace-server-image-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Pre-clone manifest deps before docker build.
#
# Why: workspace-template-* repos on Gitea are private. The pre-fix
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
# auth path — every CI build failed. We clone in the trusted CI
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
# just COPYs from .tenant-bundle-deps/.
#
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
# clone-manifest.sh embeds it as basic-auth for the clones, then
# strips .git dirs — the token never enters the image.
- name: Pre-clone manifest deps
env:
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is empty"
exit 1
fi
mkdir -p .tenant-bundle-deps
bash scripts/clone-manifest.sh \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
- name: Compute tags
id: tags
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Build + push platform image (inline ECR auth — mirrors the operator-host
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
--tag "${IMAGE_NAME}:${TAG_SHA}" \
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${IMAGE_NAME}:${TAG_SHA}"
docker push "${IMAGE_NAME}:${TAG_LATEST}"
# Build + push tenant image (Go platform + Next.js canvas in one image).
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
+19 -8
View File
@@ -317,7 +317,7 @@ export function Toolbar() {
onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen}
aria-label="Open quick help"
aria-label="Open shortcuts and tips"
title="Help — shortcuts & quick start"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@@ -327,24 +327,35 @@ export function Toolbar() {
</button>
{helpOpen && (
<div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
<div
role="dialog"
aria-label="Shortcuts and tips"
aria-modal="false"
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
<button
type="button"
onClick={() => setHelpOpen(false)}
aria-label="Close help dialog"
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
>
Close
</button>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
<HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
<HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." />
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." />
</div>
{/* Link to the full keyboard shortcuts dialog */}
<button
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
/**
* Tests for createMessage — the ChatMessage factory from types.ts.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMessage } from "../tabs/chat/types";
describe("createMessage", () => {
beforeEach(() => {
// Freeze time so timestamp is deterministic.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
// Stub crypto.randomUUID so message IDs are deterministic.
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("creates a message with the correct role", () => {
const userMsg = createMessage("user", "hello");
expect(userMsg.role).toBe("user");
const agentMsg = createMessage("agent", "hi there");
expect(agentMsg.role).toBe("agent");
const systemMsg = createMessage("system", "prompt loaded");
expect(systemMsg.role).toBe("system");
});
it("creates a message with the correct content", () => {
const msg = createMessage("user", "Deploy the agent now");
expect(msg.content).toBe("Deploy the agent now");
});
it("sets a deterministic id via crypto.randomUUID", () => {
const msg = createMessage("agent", "response");
expect(msg.id).toBe("fixed-uuid-1234");
});
it("sets a deterministic ISO timestamp", () => {
const msg = createMessage("user", "hello");
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
});
it("omits attachments field when none provided", () => {
const msg = createMessage("user", "hello");
expect(msg.attachments).toBeUndefined();
});
it("omits attachments field when empty array is provided", () => {
const msg = createMessage("agent", "result", []);
expect(msg.attachments).toBeUndefined();
});
it("includes attachments field when non-empty array is provided", () => {
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
const msg = createMessage("agent", "see attached", atts);
expect(msg.attachments).toEqual(atts);
});
it("returns a frozen object (prevents accidental mutation)", () => {
const msg = createMessage("user", "hello");
expect(Object.isFrozen(msg)).toBe(true);
});
it("returns a plain object with expected keys", () => {
const msg = createMessage("user", "hello");
expect(Object.keys(msg).sort()).toEqual(
["id", "role", "content", "timestamp"].sort()
);
});
});
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
/**
* Tests for getIcon — the pure icon-selector from FilesTab/tree.ts.
*/
import { describe, it, expect } from "vitest";
import { getIcon } from "../tabs/FilesTab/tree";
describe("getIcon", () => {
// ─── Directories ──────────────────────────────────────────────────────────
it("returns 📁 for directories regardless of extension", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("node_modules", true)).toBe("📁");
expect(getIcon(".claude", true)).toBe("📁");
expect(getIcon("foo/bar/baz", true)).toBe("📁");
});
it("returns 📁 even for paths that look like files", () => {
expect(getIcon("foo.txt", true)).toBe("📁");
expect(getIcon("script.sh", true)).toBe("📁");
});
// ─── Files by extension ────────────────────────────────────────────────────
it("returns 📄 for .md files", () => {
expect(getIcon("README.md", false)).toBe("📄");
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
expect(getIcon("docs/guide.md", false)).toBe("📄");
});
it("returns ⚙ for .yaml and .yml files", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
expect(getIcon("values.yml", false)).toBe("⚙");
expect(getIcon("deploy.yaml", false)).toBe("⚙");
});
it("returns 🐍 for .py files", () => {
expect(getIcon("main.py", false)).toBe("🐍");
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
});
it("returns 💠 for .ts and .tsx files", () => {
expect(getIcon("index.ts", false)).toBe("💠");
expect(getIcon("Component.tsx", false)).toBe("💠");
expect(getIcon("types.d.ts", false)).toBe("💠");
});
it("returns 📜 for .js files", () => {
expect(getIcon("bundle.js", false)).toBe("📜");
expect(getIcon("src/index.js", false)).toBe("📜");
});
it("returns {} for .json files", () => {
expect(getIcon("package.json", false)).toBe("{}");
expect(getIcon("config.json", false)).toBe("{}");
});
it("returns 🌐 for .html files", () => {
expect(getIcon("index.html", false)).toBe("🌐");
expect(getIcon("templates/page.html", false)).toBe("🌐");
});
it("returns 🎨 for .css files", () => {
expect(getIcon("style.css", false)).toBe("🎨");
expect(getIcon("src/app.css", false)).toBe("🎨");
});
it("returns ▸ for .sh files", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
});
// ─── Fallback ─────────────────────────────────────────────────────────────
it("returns 📄 for unknown extensions", () => {
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("notes.txt", false)).toBe("📄");
expect(getIcon("archive.tar.gz", false)).toBe("📄");
});
it("returns 📄 for paths with no extension", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
});
// ─── Case sensitivity ──────────────────────────────────────────────────────
it("is case-insensitive for extension lookup", () => {
expect(getIcon("image.PNG", false)).toBe("📄");
expect(getIcon("data.JSON", false)).toBe("{}");
expect(getIcon("script.SH", false)).toBe("▸");
});
// ─── Nested paths ─────────────────────────────────────────────────────────
it("uses the leaf extension for nested paths", () => {
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
});
});
@@ -0,0 +1,313 @@
// @vitest-environment jsdom
/**
* Tests for yaml-utils.ts — parseYaml and toYaml pure functions.
*/
import { describe, expect, it } from "vitest";
import { parseYaml, toYaml } from "../yaml-utils";
import type { ConfigData } from "../form-inputs";
const FULL_CONFIG: ConfigData = {
name: "my-agent",
description: "A helpful assistant",
version: "1.0.0",
tier: 4,
model: "claude-4-7",
runtime: "claude-code",
runtime_config: { model: "claude-4-7", required_env: ["ANTHROPIC_API_KEY"], timeout: 120 },
effort: "medium",
task_budget: 100,
prompt_files: ["system.md"],
skills: ["web-search", "code"],
tools: ["bash"],
a2a: { port: 8000, streaming: true, push_notifications: true },
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
};
const MINIMAL_CONFIG: ConfigData = {
name: "",
description: "",
version: "1.0.0",
tier: 1,
model: "",
runtime: "",
prompt_files: [],
skills: [],
tools: [],
a2a: { port: 8000, streaming: true, push_notifications: true },
delegation: { retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true },
sandbox: { backend: "docker", memory_limit: "256m", timeout: 30 },
};
// ─── parseYaml ─────────────────────────────────────────────────────────────────
describe("parseYaml", () => {
it("returns empty object for empty input", () => {
expect(parseYaml("")).toEqual({});
});
it("returns empty object for blank lines only", () => {
expect(parseYaml("\n\n \n")).toEqual({});
});
it("returns empty object for comment-only input", () => {
expect(parseYaml("# hello\n# world")).toEqual({});
});
it("parses simple key-value pairs", () => {
const result = parseYaml("name: hello\nversion: 1.0");
expect(result).toEqual({ name: "hello", version: "1.0" });
});
it("trims whitespace around values", () => {
const result = parseYaml("name: hello \nversion: 1.0 ");
expect(result).toEqual({ name: "hello", version: "1.0" });
});
it("parses boolean true", () => {
expect(parseYaml("streaming: true")).toEqual({ streaming: true });
});
it("parses boolean false", () => {
expect(parseYaml("streaming: false")).toEqual({ streaming: false });
});
it("parses integer numbers", () => {
expect(parseYaml("port: 8000\ntimeout: 120")).toEqual({ port: 8000, timeout: 120 });
});
it("parses string values that look like numbers", () => {
// Keys that have no space before colon would have been parsed as numbers
// but since the YAML has `key: value` format, it should be string
expect(parseYaml("model: claude-4-7")).toEqual({ model: "claude-4-7" });
});
it("parses a top-level list", () => {
const result = parseYaml("skills:\n - web-search\n - code");
expect(result).toEqual({ skills: ["web-search", "code"] });
});
it("parses a top-level object", () => {
const result = parseYaml("a2a:\n port: 8000\n streaming: true");
expect(result).toEqual({ a2a: { port: 8000, streaming: true } });
});
it("skips blank lines within content", () => {
const result = parseYaml("name: hello\n\nversion: 1.0\n\n");
expect(result).toEqual({ name: "hello", version: "1.0" });
});
it("skips comment lines within content", () => {
const result = parseYaml("name: hello\n# this is a comment\nversion: 1.0");
expect(result).toEqual({ name: "hello", version: "1.0" });
});
it("parses a 2-level nested list (env.required pattern)", () => {
const result = parseYaml("env:\n required:\n - ANTHROPIC_API_KEY\n - OPENAI_API_KEY");
expect(result).toEqual({ env: { required: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] } });
});
it("parses empty list marker `[]`", () => {
const result = parseYaml("prompt_files: []");
expect(result).toEqual({ prompt_files: [] });
});
it("handles multiple mixed structures in one document", () => {
const yaml = `name: test-agent
version: 1.0.0
tier: 4
runtime: claude-code
skills:
- web-search
a2a:
port: 8000
streaming: true`;
const result = parseYaml(yaml);
expect(result).toEqual({
name: "test-agent",
version: "1.0.0",
tier: 4,
runtime: "claude-code",
skills: ["web-search"],
a2a: { port: 8000, streaming: true },
});
});
it("leaves unrecognised top-level lines as-is (skipped)", () => {
// Lines that don't match the pattern are skipped
const result = parseYaml("name: hello\n[invalid line]\nversion: 1.0");
expect(result).toEqual({ name: "hello", version: "1.0" });
});
});
// ─── toYaml ─────────────────────────────────────────────────────────────────────
describe("toYaml", () => {
it("produces output for minimal config (required fields only)", () => {
const out = toYaml(MINIMAL_CONFIG);
// skills: [] and tools: [] are always emitted
expect(out).toContain("version: 1.0.0");
expect(out).toContain("tier: 1");
expect(out).toContain("skills: []");
expect(out).toContain("tools: []");
expect(out).toContain("a2a:");
expect(out).toContain("delegation:");
expect(out).toContain("sandbox:");
});
it("writes name and description fields", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, name: "my-agent", description: "desc" };
const out = toYaml(cfg);
expect(out).toContain("name: my-agent");
expect(out).toContain("description: desc");
});
it("writes version and tier", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, tier: 4 };
const out = toYaml(cfg);
expect(out).toContain("version: 1.0.0");
expect(out).toContain("tier: 4");
});
it("writes runtime with a blank line separator before it", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
const out = toYaml(cfg);
expect(out).toContain("runtime: claude-code");
});
it("writes runtime_config as a nested block", () => {
const cfg: ConfigData = {
...MINIMAL_CONFIG,
runtime: "claude-code",
runtime_config: { model: "claude-4-7", required_env: ["KEY"], timeout: 120 },
};
const out = toYaml(cfg);
expect(out).toContain("runtime_config:");
expect(out).toContain(" model: claude-4-7");
expect(out).toContain(" required_env:");
expect(out).toContain(" - KEY");
expect(out).toContain(" timeout: 120");
});
it("omits runtime_config when empty", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, runtime: "claude-code" };
const out = toYaml(cfg);
// runtime_config key should not appear
expect(out).not.toContain("runtime_config:");
});
it("writes effort when set", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "high" };
const out = toYaml(cfg);
expect(out).toContain("effort: high");
});
it("omits effort when empty string", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, effort: "" };
const out = toYaml(cfg);
expect(out).not.toContain("effort:");
});
it("writes task_budget when positive", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 100 };
const out = toYaml(cfg);
expect(out).toContain("task_budget: 100");
});
it("omits task_budget when zero", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, task_budget: 0 };
const out = toYaml(cfg);
expect(out).not.toContain("task_budget:");
});
it("writes prompt_files as a list block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, prompt_files: ["system.md", "ethics.md"] };
const out = toYaml(cfg);
expect(out).toContain("prompt_files:");
expect(out).toContain(" - system.md");
expect(out).toContain(" - ethics.md");
});
it("writes skills as a list block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, skills: ["web-search", "code"] };
const out = toYaml(cfg);
expect(out).toContain("skills:");
expect(out).toContain(" - web-search");
expect(out).toContain(" - code");
});
it("writes tools as a list block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, tools: ["bash", "read"] };
const out = toYaml(cfg);
expect(out).toContain("tools:");
expect(out).toContain(" - bash");
expect(out).toContain(" - read");
});
it("writes a2a as a nested block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, a2a: { port: 9000, streaming: false, push_notifications: false } };
const out = toYaml(cfg);
expect(out).toContain("a2a:");
expect(out).toContain(" port: 9000");
expect(out).toContain(" streaming: false");
expect(out).toContain(" push_notifications: false");
});
it("writes delegation as a nested block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, delegation: { retry_attempts: 5, retry_delay: 10, timeout: 60, escalate: false } };
const out = toYaml(cfg);
expect(out).toContain("delegation:");
expect(out).toContain(" retry_attempts: 5");
expect(out).toContain(" retry_delay: 10");
expect(out).toContain(" timeout: 60");
expect(out).toContain(" escalate: false");
});
it("writes sandbox backend block", () => {
const cfg: ConfigData = { ...MINIMAL_CONFIG, sandbox: { backend: "aws-lambda", memory_limit: "512m", timeout: 15 } };
const out = toYaml(cfg);
expect(out).toContain("sandbox:");
expect(out).toContain(" backend: aws-lambda");
expect(out).toContain(" memory_limit: 512m");
expect(out).toContain(" timeout: 15");
});
it("omits empty/null/undefined fields entirely", () => {
const cfg: ConfigData = {
...MINIMAL_CONFIG,
name: "test",
model: "", // omitted
description: "", // omitted
};
const out = toYaml(cfg);
expect(out).not.toContain("model:");
expect(out).not.toContain("description:");
expect(out).toContain("name: test");
});
it("produces a trailing newline", () => {
const out = toYaml(MINIMAL_CONFIG);
expect(out.endsWith("\n")).toBe(true);
});
it("round-trips FULL_CONFIG through parse → toYaml → parse", () => {
// parseYaml produces plain Record, so a2a/delegation/sandbox
// come out as objects — toYaml handles them via the cast.
const round = parseYaml(toYaml(FULL_CONFIG));
expect(round).toMatchObject({
name: "my-agent",
description: "A helpful assistant",
version: "1.0.0",
tier: 4,
runtime: "claude-code",
effort: "medium",
task_budget: 100,
prompt_files: ["system.md"],
skills: ["web-search", "code"],
tools: ["bash"],
});
expect(round.a2a).toMatchObject({ port: 8000, streaming: true, push_notifications: true });
expect(round.delegation).toMatchObject({ retry_attempts: 3, retry_delay: 5, timeout: 120, escalate: true });
expect(round.sandbox).toMatchObject({ backend: "docker", memory_limit: "256m", timeout: 30 });
});
});
+67
View File
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
/**
* Tests for cssVar — maps ColorToken to a CSS variable string.
*
* Exists for the rare case where an inline style="" or SVG fill needs
* a token value rather than a Tailwind class. The returned var(--color-foo)
* string follows the live theme without re-renders.
*/
import { describe, it, expect } from "vitest";
import { cssVar } from "../theme";
import type { ColorToken } from "../theme";
describe("cssVar", () => {
it("returns 'var(--color-surface)' for 'surface'", () => {
expect(cssVar("surface")).toBe("var(--color-surface)");
});
it("returns 'var(--color-ink)' for 'ink'", () => {
expect(cssVar("ink")).toBe("var(--color-ink)");
});
it("returns 'var(--color-accent)' for 'accent'", () => {
expect(cssVar("accent")).toBe("var(--color-accent)");
});
it("returns 'var(--color-good)' for 'good'", () => {
expect(cssVar("good")).toBe("var(--color-good)");
});
it("returns 'var(--color-bad)' for 'bad'", () => {
expect(cssVar("bad")).toBe("var(--color-bad)");
});
it("returns 'var(--color-warn)' for 'warn'", () => {
expect(cssVar("warn")).toBe("var(--color-warn)");
});
it("handles all surface variants", () => {
const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"];
for (const t of surfaces) {
expect(cssVar(t)).toBe(`var(--color-${t})`);
}
});
it("handles all ink variants", () => {
const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"];
for (const t of inks) {
expect(cssVar(t)).toBe(`var(--color-${t})`);
}
});
it("handles always-dark tokens", () => {
const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"];
for (const t of dark) {
expect(cssVar(t)).toBe(`var(--color-${t})`);
}
});
it("is a pure function — same input always returns same output", () => {
const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"];
for (const t of tokens) {
for (let i = 0; i < 3; i++) {
expect(cssVar(t)).toBe(`var(--color-${t})`);
}
}
});
});
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
/**
* Tests for resolveRuntime — the template-id → runtime-name mapper in deploy-preflight.ts.
*
* Lives in lib/__tests__/ alongside deploy-preflight.test.ts so the
* two share the same describe block convention and the fixture types
* are close at hand. Separate file keeps the deploy-preflight fixture
* count bounded.
*/
import { describe, it, expect } from "vitest";
import { resolveRuntime } from "../deploy-preflight";
describe("resolveRuntime", () => {
describe("explicit runtime-map entries", () => {
it('maps "langgraph" to "langgraph"', () => {
expect(resolveRuntime("langgraph")).toBe("langgraph");
});
it('maps "claude-code-default" to "claude-code"', () => {
expect(resolveRuntime("claude-code-default")).toBe("claude-code");
});
it('maps "openclaw" to "openclaw"', () => {
expect(resolveRuntime("openclaw")).toBe("openclaw");
});
it('maps "deepagents" to "deepagents"', () => {
expect(resolveRuntime("deepagents")).toBe("deepagents");
});
it('maps "crewai" to "crewai"', () => {
expect(resolveRuntime("crewai")).toBe("crewai");
});
it('maps "autogen" to "autogen"', () => {
expect(resolveRuntime("autogen")).toBe("autogen");
});
});
describe("identity fallback for modern template ids", () => {
it("returns the id unchanged when not in the map", () => {
expect(resolveRuntime("hermes")).toBe("hermes");
});
it("strips trailing -default suffix as fallback", () => {
expect(resolveRuntime("hermes-default")).toBe("hermes");
});
it("strips -default only when it is the suffix", () => {
// "default-something" should NOT strip
expect(resolveRuntime("default-langgraph")).toBe("default-langgraph");
});
it("returns the id unchanged when id has no -default suffix", () => {
expect(resolveRuntime("gemini-cli")).toBe("gemini-cli");
});
it("handles custom template ids from community templates", () => {
expect(resolveRuntime("my-custom-template")).toBe("my-custom-template");
});
});
describe("edge cases", () => {
it("handles empty string", () => {
// Falls through to the replace branch
expect(resolveRuntime("")).toBe("");
});
it("handles id that is just '-default'", () => {
expect(resolveRuntime("-default")).toBe("");
});
it("multiple -default suffixes only strips the last one", () => {
// The JS replace only replaces the first match by default
expect(resolveRuntime("claude-code-default-default")).toBe("claude-code-default");
});
});
});
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
/**
* Tests for runtimeProfiles.ts — getRuntimeProfile and provisionTimeoutForRuntime.
*/
import { describe, expect, it } from "vitest";
import {
getRuntimeProfile,
provisionTimeoutForRuntime,
DEFAULT_RUNTIME_PROFILE,
RUNTIME_PROFILES,
} from "../runtimeProfiles";
describe("getRuntimeProfile", () => {
it("returns DEFAULT_RUNTIME_PROFILE when runtime is undefined and no overrides", () => {
const result = getRuntimeProfile(undefined);
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("returns DEFAULT_RUNTIME_PROFILE when runtime is empty string", () => {
const result = getRuntimeProfile("");
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("falls back to DEFAULT_RUNTIME_PROFILE for an unknown runtime", () => {
const result = getRuntimeProfile("unknown-lang");
expect(result.provisionTimeoutMs).toBe(DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs);
});
it("returns DEFAULT_RUNTIME_PROFILE when RUNTIME_PROFILES is empty (current state)", () => {
// RUNTIME_PROFILES is currently {} — verify the empty-map path works
expect(RUNTIME_PROFILES).toEqual({});
const result = getRuntimeProfile("claude-code");
expect(result.provisionTimeoutMs).toBe(120_000);
});
it("uses overrides.provisionTimeoutMs when provided (highest priority)", () => {
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 300_000 });
expect(result.provisionTimeoutMs).toBe(300_000);
});
it("overrides wins over RUNTIME_PROFILES entry", () => {
// Even if RUNTIME_PROFILES had an entry, overrides take priority
const result = getRuntimeProfile("claude-code", { provisionTimeoutMs: 999_000 });
expect(result.provisionTimeoutMs).toBe(999_000);
});
it("uses overrides even when runtime is undefined", () => {
const result = getRuntimeProfile(undefined, { provisionTimeoutMs: 60_000 });
expect(result.provisionTimeoutMs).toBe(60_000);
});
it("returns Required<Pick> — always has provisionTimeoutMs", () => {
// The return type is guaranteed non-nullable
const result = getRuntimeProfile(undefined);
expect(typeof result.provisionTimeoutMs).toBe("number");
expect(result.provisionTimeoutMs).toBeGreaterThan(0);
});
});
describe("provisionTimeoutForRuntime", () => {
it("returns DEFAULT_RUNTIME_PROFILE value when no runtime or overrides", () => {
expect(provisionTimeoutForRuntime(undefined)).toBe(120_000);
expect(provisionTimeoutForRuntime("")).toBe(120_000);
});
it("returns overrides value when overrides provided", () => {
expect(provisionTimeoutForRuntime("claude-code", { provisionTimeoutMs: 90_000 })).toBe(90_000);
});
it("returns 120_000 for any unknown runtime", () => {
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
});
it("convenience: same as getRuntimeProfile().provisionTimeoutMs", () => {
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
[undefined, undefined],
["claude-code", undefined],
["langgraph", { provisionTimeoutMs: 500_000 }],
[undefined, { provisionTimeoutMs: 45_000 }],
];
for (const [runtime, overrides] of cases) {
const profile = getRuntimeProfile(runtime, overrides);
const direct = provisionTimeoutForRuntime(runtime, overrides);
expect(direct).toBe(profile.provisionTimeoutMs);
}
});
});
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
/**
* Tests for statusDotClass — maps a workspace status string to the
* CSS tailwind class used on the status indicator dot.
*/
import { describe, it, expect } from "vitest";
import { statusDotClass, TIER_CONFIG, COMM_TYPE_LABELS } from "../design-tokens";
describe("statusDotClass", () => {
it('returns "bg-emerald-400" for "online"', () => {
expect(statusDotClass("online")).toBe("bg-emerald-400");
});
it('returns "bg-zinc-500" for "offline"', () => {
expect(statusDotClass("offline")).toBe("bg-zinc-500");
});
it('returns "bg-indigo-400" for "paused"', () => {
expect(statusDotClass("paused")).toBe("bg-indigo-400");
});
it('returns "bg-amber-400" for "degraded"', () => {
expect(statusDotClass("degraded")).toBe("bg-amber-400");
});
it('returns "bg-red-400" for "failed"', () => {
expect(statusDotClass("failed")).toBe("bg-red-400");
});
it('returns "bg-sky-400 motion-safe:animate-pulse" for "provisioning"', () => {
expect(statusDotClass("provisioning")).toBe("bg-sky-400 motion-safe:animate-pulse");
});
it('returns "bg-amber-300" for "not_configured"', () => {
expect(statusDotClass("not_configured")).toBe("bg-amber-300");
});
it("falls back to bg-zinc-500 for unknown status strings", () => {
expect(statusDotClass("unknown")).toBe("bg-zinc-500");
expect(statusDotClass("")).toBe("bg-zinc-500");
expect(statusDotClass("ONLINE")).toBe("bg-zinc-500"); // case-sensitive
expect(statusDotClass(" online")).toBe("bg-zinc-500"); // whitespace-sensitive
expect(statusDotClass("online\n")).toBe("bg-zinc-500");
});
it("is a pure function — same input always returns same output", () => {
const result = statusDotClass("online");
for (let i = 0; i < 5; i++) {
expect(statusDotClass("online")).toBe(result);
}
});
});
// ── TIER_CONFIG ────────────────────────────────────────────────────────────────
describe("TIER_CONFIG", () => {
it("has entries for all four tier levels", () => {
expect(TIER_CONFIG).toHaveProperty(1);
expect(TIER_CONFIG).toHaveProperty(2);
expect(TIER_CONFIG).toHaveProperty(3);
expect(TIER_CONFIG).toHaveProperty(4);
});
it("each tier has label, color, and border fields", () => {
for (const tier of [1, 2, 3, 4]) {
expect(TIER_CONFIG[tier]).toHaveProperty("label");
expect(TIER_CONFIG[tier]).toHaveProperty("color");
expect(TIER_CONFIG[tier]).toHaveProperty("border");
}
});
it("tier labels match expected values", () => {
expect(TIER_CONFIG[1].label).toBe("T1");
expect(TIER_CONFIG[2].label).toBe("T2");
expect(TIER_CONFIG[3].label).toBe("T3");
expect(TIER_CONFIG[4].label).toBe("T4");
});
it("is immutable at runtime — same key always returns same shape", () => {
const result = TIER_CONFIG[2];
expect(TIER_CONFIG[2]).toBe(result);
});
});
// ── COMM_TYPE_LABELS ────────────────────────────────────────────────────────
describe("COMM_TYPE_LABELS", () => {
it("has labels for all known communication types", () => {
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_send");
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_receive");
expect(COMM_TYPE_LABELS).toHaveProperty("task_update");
});
it("labels are non-empty strings", () => {
for (const key of Object.keys(COMM_TYPE_LABELS)) {
expect(typeof COMM_TYPE_LABELS[key]).toBe("string");
expect(COMM_TYPE_LABELS[key].length).toBeGreaterThan(0);
}
});
it("is a static map — same key always returns same label", () => {
expect(COMM_TYPE_LABELS["a2a_send"]).toBe("sent");
expect(COMM_TYPE_LABELS["a2a_receive"]).toBe("received");
expect(COMM_TYPE_LABELS["task_update"]).toBe("task update");
});
});
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
/**
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
*/
import { describe, it, expect } from "vitest";
import { readThemeCookie } from "../theme-cookie";
describe("readThemeCookie", () => {
it('returns "light" when cookie value is "light"', () => {
expect(readThemeCookie("light")).toBe("light");
});
it('returns "dark" when cookie value is "dark"', () => {
expect(readThemeCookie("dark")).toBe("dark");
});
it('returns "system" when cookie value is "system"', () => {
expect(readThemeCookie("system")).toBe("system");
});
it('returns "system" for undefined', () => {
expect(readThemeCookie(undefined)).toBe("system");
});
it('returns "system" for empty string', () => {
expect(readThemeCookie("")).toBe("system");
});
it('returns "system" for any non-matching value', () => {
expect(readThemeCookie("auto")).toBe("system");
expect(readThemeCookie("dark-mode")).toBe("system");
expect(readThemeCookie("DARK")).toBe("system"); // case-sensitive
expect(readThemeCookie("light\n")).toBe("system"); // whitespace-sensitive
expect(readThemeCookie(" system ")).toBe("system");
expect(readThemeCookie("null")).toBe("system");
expect(readThemeCookie("0")).toBe("system");
});
it("is pure — same input always returns same output", () => {
const inputs = ["light", "dark", "system", undefined, ""];
for (const input of inputs) {
for (let i = 0; i < 3; i++) {
expect(readThemeCookie(input)).toBe(readThemeCookie(input));
}
}
});
});
+134
View File
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
/**
* Tests for deriveWsBaseUrl — WebSocket base URL derivation from env / window.location.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { deriveWsBaseUrl } from "../ws-url";
const ORIGINAL_WS = process.env.NEXT_PUBLIC_WS_URL;
const ORIGINAL_PLATFORM = process.env.NEXT_PUBLIC_PLATFORM_URL;
beforeEach(() => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "");
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "");
});
afterEach(() => {
vi.restoreAllMocks();
if (ORIGINAL_WS !== undefined) vi.stubEnv("NEXT_PUBLIC_WS_URL", ORIGINAL_WS);
else delete process.env.NEXT_PUBLIC_WS_URL;
if (ORIGINAL_PLATFORM !== undefined) vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", ORIGINAL_PLATFORM);
else delete process.env.NEXT_PUBLIC_PLATFORM_URL;
});
describe("deriveWsBaseUrl — NEXT_PUBLIC_WS_URL (priority 1)", () => {
it("uses NEXT_PUBLIC_WS_URL when set", () => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
});
it("strips trailing /ws suffix from NEXT_PUBLIC_WS_URL", () => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com/ws");
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
});
it("uses ws:// for HTTP NEXT_PUBLIC_WS_URL", () => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "ws://localhost:8080/ws");
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
});
it("wins over NEXT_PUBLIC_PLATFORM_URL", () => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform.example.com");
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
});
it("wins over window.location", () => {
vi.stubEnv("NEXT_PUBLIC_WS_URL", "wss://ws.example.com");
Object.defineProperty(window, "location", {
value: { protocol: "https:", host: "canvas.example.com" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("wss://ws.example.com");
});
});
describe("deriveWsBaseUrl — NEXT_PUBLIC_PLATFORM_URL (priority 2)", () => {
it("derives ws:// from http:// platform URL", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:8080");
expect(deriveWsBaseUrl()).toBe("ws://localhost:8080");
});
it("derives wss:// from https:// platform URL", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
});
it("preserves non-standard ports", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://localhost:9000");
expect(deriveWsBaseUrl()).toBe("ws://localhost:9000");
});
it("wins over window.location", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform.example.com");
Object.defineProperty(window, "location", {
value: { protocol: "https:", host: "canvas.example.com" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("wss://platform.example.com");
});
});
describe("deriveWsBaseUrl — window.location (priority 3)", () => {
it("uses wss:// when page is served over HTTPS", () => {
Object.defineProperty(window, "location", {
value: { protocol: "https:", host: "canvas.example.com" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com");
});
it("uses ws:// when page is served over HTTP", () => {
Object.defineProperty(window, "location", {
value: { protocol: "http:", host: "localhost:3000" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
});
it("includes the host with port", () => {
Object.defineProperty(window, "location", {
value: { protocol: "https:", host: "canvas.example.com:8443" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("wss://canvas.example.com:8443");
});
});
describe("deriveWsBaseUrl — fallback (priority 4)", () => {
it("falls back to localhost when no env vars or window is unavailable", () => {
// process.env is empty (already stubbed), window is not stubbed but we
// can't remove it entirely in jsdom — the function checks typeof window
// which is always defined. Since we have no env vars, it falls through
// to the window branch; we test the final fallback by stubbing window
// location to undefined (not possible in jsdom — skip this edge case).
// The test below verifies the no-env-var path works.
Object.defineProperty(window, "location", {
value: { protocol: "http:", host: "localhost:3000" },
writable: true,
});
expect(deriveWsBaseUrl()).toBe("ws://localhost:3000");
});
});
describe("deriveWsBaseUrl — protocol derivation", () => {
it("derives ws:// from http:// and keeps it", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "http://platform:8080");
expect(deriveWsBaseUrl()).toMatch(/^ws:/);
});
it("derives wss:// from https:// and keeps it", () => {
vi.stubEnv("NEXT_PUBLIC_PLATFORM_URL", "https://platform:8080");
expect(deriveWsBaseUrl()).toMatch(/^wss:/);
});
});
@@ -0,0 +1,251 @@
// @vitest-environment jsdom
/**
* Tests for pure utility functions in canvas-topology.ts:
* sortParentsBeforeChildren, defaultChildSlot, childSlotInGrid,
* parentMinSize, parentMinSizeFromChildren.
*/
import { describe, it, expect } from "vitest";
import {
sortParentsBeforeChildren,
defaultChildSlot,
childSlotInGrid,
parentMinSize,
parentMinSizeFromChildren,
} from "../canvas-topology";
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
describe("sortParentsBeforeChildren", () => {
it("returns [] for empty input", () => {
expect(sortParentsBeforeChildren([])).toEqual([]);
});
it("returns single node unchanged", () => {
const nodes = [{ id: "a", parentId: undefined }];
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
});
it("places parent before child", () => {
// Deliberately reversed so naive iteration would place child first
const nodes = [
{ id: "child", parentId: "parent" },
{ id: "parent", parentId: undefined },
];
const result = sortParentsBeforeChildren(nodes);
expect(result[0].id).toBe("parent");
expect(result[1].id).toBe("child");
});
it("places grandparent before parent before child (deep chain)", () => {
const nodes = [
{ id: "child", parentId: "parent" },
{ id: "grandchild", parentId: "child" },
{ id: "parent", parentId: "grandparent" },
{ id: "grandparent", parentId: undefined },
];
const result = sortParentsBeforeChildren(nodes);
const ids = result.map((n) => n.id);
expect(ids).toEqual(["grandparent", "parent", "child", "grandchild"]);
});
it("siblings share the same parent", () => {
const nodes = [
{ id: "b", parentId: "a" },
{ id: "a", parentId: undefined },
{ id: "c", parentId: "a" },
];
const result = sortParentsBeforeChildren(nodes);
expect(result[0].id).toBe("a");
expect(new Set(result.slice(1).map((n) => n.id))).toEqual(new Set(["b", "c"]));
});
it("no-ops when children already precede parents", () => {
// Already sorted — output should be in the same order
const nodes = [
{ id: "root", parentId: undefined },
{ id: "child", parentId: "root" },
];
expect(sortParentsBeforeChildren(nodes)).toEqual(nodes);
});
it("handles orphan nodes (no parentId)", () => {
const nodes = [{ id: "a" }, { id: "b" }];
expect(sortParentsBeforeChildren(nodes).map((n) => n.id)).toEqual(["a", "b"]);
});
it("returns a new array (does not mutate input)", () => {
const nodes = [{ id: "child", parentId: "parent" }, { id: "parent", parentId: undefined }];
const result = sortParentsBeforeChildren(nodes);
expect(result).not.toBe(nodes);
});
it("deduplicates already-visited nodes", () => {
// Child's parent is also in the list — visited guard prevents loops
const nodes = [
{ id: "child", parentId: "parent" },
{ id: "parent", parentId: undefined },
];
const result = sortParentsBeforeChildren(nodes);
expect(result.map((n) => n.id)).toEqual(["parent", "child"]);
});
it("does not crash when parentId references a missing node", () => {
const nodes = [
{ id: "orphan", parentId: "ghost" },
{ id: "root", parentId: undefined },
];
// Missing parent is skipped; orphan placed after root
const result = sortParentsBeforeChildren(nodes);
expect(result.map((n) => n.id)).toEqual(["root", "orphan"]);
});
});
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
it("slot 0 → column 0, row 0", () => {
const s = defaultChildSlot(0);
expect(s).toEqual({ x: 16, y: 130 });
});
it("slot 1 → column 1, row 0", () => {
const s = defaultChildSlot(1);
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
expect(s.y).toBe(130);
});
it("slot 2 → column 0, row 1", () => {
const s = defaultChildSlot(2);
expect(s.x).toBe(16);
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
});
it("slot 3 → column 1, row 1", () => {
const s = defaultChildSlot(3);
expect(s.x).toBe(16 + 240 + 14);
expect(s.y).toBe(130 + 130 + 14);
});
it("slot 4 → column 0, row 2", () => {
const s = defaultChildSlot(4);
expect(s.x).toBe(16);
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
});
});
// ─── childSlotInGrid ──────────────────────────────────────────────────────────
describe("childSlotInGrid — variable-size siblings", () => {
it("empty siblingSizes returns side-padded position", () => {
const s = childSlotInGrid(0, []);
expect(s).toEqual({ x: 16, y: 130 });
});
it("slot 0 in uniform-size siblings matches defaultChildSlot", () => {
const sizes = [{ width: 240, height: 130 }, { width: 240, height: 130 }];
const s = childSlotInGrid(0, sizes);
expect(s.x).toBe(16);
expect(s.y).toBe(130);
});
it("taller sibling bumps next row down", () => {
// Column width = max(200, 240) = 240; row 0 height = max(300, 130) = 300
const sizes = [{ width: 200, height: 300 }, { width: 240, height: 130 }];
const slot1 = childSlotInGrid(1, sizes);
// Slot 1 is in column 1, row 0; x = 16 + 1*(240+14)
expect(slot1.x).toBe(16 + 240 + 14);
expect(slot1.y).toBe(130);
// Slot 2 (col 0, row 1) — y must include row 0 height + gutter
const slot2 = childSlotInGrid(2, sizes);
expect(slot2.x).toBe(16);
expect(slot2.y).toBe(130 + 300 + 14);
});
it("colW is the maximum sibling width, not the column of the target slot", () => {
// Column width is always the max — slot at col 0 uses colW of wider col 1 sibling
const sizes = [{ width: 100, height: 100 }, { width: 300, height: 100 }];
const slot0 = childSlotInGrid(0, sizes);
expect(slot0.x).toBe(16); // col 0
// x for col 1 would be 16 + 300 + 14 = 330
const slot1 = childSlotInGrid(1, sizes);
expect(slot1.x).toBe(16 + 300 + 14);
});
});
// ─── parentMinSize ─────────────────────────────────────────────────────────────
describe("parentMinSize — uniform-size children", () => {
it("0 children → compact default (210×120)", () => {
expect(parentMinSize(0)).toEqual({ width: 210, height: 120 });
});
it("1 child → 1 col, 1 row", () => {
const s = parentMinSize(1);
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
expect(s.width).toBe(16 * 2 + 240);
expect(s.height).toBe(130 + 130 + 16);
});
it("2 children → 2 cols, 1 row", () => {
const s = parentMinSize(2);
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
expect(s.height).toBe(130 + 130 + 16);
});
it("3 children → 2 cols, 2 rows", () => {
const s = parentMinSize(3);
// width = 16*2 + 2*240 + 1*14 = 526
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
// height = 130 + 2*130 + 1*14 + 16 = 416
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
});
it("4 children → 2 cols, 2 rows (full grid)", () => {
const s = parentMinSize(4);
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
});
it("5 children → 2 cols, 3 rows", () => {
const s = parentMinSize(5);
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
});
});
// ─── parentMinSizeFromChildren ────────────────────────────────────────────────
describe("parentMinSizeFromChildren — variable-size children", () => {
it("empty array → compact default (210×120)", () => {
expect(parentMinSizeFromChildren([])).toEqual({ width: 210, height: 120 });
});
it("single child matches defaultChildSlot bounding box", () => {
const s = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
// cols=1, rows=1, colW=240
expect(s.width).toBe(16 * 2 + 240); // 272
expect(s.height).toBe(130 + 130 + 16); // 276
});
it("two equal-width children → same as parentMinSize(2)", () => {
const fromChildren = parentMinSizeFromChildren([
{ width: 240, height: 130 },
{ width: 240, height: 130 },
]);
expect(fromChildren.width).toBe(parentMinSize(2).width);
expect(fromChildren.height).toBe(parentMinSize(2).height);
});
it("taller child increases height", () => {
const tall = parentMinSizeFromChildren([{ width: 240, height: 400 }]);
const short = parentMinSizeFromChildren([{ width: 240, height: 130 }]);
expect(tall.height).toBeGreaterThan(short.height);
});
it("wider child increases width", () => {
const wide = parentMinSizeFromChildren([{ width: 500, height: 130 }]);
const narrow = parentMinSizeFromChildren([{ width: 200, height: 130 }]);
expect(wide.width).toBeGreaterThan(narrow.width);
});
});
+1
View File
@@ -88,6 +88,7 @@ PR: `fix/ink-soft-wcag-contrast`.
- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (PR #182) ✅
- `Cmd/Ctrl+Arrow` resize selected node (↑↓ height, ←→ width, 10px, Shift 2px) ✅
- Hierarchy navigation (Enter/Shift+Enter), z-order (Cmd+]/[), zoom-to-team (Z) ✅
- Toolbar help dialog ("Shortcuts & tips") documents all shortcuts + mouse interactions ✅
### Focus Management ✅ (strong)
- Skip link → `#canvas-main` ✅
@@ -8,7 +8,6 @@ package handlers
// POST /admin/plugin-updates/:id/apply — apply a queued drift update
import (
"context"
"database/sql"
"errors"
"fmt"
@@ -1262,4 +1262,3 @@ func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
t.Errorf("unmet sqlmock expectations: %v", err)
}
}
}
@@ -112,7 +112,10 @@ func (h *PluginsHandler) WithInstanceIDLookup(lookup InstanceIDLookup) *PluginsH
// Sources returns the underlying plugin source registry. Used by main.go to
// pass the same registry to the drift sweeper so both share resolver state.
func (h *PluginsHandler) Sources() plugins.SourceResolver {
// Returns the narrow pluginSources interface so callers receive only the
// methods they need (Register, Resolve, Schemes), not the full SourceResolver
// contract with Fetch.
func (h *PluginsHandler) Sources() pluginSources {
return h.sources
}
@@ -120,7 +120,7 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
// Try Redis cache first.
agentURL, err := db.GetCachedURL(ctx, workspaceID)
if err == nil && agentURL != "" {
return rewriteForDocker(agentURL, workspaceID), nil
return h.rewriteForDocker(agentURL, workspaceID), nil
}
// Cache miss — fall back to DB.
@@ -136,13 +136,13 @@ func (h *WorkspaceHandler) resolveAgentURLForRestartSignal(ctx context.Context,
}
agentURL = *urlNullable
_ = db.CacheURL(ctx, workspaceID, agentURL)
return rewriteForDocker(agentURL, workspaceID), nil
return h.rewriteForDocker(agentURL, workspaceID), nil
}
// rewriteForDocker rewrites a 127.0.0.1 agent URL to the Docker-DNS form
// when the platform is running inside a Docker container. When platform is
// on the host (non-Docker), 127.0.0.1 IS the host and the original URL works.
func rewriteForDocker(agentURL, workspaceID string) string {
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
if platformInDocker && h.provisioner != nil {
// Only rewrite if the URL points to localhost (the ephemeral port
// binding the container published to the host). Internal Docker
@@ -97,10 +97,10 @@ func TestRewriteForDocker_LocalhostUrlRewritten(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheHit verifies that a Redis-cached
// URL is returned without hitting the DB.
func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
_ = setupTestDB(t) // db.DB must be set before setupTestRedisWithURL
_ = setupTestRedisWithURL(t, "http://cached.internal:9000/agent")
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
// Redis cache hit → DB should NOT be queried
url, err := h.resolveAgentURLForRestartSignal(context.Background(), "ws-cache-hit-123")
@@ -110,19 +110,18 @@ func TestResolveAgentURLForRestartSignal_CacheHit(t *testing.T) {
if url == "" {
t.Fatal("expected non-empty URL from cache")
}
// DB should not be queried (no rows returned to sqlmock)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unfulfilled DB expectations: %v", err)
if url != "http://cached.internal:9000/agent" {
t.Errorf("expected cached URL, got %q", url)
}
}
// TestResolveAgentURLForRestartSignal_DBError verifies that a DB error is
// returned and propagated when neither Redis cache nor DB lookup succeeds.
func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
WithArgs("ws-db-err-789").
@@ -141,10 +140,10 @@ func TestResolveAgentURLForRestartSignal_DBError(t *testing.T) {
// TestResolveAgentURLForRestartSignal_CacheMiss verifies that on Redis miss,
// the URL is fetched from the DB and cached.
func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
mockDB, mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
mr := setupTestRedis(t) // empty → cache miss
mock := setupTestDB(t) // must come before setupTestRedis so db.DB is correct
_ = setupTestRedis(t) // empty → cache miss
h := newHandlerWithTestDepsWithDB(t, mockDB)
h := newHandlerWithTestDeps(t)
mock.ExpectQuery(`SELECT url FROM workspaces WHERE id =`).
WithArgs("ws-cache-miss-456").
@@ -159,10 +158,12 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
t.Errorf("expected DB URL, got %q", url)
}
// Verify the URL was cached in Redis
cached, err := mr.Get(context.Background(), "ws:ws-cache-miss-456:url").Result()
// Verify the URL was cached in Redis via db.GetCachedURL.
// GetCachedURL takes workspaceID and builds the key internally, so
// pass "ws-cache-miss-456" (not the full "ws:ws-cache-miss-456:url").
cached, err := db.GetCachedURL(context.Background(), "ws-cache-miss-456")
if err != nil {
t.Fatalf("URL was not cached in Redis: %v", err)
t.Fatalf("URL cache read failed: %v", err)
}
if cached != "http://db.internal:8000/agent" {
t.Errorf("expected cached URL %q, got %q", "http://db.internal:8000/agent", cached)
@@ -175,9 +176,7 @@ func TestResolveAgentURLForRestartSignal_CacheMiss(t *testing.T) {
// TestGracefulPreRestart_Success verifies that when the workspace returns 200,
// the signal is logged as acknowledged without error.
func TestGracefulPreRestart_Success(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
mr := setupTestRedisWithURL(t, "http://localhost:18000/agent")
_ = setupTestDB(t)
// httptest server simulating the workspace container's /signals/restart_pending
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -206,44 +205,40 @@ func TestGracefulPreRestart_Success(t *testing.T) {
})
}))
defer srv.Close()
mr.Set("ws:ws-ack-789:url", srv.URL, 5*time.Minute)
// Patch the handler's resolveAgentURLForRestartSignal to return the test server URL
// (avoids needing a real provisioner for this test)
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return srv.URL + "/agent", nil
// Pre-populate Redis cache with the test server URL
_ = setupTestRedisWithURL(t, srv.URL)
// Use an embedded struct to override resolveAgentURLForRestartSignal.
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
// gracefulPreRestart runs in a goroutine with its own timeout.
// We give it time to complete before the test ends.
h.gracefulPreRestart(context.Background(), "ws-ack-789")
hWrapper.gracefulPreRestart(context.Background(), "ws-ack-789")
time.Sleep(200 * time.Millisecond)
}
// TestGracefulPreRestart_NotImplemented verifies that when the workspace returns
// 404 (old SDK version), the platform proceeds gracefully (log + no error).
func TestGracefulPreRestart_NotImplemented(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
mr := setupTestRedisWithURL(t, "http://localhost:18001/agent")
_ = setupTestDB(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
mr.Set("ws:ws-noimpl-999:url", srv.URL, 5*time.Minute)
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return srv.URL + "/agent", nil
_ = setupTestRedisWithURL(t, srv.URL)
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: srv.URL + "/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-noimpl-999")
hWrapper.gracefulPreRestart(context.Background(), "ws-noimpl-999")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — graceful degradation
}
@@ -251,19 +246,17 @@ func TestGracefulPreRestart_NotImplemented(t *testing.T) {
// TestGracefulPreRestart_ConnectionRefused verifies that when the workspace
// is unreachable, the platform proceeds gracefully without error.
func TestGracefulPreRestart_ConnectionRefused(t *testing.T) {
_ = setupTestDB(t) // must come before setupTestRedisWithURL so db.DB is correct
_ = setupTestDB(t)
mr := setupTestRedisWithURL(t, "http://localhost:19999/agent") // nothing listening on 19999
mr.Set("ws:ws-unreachable-000:url", "http://localhost:19999/agent", 5*time.Minute)
_ = mr
h := newHandlerWithTestDeps(t)
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return "http://localhost:19999/agent", nil
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
testURL: "http://localhost:19999/agent",
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-unreachable-000")
hWrapper.gracefulPreRestart(context.Background(), "ws-unreachable-000")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — proceeds with stop as documented
}
@@ -274,36 +267,35 @@ func TestGracefulPreRestart_URLResolutionError(t *testing.T) {
_ = setupTestDB(t)
_ = setupTestRedis(t) // empty → URL resolution will fail in resolveAgentURLForRestartSignal
h := newHandlerWithTestDeps(t)
// Override resolveAgentURLForRestartSignal to return an error
origResolve := h.resolveAgentURLForRestartSignal
h.resolveAgentURLForRestartSignal = func(ctx context.Context, wsID string) (string, error) {
return "", context.DeadlineExceeded
hWrapper := &resolveURLTestWrapper{
WorkspaceHandler: newHandlerWithTestDeps(t),
errToReturn: context.DeadlineExceeded,
}
defer func() { h.resolveAgentURLForRestartSignal = origResolve }()
h.gracefulPreRestart(context.Background(), "ws-url-err-111")
hWrapper.gracefulPreRestart(context.Background(), "ws-url-err-111")
time.Sleep(200 * time.Millisecond)
// No panic or error expected — proceeds with stop as documented
}
// ─── helpers ─────────────────────────────────────────────────────────────────
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
// provisioner is nil so rewriteForDocker returns URL unchanged.
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// resolveURLTestWrapper embeds *WorkspaceHandler and overrides
// resolveAgentURLForRestartSignal so tests can inject a fixed URL or error.
type resolveURLTestWrapper struct {
*WorkspaceHandler
testURL string
errToReturn error
}
// newHandlerWithTestDepsWithDB creates a WorkspaceHandler with a specific mock DB.
// Use this when you need to control the DB mock expectations.
func newHandlerWithTestDepsWithDB(t *testing.T, mockDB *sql.DB) *WorkspaceHandler {
// We need to temporarily replace db.DB with our mock
origDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = origDB })
func (w *resolveURLTestWrapper) resolveAgentURLForRestartSignal(ctx context.Context, workspaceID string) (string, error) {
if w.errToReturn != nil {
return "", w.errToReturn
}
return w.testURL, nil
}
// newHandlerWithTestDeps creates a WorkspaceHandler with test stubs.
func newHandlerWithTestDeps(t *testing.T) *WorkspaceHandler {
return NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
}
@@ -314,7 +306,6 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
t.Fatalf("failed to start miniredis: %v", err)
}
db.RDB = redis.NewClient(&redis.Options{Addr: mr.Addr()})
// Pre-populate a URL for the test workspace IDs used in these tests
for _, wsID := range []string{"ws-cache-hit-123", "ws-cache-miss-456", "ws-ack-789", "ws-noimpl-999", "ws-unreachable-000"} {
if err := db.CacheURL(context.Background(), wsID, url); err != nil {
t.Fatalf("failed to cache URL for %s: %v", wsID, err)
@@ -322,9 +313,4 @@ func setupTestRedisWithURL(t *testing.T, url string) *miniredis.Miniredis {
}
t.Cleanup(func() { mr.Close() })
return mr
}
// rewriteForDocker is exported from restart_signals.go so it can be tested here.
func (h *WorkspaceHandler) rewriteForDocker(agentURL, workspaceID string) string {
return rewriteForDocker(agentURL, workspaceID)
}
}
+16 -10
View File
@@ -248,6 +248,19 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
// Begin a transaction so the workspace row and any initial secrets are
// committed atomically. A secret-encrypt or DB error rolls back the
// workspace insert so we never leave a workspace row with missing secrets.
// SSRF guard: validate workspace URL before starting any DB transaction.
// registry.go:324 calls this same guard for agent self-registration;
// the admin-create path must be covered too (core#212).
// Must stay above BeginTx so the rejection path never touches the DB.
if payload.URL != "" {
if err := validateAgentURL(payload.URL); err != nil {
log.Printf("Create: workspace URL rejected: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
return
}
}
tx, txErr := db.DB.BeginTx(ctx, nil)
if txErr != nil {
log.Printf("Create workspace: begin tx error: %v", txErr)
@@ -383,16 +396,9 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
if payload.External || payload.Runtime == "external" {
var connectionToken string
if payload.URL != "" {
// SSRF guard (issue #212): validateAgentURL blocks cloud metadata
// IPs (169.254/16), loopback, link-local, and RFC-1918 in
// strict/self-hosted mode. AdminAuth is required here, but the
// admin token could be leaked or a compromised insider — defence
// in depth. Compare: registry.go:324 (heartbeat path) also
// calls validateAgentURL; external_rotate.go should too.
if err := validateAgentURL(payload.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe workspace URL: " + err.Error()})
return
}
// URL already validated by validateAgentURL above (before BeginTx).
// Now persist it: the external URL is set after the workspace row
// commits so that a failed URL UPDATE doesn't roll back the row.
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = 'external', updated_at = now() WHERE id = $3`, payload.URL, models.StatusOnline, id)
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
@@ -537,17 +537,15 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
WithArgs(sqlmock.AnyArg(), "Ext Agent", nil, 3, "external", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
// External URL update (SSRF-safe public URL passes validateAgentURL).
// External URL update (localhost is explicitly allowed by validateAgentURL).
mock.ExpectExec("UPDATE workspaces SET url").
WillReturnResult(sqlmock.NewResult(0, 1))
// CacheURL is non-fatal but still called.
mock.ExpectExec("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"ok"}).AddRow("ok"))
// CacheURL is non-fatal — uses Redis (db.RDB, set by setupTestRedis), not the DB.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"https://agent.example.com/a2a"}`
body := `{"name":"Ext Agent","runtime":"external","external":true,"url":"http://localhost:8000"}`
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
@@ -9,7 +9,7 @@ package plugins
// 1. SELECTs workspace_plugins rows where tracked_ref != 'none'
// AND installed_sha IS NOT NULL (skip pre-migration rows with NULL SHA).
// 2. For each row, resolves the tracked ref to its current upstream SHA
// using the appropriate SourceResolver.
// using the appropriate PluginResolver.
// 3. If the resolved SHA differs from installed_sha → drift detected.
// 4. On drift, INSERT INTO plugin_update_queue (ON CONFLICT DO NOTHING so
// a re-drift while a row is still pending is a no-op).
@@ -61,10 +61,12 @@ const DriftSweepInterval = 1 * time.Hour
// that handles Gitea instances on high-latency links.
const ResolveRefDeadline = 60 * time.Second
// SourceResolver resolves plugin sources to installable directories.
// PluginResolver resolves plugin sources to installable directories.
// Satisfied by *Registry (which wraps GithubResolver + LocalResolver).
type SourceResolver interface {
Resolve(source Source) (SourceResolver, error)
// Named PluginResolver (not SourceResolver) to avoid redeclaring the
// SourceResolver interface defined in source.go (core#228 fix).
type PluginResolver interface {
Resolve(source Source) (PluginResolver, error)
Schemes() []string
}
@@ -74,7 +76,7 @@ type SourceResolver interface {
//
// Registers itself via atexits in cmd/server/main.go so the process
// shuts down cleanly on SIGTERM.
func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
func StartPluginDriftSweeper(ctx context.Context, resolver PluginResolver) {
if resolver == nil {
log.Println("Plugin drift sweeper: resolver is nil — sweeper disabled")
return
@@ -107,7 +109,7 @@ func StartPluginDriftSweeper(ctx context.Context, resolver SourceResolver) {
// sweepDriftOnce runs one full drift-detection cycle.
// Errors are non-fatal — each row is handled independently so a single
// slow row doesn't block the rest of the sweep.
func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
func sweepDriftOnce(parent context.Context, resolver PluginResolver) {
ctx, cancel := context.WithTimeout(parent, 10*time.Minute)
defer cancel()
@@ -170,7 +172,7 @@ func sweepDriftOnce(parent context.Context, resolver SourceResolver) {
// resolveLatestSHA resolves the tracked ref to its current upstream SHA.
// Handles both github:// and local:// sources; local sources are skipped
// (no meaningful upstream to drift against).
func resolveLatestSHA(ctx context.Context, resolver SourceResolver, sourceRaw, trackedRef string) (string, error) {
func resolveLatestSHA(ctx context.Context, resolver PluginResolver, sourceRaw, trackedRef string) (string, error) {
// Strip the scheme prefix to get the raw spec.
// sourceRaw is stored as the full string, e.g. "github://owner/repo#tag:v1.0.0"
spec := sourceRaw
@@ -231,7 +233,7 @@ func queueDriftEntry(ctx context.Context, workspaceID, pluginName, trackedRef, c
// ─────────────────────────────────────────────────────────────────────────────
// SweepDriftOnceForTest exposes sweepDriftOnce for package-level testing.
func SweepDriftOnceForTest(parent context.Context, resolver SourceResolver) {
func SweepDriftOnceForTest(parent context.Context, resolver PluginResolver) {
sweepDriftOnce(parent, resolver)
}