Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d287eb56a6 |
+1
-1
@@ -51,7 +51,7 @@ MOLECULE_ENV=development # Environment label (development/
|
||||
# MOLECULE_IN_DOCKER= # Set when running the platform inside Docker (accepts 1/0, true/false). Triggers A2A proxy to rewrite 127.0.0.1:<port> agent URLs to Docker bridge hostnames. Auto-detected via /.dockerenv; only set if detection fails or to force off.
|
||||
|
||||
# GitHub
|
||||
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-core). Read inside workspace containers.
|
||||
# GITHUB_REPO=owner/repo # Target repo for agent initial_prompt clone (e.g. Molecule-AI/molecule-monorepo). Read inside workspace containers.
|
||||
# GITHUB_TOKEN= # Personal access token / installation token used by agents that clone private repos. Register as a global secret via POST /admin/secrets for propagation to workspace env. Token is used in-URL during clone and then scrubbed from .git/config via `git remote set-url`.
|
||||
|
||||
# Webhooks
|
||||
|
||||
@@ -18,24 +18,15 @@
|
||||
# per §SOP-6 security model). No-op when merged=false.
|
||||
#
|
||||
# Required env (set by the workflow):
|
||||
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER
|
||||
# plus one of REQUIRED_CHECKS_JSON (preferred) or REQUIRED_CHECKS (legacy)
|
||||
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS
|
||||
#
|
||||
# REQUIRED_CHECKS_JSON is a JSON object keyed by branch name. Each value
|
||||
# is an array of status-check context names that branch protection
|
||||
# requires for that branch. The script looks up the PR's base branch and
|
||||
# evaluates only the checks declared for that branch.
|
||||
#
|
||||
# {"main": ["CI / all-required (pull_request)", ...],
|
||||
# "staging": ["CI / all-required (pull_request)", ...]}
|
||||
#
|
||||
# REQUIRED_CHECKS (legacy) is a newline-separated list used when the
|
||||
# JSON variable is not set. Declared in the workflow YAML rather than
|
||||
# fetched from /branch_protections (which needs admin scope — sop-tier-bot
|
||||
# has read-only). Trade dynamism for simplicity: when the required-check
|
||||
# set changes, update both branch protection AND this env. Keeping them
|
||||
# in sync is less complexity than granting the audit bot admin perms on
|
||||
# every repo.
|
||||
# REQUIRED_CHECKS is a newline-separated list of status-check context
|
||||
# names that branch protection requires. Declared in the workflow YAML
|
||||
# rather than fetched from /branch_protections (which needs admin
|
||||
# scope — sop-tier-bot has read-only). Trade dynamism for simplicity:
|
||||
# when the required-check set changes, update both branch protection
|
||||
# AND this env. Keeping them in sync is less complexity than granting
|
||||
# the audit bot admin perms on every repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -43,10 +34,7 @@ set -euo pipefail
|
||||
: "${GITEA_HOST:?required}"
|
||||
: "${REPO:?required}"
|
||||
: "${PR_NUMBER:?required}"
|
||||
if [ -z "${REQUIRED_CHECKS_JSON:-}" ] && [ -z "${REQUIRED_CHECKS:-}" ]; then
|
||||
echo "::error::Either REQUIRED_CHECKS_JSON or REQUIRED_CHECKS must be set"
|
||||
exit 1
|
||||
fi
|
||||
: "${REQUIRED_CHECKS:?required (newline-separated context names)}"
|
||||
|
||||
OWNER="${REPO%%/*}"
|
||||
NAME="${REPO##*/}"
|
||||
@@ -77,14 +65,10 @@ if [ -z "$MERGE_SHA" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Required status checks — branch-aware JSON dict takes precedence.
|
||||
if [ -n "${REQUIRED_CHECKS_JSON:-}" ]; then
|
||||
REQUIRED=$(echo "$REQUIRED_CHECKS_JSON" | jq -r --arg branch "$BASE_BRANCH" '.[$branch] // [] | .[]')
|
||||
else
|
||||
REQUIRED="$REQUIRED_CHECKS"
|
||||
fi
|
||||
# 2. Required status checks declared in the workflow env.
|
||||
REQUIRED="$REQUIRED_CHECKS"
|
||||
if [ -z "${REQUIRED//[[:space:]]/}" ]; then
|
||||
echo "::notice::REQUIRED_CHECKS empty for branch '$BASE_BRANCH' — force-merge not applicable."
|
||||
echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -296,15 +296,7 @@ fi
|
||||
# 403 → token owner is not in this team (Gitea 1.22.6 'Must be a team
|
||||
# member' constraint — see follow-up issue for token-provisioning)
|
||||
# 404 → not a member
|
||||
# Track whether every candidate returned 403 (token owner not in team).
|
||||
# When this happens the root cause is a token-provisioning issue, not a
|
||||
# reviewer-eligibility issue — surface it clearly so ops don't waste time
|
||||
# verifying team roster (Bug C / RFC#324 follow-up).
|
||||
_ALL_CANDIDATES_403="yes"
|
||||
_CANDIDATE_COUNT=0
|
||||
|
||||
for U in $CANDIDATES; do
|
||||
_CANDIDATE_COUNT=$((_CANDIDATE_COUNT + 1))
|
||||
CODE=$(curl -sS -o "$TEAM_PROBE_TMP" -w '%{http_code}' \
|
||||
-K "$CURL_AUTH_FILE" "${API}/teams/${TEAM_ID}/members/${U}")
|
||||
debug "probe ${U} in team ${TEAM} (id=${TEAM_ID}) → HTTP ${CODE}"
|
||||
@@ -325,20 +317,14 @@ for U in $CANDIDATES; do
|
||||
continue
|
||||
;;
|
||||
404)
|
||||
_ALL_CANDIDATES_403="no"
|
||||
debug "${U} not a member of ${TEAM}"
|
||||
;;
|
||||
*)
|
||||
_ALL_CANDIDATES_403="no"
|
||||
echo "::warning::team-probe for ${U} in ${TEAM} returned unexpected HTTP ${CODE}"
|
||||
cat "$TEAM_PROBE_TMP" >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$_ALL_CANDIDATES_403" = "yes" ] && [ "$_CANDIDATE_COUNT" -gt 0 ]; then
|
||||
echo "::error::${TEAM}-review FAILED — every candidate returned 403 (token owner is not a member of the ${TEAM} team). This is a TOKEN PROVISIONING issue, not a reviewer-eligibility issue. Add the token owner to the '${TEAM}' Gitea team (id=${TEAM_ID}) or use a token whose owner is already in that team."
|
||||
else
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (candidates: $(echo "$CANDIDATES" | tr '\n' ',' | sed 's/,$//') — none are in team)"
|
||||
fi
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (candidates: $(echo "$CANDIDATES" | tr '\n' ',' | sed 's/,$//') — none are in team)"
|
||||
exit 1
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# Invoked by .gitea/workflows/sop-checklist.yml on:
|
||||
# - pull_request_target: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
# - issue_comment: [created] # edited/deleted omitted (Gitea 1.22.6 job-parsing quirk)
|
||||
# - pull_request_target: [opened, edited, synchronize, reopened]
|
||||
# - issue_comment: [created, edited, deleted]
|
||||
#
|
||||
# Flow:
|
||||
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
|
||||
@@ -639,7 +639,9 @@ def load_config(path: str) -> dict[str, Any]:
|
||||
# yaml is an optional dep; the canonical loader is used when available,
|
||||
# but the SOP runs on runners that may not have PyYAML installed. The
|
||||
# fallback _load_config_minimal covers the same config shape without
|
||||
import yaml # type: ignore[import-not-found] # optional dep; fall back silently if absent
|
||||
# requiring the dep, so the ignore is safe: if yaml loads, we use it;
|
||||
# otherwise we fall back silently.
|
||||
import yaml # type: ignore[import-not-found]
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
@@ -1031,7 +1033,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
for t in data:
|
||||
if t.get("name") == tn:
|
||||
tid = t.get("id")
|
||||
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001 # write-through cache; intentional side-effect for reuse across calls
|
||||
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001 # internal write-through cache
|
||||
break
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
|
||||
@@ -11,100 +11,21 @@ def load_workflow(name: str) -> dict:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _all_required(workflow: dict) -> dict:
|
||||
return workflow["jobs"]["all-required"]
|
||||
|
||||
|
||||
def test_all_required_uses_dedicated_meta_runner_lane():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = _all_required(workflow)
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
|
||||
# Stays on the dedicated `ci-meta` lane (the sentinel does no docker
|
||||
# work, so it must NOT occupy the general docker-host pool).
|
||||
assert all_required["runs-on"] == "ci-meta"
|
||||
assert "needs" not in all_required
|
||||
|
||||
|
||||
def test_all_required_is_needs_aggregator_not_a_polling_gate():
|
||||
"""fix/ci-scheduler-fanout (2026-06-01): the sentinel was converted
|
||||
from a status-polling loop (which squatted a ci-meta executor slot for
|
||||
up to 40 min per PR) into a plain `needs:` aggregator that frees the
|
||||
slot immediately. Pin the new shape so a regression to the poller is
|
||||
caught.
|
||||
"""
|
||||
def test_all_required_reuses_path_filter_before_polling():
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = _all_required(workflow)
|
||||
all_required = workflow["jobs"]["all-required"]
|
||||
rendered = str(all_required)
|
||||
|
||||
# The job MUST aggregate via `needs:` (the slot-freeing design).
|
||||
assert "needs" in all_required, "all-required must be a needs: aggregator"
|
||||
|
||||
# It MUST NOT reintroduce the polling loop / per-SHA status fetch that
|
||||
# was the throughput sink.
|
||||
assert "detect-changes.py" not in rendered, (
|
||||
"all-required must not run the detect-changes poller path"
|
||||
)
|
||||
assert "commits/" not in rendered and "statuses" not in rendered, (
|
||||
"all-required must not poll commit statuses (the slot-squat path)"
|
||||
)
|
||||
|
||||
|
||||
def test_all_required_does_not_use_if_always():
|
||||
"""Plain `needs:` works on Gitea 1.22.6 / act_runner v0.6.1; `needs:` +
|
||||
`if: always()` is BROKEN (feedback_gitea_needs_works_only_ifalways_broken)
|
||||
and would let a non-success need pass the gate. The sentinel must use
|
||||
plain `needs:` WITHOUT a job-level `if: always()`.
|
||||
"""
|
||||
workflow = load_workflow("ci.yml")
|
||||
all_required = _all_required(workflow)
|
||||
|
||||
job_if = all_required.get("if")
|
||||
assert not (isinstance(job_if, str) and "always()" in job_if), (
|
||||
"all-required must not combine needs: with if: always()"
|
||||
)
|
||||
|
||||
|
||||
def test_all_required_needs_matches_ci_required_drift_f1_set():
|
||||
"""The sentinel `needs:` list MUST equal ci-required-drift.py's
|
||||
`ci_job_names()` set: every job MINUS the sentinel itself MINUS jobs
|
||||
whose `if:` gates on github.event_name/github.ref (event-gated jobs
|
||||
skip on PRs and a `needs:` on a skipped job would never let the
|
||||
sentinel run). If they diverge, ci-required-drift F1 fires.
|
||||
"""
|
||||
workflow = load_workflow("ci.yml")
|
||||
jobs = workflow["jobs"]
|
||||
sentinel = "all-required"
|
||||
|
||||
expected = set()
|
||||
for key, body in jobs.items():
|
||||
if key == sentinel:
|
||||
continue
|
||||
gate = body.get("if") if isinstance(body, dict) else None
|
||||
if isinstance(gate, str) and (
|
||||
"github.event_name" in gate or "github.ref" in gate
|
||||
):
|
||||
# event-gated → legitimately skips on some triggers; excluded
|
||||
# from both `needs:` and the F1 set.
|
||||
continue
|
||||
expected.add(key)
|
||||
|
||||
needs = jobs[sentinel].get("needs", [])
|
||||
if isinstance(needs, str):
|
||||
needs = [needs]
|
||||
actual = set(needs)
|
||||
|
||||
assert actual == expected, (
|
||||
f"all-required needs: {sorted(actual)} != ci_job_names() "
|
||||
f"{sorted(expected)} — ci-required-drift F1 would fire"
|
||||
)
|
||||
|
||||
|
||||
def test_all_required_needs_reference_real_jobs():
|
||||
"""F1b guard: every entry in `needs:` must name an existing job."""
|
||||
workflow = load_workflow("ci.yml")
|
||||
jobs = workflow["jobs"]
|
||||
needs = jobs["all-required"].get("needs", [])
|
||||
if isinstance(needs, str):
|
||||
needs = [needs]
|
||||
job_keys = set(jobs)
|
||||
for dep in needs:
|
||||
assert dep in job_keys, f"all-required needs unknown job {dep!r}"
|
||||
assert "--profile ci" in rendered
|
||||
assert ".gitea/scripts/detect-changes.py" in rendered
|
||||
assert "REQUIRE_PLATFORM" in rendered
|
||||
assert "REQUIRE_CANVAS" in rendered
|
||||
assert "REQUIRE_SCRIPTS" in rendered
|
||||
|
||||
@@ -47,25 +47,13 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
# Required-status-check contexts to evaluate at merge time.
|
||||
# Branch-aware JSON dict: keys are protected branch names,
|
||||
# values are arrays of context names that branch protection
|
||||
# requires for that branch. Mirror this against branch
|
||||
# protection (settings → branches → protected branch →
|
||||
# required checks) for each branch listed here.
|
||||
#
|
||||
# Newline-separated. Mirror this against branch protection
|
||||
# (settings → branches → protected branch → required checks).
|
||||
# Declared here rather than fetched from /branch_protections
|
||||
# because that endpoint requires admin write — sop-tier-bot is
|
||||
# read-only by design (least-privilege).
|
||||
REQUIRED_CHECKS_JSON: |
|
||||
{
|
||||
"main": [
|
||||
"CI / all-required (pull_request)",
|
||||
"E2E API Smoke Test / E2E API Smoke Test (pull_request)",
|
||||
"Handlers Postgres Integration / Handlers Postgres Integration (pull_request)"
|
||||
],
|
||||
"staging": [
|
||||
"CI / all-required (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)"
|
||||
]
|
||||
}
|
||||
REQUIRED_CHECKS: |
|
||||
CI / all-required (pull_request)
|
||||
E2E API Smoke Test / E2E API Smoke Test (pull_request)
|
||||
Handlers Postgres Integration / Handlers Postgres Integration (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
|
||||
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
|
||||
runs-on: [self-hosted, macos-self-hosted]
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#1982
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#774
|
||||
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
|
||||
continue-on-error: true
|
||||
# event_name gate: functional (only meaningful on push/PR) AND keeps
|
||||
|
||||
+123
-85
@@ -106,7 +106,7 @@ jobs:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#1982 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
||||
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
|
||||
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
|
||||
# Phase-3-masked "green on main 2026-05-12". Two failure classes then surfaced:
|
||||
# (1) 4x delegation_test.go sqlmock gaps (PR #669 / #634 fix-forward, closed).
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-pu.log
|
||||
echo "::endgroup::"
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Run tests with coverage (blocking gate)
|
||||
@@ -392,7 +392,7 @@ jobs:
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: docker-host
|
||||
# mc#1982 root-fix: added job-level `if:` so ci-required-drift.py's
|
||||
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
|
||||
# ci_job_names() detects this as github.ref-gated and skips it from F1.
|
||||
# The step-level exit 0 handles the "not main push" case; the job-level
|
||||
# `if:` makes the gating explicit so the drift script sees it.
|
||||
@@ -475,10 +475,10 @@ jobs:
|
||||
#
|
||||
# Emits `CI / all-required (<event>)` where <event> is the workflow trigger
|
||||
# (e.g. `CI / all-required (pull_request)`, `CI / all-required (push)`).
|
||||
# Branch protection requires the event-suffixed name —
|
||||
# Branch protection MUST be updated to require the event-suffixed name —
|
||||
# requiring `CI / all-required` (bare, no suffix) silently blocks all merges
|
||||
# because Gitea treats absent status contexts as pending (not skipped), and
|
||||
# no workflow emits the bare name. BP requires
|
||||
# no workflow emits the bare name. Fixed: BP now requires
|
||||
# `CI / all-required (pull_request)` per issue #1473.
|
||||
#
|
||||
# Closes the failure mode where status_check_contexts on molecule-core/main
|
||||
@@ -487,91 +487,129 @@ jobs:
|
||||
# red silently merged through. See internal#286 for the three concrete
|
||||
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
||||
#
|
||||
# ── 2026-06-01 CI-scheduler-overload fix (fix/ci-scheduler-fanout) ──
|
||||
# PREVIOUS shape: a poll-gate that ran detect-changes then LOOPED on
|
||||
# `GET /commits/{sha}/statuses` every 15s for up to 40 min, occupying a
|
||||
# `ci-meta` executor slot the entire time it waited for upstream jobs.
|
||||
# With only 2 ci-meta runners, that poll-loop squatted half the lane on
|
||||
# every PR — a confirmed throughput sink in the live RCA (two concurrent
|
||||
# `JOB-all-required` containers observed pinning the lane). The polling
|
||||
# design existed only to dodge the Gitea `needs:` + `if: always()` bug,
|
||||
# where an always()-guarded sentinel could be marked skipped before
|
||||
# upstream jobs settled (leaving BP pending forever).
|
||||
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
|
||||
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
|
||||
# jobs settle, leaving branch protection with a permanent pending
|
||||
# `CI / all-required` context. Instead, this independent sentinel polls the
|
||||
# required commit-status contexts for this SHA and fails if any fail, skip,
|
||||
# or never emit. It runs the same path detector as `changes` and only waits
|
||||
# for path-relevant jobs; Gitea can otherwise leave needs/output-skipped
|
||||
# jobs permanently pending with "Blocked by required conditions". It runs on
|
||||
# the dedicated `ci-meta` lane so the poller does not occupy the same
|
||||
# general runner pool as the jobs it is waiting for.
|
||||
#
|
||||
# NEW shape: a plain `needs:` aggregator with NO polling loop. This is
|
||||
# safe here — and was NOT safe at the time the poller was written —
|
||||
# because every aggregated CI job now gates its real work PER-STEP
|
||||
# (`if: needs.changes.outputs.* != 'true'`) rather than at the JOB level.
|
||||
# A per-step-gated job always reaches a terminal SUCCESS (it no-ops its
|
||||
# expensive steps but the job itself still completes), so it is never
|
||||
# `skipped`. Plain `needs:` (WITHOUT `if: always()`) works correctly on
|
||||
# Gitea 1.22.6 / act_runner v0.6.1 — only `needs:` + `if: always()` is
|
||||
# broken (feedback_gitea_needs_works_only_ifalways_broken). We therefore
|
||||
# use plain `needs:` + an explicit per-need result check (NOT
|
||||
# `if: always()`); if any need fails/errors, Gitea never starts this job
|
||||
# and BP sees `CI / all-required` go red via the failed dependency
|
||||
# propagation — exactly the gate we want, with zero runner-squat.
|
||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
||||
# it in this dependency list lets a skipped reminder skip the required
|
||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
||||
#
|
||||
# The `needs:` list MUST stay in lockstep with ci-required-drift.py's
|
||||
# F1 check (`ci_job_names()` = every job MINUS the sentinel MINUS jobs
|
||||
# whose `if:` gates on github.event_name/github.ref). canvas-deploy-
|
||||
# reminder is event-gated (`if: github.ref == refs/heads/{main,staging}`)
|
||||
# so it is intentionally EXCLUDED — it skips on PRs and a `needs:` on a
|
||||
# skipped job would never let the sentinel run. If a new always-running
|
||||
# CI job is added, add it here too or ci-required-drift F1 will flag it.
|
||||
#
|
||||
# Stays on the dedicated `ci-meta` lane (no docker work, so the
|
||||
# docker-host-pin lint does not apply), but now the job is sub-second:
|
||||
# it only inspects already-settled `needs.*.result` values, so it frees
|
||||
# the slot immediately instead of holding it for the whole CI duration.
|
||||
#
|
||||
needs:
|
||||
- changes
|
||||
- platform-build
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
continue-on-error: false
|
||||
runs-on: ci-meta
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Verify all aggregated CI jobs succeeded
|
||||
# NO polling, NO API call, NO checkout. Because this job lists the
|
||||
# aggregated jobs under `needs:` (without `if: always()`), Gitea only
|
||||
# starts it once every need has reached SUCCESS — a failed/errored
|
||||
# need short-circuits the job and propagates red to the
|
||||
# `CI / all-required` context. This explicit check is a
|
||||
# belt-and-suspenders assertion + a readable run summary; the real
|
||||
# gating is the `needs:` edge itself.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
PLATFORM_RESULT: ${{ needs.platform-build.result }}
|
||||
CANVAS_RESULT: ${{ needs.canvas-build.result }}
|
||||
SHELLCHECK_RESULT: ${{ needs.shellcheck.result }}
|
||||
PYTHON_LINT_RESULT: ${{ needs.python-lint.result }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
python3 .gitea/scripts/detect-changes.py \
|
||||
--profile ci \
|
||||
--event-name "${{ github.event_name }}" \
|
||||
--pr-base-sha "$PR_BASE_SHA" \
|
||||
--base-ref "$PR_BASE_REF" \
|
||||
--push-before "${GITHUB_EVENT_BEFORE:-$PUSH_BEFORE}"
|
||||
- name: Wait for required CI contexts
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API_ROOT: ${{ github.server_url }}/api/v1
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REQUIRE_PLATFORM: ${{ steps.check.outputs.platform }}
|
||||
REQUIRE_CANVAS: ${{ steps.check.outputs.canvas }}
|
||||
REQUIRE_SCRIPTS: ${{ steps.check.outputs.scripts }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail=0
|
||||
check() {
|
||||
name="$1"; result="$2"
|
||||
printf 'CI / %s = %s\n' "$name" "$result"
|
||||
# `success` is the only green terminal state we accept. A plain
|
||||
# `needs:` job is only started when all needs succeed, so reaching
|
||||
# this step already implies success — but assert explicitly so a
|
||||
# future `if: always()` reintroduction (which WOULD let non-success
|
||||
# through) fails loudly instead of silently passing the gate.
|
||||
if [ "$result" != "success" ]; then
|
||||
echo "::error::aggregated CI job '${name}' did not succeed (result=${result})"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
check "Detect changes" "$CHANGES_RESULT"
|
||||
check "Platform (Go)" "$PLATFORM_RESULT"
|
||||
check "Canvas (Next.js)" "$CANVAS_RESULT"
|
||||
check "Shellcheck (E2E scripts)" "$SHELLCHECK_RESULT"
|
||||
check "Python Lint & Test" "$PYTHON_LINT_RESULT"
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "::error::all-required: one or more aggregated CI jobs did not succeed"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: all aggregated CI jobs succeeded — CI / all-required green."
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
api_root = os.environ["API_ROOT"].rstrip("/")
|
||||
repo = os.environ["REPOSITORY"]
|
||||
sha = os.environ["COMMIT_SHA"]
|
||||
event = os.environ["EVENT_NAME"]
|
||||
required = [
|
||||
f"CI / Detect changes ({event})",
|
||||
f"CI / Python Lint & Test ({event})",
|
||||
]
|
||||
if os.environ.get("REQUIRE_PLATFORM") == "true":
|
||||
required.append(f"CI / Platform (Go) ({event})")
|
||||
if os.environ.get("REQUIRE_CANVAS") == "true":
|
||||
required.append(f"CI / Canvas (Next.js) ({event})")
|
||||
if os.environ.get("REQUIRE_SCRIPTS") == "true":
|
||||
required.append(f"CI / Shellcheck (E2E scripts) ({event})")
|
||||
terminal_bad = {"failure", "error"}
|
||||
deadline = time.time() + 40 * 60
|
||||
last_summary = None
|
||||
|
||||
def fetch_statuses():
|
||||
statuses = []
|
||||
for page in range(1, 6):
|
||||
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
chunk = json.load(resp)
|
||||
if not chunk:
|
||||
break
|
||||
statuses.extend(chunk)
|
||||
latest = {}
|
||||
for item in statuses:
|
||||
ctx = item.get("context")
|
||||
if not ctx:
|
||||
continue
|
||||
prev = latest.get(ctx)
|
||||
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
|
||||
latest[ctx] = item
|
||||
return latest
|
||||
|
||||
while True:
|
||||
try:
|
||||
latest = fetch_statuses()
|
||||
except (TimeoutError, OSError, urllib.error.URLError) as exc:
|
||||
if time.time() >= deadline:
|
||||
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
|
||||
time.sleep(15)
|
||||
continue
|
||||
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
|
||||
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
|
||||
if summary != last_summary:
|
||||
print(summary, flush=True)
|
||||
last_summary = summary
|
||||
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
|
||||
if bad:
|
||||
print("FAIL: required CI context failed:", file=sys.stderr)
|
||||
for ctx, state in bad.items():
|
||||
desc = (latest.get(ctx) or {}).get("description") or ""
|
||||
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if all(state == "success" for state in states.values()):
|
||||
print(f"OK: all {len(required)} required CI contexts succeeded")
|
||||
sys.exit(0)
|
||||
if time.time() >= deadline:
|
||||
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
|
||||
for ctx, state in states.items():
|
||||
print(f" - {ctx}: {state}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
time.sleep(15)
|
||||
PY
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
name: Synthetic E2E against staging
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# Bumped from 12 → 20 (2026-05-04). Tenant user-data install phase
|
||||
# (apt-get update + install docker.io/jq/awscli/caddy + snap install
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
# integration). See internal#512 for the class defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
api: ${{ steps.decide.outputs.api }}
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
# detect-changes for the full rationale.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
chat: ${{ steps.decide.outputs.chat }}
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
# Must land on operator-host Linux (docker-host).
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
canvas: ${{ steps.decide.outputs.canvas }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
name: Canvas tabs E2E
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 40
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
name: E2E Staging External Runtime
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
|
||||
|
||||
@@ -94,20 +94,20 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
- name: YAML validation (best-effort)
|
||||
run: |
|
||||
echo "e2e-staging-saas.yml — PR validation: workflow YAML is valid."
|
||||
echo "E2E step runs only when provisioning-critical files change."
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
# Actual E2E: runs on trunk pushes and PRs that touch provisioning-critical
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
name: E2E Staging SaaS
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
name: Intentional-failure teardown sanity
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 20
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # Never block on our own detector failing
|
||||
steps:
|
||||
- name: Check out BASE ref (never PR-head under pull_request_target)
|
||||
|
||||
@@ -87,8 +87,8 @@ jobs:
|
||||
# both jobs on the same label avoids workspace-volume cross-host
|
||||
# surprises and keeps the routing rule discoverable in one place.
|
||||
runs-on: docker-host
|
||||
# mc#1982 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
handlers: ${{ steps.filter.outputs.handlers }}
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
# mc#1529 §1: must run on operator-host (where `molecule-core-net`
|
||||
# exists). See detect-changes for the full routing rationale.
|
||||
runs-on: docker-host
|
||||
# mc#1982 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Unique name per run so concurrent jobs don't collide on the
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# of mc#1543; see internal#512 for class defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
run: ${{ steps.decide.outputs.run }}
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
# beta containers. Must run on operator-host Linux (docker-host).
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: lint-bp-context-emit-match
|
||||
|
||||
# Tier 2f scheduled lint (per mc#1982) — detects drift between
|
||||
# Tier 2f scheduled lint (per mc#774) — detects drift between
|
||||
# `branch_protections/<branch>.status_check_contexts` and the set of
|
||||
# contexts emitted by `.gitea/workflows/*.yml`.
|
||||
#
|
||||
@@ -60,7 +60,7 @@ name: lint-bp-context-emit-match
|
||||
#
|
||||
# Cross-links
|
||||
# -----------
|
||||
# - mc#1982 (the RFC that specs this lint)
|
||||
# - mc#774 (the RFC that specs this lint)
|
||||
# - internal#349 (cross-repo BP sweep)
|
||||
# - feedback_phantom_required_check_after_gitea_migration
|
||||
# - feedback_tier_label_ids_are_per_repo
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface drift without blocking. After 7
|
||||
# clean scheduled runs on main, flip to false so a scheduled
|
||||
# failure is a hard CI signal.
|
||||
continue-on-error: true # mc#1982 Phase 3 — flip to false after 7 clean main runs
|
||||
continue-on-error: true # mc#774 Phase 3 — flip to false after 7 clean main runs
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: lint-continue-on-error-tracking
|
||||
|
||||
# Tier 2e hard-gate lint (per mc#1982) — every
|
||||
# Tier 2e hard-gate lint (per mc#774) — every
|
||||
# `continue-on-error: true` in `.gitea/workflows/*.yml` must carry a
|
||||
# `# mc#NNNN` or `# internal#NNNN` tracker comment within 2 lines,
|
||||
# the referenced issue must be OPEN, and ≤14 days old.
|
||||
@@ -8,7 +8,7 @@ name: lint-continue-on-error-tracking
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# `continue-on-error: true` on `platform-build` had been hiding
|
||||
# mc#1982-class regressions for ~3 weeks before #656 surfaced them on
|
||||
# mc#774-class regressions for ~3 weeks before #656 surfaced them on
|
||||
# 2026-05-12. A 14-day cap on tracker age forces a review cycle and
|
||||
# surfaces mask-drift within at most 14 days of the original defect.
|
||||
# Each `continue-on-error: true` gets a paper trail — close or renew.
|
||||
@@ -45,12 +45,12 @@ name: lint-continue-on-error-tracking
|
||||
# close-and-flip, or document the deliberate keep-mask in a fresh
|
||||
# 14-day-renewable tracker. After main is clean for 3 days,
|
||||
# follow-up PR flips this workflow's continue-on-error to false.
|
||||
# Tracking: mc#1982.
|
||||
# Tracking: mc#774.
|
||||
#
|
||||
# Cross-links
|
||||
# -----------
|
||||
# - mc#1982 (the RFC that specs this lint)
|
||||
# - mc#1982 (the empirical masked-3-weeks case)
|
||||
# - mc#774 (the RFC that specs this lint)
|
||||
# - mc#774 (the empirical masked-3-weeks case)
|
||||
# - feedback_chained_defects_in_never_tested_workflows
|
||||
# - feedback_behavior_based_ast_gates
|
||||
# - feedback_strict_root_only_after_class_a
|
||||
@@ -97,9 +97,9 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface masked defects without blocking
|
||||
# PRs. Pre-existing continue-on-error: true directives on main
|
||||
# all violate this lint at first — intentional. Flip to false
|
||||
# follow-up after main is clean for 3 days. mc#1982.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # mc#1982 Phase 3 mask — 14d forced-renewal cadence
|
||||
# follow-up after main is clean for 3 days. mc#774.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # mc#774 Phase 3 mask — 14d forced-renewal cadence
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -25,21 +25,6 @@ name: Lint forbidden tenant-env keys
|
||||
# feedback_path_filtered_workflow_cant_be_required). The scan itself
|
||||
# targets workspace_secrets-writer paths via grep -r; it's fast
|
||||
# (sub-second) so unconditional run is fine.
|
||||
#
|
||||
# ── 2026-06-01 CI-scheduler-fanout consolidation (fix/ci-scheduler-fanout) ──
|
||||
# The RFC#523 sibling lint formerly in its own file
|
||||
# `lint-no-tenant-gitea-token.yml` (the broader "no repo-host token into
|
||||
# any tenant-writer surface" scan) is now a SECOND job in THIS workflow
|
||||
# (`scan-tenant-token-write`). Both are sub-second Go-source greps that
|
||||
# fired as two separate workflow runs on every PR — pure scheduler
|
||||
# fan-out. Folding the sibling in here drops one workflow run + one
|
||||
# checkout per PR while keeping BOTH scans firing unconditionally on
|
||||
# every PR (the no-paths discipline above is preserved — neither job is
|
||||
# paths-filtered). The moved job keeps its exact `name:` so its emitted
|
||||
# status context is unchanged in substance; its `# bp-exempt:` directive
|
||||
# moves with it (Tier 2g). The old `Lint no tenant GITEA or GITHUB token
|
||||
# write / …` context is retired (a disappearing context needs no
|
||||
# directive; only NEW emitters do).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -181,126 +166,3 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "OK No forbidden operator-scope env key names hardcoded in writer paths."
|
||||
|
||||
# bp-exempt: advisory RFC#523 lint; PR review gate is review-driven, not BP-driven.
|
||||
# (Carried with the workflow-name rename in PR mc#1593 so the renamed
|
||||
# context emission satisfies lint_required_context_exists_in_bp Tier 2g.)
|
||||
scan-tenant-token-write:
|
||||
name: Scan for repo-host token write into tenant workspace surface
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Find Go files referencing a tenant-writer surface AND a repo-host token
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Repo-host token NAMES — the threat-model subset. Operator-fleet
|
||||
# tokens (CP_ADMIN_API_TOKEN, RAILWAY_TOKEN, INFISICAL_*) are
|
||||
# caught by lint-forbidden-env-keys.yml's broader deny set; this
|
||||
# lint focuses on the git-host class so a single co-occurrence
|
||||
# match has a low false-positive rate.
|
||||
FORBIDDEN_KEYS=(
|
||||
"GITEA_TOKEN"
|
||||
"GITEA_PAT"
|
||||
"GITHUB_TOKEN"
|
||||
"GITHUB_PAT"
|
||||
"GH_TOKEN"
|
||||
)
|
||||
|
||||
# Tenant-writer surface markers. A file matches the surface set
|
||||
# if it references ANY of these strings. This is the "is this
|
||||
# code path writing into a tenant workspace?" heuristic.
|
||||
# Curated to catch the actual code shapes used in this repo
|
||||
# (verified by grep against current main 2026-05-19):
|
||||
# - "workspace_secrets" / "global_secrets" → DB table writes
|
||||
# - "seedAllowList" → CP-side seed table
|
||||
# - "/settings/secrets" → tenant HTTP API write
|
||||
# - "envVars[" → in-memory env map write
|
||||
# - "containerEnv" → docker-run env-set
|
||||
# - "userData" → EC2 user-data script
|
||||
# - "provisionPayload" / "provisionContext" → provision-request shape
|
||||
SURFACE_PATTERN='workspace_secrets|global_secrets|seedAllowList|/settings/secrets|envVars\[|containerEnv|userData|provisionPayload|provisionContext'
|
||||
|
||||
# Files that legitimately reference these names AND a surface
|
||||
# marker, but do so for guard / strip / test / doc-comment
|
||||
# reasons. New entries require reviewer signoff and a one-line
|
||||
# justification in the diff.
|
||||
EXEMPT_FILES=(
|
||||
# RFC#523 L1 deny-set source-of-truth + tests
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
|
||||
# Forensic-#145 silent-strip denylist (defense-in-depth, by design lists the names)
|
||||
"workspace-server/internal/provisioner/provisioner.go"
|
||||
"workspace-server/internal/provisioner/provisioner_test.go"
|
||||
# Pre-RFC#523 persona-fallback / org-helper paths. The L1
|
||||
# fail-closed runs BEFORE these writers; downstream silent-strip
|
||||
# also covers them. See applyAgentGitHTTPCreds doc-comment.
|
||||
"workspace-server/internal/handlers/agent_git_identity.go"
|
||||
"workspace-server/internal/handlers/org_helpers.go"
|
||||
"workspace-server/internal/handlers/org.go"
|
||||
# CP→platform admin auth (NOT a tenant env write).
|
||||
"workspace-server/internal/provisioner/cp_provisioner.go"
|
||||
)
|
||||
|
||||
# Build an extended-regex alternation of forbidden keys.
|
||||
KEY_ALT="$(IFS='|'; echo "${FORBIDDEN_KEYS[*]}")"
|
||||
|
||||
# Find candidate files: Go non-test sources that contain a
|
||||
# tenant-writer surface marker.
|
||||
mapfile -t CANDIDATES < <(
|
||||
grep -rlE --include='*.go' --exclude='*_test.go' \
|
||||
"${SURFACE_PATTERN}" . 2>/dev/null \
|
||||
| sed 's|^\./||' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
|
||||
echo "OK No tenant-writer-surface files found in tree (unexpected, but not a lint failure)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HITS=""
|
||||
for f in "${CANDIDATES[@]}"; do
|
||||
# Skip exempt files.
|
||||
skip=0
|
||||
for ex in "${EXEMPT_FILES[@]}"; do
|
||||
if [ "$f" = "$ex" ]; then skip=1; break; fi
|
||||
done
|
||||
[ "$skip" = "1" ] && continue
|
||||
|
||||
# File contains a surface marker; now grep for a forbidden
|
||||
# key NAME. We require a QUOTED-literal match to avoid
|
||||
# firing on a comment like "// also handle GITEA_TOKEN".
|
||||
#
|
||||
# The literal form catches:
|
||||
# - os.Getenv("GITEA_TOKEN")
|
||||
# - envVars["GITEA_TOKEN"] = ...
|
||||
# - {envKey: "GITEA_TOKEN", tenantKey: "GITEA_TOKEN"}
|
||||
# but not:
|
||||
# - // see GITEA_TOKEN below (no quotes)
|
||||
found=$(grep -nE "\"(${KEY_ALT})\"" "$f" 2>/dev/null || true)
|
||||
if [ -n "$found" ]; then
|
||||
HITS="${HITS}--- ${f} ---\n${found}\n"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
echo "::error::Task #146 lint: repo-host token name(s) quoted in a tenant-writer-surface file:"
|
||||
printf "$HITS"
|
||||
echo ""
|
||||
echo "These files reference a tenant-writer surface (workspace_secrets,"
|
||||
echo "seedAllowList, /settings/secrets, containerEnv, userData, etc.)"
|
||||
echo "AND quote a repo-host token name (GITEA_TOKEN/GITHUB_TOKEN/…)."
|
||||
echo "Per RFC#523 threat model, tenant workspaces MUST NOT receive"
|
||||
echo "operator-scope repo-host tokens. If your code legitimately needs"
|
||||
echo "to reference one of these names in a tenant-writer file (e.g."
|
||||
echo "a deny-set definition or silent-strip list), add the file to"
|
||||
echo "EXEMPT_FILES with a one-line justification — reviewer signoff"
|
||||
echo "required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK No tenant-writer-surface file co-mentions a repo-host token literal."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: lint-mask-pr-atomicity
|
||||
|
||||
# Tier 2d hard-gate lint (per mc#1982) — blocks PRs that touch
|
||||
# Tier 2d hard-gate lint (per mc#774) — blocks PRs that touch
|
||||
# `.gitea/workflows/ci.yml` and modify ONLY ONE of {continue-on-error,
|
||||
# all-required.sentinel.needs} without a `Paired: #NNN` reference in
|
||||
# the PR body or in a commit message.
|
||||
@@ -37,13 +37,13 @@ name: lint-mask-pr-atomicity
|
||||
# This workflow lands at `continue-on-error: true` (Phase 3 — surface
|
||||
# regressions without blocking PRs while the rule beds in).
|
||||
# Follow-up PR flips to `false` once we have ≥3 days of clean runs on
|
||||
# `main` and no false-positives. Tracking issue: mc#1982.
|
||||
# `main` and no false-positives. Tracking issue: mc#774.
|
||||
#
|
||||
# Cross-links
|
||||
# -----------
|
||||
# - mc#1982 (the RFC that specs this lint)
|
||||
# - mc#774 (the RFC that specs this lint)
|
||||
# - PR#665 / PR#668 (the empirical split-pair)
|
||||
# - mc#1982 (the main-red incident the split caused)
|
||||
# - mc#774 (the main-red incident the split caused)
|
||||
# - feedback_strict_root_only_after_class_a
|
||||
# - feedback_behavior_based_ast_gates
|
||||
#
|
||||
@@ -92,8 +92,8 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking
|
||||
# PRs. Follow-up PR flips this to `false` once recent runs on main
|
||||
# are confirmed clean (eat-our-own-dogfood discipline mirrors
|
||||
# PR#673's same-shape comment). Tracking: mc#1982.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# PR#673's same-shape comment). Tracking: mc#774.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out PR head with full history (need base SHA blobs)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
name: Lint no tenant GITEA or GITHUB token write
|
||||
|
||||
# Task #146 — CI guardrail companion to RFC#523's `lint-forbidden-env-keys.yml`.
|
||||
#
|
||||
# `lint-forbidden-env-keys.yml` (Layer 3) catches code that hardcodes a
|
||||
# forbidden env-var key NAME as a quoted literal in workspace_secrets
|
||||
# writer paths under workspace-server/internal/.
|
||||
#
|
||||
# This workflow catches a BROADER class: any code path that reads a
|
||||
# repo-host token (GITEA_TOKEN / GITHUB_TOKEN / GH_TOKEN) and then writes
|
||||
# it into a TENANT WORKSPACE's env, secret store, user-data, or
|
||||
# provision payload. This is the actual RFC#523 threat-model statement —
|
||||
# the goal is "no tenant workspace ever receives an operator-scope repo
|
||||
# token," not just "no _quoted_ literal `GITEA_TOKEN`." A future writer
|
||||
# could route the value via a variable, a struct field, or a config key
|
||||
# and slip past the existing literal scan; this lint catches those
|
||||
# routing patterns at PR review time.
|
||||
#
|
||||
# Scope
|
||||
# Scans the WHOLE repo's Go sources (not just workspace-server/) for
|
||||
# co-occurrences of:
|
||||
# - a repo-host token NAME (GITEA_TOKEN / GITHUB_TOKEN / GH_TOKEN /
|
||||
# GITEA_PAT / GITHUB_PAT) used as os.Getenv argument or string
|
||||
# literal
|
||||
# - within a file that ALSO references a tenant-writer surface
|
||||
# (`tenant`, `workspace_secrets`, `global_secrets`, `seedAllowList`,
|
||||
# `/settings/secrets`, `userData`, `provisionPayload`,
|
||||
# `envVars[`, `containerEnv`).
|
||||
#
|
||||
# Co-occurrence (not single-line) is the false-positive control: a
|
||||
# file that just LOGS the variable name (e.g. "missing GITEA_TOKEN")
|
||||
# without touching any tenant surface won't fire.
|
||||
#
|
||||
# Drift contract with lint-forbidden-env-keys.yml
|
||||
# Both lints share the same FORBIDDEN_KEYS list (a subset — only the
|
||||
# repo-host tokens, since this lint's threat model is "tenant gets
|
||||
# write access to operator's git host"). If RFC#523's deny set grows,
|
||||
# update BOTH this file AND lint-forbidden-env-keys.yml AND the Go
|
||||
# source-of-truth in
|
||||
# workspace-server/internal/handlers/workspace_provision_forbidden_env.go.
|
||||
#
|
||||
# Open-source-template-friendly
|
||||
# The patterns scanned are generic (no MOLECULE_-prefix literals).
|
||||
# A fork can copy this workflow as-is and adjust FORBIDDEN_KEYS.
|
||||
#
|
||||
# Path-filter discipline
|
||||
# No `paths:` filter — required-status workflows must run on every PR
|
||||
# per `feedback_path_filtered_workflow_cant_be_required`. Scan is
|
||||
# sub-second.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: advisory RFC#523 lint; PR review gate is review-driven, not BP-driven.
|
||||
# (Carried with the workflow-name rename in PR mc#1593 so the renamed
|
||||
# context emission satisfies lint_required_context_exists_in_bp Tier 2g.)
|
||||
scan:
|
||||
name: Scan for repo-host token write into tenant workspace surface
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Find Go files referencing a tenant-writer surface AND a repo-host token
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Repo-host token NAMES — the threat-model subset. Operator-fleet
|
||||
# tokens (CP_ADMIN_API_TOKEN, RAILWAY_TOKEN, INFISICAL_*) are
|
||||
# caught by lint-forbidden-env-keys.yml's broader deny set; this
|
||||
# lint focuses on the git-host class so a single co-occurrence
|
||||
# match has a low false-positive rate.
|
||||
FORBIDDEN_KEYS=(
|
||||
"GITEA_TOKEN"
|
||||
"GITEA_PAT"
|
||||
"GITHUB_TOKEN"
|
||||
"GITHUB_PAT"
|
||||
"GH_TOKEN"
|
||||
)
|
||||
|
||||
# Tenant-writer surface markers. A file matches the surface set
|
||||
# if it references ANY of these strings. This is the "is this
|
||||
# code path writing into a tenant workspace?" heuristic.
|
||||
# Curated to catch the actual code shapes used in this repo
|
||||
# (verified by grep against current main 2026-05-19):
|
||||
# - "workspace_secrets" / "global_secrets" → DB table writes
|
||||
# - "seedAllowList" → CP-side seed table
|
||||
# - "/settings/secrets" → tenant HTTP API write
|
||||
# - "envVars[" → in-memory env map write
|
||||
# - "containerEnv" → docker-run env-set
|
||||
# - "userData" → EC2 user-data script
|
||||
# - "provisionPayload" / "provisionContext" → provision-request shape
|
||||
SURFACE_PATTERN='workspace_secrets|global_secrets|seedAllowList|/settings/secrets|envVars\[|containerEnv|userData|provisionPayload|provisionContext'
|
||||
|
||||
# Files that legitimately reference these names AND a surface
|
||||
# marker, but do so for guard / strip / test / doc-comment
|
||||
# reasons. New entries require reviewer signoff and a one-line
|
||||
# justification in the diff.
|
||||
EXEMPT_FILES=(
|
||||
# RFC#523 L1 deny-set source-of-truth + tests
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
|
||||
# Forensic-#145 silent-strip denylist (defense-in-depth, by design lists the names)
|
||||
"workspace-server/internal/provisioner/provisioner.go"
|
||||
"workspace-server/internal/provisioner/provisioner_test.go"
|
||||
# Pre-RFC#523 persona-fallback / org-helper paths. The L1
|
||||
# fail-closed runs BEFORE these writers; downstream silent-strip
|
||||
# also covers them. See applyAgentGitHTTPCreds doc-comment.
|
||||
"workspace-server/internal/handlers/agent_git_identity.go"
|
||||
"workspace-server/internal/handlers/org_helpers.go"
|
||||
"workspace-server/internal/handlers/org.go"
|
||||
# CP→platform admin auth (NOT a tenant env write).
|
||||
"workspace-server/internal/provisioner/cp_provisioner.go"
|
||||
)
|
||||
|
||||
# Build an extended-regex alternation of forbidden keys.
|
||||
KEY_ALT="$(IFS='|'; echo "${FORBIDDEN_KEYS[*]}")"
|
||||
|
||||
# Find candidate files: Go non-test sources that contain a
|
||||
# tenant-writer surface marker.
|
||||
mapfile -t CANDIDATES < <(
|
||||
grep -rlE --include='*.go' --exclude='*_test.go' \
|
||||
"${SURFACE_PATTERN}" . 2>/dev/null \
|
||||
| sed 's|^\./||' \
|
||||
| sort -u
|
||||
)
|
||||
|
||||
if [ "${#CANDIDATES[@]}" -eq 0 ]; then
|
||||
echo "OK No tenant-writer-surface files found in tree (unexpected, but not a lint failure)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HITS=""
|
||||
for f in "${CANDIDATES[@]}"; do
|
||||
# Skip exempt files.
|
||||
skip=0
|
||||
for ex in "${EXEMPT_FILES[@]}"; do
|
||||
if [ "$f" = "$ex" ]; then skip=1; break; fi
|
||||
done
|
||||
[ "$skip" = "1" ] && continue
|
||||
|
||||
# File contains a surface marker; now grep for a forbidden
|
||||
# key NAME. We require a QUOTED-literal match to avoid
|
||||
# firing on a comment like "// also handle GITEA_TOKEN".
|
||||
#
|
||||
# The literal form catches:
|
||||
# - os.Getenv("GITEA_TOKEN")
|
||||
# - envVars["GITEA_TOKEN"] = ...
|
||||
# - {envKey: "GITEA_TOKEN", tenantKey: "GITEA_TOKEN"}
|
||||
# but not:
|
||||
# - // see GITEA_TOKEN below (no quotes)
|
||||
found=$(grep -nE "\"(${KEY_ALT})\"" "$f" 2>/dev/null || true)
|
||||
if [ -n "$found" ]; then
|
||||
HITS="${HITS}--- ${f} ---\n${found}\n"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
echo "::error::Task #146 lint: repo-host token name(s) quoted in a tenant-writer-surface file:"
|
||||
printf "$HITS"
|
||||
echo ""
|
||||
echo "These files reference a tenant-writer surface (workspace_secrets,"
|
||||
echo "seedAllowList, /settings/secrets, containerEnv, userData, etc.)"
|
||||
echo "AND quote a repo-host token name (GITEA_TOKEN/GITHUB_TOKEN/…)."
|
||||
echo "Per RFC#523 threat model, tenant workspaces MUST NOT receive"
|
||||
echo "operator-scope repo-host tokens. If your code legitimately needs"
|
||||
echo "to reference one of these names in a tenant-writer file (e.g."
|
||||
echo "a deny-set definition or silent-strip list), add the file to"
|
||||
echo "EXEMPT_FILES with a one-line justification — reviewer signoff"
|
||||
echo "required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK No tenant-writer-surface file co-mentions a repo-host token literal."
|
||||
@@ -4,7 +4,7 @@ name: Lint pre-flip continue-on-error
|
||||
# on any job in `.gitea/workflows/*.yml` WITHOUT proof that the affected
|
||||
# job's recent runs on the target branch (PR base) are actually green.
|
||||
#
|
||||
# Empirical class: PR #656 / mc#1982. PR #656 (RFC internal#219 Phase 4)
|
||||
# Empirical class: PR #656 / mc#774. PR #656 (RFC internal#219 Phase 4)
|
||||
# flipped 5 platform-build-class jobs `continue-on-error: true → false`
|
||||
# on the basis of a "verified green on main via combined-status check".
|
||||
# But that "green" was the LIE the prior `continue-on-error: true`
|
||||
@@ -13,7 +13,7 @@ name: Lint pre-flip continue-on-error
|
||||
# job-level status. The precondition the PR claimed to verify was
|
||||
# structurally fooled by the bug being flipped.
|
||||
#
|
||||
# mc#1982 captured the surfaced defects (2 mutually-masked regressions):
|
||||
# mc#774 captured the surfaced defects (2 mutually-masked regressions):
|
||||
# - Class 1: sqlmock helper drift since 2f36bb9a (24 days old)
|
||||
# - Class 2: OFFSEC-001 contract collision since 7d1a189f (1 day old)
|
||||
#
|
||||
@@ -55,7 +55,7 @@ name: Lint pre-flip continue-on-error
|
||||
# - YAML parse error in one of the workflow files: warn-only,
|
||||
# don't block — the YAML lint workflows catch this separately.
|
||||
#
|
||||
# Cross-links: PR#656, mc#1982, PR#665 (interim re-mask),
|
||||
# Cross-links: PR#656, mc#774, PR#665 (interim re-mask),
|
||||
# Quirk #10 (internal#342 + dup #287), hongming-pc2 charter
|
||||
# §SOP-N rule (e), feedback_strict_root_only_after_class_a,
|
||||
# feedback_no_shared_persona_token_use.
|
||||
@@ -99,8 +99,8 @@ jobs:
|
||||
timeout-minutes: 8
|
||||
# Phase 3 (RFC internal#219 §1): surface broken flips without blocking
|
||||
# the PR yet. Follow-up flips this to `false` once the workflow itself
|
||||
# has clean recent runs on main. mc#1982 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#1982
|
||||
# has clean recent runs on main. mc#774 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#774
|
||||
steps:
|
||||
- name: Check out PR head (full history for base-SHA access)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: lint-required-context-exists-in-bp
|
||||
|
||||
# Tier 2g hard-gate lint (per mc#1982) — diff-based PR-time
|
||||
# Tier 2g hard-gate lint (per mc#774) — diff-based PR-time
|
||||
# check. When a PR adds a NEW commit-status emission (workflow YAML
|
||||
# `name:` + job `name:`-or-key + on:-event), the workflow file must
|
||||
# carry one of three directives adjacent to the new job:
|
||||
@@ -16,7 +16,7 @@ name: lint-required-context-exists-in-bp
|
||||
# PR#656 added `CI / all-required (pull_request)` as a sentinel
|
||||
# context that workflows emit, but BP did NOT list it. When
|
||||
# platform-build failed, all-required failed, but BP let the PR
|
||||
# merge anyway → cascade to mc#1982. With this lint, PR#656 would
|
||||
# merge anyway → cascade to mc#774. With this lint, PR#656 would
|
||||
# have been blocked until either the BP PATCH ran alongside OR
|
||||
# the author added a `bp-required: pending` directive.
|
||||
#
|
||||
@@ -27,7 +27,7 @@ name: lint-required-context-exists-in-bp
|
||||
# share the workflow-context enumeration helpers
|
||||
# (`_event_map`, `workflow_contexts`, `_job_display`) but the
|
||||
# semantics are intentionally distinct so they're separate scripts.
|
||||
# Co-design is documented in mc#1982.
|
||||
# Co-design is documented in mc#774.
|
||||
#
|
||||
# Directive comment lives in the workflow file (NOT PR body)
|
||||
# ----------------------------------------------------------
|
||||
@@ -42,13 +42,13 @@ name: lint-required-context-exists-in-bp
|
||||
# Lands at `continue-on-error: true` (Phase 3 — surface the
|
||||
# pattern without blocking PRs while the directive convention
|
||||
# beds in). After 7 days of clean runs on `main` with no false
|
||||
# positives, follow-up flips to `false`. Tracking: mc#1982.
|
||||
# positives, follow-up flips to `false`. Tracking: mc#774.
|
||||
#
|
||||
# Cross-links
|
||||
# -----------
|
||||
# - mc#1982 (the RFC that specs this lint)
|
||||
# - mc#774 (the RFC that specs this lint)
|
||||
# - PR#656 (the empirical case)
|
||||
# - mc#1982 (the surfaced cascade)
|
||||
# - mc#774 (the surfaced cascade)
|
||||
# - feedback_phantom_required_check_after_gitea_migration (Tier 2f cousin)
|
||||
# - feedback_behavior_based_ast_gates
|
||||
#
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
# Phase 3 (RFC #219 §1): surface the pattern without blocking PRs
|
||||
# while the directive convention beds in. Follow-up flip to false
|
||||
# after 7 clean days on main. mc#1982.
|
||||
continue-on-error: true # mc#1982 Phase 3 — flip to false after 7 clean main runs
|
||||
# after 7 clean days on main. mc#774.
|
||||
continue-on-error: true # mc#774 Phase 3 — flip to false after 7 clean main runs
|
||||
steps:
|
||||
- name: Check out PR head with full history (need base SHA blobs)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking PRs.
|
||||
# Follow-up PR flips this off after the 4 existing-on-main rule-2
|
||||
# (workflow_run) violations are migrated to a supported trigger.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# in this rollout (internal#462) so the precondition holds.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#1982
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#774
|
||||
continue-on-error: true
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
name: Audit Railway env vars for drift-prone pins
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# `publish` -> molecule-runner-publish-* sub-pool.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# runners with internet access to package mirrors). Falls back to GitHub
|
||||
# binary download. GitHub releases may be blocked on some runner networks
|
||||
# (infra#241 follow-up).
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if apt-get update -qq && apt-get install -y -qq jq; then
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
name: Detect SECRET_PATTERNS drift
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
# window closed. continue-on-error: true has been removed from the
|
||||
# tier-check job; AND-composition is now fully enforced. If you need
|
||||
# to temporarily re-introduce a mask, file a tracker and follow the
|
||||
# mc#1982 protocol (Tier 2e lint requires a current tracker within
|
||||
# mc#774 protocol (Tier 2e lint requires a current tracker within
|
||||
# 2 lines of any continue-on-error: true).
|
||||
|
||||
name: sop-tier-check
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
# runners). The sop-tier-check script has its own fallback as a
|
||||
# third line of defense. continue-on-error: true ensures this step
|
||||
# failing does not block the job.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# apt-get is the primary method — Ubuntu package mirrors are reliably
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
# continue-on-error: true at step level — job-level is ignored by Gitea
|
||||
# Actions (quirk #10, internal runbooks). Belt-and-suspenders with
|
||||
# SOP_FAIL_OPEN=1 + || true below.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
staging-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
sha: ${{ steps.compute.outputs.sha }}
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
if: ${{ needs.staging-smoke.result == 'success' && needs.staging-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SHA: ${{ needs.staging-smoke.outputs.sha }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
name: Sweep CF orphans
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# 3 min surfaces hangs (CF API stall, AWS describe-instances stuck)
|
||||
# within one cron interval instead of burning a full tick. Realistic
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
name: Sweep CF tunnels
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# 30 min cap. Was 5 min on the theory that the only thing that
|
||||
# could take >5min is a CF-API hang — but on 2026-05-02 a backlog
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
name: Ops scripts (unittest)
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -35,26 +35,8 @@ name: verify-providers-gen
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# CI-scheduler-overload fix (fix/ci-scheduler-fanout, 2026-06-01):
|
||||
# this gate only verifies that the generated providers artifact is in
|
||||
# sync with the schema SSOT. Its verdict can ONLY change when one of
|
||||
# the codegen inputs/outputs changes, so firing the Go toolchain on
|
||||
# every unrelated PR (docs, canvas, scripts) is pure fan-out cost.
|
||||
# Scoped to the codegen surface. SAFE because this workflow is NOT a
|
||||
# branch-protection status_check_context (see header §ENFORCEMENT
|
||||
# GATING) — lint-required-no-paths only forbids paths filters on
|
||||
# REQUIRED workflows; this is advisory, so a paths filter is allowed.
|
||||
# Mirrors the sibling sync-providers-yaml.yml scoping convention.
|
||||
paths:
|
||||
- 'workspace-server/internal/providers/**'
|
||||
- 'workspace-server/cmd/gen-providers/**'
|
||||
- '.gitea/workflows/verify-providers-gen.yml'
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace-server/internal/providers/**'
|
||||
- 'workspace-server/cmd/gen-providers/**'
|
||||
- '.gitea/workflows/verify-providers-gen.yml'
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
name: Weekly Platform-Go Surface
|
||||
runs-on: ubuntu-latest
|
||||
# continue-on-error: surface only, never block
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
|
||||
cd molecule-core
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
./scripts/dev-start.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function PricingPage() {
|
||||
<p className="mt-2 text-ink-mid">
|
||||
We publish the{" "}
|
||||
<a
|
||||
href="https://git.moleculesai.app/molecule-ai/molecule-core"
|
||||
href="https://git.moleculesai.app/molecule-ai/molecule-monorepo"
|
||||
className="text-accent underline hover:text-accent"
|
||||
>
|
||||
full source on GitHub
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Molecule AI — Comprehensive Technical Documentation
|
||||
|
||||
> Definitive technical reference for the Molecule AI Agent Team platform.
|
||||
> Based on a full non-invasive scan of the [molecule-core](https://git.moleculesai.app/molecule-ai/molecule-core) repository.
|
||||
> Based on a full non-invasive scan of the [molecule-monorepo](https://git.moleculesai.app/molecule-ai/molecule-monorepo) repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -1131,11 +1131,11 @@ Molecule AI's workspace abstraction is **runtime-agnostic by design**. A workspa
|
||||
|
||||
## Links
|
||||
|
||||
- **GitHub**: https://git.moleculesai.app/molecule-ai/molecule-core
|
||||
- **Architecture Docs**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/architecture
|
||||
- **API Protocol**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/api-protocol
|
||||
- **Agent Runtime**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/agent-runtime
|
||||
- **Product Docs**: https://git.moleculesai.app/molecule-ai/molecule-core/src/branch/main/docs/product
|
||||
- **GitHub**: https://git.moleculesai.app/molecule-ai/molecule-monorepo
|
||||
- **Architecture Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/architecture
|
||||
- **API Protocol**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/api-protocol
|
||||
- **Agent Runtime**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/agent-runtime
|
||||
- **Product Docs**: https://git.moleculesai.app/molecule-ai/molecule-monorepo/src/branch/main/docs/product
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ DATABASE_URL=postgres://dev:dev@postgres:5432/molecule?sslmode=prefer
|
||||
REDIS_URL=redis://redis:6379
|
||||
PORT=8080
|
||||
SECRETS_ENCRYPTION_KEY=dev-key-change-in-production
|
||||
WORKSPACE_DIR=/path/to/molecule-core # Optional global fallback; prefer per-workspace workspace_dir in org.yaml or API
|
||||
WORKSPACE_DIR=/path/to/molecule-monorepo # Optional global fallback; prefer per-workspace workspace_dir in org.yaml or API
|
||||
```
|
||||
|
||||
### Canvas (Next.js)
|
||||
|
||||
@@ -16,9 +16,11 @@ workspace container running on it) over an [EC2 Instance Connect
|
||||
Endpoint](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-connect-setup-ec2-instance-connect-endpoint.html).
|
||||
End users see a terminal; no direct public SSH ingress is required.
|
||||
|
||||
Tracking: originally `molecule-core#1528` (resolved 2026-04-22). Future
|
||||
terminal work is tracked in `molecule-core` issues (workspace-server scope)
|
||||
and in `molecule-controlplane` issues for the EIC / per-tenant SG path.
|
||||
Tracking: originally `molecule-core#1528` (resolved 2026-04-22). The
|
||||
`molecule-core` repo has since been renamed to `molecule-monorepo` and no
|
||||
longer accepts new issues under the old name; future terminal work is
|
||||
tracked in `molecule-monorepo` issues (workspace-server scope) and in
|
||||
`molecule-controlplane` issues for the EIC / per-tenant SG path.
|
||||
|
||||
## Where things are
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ When opencode connects to the Molecule MCP endpoint, the agent gains access to:
|
||||
"tool": "delegate_task",
|
||||
"arguments": {
|
||||
"target": "research-lead",
|
||||
"task": "Summarise the last 7 days of commits in Molecule-AI/molecule-core"
|
||||
"task": "Summarise the last 7 days of commits in Molecule-AI/molecule-monorepo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Internal content policy
|
||||
|
||||
The `Molecule-AI/molecule-core` repo is **public**. Anything internal
|
||||
The `Molecule-AI/molecule-monorepo` repo is **public**. Anything internal
|
||||
(positioning, competitive briefs, sales playbooks, PMM/press drip, draft
|
||||
campaigns, raw research notes, ops runbooks, retrospectives) lives in
|
||||
**`Molecule-AI/internal`**.
|
||||
@@ -18,14 +18,14 @@ This page is the canonical decision tree.
|
||||
| Draft campaign asset (still iterating, not yet customer-visible) | `Molecule-AI/internal/marketing/campaigns/` |
|
||||
| Roadmap discussion, planning doc, retrospective | `Molecule-AI/internal/PLAN.md` or `Molecule-AI/internal/retrospectives/` |
|
||||
| Runbook, ops procedure, incident postmortem | `Molecule-AI/internal/runbooks/` |
|
||||
| **Public-ready** blog post (final draft, ready to ship to docs site) | `Molecule-AI/molecule-core/docs/blog/` |
|
||||
| **Public-ready** tutorial / quickstart | `Molecule-AI/molecule-core/docs/tutorials/` |
|
||||
| Public DevRel content (code samples, demos for users) | `Molecule-AI/molecule-core/docs/devrel/` |
|
||||
| API reference, architecture docs for external developers | `Molecule-AI/molecule-core/docs/api/` |
|
||||
| **Public-ready** blog post (final draft, ready to ship to docs site) | `Molecule-AI/molecule-monorepo/docs/blog/` |
|
||||
| **Public-ready** tutorial / quickstart | `Molecule-AI/molecule-monorepo/docs/tutorials/` |
|
||||
| Public DevRel content (code samples, demos for users) | `Molecule-AI/molecule-monorepo/docs/devrel/` |
|
||||
| API reference, architecture docs for external developers | `Molecule-AI/molecule-monorepo/docs/api/` |
|
||||
| Code, tests, infrastructure | wherever is appropriate inside this repo |
|
||||
|
||||
**Rule of thumb:** *"Would I be comfortable if a competitor / journalist / customer
|
||||
read this verbatim today?"* — yes → `molecule-core/docs/`. No / not yet → `internal/`.
|
||||
read this verbatim today?"* — yes → `monorepo/docs/`. No / not yet → `internal/`.
|
||||
|
||||
## Why
|
||||
|
||||
@@ -82,7 +82,7 @@ git push -u origin HEAD
|
||||
gh pr create --base main --fill
|
||||
```
|
||||
|
||||
Yes, this is more steps than `cd molecule-core && git add research/foo.md`.
|
||||
Yes, this is more steps than `cd molecule-monorepo && git add research/foo.md`.
|
||||
That cost is intentional: the friction is the point. Public space and
|
||||
internal space are different products with different audiences and
|
||||
different durability guarantees.
|
||||
|
||||
+4
-4
@@ -17,8 +17,8 @@ This path is aligned to the current repository and current UI. It gets you from
|
||||
## The one-command path
|
||||
|
||||
```bash
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
|
||||
cd molecule-core
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
./scripts/dev-start.sh
|
||||
```
|
||||
|
||||
@@ -42,8 +42,8 @@ If you'd rather run each component yourself — useful when you're iterating on
|
||||
### Step 1: Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git
|
||||
cd molecule-core
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
```
|
||||
|
||||
### Step 2: Start the shared infrastructure
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
# Engineer-Agent Gitea Token Scope Runbook
|
||||
|
||||
## Symptom
|
||||
|
||||
Engineer-class agents (e.g. `agent-dev-a`, `agent-dev-b`) fail swarm-pull issue discovery or receive HTTP 403 when calling Gitea issue-list APIs, while PR review and repository API operations continue to work.
|
||||
|
||||
Typical failing call:
|
||||
```bash
|
||||
GET /api/v1/repos/molecule-ai/molecule-core/issues?state=open&labels=approved&limit=50
|
||||
# => 403 Forbidden
|
||||
```
|
||||
|
||||
Typical working calls (same token):
|
||||
```bash
|
||||
GET /api/v1/repos/molecule-ai/molecule-core/pulls?state=open&limit=50
|
||||
POST /api/v1/repos/molecule-ai/molecule-core/pulls/1666/comments
|
||||
# => 200 OK
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
Gitea v1.22.6 routes issue-list under the `Issue` scope category (`routers/api/v1/api.go:1379-1491`), while PR routes live under repository/pull routing (`api.go:1278-1305`). The scope gate derives required read/write level from HTTP method (`api.go:309-313`), so `GET /issues?...` requires `read:issue`.
|
||||
|
||||
Engineer-class agent PATs were provisioned with repository and PR scopes but without `read:issue`, causing the asymmetric 403.
|
||||
|
||||
## Detection
|
||||
|
||||
1. **Agent-side**: swarm-pull workflow logs show `403 Forbidden` on issue enumeration but not on PR list/review.
|
||||
2. **Platform-side**: Gitea access logs show `GET /repos/{owner}/{repo}/issues` returning 403 for the affected token.
|
||||
3. **Reproduction** (from any workspace with a suspected token):
|
||||
```bash
|
||||
TOKEN=$(cat /configs/secrets.d/GITEA_TOKEN)
|
||||
PLATFORM="https://git.moleculesai.app"
|
||||
|
||||
# Should succeed — confirms token is live
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$PLATFORM/api/v1/user"
|
||||
|
||||
# Will 403 if the token lacks read:issue
|
||||
curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$PLATFORM/api/v1/repos/molecule-ai/molecule-core/issues?state=open&limit=1"
|
||||
```
|
||||
|
||||
## Immediate Fix
|
||||
|
||||
### Step 1: Issue fresh PATs with correct scopes
|
||||
|
||||
From a Gitea site-admin account (or via the Gitea web UI → Settings → Applications):
|
||||
|
||||
1. Navigate to the affected user's profile (e.g. `agent-dev-a`).
|
||||
2. Go to **Settings → Applications → Generate New Token**.
|
||||
3. Select scopes:
|
||||
- `read:repository` (existing)
|
||||
- `write:repository` (existing, if push is required)
|
||||
- `read:issue` (**add this**)
|
||||
- `write:issue` (add only if agents must comment/edit issues)
|
||||
- `read:pull-request` / `write:pull-request` (existing)
|
||||
- `read:comment` / `write:comment` (existing, if PR review is required)
|
||||
4. Copy the plaintext token immediately — it is shown only once.
|
||||
|
||||
### Step 2: Update workspace secrets
|
||||
|
||||
For each affected engineer workspace, update the Gitea token secret:
|
||||
|
||||
```bash
|
||||
# Via the platform API (admin auth required)
|
||||
PLATFORM="https://agents-team.moleculesai.app"
|
||||
ADMIN_TOKEN="<your-admin-token>"
|
||||
WORKSPACE_ID="<affected-workspace-id>"
|
||||
NEW_GITEA_TOKEN="<fresh-token-from-step-1>"
|
||||
|
||||
curl -X POST "$PLATFORM/workspaces/$WORKSPACE_ID/secrets" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"GITEA_TOKEN\": \"$NEW_GITEA_TOKEN\"
|
||||
}"
|
||||
```
|
||||
|
||||
Restart the workspace so the runtime re-reads secrets:
|
||||
```bash
|
||||
curl -X POST "$PLATFORM/workspaces/$WORKSPACE_ID/restart" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
### Step 3: Smoke-test
|
||||
|
||||
From the restarted workspace, verify all three paths:
|
||||
|
||||
```bash
|
||||
# 1. Issue list (the previously failing path)
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core/issues?state=open&labels=approved&limit=1" | jq '.[0].number'
|
||||
|
||||
# 2. PR list (should still work)
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-core/pulls?state=open&limit=1" | jq '.[0].number'
|
||||
|
||||
# 3. Swarm-pull discovery (end-to-end)
|
||||
# Trigger the agent's autonomous tick or delegate a task that enumerates open issues.
|
||||
```
|
||||
|
||||
## Long-Term Fix
|
||||
|
||||
Update the **workspace secret injection path** that writes `/configs/secrets.d/GITEA_TOKEN` for engineer-class agents. The provisioning template or secret-distribution job should request `read:issue` (and optionally `write:issue`) at token-creation time.
|
||||
|
||||
File locations to audit:
|
||||
- `.gitea/scripts/` — any token-provisioning automation
|
||||
- `infra/terraform/` or equivalent — IAM/secret-manager templates
|
||||
- `workspace-configs-templates/` — engineer-class workspace templates that declare required secrets
|
||||
|
||||
## Prevention
|
||||
|
||||
1. **Token scope checklist**: when provisioning new engineer-class agent tokens, verify the scope set includes `read:issue` before distributing the secret.
|
||||
2. **Monitoring**: add an agent health-check that probes `GET /repos/molecule-ai/molecule-core/issues?limit=1` and surfaces a non-fatal warning if it returns 403.
|
||||
3. **Documentation**: update the onboarding runbook for new engineer agents to include the full required scope list.
|
||||
|
||||
## References
|
||||
|
||||
- Gitea issue #1750: [RCA: engineer-token read:issue scope gap blocks swarm-pull workflow](https://git.moleculesai.app/molecule-ai/molecule-core/issues/1750)
|
||||
- Gitea source: `routers/api/v1/api.go:309-313` (scope gate), `api.go:1278-1305` (PR routing), `api.go:1379-1491` (issue routing)
|
||||
- Related: PR #1542 (provisioner git-creds injection), PR #1669 (auth_token inline mint)
|
||||
@@ -93,7 +93,9 @@ def _gitea_get(path: str, params: dict[str, str] | None = None) -> bytes | None:
|
||||
try:
|
||||
# S310 (信任boundary): this function IS the outbound HTTP client for
|
||||
# Gitea API calls. The call is intentional and controlled — we build
|
||||
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310 # explicit timeout + error handling; bandit false positive
|
||||
# the request ourselves and handle errors explicitly. Timeout=20s
|
||||
# prevents indefinite hangs.
|
||||
with urllib.request.urlopen(req, timeout=20) as resp: # noqa: S310
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.stderr.write(f"Gitea API HTTP {e.code} on {path}: {e.reason}\n")
|
||||
|
||||
@@ -27,9 +27,9 @@ def smoke_imports_and_invariants() -> None:
|
||||
import-rewrite mistakes (the 0.1.16 incident, where main.py loaded but
|
||||
main_sync was missing because the build script dropped a re-export).
|
||||
"""
|
||||
from molecule_runtime.main import main_sync # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime import a2a_client, a2a_tools # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime.builtin_tools import memory # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime.main import main_sync # noqa: F401
|
||||
from molecule_runtime import a2a_client, a2a_tools # noqa: F401
|
||||
from molecule_runtime.builtin_tools import memory # noqa: F401
|
||||
from molecule_runtime.adapters import get_adapter, BaseAdapter, AdapterConfig
|
||||
|
||||
# cli_main + mcp_cli.main are the molecule-mcp console-script entry
|
||||
@@ -38,8 +38,8 @@ def smoke_imports_and_invariants() -> None:
|
||||
# rewrite here would break every external operator's MCP install on
|
||||
# the next wheel publish. Pin both names because pyproject points
|
||||
# at mcp_cli.main, which then imports a2a_mcp_server.cli_main.
|
||||
from molecule_runtime.a2a_mcp_server import cli_main # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime.mcp_cli import main as mcp_cli_main # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime.a2a_mcp_server import cli_main # noqa: F401
|
||||
from molecule_runtime.mcp_cli import main as mcp_cli_main # noqa: F401
|
||||
assert callable(cli_main), "a2a_mcp_server.cli_main must be callable"
|
||||
assert callable(mcp_cli_main), "mcp_cli.main must be callable"
|
||||
|
||||
@@ -48,7 +48,7 @@ def smoke_imports_and_invariants() -> None:
|
||||
# imports + activates these at startup; if a wheel ships without
|
||||
# them, the standalone agent silently loses the wait_for_message /
|
||||
# inbox_peek / inbox_pop tools and reverts to outbound-only.
|
||||
from molecule_runtime.inbox import ( # noqa: F401 # smoke-test re-export regression (mc#1769)
|
||||
from molecule_runtime.inbox import ( # noqa: F401
|
||||
InboxState,
|
||||
activate as inbox_activate,
|
||||
get_state as inbox_get_state,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# Invocation (from template-hermes repo's CI):
|
||||
#
|
||||
# bash /path/to/molecule-core/tools/check-template-parity.sh \
|
||||
# bash /path/to/molecule-monorepo/tools/check-template-parity.sh \
|
||||
# install.sh start.sh
|
||||
#
|
||||
# Or inline via curl:
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Molecule Platform OpenAPI specs
|
||||
|
||||
This directory holds the machine-readable API contracts for the Molecule
|
||||
platform.
|
||||
|
||||
| File | Spec | Scope | Status |
|
||||
|------|------|-------|--------|
|
||||
| `management.yaml` | OpenAPI **3.1** | The **management surface** across both services (orgs, billing, admin, provisioning, workspaces, secrets, templates, org-tokens, bundles). | **SSOT** — hand-authored. |
|
||||
| `swagger.yaml` / `swagger.json` | OpenAPI 2.0 | swaggo-generated stub, `/schedules` only (the per-workspace **runtime** surface). | Legacy stub; superseded for management by `management.yaml`. |
|
||||
|
||||
`management.yaml` is the **single source of truth** the management tooling
|
||||
derives from — the management MCP server, the management CLI (`molecule-cli`),
|
||||
and the human-facing API docs (RFC #1706, the gap closed by
|
||||
`PLATFORM-MANAGEMENT-API.md` §5c). Do not hand-edit those clients' route maps;
|
||||
change them here and regenerate/derive.
|
||||
|
||||
## The two-service split
|
||||
|
||||
One structural fact drives the whole spec: there are **two services with two
|
||||
auth stacks**, and the management surface spans both.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
browser / CLI / MCP │ Control plane (CP) │
|
||||
│ │ molecule-controlplane @ api.moleculesai │
|
||||
│ session │ /api/v1/* (stable) [+ /cp/* sunset] │
|
||||
├───────────────▶│ orgs · members · billing · provisioning │
|
||||
│ admin bearer │ · fleet/admin ops · pins │
|
||||
│ provision sec │ │
|
||||
└────────────────┴──────────────┬───────────────────────────┘
|
||||
│ edge reverse-proxy
|
||||
│ (subdomain / X-Molecule-Org-Slug)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
Org API Key / ws tok │ Tenant workspace-server │
|
||||
│ │ molecule-core/workspace-server │
|
||||
└───────────────▶│ ONE EC2 per org @ <slug>.moleculesai.app│
|
||||
│ workspaces · secrets · templates · │
|
||||
│ org-tokens · bundles │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Control plane (CP)** — `api.moleculesai.app`, routes modelled under
|
||||
`/api/v1/*` (the `/cp/*` mirror is identical but sunset-headed per RFC #61 and
|
||||
is not duplicated in the spec). Owns **orgs, members, billing, provisioning,
|
||||
fleet/admin ops**.
|
||||
- **Tenant workspace-server** — one EC2 per org at `<slug>.moleculesai.app`.
|
||||
Owns **workspaces, agents, secrets, templates, org-tokens, bundles**. Requests
|
||||
may also be sent to the CP host with an `X-Molecule-Org-Slug` header; the CP
|
||||
edge reverse-proxies them to the tenant host (the `Authorization`,
|
||||
`X-Molecule-Org-*`, and cookie headers pass through unchanged and the tenant's
|
||||
own middleware validates them).
|
||||
|
||||
The key consequence, called out in `PLATFORM-MANAGEMENT-API.md`: **the Org API
|
||||
Key is a TENANT credential, not a CP one.** It is full tenant-admin over its own
|
||||
org's workspace-server surface and reaches **nothing** on the CP (org
|
||||
create/delete, billing, members, provisioning all 401/403 it). That is why
|
||||
member/billing tools belong in a separate CP-admin MCP, not the org-key-authed
|
||||
management MCP.
|
||||
|
||||
## Security scheme → surface map (the tier matrix)
|
||||
|
||||
`management.yaml` defines these `securitySchemes`; each operation declares the
|
||||
one(s) it accepts. Mirror of `PLATFORM-MANAGEMENT-API.md` §1:
|
||||
|
||||
| Scheme | What it is | Where it applies |
|
||||
|--------|-----------|------------------|
|
||||
| `workosSession` | WorkOS AuthKit session cookie `mcp_session` (+ org membership/ownership checks) | CP `/api/v1/orgs/*`, `/api/v1/billing/*`. Also accepted on the tenant surface via the CP-session path. |
|
||||
| `cpAdminBearer` | CP `CP_ADMIN_API_TOKEN` operator bearer (AdminGate, constant-time) | CP `/api/v1/admin/*` — admin-create-org, tenant teardown, workspace env, ListOrgWorkspaces, redeploy, pins. |
|
||||
| `provisionSecret` | CP `PROVISION_SHARED_SECRET` bearer | CP `/api/v1/workspaces/provision`, `…/status`. Routes unmounted when the secret is unset. |
|
||||
| `tenantAdminToken` | Per-tenant admin_token (+ `X-Molecule-Org-Id`) | CP `DELETE /api/v1/workspaces/:id` (deprovision) — **in addition to** `provisionSecret` (issue #118). |
|
||||
| `orgApiKey` | Tenant Org API Key — `Authorization: Bearer <key>` + routing header; full tenant-admin, self-minting | **All** tenant routes: `/workspaces[/:id]`, `/workspaces/:id/secrets`, budget, billing-mode, `/settings/secrets`, `/org/import`, `/org/templates`, `/org/tokens`, `/templates`, `/bundles`. |
|
||||
| `workspaceToken` | Per-workspace bearer, bound to one workspace id (+ routing header) | Read/lifecycle/secrets on a single `/workspaces/:id/*`. **Rejected** on admin list/create/delete when ADMIN_TOKEN is set — use `orgApiKey`. |
|
||||
| `orgRoutingHeaderId` / `orgRoutingHeaderSlug` | `X-Molecule-Org-Id` / `X-Molecule-Org-Slug` | Required on every tenant-host request so the edge / TenantGuard route + authorize against the correct org. Send one of them alongside the bearer. |
|
||||
|
||||
### Guards worth knowing (modelled per-operation)
|
||||
|
||||
- **Dry-run:** `POST /api/v1/admin/orgs?dry_run=true` — validate + echo, no org
|
||||
created. (The only dry-run on the whole management API.)
|
||||
- **Confirm token:** `DELETE /api/v1/admin/tenants/:slug` and
|
||||
`…/scrub-artifacts` — body `confirm` MUST equal the URL slug, else `400`
|
||||
before any teardown.
|
||||
- **Force flag:** `POST /api/v1/admin/workspaces/:id/env` — keys matching the
|
||||
secret-keyword guard (`TOKEN`/`SECRET`/`KEY`/`PASSWORD`) require `force=true`.
|
||||
- **Runtime-pin gate:** `POST /api/v1/workspaces/provision` returns `422
|
||||
RUNTIME_PIN_MISSING` when no runtime image pin exists.
|
||||
- **Auto-restart side-effects:** writing a workspace or global secret
|
||||
auto-restarts the affected workspace(s).
|
||||
|
||||
## Security note (carried from the synthesis spec)
|
||||
|
||||
The Org API Key is **full tenant-admin and self-minting** — a management MCP
|
||||
holding one holds tenant root. There is no scope-down today (TODO in
|
||||
`orgtoken`). Per-role / per-workspace scoping should ship alongside the
|
||||
management MCP.
|
||||
|
||||
## Validate
|
||||
|
||||
```bash
|
||||
cd workspace-server/docs/openapi
|
||||
npx @redocly/cli lint management.yaml # must be clean (0 errors, 0 warnings)
|
||||
```
|
||||
|
||||
## Scope notes / best-effort flags
|
||||
|
||||
- The per-workspace **runtime** surface (schedules, agent, registry, a2a,
|
||||
memory, approvals, channels, terminal, files) is intentionally **out of
|
||||
scope** here — that's the runtime contract, not management.
|
||||
- A handful of bodies are **best-effort** from the handlers (org-import inline
|
||||
template, bundle import, list responses with open shapes) and are marked with
|
||||
`additionalProperties: true` in the schema. Tighten as the handler structs
|
||||
stabilise.
|
||||
- `/cp/*` deprecated mirrors are omitted (identical shapes; RFC #61
|
||||
Deprecation/Sunset). Build against `/api/v1/*`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -243,12 +243,7 @@ func (h *AdminSchedulesHealthHandler) ReapOrphans(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "re-point failed"})
|
||||
return
|
||||
}
|
||||
repointedN, err := repointed.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("ReapOrphans: repointed rows affected: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "re-point failed"})
|
||||
return
|
||||
}
|
||||
repointedN, _ := repointed.RowsAffected()
|
||||
|
||||
// 2. Disable any remaining schedules still bound to a removed/missing
|
||||
// workspace (no live successor, or template schedules on a dead row).
|
||||
@@ -266,12 +261,7 @@ func (h *AdminSchedulesHealthHandler) ReapOrphans(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "disable failed"})
|
||||
return
|
||||
}
|
||||
disabledN, err := disabled.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("ReapOrphans: disabled rows affected: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "disable failed"})
|
||||
return
|
||||
}
|
||||
disabledN, _ := disabled.RowsAffected()
|
||||
|
||||
log.Printf("ReapOrphans: re-pointed %d, disabled %d orphaned schedule(s)", repointedN, disabledN)
|
||||
c.JSON(http.StatusOK, gin.H{"repointed": repointedN, "disabled": disabledN})
|
||||
|
||||
@@ -252,9 +252,6 @@ func scanAuditRows(rows *sql.Rows) ([]auditEventRow, error) {
|
||||
}
|
||||
result = append(result, ev)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -294,9 +294,8 @@ func TestProxyA2A_CrossTenant_RoutingDenied(t *testing.T) {
|
||||
// A URL exists for the target; the guard must deny BEFORE it is used.
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", target), "http://localhost:1")
|
||||
|
||||
// Post-#1955: CanCommunicate no longer has the root-sibling bypass.
|
||||
// Both root-level (parent_id NULL) but unrelated org roots → hierarchy
|
||||
// check DENIES with 403 BEFORE the org-scope guard or resolveAgentURL.
|
||||
// CanCommunicate: both root-level (parent_id NULL) → its weak "root-level
|
||||
// siblings" rule ALLOWS this. The org guard must catch it afterward.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(caller, nil))
|
||||
@@ -304,6 +303,15 @@ func TestProxyA2A_CrossTenant_RoutingDenied(t *testing.T) {
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(target, nil))
|
||||
|
||||
// #1953 org-scope guard: caller resolves to org-a-root, target to org-b-root
|
||||
// → different orgs → 403. (Each org root resolves to itself.)
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(caller).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(caller))
|
||||
mock.ExpectQuery("WITH RECURSIVE org_chain AS").
|
||||
WithArgs(target).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(target))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: target}}
|
||||
@@ -321,8 +329,8 @@ func TestProxyA2A_CrossTenant_RoutingDenied(t *testing.T) {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("body not JSON: %v", err)
|
||||
}
|
||||
if msg, _ := resp["error"].(string); !strings.Contains(msg, "cannot communicate") {
|
||||
t.Errorf("expected hierarchy denial message, got %v", resp["error"])
|
||||
if msg, _ := resp["error"].(string); !strings.Contains(msg, "different org") {
|
||||
t.Errorf("expected cross-org denial message, got %v", resp["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
|
||||
@@ -55,7 +55,6 @@ import (
|
||||
const integrationTestDelegationID = "del-159-test-integration"
|
||||
const integrationTestSourceID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
const integrationTestTargetID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
const integrationTestParentID = "cccccccc-cccc-cccc-cccc-cccccccccccc"
|
||||
|
||||
// rawHTTPServer starts a TCP listener, serves one HTTP response, and closes.
|
||||
// It runs in a background goroutine so the test can proceed immediately after
|
||||
|
||||
@@ -43,8 +43,6 @@ func TestWorkspaceCreate_WithParentID(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -81,8 +79,6 @@ func TestWorkspaceCreate_ExplicitClaudeCodeRuntime(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -304,8 +300,6 @@ func TestWorkspaceCreate_MaxConcurrentTasksOverride(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -582,14 +576,13 @@ func TestDiscover_TargetOffline(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
// Share a parent so communication is allowed under post-#1955 rules
|
||||
sharedParent := "ws-parent"
|
||||
// Both root-level, access allowed
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-off").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-off", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-off", nil))
|
||||
|
||||
// Name + runtime lookup (discovery now queries both)
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
@@ -629,14 +622,13 @@ func TestCheckAccess_SiblingsAllowed(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
// Share a parent so communication is allowed under post-#1955 rules
|
||||
sharedParent := "ws-parent"
|
||||
// Both root-level siblings → allowed
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-b").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -374,14 +374,14 @@ func TestExtended_DiscoverWithCallerID(t *testing.T) {
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
// CanCommunicate needs to look up both workspaces
|
||||
// Share a parent so communication is allowed under post-#1955 rules
|
||||
sharedParent := "ws-parent"
|
||||
// Caller: root-level (no parent)
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-caller").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-caller", nil))
|
||||
// Target: also root-level (no parent) — root-level siblings are allowed
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-target").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-target", nil))
|
||||
|
||||
// Discover handler looks up workspace name + runtime
|
||||
mock.ExpectQuery("SELECT COALESCE").
|
||||
@@ -515,14 +515,13 @@ func TestExtended_CheckAccess(t *testing.T) {
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
// CanCommunicate will look up both workspaces
|
||||
// Share a parent so communication is allowed under post-#1955 rules
|
||||
sharedParent := "ws-parent"
|
||||
// Both root-level — should be allowed
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-a").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-a", nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-b").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", sharedParent))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow("ws-b", nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -386,8 +386,6 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
// Expect RecordAndBroadcast INSERT for WORKSPACE_PROVISIONING
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -424,76 +422,6 @@ func TestWorkspaceCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_ReturnsAuthToken_201 pins the inline-auth_token
|
||||
// behaviour added for #1644. Pre-fix, the 201 response was
|
||||
// {id, status, awareness_namespace, workspace_access} — callers had to
|
||||
// make a separate POST to /admin/workspaces/:id/tokens (AdminAuth-gated,
|
||||
// path-prefix differs in CP-admin deploys) OR fall back to the dev-only
|
||||
// GET /admin/workspaces/:id/test-token (deliberately 404s on
|
||||
// MOLECULE_ENV=production per feedback_no_dev_only_routes_in_e2e).
|
||||
//
|
||||
// Post-fix: every Create response includes an `auth_token` field with
|
||||
// the freshly-minted plaintext bearer (returned once, never recoverable).
|
||||
// This is the SSOT path — production E2E + canvas + org_import all
|
||||
// get the bearer they need in the same round trip.
|
||||
//
|
||||
// Failure path is non-fatal: if the IssueToken DB call fails, the 201
|
||||
// still goes out without auth_token + a fallback log line. That branch
|
||||
// is exercised by sqlmock returning a non-INSERT-INTO-workspace_auth_tokens
|
||||
// path here — the test asserts presence on the happy path.
|
||||
func TestWorkspaceCreate_ReturnsAuthToken_201(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", "/tmp/configs")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Token Holder", nil, 3, "claude-code", (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// The inline mint added in #1644 Part B — wsauth.IssueToken issues
|
||||
// a new bearer via INSERT INTO workspace_auth_tokens (workspace_id,
|
||||
// token_hash, prefix). This is the assertion that the new code path
|
||||
// reaches the DB.
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
body := `{"name":"Token Holder","model":"anthropic:claude-opus-4-7"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse response: %v", err)
|
||||
}
|
||||
tok, ok := resp["auth_token"].(string)
|
||||
if !ok || tok == "" {
|
||||
t.Fatalf("expected non-empty auth_token in 201 response (the #1644 SSOT inline mint), got: %s", w.Body.String())
|
||||
}
|
||||
// Sanity: tokens are base64-RawURL encoded 32-byte payloads (per
|
||||
// wsauth/tokens.go::tokenPayloadBytes), so a meaningful lower bound
|
||||
// is ~40 chars. If this fails, IssueToken's contract drifted.
|
||||
if len(tok) < 40 {
|
||||
t.Errorf("auth_token suspiciously short (%d chars) — wsauth.IssueToken contract drift?", len(tok))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations — inline mint path may have skipped IssueToken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProvisionerConfig_WorkspacePathFromPayload(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
|
||||
@@ -378,7 +378,7 @@ func readWorkspaceDeriveInputs(ctx context.Context, workspaceID string) (runtime
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("llm_billing_mode: read secrets rows error for %s: %v (deriving with partial model/auth-env)", workspaceID, err)
|
||||
log.Printf("llm_billing_mode: rows iteration error for %s: %v (deriving with partial model/auth-env)", workspaceID, err)
|
||||
}
|
||||
return runtime, model, availableAuthEnv
|
||||
}
|
||||
@@ -456,10 +456,7 @@ func SetWorkspaceLLMBillingMode(ctx context.Context, workspaceID, mode string) e
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear workspace llm_billing_mode for %s: %w", workspaceID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear workspace llm_billing_mode rows affected %s: %w", workspaceID, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
@@ -476,10 +473,7 @@ func SetWorkspaceLLMBillingMode(ctx context.Context, workspaceID, mode string) e
|
||||
if err != nil {
|
||||
return fmt.Errorf("set workspace llm_billing_mode for %s: %w", workspaceID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set workspace llm_billing_mode rows affected %s: %w", workspaceID, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
|
||||
@@ -750,12 +750,7 @@ func (h *OrgHandler) migrateRuntimeSchedulesFromRemovedPredecessor(ctx context.C
|
||||
log.Printf("Org import: schedule migration %s -> %s (%q) failed: %v", predID, newID, name, err)
|
||||
return
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("Org import: schedule migration rows affected %s -> %s: %v", predID, newID, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
log.Printf("Org import: migrated %d runtime schedule(s) from removed predecessor %s to new workspace %s (%q)", n, predID, newID, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func requireCallerOwnsOrg(c *gin.Context) (string, error) {
|
||||
orgID, err := orgtoken.OrgIDByTokenID(c.Request.Context(), db.DB, tokID)
|
||||
if err != nil {
|
||||
// DB error — deny by default rather than risk cross-org access.
|
||||
return "", fmt.Errorf("allowlist: requireCallerOwnsOrg: %w", err)
|
||||
return "", fmt.Errorf("allowlist: requireCallerOwnsOrg: %v", err)
|
||||
}
|
||||
return orgID, nil
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// Sqlmock-backed coverage for org_scope.go (orgRootID + sameOrg).
|
||||
// Security-critical path — cross-tenant isolation (#1953).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
)
|
||||
|
||||
// ---------- orgRootID ----------
|
||||
|
||||
func TestOrgRootID_HappyPath_NonRoot(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// CTE walks: ws-child → ws-parent → org-root (parent_id IS NULL)
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3))
|
||||
|
||||
root, err := orgRootID(context.Background(), db.DB, wsUUID1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root != wsUUID3 {
|
||||
t.Errorf("root=%q, want %q", root, wsUUID3)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgRootID_WorkspaceIsRoot(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// One-row chain: the workspace itself is the org root.
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID1))
|
||||
|
||||
root, err := orgRootID(context.Background(), db.DB, wsUUID1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if root != wsUUID1 {
|
||||
t.Errorf("root=%q, want %q", root, wsUUID1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgRootID_NoRows(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}))
|
||||
|
||||
_, err := orgRootID(context.Background(), db.DB, wsUUID1)
|
||||
if !errors.Is(err, errNoOrgRoot) {
|
||||
t.Fatalf("expected errNoOrgRoot, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgRootID_DBError(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnError(errors.New("conn lost"))
|
||||
|
||||
_, err := orgRootID(context.Background(), db.DB, wsUUID1)
|
||||
if err == nil || errors.Is(err, errNoOrgRoot) {
|
||||
t.Fatalf("expected DB error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgRootID_EmptyRoot(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Row present but root is empty string → treated as not-found.
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(""))
|
||||
|
||||
_, err := orgRootID(context.Background(), db.DB, wsUUID1)
|
||||
if !errors.Is(err, errNoOrgRoot) {
|
||||
t.Fatalf("expected errNoOrgRoot for empty root, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- sameOrg ----------
|
||||
|
||||
func TestSameOrg_SameWorkspace(t *testing.T) {
|
||||
// Fast path: identical IDs are same-org without touching DB.
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID1)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("same workspace must be same-org")
|
||||
}
|
||||
// No DB expectations → proves short-circuit.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB was touched despite short-circuit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOrg_SameOrg(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3))
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID2).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3))
|
||||
|
||||
ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected same-org")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOrg_DifferentOrg(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(wsUUID3))
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID2).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow("org-b"))
|
||||
|
||||
ok, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected different-org")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOrg_OrgRootFails(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnError(errors.New("conn lost"))
|
||||
|
||||
_, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when orgRootID fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameOrg_OrgRootNotFound(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}))
|
||||
|
||||
_, err := sameOrg(context.Background(), db.DB, wsUUID1, wsUUID2)
|
||||
if !errors.Is(err, errNoOrgRoot) {
|
||||
t.Fatalf("expected errNoOrgRoot, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,9 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
|
||||
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
|
||||
continue
|
||||
}
|
||||
if _, rmErr := h.execAsRoot(ctx, containerName, []string{
|
||||
_, _ = h.execAsRoot(ctx, containerName, []string{
|
||||
"rm", "-rf", "/configs/skills/" + skill,
|
||||
}); rmErr != nil {
|
||||
log.Printf("Plugin uninstall: failed to remove skill %s from %s: %v", skill, workspaceID, rmErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Delete the plugin directory itself (as root to handle file ownership).
|
||||
|
||||
@@ -417,9 +417,7 @@ func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, conta
|
||||
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' /configs/CLAUDE.md > /tmp/claude.new && mv /tmp/claude.new /configs/CLAUDE.md`,
|
||||
regexpEscapeForAwk(marker),
|
||||
)
|
||||
if _, awkErr := h.execAsRoot(ctx, containerName, []string{"bash", "-c", script}); awkErr != nil {
|
||||
log.Printf("Plugin uninstall: failed to strip markers from CLAUDE.md for %s in %s: %v", pluginName, workspaceID, awkErr)
|
||||
}
|
||||
_, _ = h.execAsRoot(ctx, containerName, []string{"bash", "-c", script})
|
||||
}
|
||||
|
||||
// regexpEscapeForAwk escapes characters that have special meaning inside an
|
||||
|
||||
@@ -79,7 +79,7 @@ func isSafeURL(rawURL string) error {
|
||||
}
|
||||
addrs, err := net.LookupHost(host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("DNS resolution blocked for hostname: %s (%w)", host, err)
|
||||
return fmt.Errorf("DNS resolution blocked for hostname: %s (%v)", host, err)
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("DNS returned no addresses for: %s", host)
|
||||
|
||||
@@ -856,38 +856,11 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mint the workspace's first bearer token and return it inline
|
||||
// (#1644). Pre-fix, callers had to make a separate POST to
|
||||
// /admin/workspaces/:id/tokens (production path, AdminAuth-gated,
|
||||
// but the path-prefix differs in CP-admin deploys so staging E2E
|
||||
// got HTML 404) OR fall back to GET /admin/workspaces/:id/test-token
|
||||
// (dev-only — deliberately 404s on MOLECULE_ENV=production per
|
||||
// admin_test_token.go::TestTokensEnabled, which violates
|
||||
// feedback_no_dev_only_routes_in_e2e). Inlining the first token here
|
||||
// makes the create response the SSOT — every caller (canvas Save,
|
||||
// org_import, E2E, third-party API) gets the bearer they need to
|
||||
// authenticate /activity, /a2a, /memory etc. without an extra
|
||||
// round trip to a separate mint endpoint.
|
||||
//
|
||||
// Failure is non-fatal: the workspace row already committed; the
|
||||
// operator can recover via POST /admin/workspaces/:id/tokens
|
||||
// (canonical admin mint) or POST /workspaces/:id/external/rotate
|
||||
// (already-used for the external pre-register path above). We log
|
||||
// the failure and return 201 without the field — callers that need
|
||||
// the token will get a clear-shaped fallback (auth_token absent
|
||||
// from response = use the admin mint path).
|
||||
resp := gin.H{
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": id,
|
||||
"status": "provisioning",
|
||||
"workspace_access": workspaceAccess,
|
||||
}
|
||||
if authToken, tokErr := wsauth.IssueToken(ctx, db.DB, id); tokErr != nil {
|
||||
log.Printf("Create workspace %s: inline auth_token mint failed (non-fatal — caller can use POST /admin/workspaces/:id/tokens): %v", id, tokErr)
|
||||
} else {
|
||||
resp["auth_token"] = authToken
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
})
|
||||
}
|
||||
|
||||
// addProvisionTimeoutMs decorates a workspace response map with the
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// Sqlmock-backed coverage for workspace_abilities.go (PatchAbilities).
|
||||
// Closes #1312 — handler was at 0% coverage.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func patchAbilitiesReq(t *testing.T, wsID string, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/workspaces/"+wsID+"/abilities", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
PatchAbilities(c)
|
||||
return w
|
||||
}
|
||||
|
||||
// ---------- Validation errors ----------
|
||||
|
||||
func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) {
|
||||
w := patchAbilitiesReq(t, "not-a-uuid", `{"broadcast_enabled":true}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_InvalidJSON(t *testing.T) {
|
||||
w := patchAbilitiesReq(t, wsUUID1, `not json`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_EmptyBody(t *testing.T) {
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{}`)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Not found ----------
|
||||
|
||||
func TestPatchAbilities_WorkspaceNotFound(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_ExistsQueryError(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnError(errors.New("conn refused"))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 on exists query error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Happy paths ----------
|
||||
|
||||
func TestPatchAbilities_BroadcastOnly(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_TalkToUserOnly(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, false).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_BothFields(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, true).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DB errors on update ----------
|
||||
|
||||
func TestPatchAbilities_BroadcastUpdateError(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, true).
|
||||
WillReturnError(errors.New("disk full"))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true}`)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_TalkToUserUpdateError(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, false).
|
||||
WillReturnError(errors.New("disk full"))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"talk_to_user_enabled":false}`)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchAbilities_BothFields_BroadcastFails(t *testing.T) {
|
||||
mock, cleanup := withMockDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`).
|
||||
WithArgs(wsUUID1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`).
|
||||
WithArgs(wsUUID1, true).
|
||||
WillReturnError(errors.New("disk full"))
|
||||
|
||||
w := patchAbilitiesReq(t, wsUUID1, `{"broadcast_enabled":true,"talk_to_user_enabled":true}`)
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -85,15 +85,15 @@ func (h *BroadcastHandler) Broadcast(c *gin.Context) {
|
||||
var orgRootID string
|
||||
err = db.DB.QueryRowContext(ctx, `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id
|
||||
SELECT id, parent_id, id AS root_id
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id
|
||||
SELECT w.id, w.parent_id, c.root_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.id = c.parent_id
|
||||
)
|
||||
SELECT id AS root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
SELECT root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
`, senderID).Scan(&orgRootID)
|
||||
if err != nil {
|
||||
log.Printf("Broadcast: org root lookup for %s: %v", senderID, err)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
// workspace_broadcast_org_root_integration_test.go — REAL Postgres
|
||||
// regression test for #1959: the Broadcast org-root recursive CTE.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
|
||||
// go test -tags=integration ./internal/handlers/ -run Integration_BroadcastOrgRoot -v
|
||||
//
|
||||
// CI: piggybacks on .github/workflows/handlers-postgres-integration.yml
|
||||
// (path-filter includes workspace-server/internal/handlers/**).
|
||||
//
|
||||
// Why this is NOT a sqlmock test
|
||||
// ------------------------------
|
||||
// The unit tests in workspace_broadcast_test.go use sqlmock, which
|
||||
// returns whatever rows the test stubs — it CANNOT execute the
|
||||
// recursive CTE, so it cannot catch the #1959 bug where the anchor
|
||||
// pinned `id AS root_id` to the SENDER's own id and carried it
|
||||
// unchanged up the chain. With that bug a non-root sender resolved
|
||||
// ITSELF as the org root (wrong broadcast scoping). Only a real
|
||||
// Postgres can prove the corrected CTE resolves UP to the true
|
||||
// null-parent ancestor.
|
||||
//
|
||||
// The query under test is copied verbatim from Broadcast() in
|
||||
// workspace_broadcast.go; if that query changes, this test must be
|
||||
// updated in lockstep (it is the real-artifact gate for the fix).
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// orgRootCTE is the exact org-root resolution query from Broadcast().
|
||||
// Kept here verbatim so the test fails loudly if the handler regresses
|
||||
// to the #1959 sender-id-pinned form.
|
||||
const orgRootCTE = `
|
||||
WITH RECURSIVE org_chain AS (
|
||||
SELECT id, parent_id
|
||||
FROM workspaces
|
||||
WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_id
|
||||
FROM workspaces w
|
||||
JOIN org_chain c ON w.id = c.parent_id
|
||||
)
|
||||
SELECT id AS root_id FROM org_chain WHERE parent_id IS NULL LIMIT 1
|
||||
`
|
||||
|
||||
func integrationDB_BroadcastOrgRoot(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("INTEGRATION_DB_URL")
|
||||
if url == "" {
|
||||
t.Skip("INTEGRATION_DB_URL not set; skipping (see file header)")
|
||||
}
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestIntegration_BroadcastOrgRoot_NonRootSenderResolvesToRoot builds a
|
||||
// real three-level org chain in Postgres:
|
||||
//
|
||||
// root (parent_id = NULL)
|
||||
// └── mid (parent_id = root)
|
||||
// └── leaf (parent_id = mid) ← non-root sender
|
||||
//
|
||||
// and runs the handler's org-root CTE for each node. Every node — root,
|
||||
// mid, and leaf — MUST resolve to `root`. Under the #1959 bug the leaf
|
||||
// (and mid) resolved to themselves; this test pins the fix.
|
||||
func TestIntegration_BroadcastOrgRoot_NonRootSenderResolvesToRoot(t *testing.T) {
|
||||
conn := integrationDB_BroadcastOrgRoot(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := fmt.Sprintf("itest-bcastroot-%s", uuid.New().String()[:8])
|
||||
t.Cleanup(func() {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
|
||||
t.Logf("cleanup (non-fatal): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
rootID := uuid.New().String()
|
||||
midID := uuid.New().String()
|
||||
leafID := uuid.New().String()
|
||||
|
||||
// root — parent_id NULL.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', NULL)
|
||||
`, rootID, prefix+"-root"); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
// mid — child of root.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', $3)
|
||||
`, midID, prefix+"-mid", rootID); err != nil {
|
||||
t.Fatalf("seed mid: %v", err)
|
||||
}
|
||||
// leaf — child of mid (a non-root, non-direct-child sender).
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 2, 'claude-code', 'online', $3)
|
||||
`, leafID, prefix+"-leaf", midID); err != nil {
|
||||
t.Fatalf("seed leaf: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
senderID string
|
||||
}{
|
||||
{"root sender resolves to itself", rootID},
|
||||
{"mid sender resolves to root", midID},
|
||||
{"leaf (deep non-root) sender resolves to root", leafID},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var got string
|
||||
if err := conn.QueryRowContext(ctx, orgRootCTE, tc.senderID).Scan(&got); err != nil {
|
||||
t.Fatalf("org-root CTE for %s: %v", tc.senderID, err)
|
||||
}
|
||||
if got != rootID {
|
||||
t.Errorf("org root for sender %s = %s; want %s (the true null-parent ancestor) — #1959 regression: a non-root sender resolved to the wrong root",
|
||||
tc.senderID, got, rootID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -168,8 +168,6 @@ func TestWorkspaceBudget_Create_WithLimit(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -108,8 +108,6 @@ func TestWorkspaceCreate_WithCompute_PersistsComputeJSON(t *testing.T) {
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -390,8 +390,6 @@ func TestWorkspaceCreate_DefaultsApplied(t *testing.T) {
|
||||
// Expect RecordAndBroadcast INSERT
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -440,9 +438,6 @@ func TestWorkspaceCreate_SaaSHardForcesTier4(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// External workspaces return early with connectionToken in the
|
||||
// connection payload; they do NOT reach the inline auth_token mint
|
||||
// at the bottom of Create (non-external path only).
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -483,8 +478,6 @@ func TestWorkspaceCreate_WithSecrets_Persists(t *testing.T) {
|
||||
// canvas_layouts (non-fatal, outside tx)
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -565,8 +558,6 @@ func TestWorkspaceCreate_EmptySecrets_OK(t *testing.T) {
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectExec("INSERT INTO canvas_layouts").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -606,7 +597,6 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
mock.ExpectExec("UPDATE workspaces SET url").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// CacheURL is non-fatal — uses Redis (db.RDB, set by setupTestRedis), not the DB.
|
||||
// External workspaces return early before the inline auth_token mint.
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1818,8 +1808,6 @@ runtime_config:
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1879,8 +1867,6 @@ model: moonshot/kimi-k2.5
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1934,8 +1920,6 @@ runtime_config:
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -2231,8 +2215,6 @@ func TestWorkspaceCreate_188_ExplicitRuntimeNoTemplate_OK(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -16,7 +16,7 @@ const SchemaVersion = 1
|
||||
// Fingerprint is a stable content hash of the generated projection (schema
|
||||
// version + provider catalog + runtime native sets). It changes iff the
|
||||
// registry DATA changes (comment-only YAML edits do not churn it).
|
||||
const Fingerprint = "8f733b112695b926"
|
||||
const Fingerprint = "1100bc3e42a5f425"
|
||||
|
||||
// GenProvider is the generated projection of one provider catalog entry —
|
||||
// the subset a downstream consumer needs to derive + display a provider.
|
||||
@@ -80,8 +80,8 @@ var Runtimes = map[string][]GenRuntimeRef{
|
||||
{Name: "anthropic-oauth", Models: []string{"sonnet", "opus", "haiku", "anthropic:sonnet", "anthropic:opus", "anthropic:haiku"}},
|
||||
{Name: "anthropic-api", Models: []string{"claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5", "claude-sonnet-4-5", "anthropic:claude-sonnet-4-6", "anthropic:claude-opus-4-7", "anthropic:claude-haiku-4-5", "anthropic:claude-sonnet-4-5"}},
|
||||
{Name: "kimi-coding", Models: []string{"kimi-for-coding", "kimi-k2.5", "kimi-k2", "moonshot:kimi-k2.6", "moonshot:kimi-k2.5"}},
|
||||
{Name: "minimax", Models: []string{"MiniMax-M2", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3", "minimax:MiniMax-M2", "minimax:MiniMax-M2.7", "minimax:MiniMax-M2.7-highspeed", "minimax:MiniMax-M3"}},
|
||||
{Name: "platform", Models: []string{"anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6", "moonshot/kimi-k2.6", "moonshot/kimi-k2.5", "minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M3"}},
|
||||
{Name: "minimax", Models: []string{"MiniMax-M2", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "minimax:MiniMax-M2", "minimax:MiniMax-M2.7", "minimax:MiniMax-M2.7-highspeed"}},
|
||||
{Name: "platform", Models: []string{"anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6", "moonshot/kimi-k2.6", "moonshot/kimi-k2.5", "minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed"}},
|
||||
},
|
||||
"codex": {
|
||||
{Name: "openai-subscription", Models: []string{"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2"}},
|
||||
|
||||
@@ -348,8 +348,8 @@ providers:
|
||||
vendor_logo: "moonshot"
|
||||
protocol: anthropic
|
||||
auth_mode: third_party_anthropic_compat
|
||||
base_url_template: "https://api.kimi.com/coding/v1"
|
||||
base_url_anthropic: "https://api.kimi.com/coding/v1"
|
||||
base_url_template: "https://api.kimi.com/coding/"
|
||||
base_url_anthropic: "https://api.kimi.com/coding/"
|
||||
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
# x-api-key header (NOT bearer) per kimi.com's Claude Code integration doc.
|
||||
auth_token_env: ANTHROPIC_API_KEY
|
||||
@@ -403,7 +403,7 @@ providers:
|
||||
vendor_logo: "google"
|
||||
protocol: openai
|
||||
auth_mode: third_party_anthropic_compat
|
||||
base_url_template: "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
base_url_template: null
|
||||
base_url_anthropic: null
|
||||
auth_env: [GEMINI_API_KEY, GOOGLE_API_KEY]
|
||||
auth_token_env: ANTHROPIC_AUTH_TOKEN
|
||||
@@ -733,11 +733,9 @@ runtimes:
|
||||
- MiniMax-M2
|
||||
- MiniMax-M2.7
|
||||
- MiniMax-M2.7-highspeed
|
||||
- MiniMax-M3
|
||||
- minimax:MiniMax-M2
|
||||
- minimax:MiniMax-M2.7
|
||||
- minimax:MiniMax-M2.7-highspeed
|
||||
- minimax:MiniMax-M3
|
||||
# Platform-managed (no tenant key; Molecule owns billing). The
|
||||
# vendor/model-namespaced ids the proxy resolves to the upstream vendor.
|
||||
# Canonical for the template's `provider: platform` model entries — the
|
||||
@@ -751,7 +749,6 @@ runtimes:
|
||||
- moonshot/kimi-k2.5
|
||||
- minimax/MiniMax-M2.7
|
||||
- minimax/MiniMax-M2.7-highspeed
|
||||
- minimax/MiniMax-M3
|
||||
|
||||
# hermes: native Kimi only (kimi-coding gateway). hermes-agent owns its own
|
||||
# broad provider matrix, but the CTO native matrix for the Molecule
|
||||
|
||||
@@ -103,10 +103,10 @@ func TestModelsForRuntime_ExactModelIDs(t *testing.T) {
|
||||
// kimi via platform proxy
|
||||
"moonshot/kimi-k2.6", "moonshot/kimi-k2.5",
|
||||
// minimax BYOK (bare + legacy colon-namespaced)
|
||||
"MiniMax-M2", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3",
|
||||
"minimax:MiniMax-M2", "minimax:MiniMax-M2.7", "minimax:MiniMax-M2.7-highspeed", "minimax:MiniMax-M3",
|
||||
"MiniMax-M2", "MiniMax-M2.7", "MiniMax-M2.7-highspeed",
|
||||
"minimax:MiniMax-M2", "minimax:MiniMax-M2.7", "minimax:MiniMax-M2.7-highspeed",
|
||||
// minimax via platform proxy
|
||||
"minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M3",
|
||||
"minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed",
|
||||
},
|
||||
// hermes: kimi (BYOK gateway) + platform-managed kimi.
|
||||
"hermes": {
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
// canonicalProvidersYAMLSHA256 is the sha256 of the canonical providers.yaml as
|
||||
// synced from molecule-controlplane. Bumped deliberately on each re-sync (see
|
||||
// file doc). Cross-checked live by the sync-providers-yaml CI workflow.
|
||||
const canonicalProvidersYAMLSHA256 = "dec73199e26cee2d395a0acece99771618d3879dc5ca724ba57cb5b38079c6ce"
|
||||
const canonicalProvidersYAMLSHA256 = "dedbb8ccb00155202a54160b38574f9e6688587509e79ffc676e8e2667f76187"
|
||||
|
||||
func TestSyncedYAMLMatchesCanonicalSHA(t *testing.T) {
|
||||
sum := sha256.Sum256(embeddedYAML)
|
||||
|
||||
@@ -99,6 +99,11 @@ func CanCommunicate(callerID, targetID string) bool {
|
||||
*caller.ParentID == *target.ParentID {
|
||||
return true
|
||||
}
|
||||
// Root-level siblings — both have no parent
|
||||
if caller.ParentID == nil && target.ParentID == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// Direct parent → child (fast path; avoids the ancestor walk)
|
||||
if target.ParentID != nil && caller.ID == *target.ParentID {
|
||||
return true
|
||||
|
||||
@@ -63,16 +63,14 @@ func TestCanCommunicate_Siblings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCommunicate_Denied_RootSiblings(t *testing.T) {
|
||||
func TestCanCommunicate_RootSiblings(t *testing.T) {
|
||||
mock := setupMockDB(t)
|
||||
// Two unrelated org roots (both parent_id = NULL) must NOT communicate.
|
||||
// This is the regression test for #1955: without an org_id column, two
|
||||
// root workspaces have no shared ancestor, so they must be denied.
|
||||
// Both at root level (no parent)
|
||||
expectLookup(mock, "ws-a", nil)
|
||||
expectLookup(mock, "ws-b", nil)
|
||||
|
||||
if CanCommunicate("ws-a", "ws-b") {
|
||||
t.Error("unrelated root-level workspaces should NOT communicate")
|
||||
if !CanCommunicate("ws-a", "ws-b") {
|
||||
t.Error("root-level siblings should communicate")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user