Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4729e99be5 | |||
| 1760b6b642 | |||
| 1331780794 | |||
| 6a8d95ee4e | |||
| e3d5b9f0b2 | |||
| b17cac0f55 | |||
| 740830e443 | |||
| 31a20b63aa | |||
| 93a963becc | |||
| 4f4604eabe | |||
| e31c17695a | |||
| 9c2ad2562f | |||
| d86c6b7943 | |||
| bbc2daea4a | |||
| 12dd60413d | |||
| c93214e4e0 | |||
| 66e3b7edb3 | |||
| 5bc87ea75d | |||
| 73827045bc | |||
| 38353e9a4f | |||
| 8bcc19c38e | |||
| 47263db7ad | |||
| 43a86d44da | |||
| c2a0bdea96 | |||
| d2585700f5 | |||
| aaa2a79e81 | |||
| 4b038f2947 | |||
| 8adc3576fd | |||
| 134ba7f82c |
@@ -0,0 +1,434 @@
|
||||
name: CI
|
||||
|
||||
# Ported from .github/workflows/ci.yml on 2026-05-11 per internal#326
|
||||
# (Class-A root: cross-repo `uses:` blocker for Gitea 1.22.6 —
|
||||
# feedback_gitea_cross_repo_uses_blocked).
|
||||
#
|
||||
# Root cause of the main-red CI on this repo:
|
||||
# The .github/ original used
|
||||
# uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
|
||||
# which Gitea 1.22.6 rejects (DEFAULT_ACTIONS_URL=github → 404 against
|
||||
# the remote repo even though it lives on the same Gitea instance).
|
||||
# Gitea reads .github/ as a fallback when .gitea/ is absent
|
||||
# (reference_per_repo_gitea_vs_github_actions_dir), so the .github/
|
||||
# workflow was firing on Gitea and failing in 1s.
|
||||
#
|
||||
# Fix shape: inline the validation logic directly. The canonical
|
||||
# validator in molecule-ai/molecule-ci already self-clones into the
|
||||
# runner via a direct HTTPS `git clone` step (validate-workspace-template.yml
|
||||
# does this verbatim) — so the inline port is just "do that clone +
|
||||
# invoke the validator script in-place", preserving the
|
||||
# single-source-of-truth property (each CI run still fetches the
|
||||
# canonical validator fresh).
|
||||
#
|
||||
# Four-surface migration audit (feedback_gitea_actions_migration_audit_pattern):
|
||||
# 1. YAML — no `workflow_dispatch.inputs`; no `merge_group`; preserved
|
||||
# `on: [push, pull_request]` from the original. Added workflow-level
|
||||
# env.GITHUB_SERVER_URL (feedback_act_runner_github_server_url).
|
||||
# 2. Cache — `actions/setup-python` `cache: pip` preserved; works against
|
||||
# Gitea's built-in cache server when runner.cache is configured.
|
||||
# 3. Token — uses auto-injected GITHUB_TOKEN (Gitea-aliased). Validator
|
||||
# job needs only `contents: read` (no write to issues/PRs).
|
||||
# 4. Docs — anonymous git-clone of molecule-ci (no token in URL); the
|
||||
# molecule-ci repo is public on the Gitea instance.
|
||||
#
|
||||
# Fork-PR semantics: validate-runtime is intentionally skipped on fork
|
||||
# PRs because pip-install + docker-build + adapter-import are arbitrary
|
||||
# code execution. Internal PRs and main pushes get full coverage. The
|
||||
# `github.event.pull_request.head.repo.fork` field is null for non-PR
|
||||
# events; the `!= true` comparison defaults to running.
|
||||
#
|
||||
# Cross-links:
|
||||
# - internal#326 — parent tracking issue
|
||||
# - molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml — pattern source
|
||||
# - molecule-ai/molecule-core/.gitea/workflows/ci.yml — Gitea port style reference
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
# Defense-in-depth de-dup ONLY (the t4-conformance unique-name fix is the
|
||||
# actual fail-closed primitive against the shared-host-daemon race; see
|
||||
# that job). Scope per workflow + ref + EVENT so the push run and the
|
||||
# pull_request run of the same internal-PR commit get DISTINCT groups —
|
||||
# they must both complete (each emits its own required-status context;
|
||||
# feedback_gitea_gate_check_required_list_not_combined_status). Never
|
||||
# per-SHA-global: that silently cross-cancels legit required checks
|
||||
# (feedback_concurrency_group_per_sha). cancel-in-progress:false so an
|
||||
# in-flight live T4 probe is never aborted mid-assertion (a cancelled
|
||||
# privileged probe would look like a gate failure / flake); a newer push
|
||||
# to the same ref+event simply queues behind it.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Belt-and-suspenders against the runner-default trap
|
||||
# (feedback_act_runner_github_server_url). Runners are configured
|
||||
# with this env via /opt/molecule/runners/config.yaml runner.envs,
|
||||
# but pinning at the workflow level protects against a runner
|
||||
# regenerated without the config file.
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
# Defense-in-depth on the GITHUB_TOKEN scope. The validate-runtime job
|
||||
# runs untrusted-by-design code from the calling repo — pip-installs
|
||||
# requirements.txt (post-install hooks), imports adapter.py, and
|
||||
# docker-builds the Dockerfile. Each primitive can execute arbitrary
|
||||
# code with the token in env. Pinning `contents: read` means the worst
|
||||
# a malicious template PR can do with the token is read public repo
|
||||
# state — no write to issues, no push to branches, no comment-spam.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-static:
|
||||
name: Template validation (static)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
# Canonical validator script lives in molecule-ci, fetched fresh on
|
||||
# every run. Anonymous fetch of the public molecule-ci repo — no
|
||||
# token needed; no actions/checkout cross-repo idiosyncrasies.
|
||||
- name: Fetch molecule-ci canonical scripts
|
||||
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Secret scan — the most important check. Always runs, including
|
||||
# on fork PRs (no third-party code executes here).
|
||||
- name: Check for secrets
|
||||
run: |
|
||||
python3 - << 'PYEOF'
|
||||
import os, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
PATTERNS = [
|
||||
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
|
||||
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
|
||||
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
|
||||
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
|
||||
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
|
||||
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
|
||||
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
|
||||
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
|
||||
]
|
||||
SKIP_DIRS = {'.molecule-ci', '.molecule-ci-canonical', '.git', 'node_modules', '__pycache__'}
|
||||
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
|
||||
|
||||
def is_false_positive(line):
|
||||
ctx = line.lower()
|
||||
return '...' in ctx or '<example' in ctx or '</example' in ctx
|
||||
|
||||
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
|
||||
warnings = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||
for filename in filenames:
|
||||
if Path(filename).suffix not in EXTENSIONS:
|
||||
continue
|
||||
filepath = Path(dirpath) / filename
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
for lineno, line in enumerate(f.readlines(), 1):
|
||||
for pattern in PATTERNS:
|
||||
for match in pattern.finditer(line):
|
||||
if not is_false_positive(line):
|
||||
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if warnings:
|
||||
print("::error::Potential secret found in committed files:")
|
||||
for w in warnings:
|
||||
print(w)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("::notice::No secrets detected")
|
||||
PYEOF
|
||||
# Static-only validator — file existence checks, YAML parse,
|
||||
# AST inspection of adapter.py (no import). Doesn't execute any
|
||||
# third-party code; safe on fork PRs.
|
||||
- run: pip install pyyaml -q
|
||||
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py --static-only
|
||||
|
||||
validate-runtime:
|
||||
name: Template validation (runtime)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: validate-static
|
||||
# Skip when the PR comes from a fork — those are external,
|
||||
# untrusted, and would let attackers run pip install / docker build
|
||||
# / adapter.py import on our runner.
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Fetch molecule-ci canonical scripts
|
||||
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "pip"
|
||||
cache-dependency-path: requirements.txt
|
||||
- run: pip install pyyaml -q
|
||||
# Install the template's runtime dependencies so the validator's
|
||||
# check_adapter_runtime_load() can import adapter.py the same way
|
||||
# the workspace container does at boot. Without this, a
|
||||
# syntactically-valid adapter that ImportErrors on a missing
|
||||
# transitive dep would build clean and crash on first user prompt.
|
||||
- if: hashFiles('requirements.txt') != ''
|
||||
run: pip install -q -r requirements.txt
|
||||
- if: hashFiles('requirements.txt') == ''
|
||||
run: pip install -q molecule-ai-workspace-runtime
|
||||
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py
|
||||
- name: Docker build smoke test
|
||||
if: hashFiles('Dockerfile') != ''
|
||||
run: |
|
||||
# Graceful skip when the runner's job-container can't reach the
|
||||
# Docker daemon (e.g. /var/run/docker.sock not mounted into the
|
||||
# act job container, or the in-container uid not in the docker
|
||||
# group). Without this guard, CI stays red even when the
|
||||
# template's Dockerfile is fine — see internal#222 for the
|
||||
# proper runner-config fix.
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "::warning::docker daemon unreachable from runner job container — skipping Docker build smoke (runner-config gap, not a template issue)."
|
||||
exit 0
|
||||
fi
|
||||
docker build -t template-test . --no-cache 2>&1 | tail -5 && echo "Docker build succeeded"
|
||||
|
||||
# --- Layer-3: real T4 tier-4 conformance gate (RFC internal#456 §11) ---
|
||||
# NOT a string-match. Builds the actual image, runs it under the EXACT
|
||||
# flags the controlplane provisioner emits for tier-4
|
||||
# (userdata_containerized.go @ec2384c: --privileged --pid=host --network host
|
||||
# -v /:/host -v /var/run/docker.sock:/var/run/docker.sock), then drives
|
||||
# the *uniform T4 privilege contract* defined in
|
||||
# molecule-ai/molecule-core's workspace-server/internal/provisioner/
|
||||
# t4_privilege_contract.go and rendered via
|
||||
# `go run ./workspace-server/cmd/t4-contract-dump`. Each capability
|
||||
# in the YAML has a stable name, a shell probe that exits 0 on pass,
|
||||
# and a severity (hard|advisory). Hard misses fail the gate; new
|
||||
# capabilities propagate WITHOUT a per-template PR (just bump the
|
||||
# MOLECULE_CORE_REF env, or let it float to main).
|
||||
#
|
||||
# PILOT (internal #174): this is the first template to consume the
|
||||
# uniform contract. template-hermes / template-codex follow on
|
||||
# sequenced PRs after this lands green.
|
||||
#
|
||||
# Anti-tautology (per memory feedback_hermes_listpeers_401_token_…):
|
||||
# all probes run against a RUNNING container started via the real
|
||||
# `docker run` flags the provisioner emits — no `chown` + immediate
|
||||
# `stat` self-fulfilling pairs. The contract's
|
||||
# `host_root_reach_via_nsenter` probe fails closed if `exec gosu agent`
|
||||
# ever regresses, exactly as the Hermes equivalent does.
|
||||
#
|
||||
# The `list_peers_http_200` probe is OPT-IN (advisory by default in
|
||||
# this template) because the platform a2a_mcp_server is only spun up
|
||||
# by the real start.sh boot path with credentials we don't want in
|
||||
# CI. The probe iterates capabilities; for `list_peers_http_200` we
|
||||
# skip-with-warning if `/configs/.auth_token` is absent (smoke-mode).
|
||||
# On a fresh prod provision the probe is exercised end-to-end by the
|
||||
# post-pin live-verify (task #195).
|
||||
#
|
||||
# Concurrency-flake: per-run-unique `--name` + per-run-unique probe
|
||||
# file paths under /host/tmp/. Push and pull_request runs of the
|
||||
# same commit share a host Docker daemon (--network host); a static
|
||||
# name would collide and false-negative. See sibling template-hermes
|
||||
# ci.yml + task #207 for the canonical rationale.
|
||||
t4-conformance:
|
||||
name: T4 tier-4 conformance (live)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: validate-static
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
# The molecule-core ref the contract YAML is generated from.
|
||||
# Default `main` floats with the latest contract; pin to a SHA
|
||||
# for deterministic gate behavior across template branches.
|
||||
# Adopters MAY override per-PR to test an unmerged contract change.
|
||||
MOLECULE_CORE_REF: main
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v6.0.0
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- run: pip install -q pyyaml
|
||||
- name: Fetch molecule-core + generate t4_capabilities.yaml from the uniform contract
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch "${MOLECULE_CORE_REF}" \
|
||||
https://git.moleculesai.app/molecule-ai/molecule-core.git .molecule-core
|
||||
( cd .molecule-core/workspace-server && go run ./cmd/t4-contract-dump ) > t4_capabilities.yaml
|
||||
# Defense-in-depth: schema-version assertion so a contract
|
||||
# bump that breaks the parser shape is caught here, not at
|
||||
# runtime where it would look like a phantom capability miss.
|
||||
grep -q '^version: 1$' t4_capabilities.yaml || { echo "::error::t4_capabilities.yaml schema version unrecognized"; exit 1; }
|
||||
echo "=== contract preview ==="
|
||||
head -40 t4_capabilities.yaml
|
||||
echo "=== capability names ==="
|
||||
grep '^ - name:' t4_capabilities.yaml
|
||||
- name: Build the runtime image
|
||||
run: |
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "::error::docker daemon unreachable — T4 conformance gate CANNOT verify host-root reach. This is a hard gate; failing closed (do NOT treat as skip). Fix runner-config (internal#222) to unblock."
|
||||
exit 1
|
||||
fi
|
||||
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
docker build -t "$T4_TAG" . --no-cache 2>&1 | tail -5
|
||||
- name: Run under EXACT tier-4 provisioner flags + iterate contract capabilities
|
||||
env:
|
||||
# Per-run-unique probe-id. Used by individual capability
|
||||
# probes (agent_home_writable, host_fs_write_readback) to
|
||||
# scope their on-disk markers; without this, concurrent
|
||||
# same-commit push+pull_request runs would collide on the
|
||||
# /host/tmp/* path (see template-hermes ci.yml + task #207).
|
||||
MOLECULE_T4_PROBE_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
# Container name is computed in the script body and exported
|
||||
# so the inline Python iterator can `docker exec` into it.
|
||||
T4_PROBE_NAME: "t4probe-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
T4_PROBE="$T4_PROBE_NAME"
|
||||
docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true
|
||||
docker run -d \
|
||||
--name "$T4_PROBE" \
|
||||
--network host \
|
||||
--privileged \
|
||||
--pid=host \
|
||||
-v /:/host \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e MOLECULE_T4_PROBE_ID="$MOLECULE_T4_PROBE_ID" \
|
||||
-e MOLECULE_T4_EGRESS_TARGETS="https://api.github.com/zen https://www.google.com/generate_204" \
|
||||
--entrypoint /bin/sh \
|
||||
"$T4_TAG" -c 'sleep 600' >/dev/null
|
||||
trap 'docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true; docker rmi -f "$T4_TAG" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# ----- Reproduce SaaS-mode token agent-ownership pre-state -----
|
||||
# The real entrypoint chowns /configs:agent before gosu; in this
|
||||
# smoke probe /configs is unmounted, so reproduce the contract
|
||||
# step. The `auth_token_agent_owned` probe THEN asserts the
|
||||
# post-condition. This is NOT a tautology: the probe asserts
|
||||
# `stat -c %u` returns 1000, which would fail if the entrypoint
|
||||
# ever wrote the token as root in the live boot path
|
||||
# (`host_root_reach_via_nsenter` + the gosu chain is the
|
||||
# anti-regression guard for that — both probes must pass).
|
||||
docker exec "$T4_PROBE" sh -c 'mkdir -p /configs && touch /configs/.auth_token && chown -R agent:agent /configs'
|
||||
|
||||
# ----- Iterate the contract YAML -----
|
||||
# Pure-python YAML walker (PyYAML installed earlier). We
|
||||
# don't exec the probe via shell-only because shell-parsing
|
||||
# YAML is fragile; we do execute each probe IN the running
|
||||
# container via `docker exec -u agent` so uid-1000 context is
|
||||
# enforced.
|
||||
python3 - <<'PYEOF'
|
||||
import os, subprocess, sys, yaml
|
||||
with open("t4_capabilities.yaml") as f:
|
||||
doc = yaml.safe_load(f)
|
||||
probe = os.environ["T4_PROBE_NAME"]
|
||||
fails_hard = []
|
||||
fails_soft = []
|
||||
for cap in doc.get("capabilities", []):
|
||||
name = cap["name"]
|
||||
sev = cap.get("severity", "advisory")
|
||||
probe_sh = cap["probe"]
|
||||
# OPT-OUT semantics for capabilities that need a live
|
||||
# platform/runtime not stood up in this probe. They are
|
||||
# exercised end-to-end by the post-pin live-verify burst
|
||||
# (task #195) instead.
|
||||
if name == "list_peers_http_200":
|
||||
# Only run if the in-container runtime has spun up;
|
||||
# smoke-mode does not. Skip-with-notice keeps the
|
||||
# gate honest without false negatives.
|
||||
port = subprocess.run(
|
||||
["docker","exec","-u","agent",probe,"sh","-c","[ -f /configs/.platform_port ]"],
|
||||
capture_output=True,
|
||||
).returncode
|
||||
if port != 0:
|
||||
print(f"::notice::skipping {name} — runtime not booted in CI smoke probe; covered by live post-pin verify")
|
||||
continue
|
||||
r = subprocess.run(
|
||||
["docker","exec","-u","agent",probe,"sh","-c",probe_sh],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
print(f" PASS {name} ({sev})")
|
||||
else:
|
||||
msg = f"FAIL {name} ({sev}): rc={r.returncode} source={cap.get('source','?')}"
|
||||
print(f"::error::{msg}")
|
||||
if r.stderr.strip():
|
||||
print(f" stderr: {r.stderr.strip()}")
|
||||
if sev == "hard":
|
||||
fails_hard.append(name)
|
||||
else:
|
||||
fails_soft.append(name)
|
||||
if fails_hard:
|
||||
print(f"::error::T4 conformance FAILED — hard capabilities not satisfied: {fails_hard} (RFC internal#456 §11; the gate is fail-closed)")
|
||||
sys.exit(1)
|
||||
if fails_soft:
|
||||
print(f"::warning::T4 conformance: advisory capabilities failed: {fails_soft} (non-blocking, but inspect)")
|
||||
print(f"::notice::T4 tier-4 conformance PASS — uniform contract satisfied ({len(doc.get('capabilities',[]))} capabilities checked)")
|
||||
PYEOF
|
||||
|
||||
# Aggregator that emits a single `validate` check name — matches the
|
||||
# historical required-check name on this repo's branch protection.
|
||||
validate:
|
||||
name: validate
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-static, validate-runtime, t4-conformance]
|
||||
if: always()
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Aggregate
|
||||
run: |
|
||||
static="${{ needs.validate-static.result }}"
|
||||
runtime="${{ needs.validate-runtime.result }}"
|
||||
t4="${{ needs.t4-conformance.result }}"
|
||||
echo "validate-static: $static"
|
||||
echo "validate-runtime: $runtime"
|
||||
echo "t4-conformance: $t4"
|
||||
if [ "$static" != "success" ]; then
|
||||
echo "::error::validate-static did not succeed: $static"
|
||||
exit 1
|
||||
fi
|
||||
# Treat `skipped` as a pass for fork-PR semantics (validate-runtime
|
||||
# is intentionally skipped on forks; static coverage is the gate).
|
||||
if [ "$runtime" != "success" ] && [ "$runtime" != "skipped" ]; then
|
||||
echo "::error::validate-runtime did not succeed: $runtime"
|
||||
exit 1
|
||||
fi
|
||||
# T4 conformance is a HARD gate on internal (non-fork) PRs and
|
||||
# main pushes. `skipped` is only acceptable on fork PRs (where
|
||||
# the `if:` fork guard short-circuits it) — there the static
|
||||
# gate is the floor. Any other non-success fails the build:
|
||||
# "verified" T4 requires this live gate green, never inference.
|
||||
if [ "$t4" != "success" ] && [ "$t4" != "skipped" ]; then
|
||||
echo "::error::t4-conformance did not succeed: $t4 — T4 host-root reach / token-ownership not verified on a live container. Failing closed (RFC internal#456 §11)."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime, t4=$t4)"
|
||||
|
||||
tests:
|
||||
name: Adapter unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# pyyaml is the runtime dep that adapter.py's _load_providers reads
|
||||
# /configs/config.yaml through. In production it arrives transitively
|
||||
# via molecule-ai-workspace-runtime; in this minimal test env we
|
||||
# install it explicitly so the YAML-loading code path is actually
|
||||
# exercised (without it, _load_providers' broad except-Exception
|
||||
# swallows the ImportError and silently falls back to _BUILTIN_PROVIDERS,
|
||||
# which is exactly the behavior that bit us 2026-04-30 when CI
|
||||
# claimed green on a build that couldn't route any third-party model).
|
||||
- run: pip install -q pytest pytest-asyncio pyyaml
|
||||
# Tests live under tests/ with their own pytest.ini that anchors
|
||||
# rootdir there — keeps pytest from importing the package
|
||||
# __init__.py (which does `from .adapter import ...` for runtime
|
||||
# discovery and can't be satisfied without molecule_runtime
|
||||
# installed). See tests/pytest.ini for the full rationale.
|
||||
- run: python3 -m pytest tests/ -v
|
||||
@@ -0,0 +1,235 @@
|
||||
name: publish-image
|
||||
|
||||
# Builds the claude-code workspace template Dockerfile and pushes it to ECR as
|
||||
# `<REGISTRY>/workspace-template-claude-code:latest` + `:sha-<7>`.
|
||||
#
|
||||
# Ported/inlined from molecule-ci's publish-template-image.yml reusable
|
||||
# workflow. Cross-repo `uses:` is BLOCKED on Gitea 1.22.6 because
|
||||
# DEFAULT_ACTIONS_URL=github causes the runner to attempt the lookup against
|
||||
# github.com, which always 404s even for same-instance repos.
|
||||
# (feedback_gitea_cross_repo_uses_blocked)
|
||||
#
|
||||
# Registry: production uses ECR (MOLECULE_IMAGE_REGISTRY env var on EC2 /
|
||||
# Railway) backed by org-level AWS creds. The OSS default in registry.go is
|
||||
# ghcr.io/molecule-ai but the ECR repo `molecule-ai/workspace-template-claude-code`
|
||||
# already exists (created by the migration sweep). No GHCR token is in the
|
||||
# credentials store — Gitea's GITHUB_TOKEN cannot authenticate to ghcr.io.
|
||||
#
|
||||
# Gitea 1.22.6 hostile-shape checklist applied:
|
||||
# - No workflow_dispatch.inputs (silently rejected on 1.22.6)
|
||||
# - No merge_group: trigger
|
||||
# - No cross-repo uses:
|
||||
# - GITHUB_SERVER_URL pinned at workflow level
|
||||
# (feedback_act_runner_github_server_url)
|
||||
# - No on.push.paths: (would permanently block path-excluded pushes)
|
||||
# - timeout-minutes on every job
|
||||
#
|
||||
# Cascade signal: molecule-core/publish-runtime.yml fans out by git-pushing
|
||||
# an updated `.runtime-version` file to this repo's main branch, which trips
|
||||
# the `on: push: branches: [main]` trigger here. The resolve-version job reads
|
||||
# that file and forwards the version as a RUNTIME_VERSION docker build-arg so
|
||||
# pip install resolves the exact fresh version.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Belt-and-suspenders for act_runner runners regenerated without the
|
||||
# config.yaml envs block. (feedback_act_runner_github_server_url)
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
ECR_REGISTRY: 153263036946.dkr.ecr.us-east-2.amazonaws.com
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/workspace-template-claude-code
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
resolve-version:
|
||||
name: Resolve runtime version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
version: ${{ steps.read.outputs.version }}
|
||||
sha: ${{ steps.read.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- id: read
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f .runtime-version ]; then
|
||||
v="$(head -n1 .runtime-version | tr -d '[:space:]')"
|
||||
echo "version=${v}" >> "$GITHUB_OUTPUT"
|
||||
echo "resolved runtime version from .runtime-version: ${v}"
|
||||
else
|
||||
echo "version=" >> "$GITHUB_OUTPUT"
|
||||
echo "no .runtime-version file — will use Dockerfile/requirements.txt pin"
|
||||
fi
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish:
|
||||
name: Build & push workspace-template-claude-code image
|
||||
# internal#512: pin to the dedicated Linux publish runners (label
|
||||
# "publish" → molecule-runner-publish-1/2). MUST NOT use `ubuntu-latest`:
|
||||
# that label is also advertised by the Windows/WSL self-hosted runners
|
||||
# (hongming-pc-runner-*), so this docker build/push job lands
|
||||
# non-deterministically on a Windows runner where `aws ecr
|
||||
# get-login-password | docker login --password-stdin` fails with
|
||||
# "Failed to initialize: protocol not available" and the image never
|
||||
# publishes. Placement-dependent, NOT a transient flake. Mirrors the
|
||||
# molecule-core convention (publish-workspace-server-image.yml /
|
||||
# publish-runtime.yml / publish-canvas-image.yml: `runs-on: publish`)
|
||||
# and the codex sibling fix (PR#9).
|
||||
# AND-of-labels: `publish` is also advertised by some
|
||||
# hongming-pc-runner-publish-* runners (Windows), whose runner-base
|
||||
# image (`docker-config-fix`) breaks `docker login --password-stdin`
|
||||
# with `Error saving credentials: mkdir /home/hongming: permission
|
||||
# denied` (same EACCES bug class as internal#597/#603 act_runner HOME
|
||||
# injection). op-host molecule-runner-publish-{1,2} are the only
|
||||
# runners advertising BOTH `publish` AND `release` (op-host
|
||||
# /opt/molecule/runners/config.publish.yaml lines 28-29). Requiring
|
||||
# both labels routes publish to op-host deterministically. Matches
|
||||
# template-codex tc#22 (merge 0fb25352).
|
||||
runs-on: [publish, release]
|
||||
timeout-minutes: 30
|
||||
needs: resolve-version
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Lint — no bare imports of runtime modules
|
||||
# Catches `from plugins import ...` style bare imports that work in the
|
||||
# monorepo layout but explode at startup in the published container
|
||||
# (ModuleNotFoundError). Runs before Docker login so a bad adapter
|
||||
# returns red in seconds.
|
||||
# Fallback module list mirrors scripts/build_runtime_package.py:
|
||||
# TOP_LEVEL_MODULES as of 2026-04-27.
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
FALLBACK_MODULES='plugins|adapter_base|config|main|preflight|prompt|coordinator|consolidation|events|heartbeat|transcript_auth|runtime_wedge|watcher|skill_loader|policies|adapters|builtin_tools|executor_helpers|a2a_executor|a2a_client|a2a_tools|a2a_cli|a2a_mcp_server|agent|agents_md|initial_prompt|molecule_ai_status|platform_auth|shared_runtime'
|
||||
RUNTIME_MODULES=""
|
||||
mkdir -p /tmp/runtime-wheel
|
||||
if pip download --quiet molecule-ai-workspace-runtime --no-deps -d /tmp/runtime-wheel 2>/dev/null; then
|
||||
WHEEL=$(ls /tmp/runtime-wheel/*.whl 2>/dev/null | head -1)
|
||||
if [ -n "$WHEEL" ]; then
|
||||
RUNTIME_MODULES=$(unzip -p "$WHEEL" molecule_runtime/_runtime_modules.json 2>/dev/null \
|
||||
| python3 -c "import sys,json; m=json.load(sys.stdin); print('|'.join(sorted(set(m['top_level_modules']) | set(m['subpackages']))))" 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$RUNTIME_MODULES" ]; then
|
||||
echo "::notice::lint module list from published wheel"
|
||||
else
|
||||
RUNTIME_MODULES="$FALLBACK_MODULES"
|
||||
echo "::warning::could not read _runtime_modules.json from wheel — using inline fallback"
|
||||
fi
|
||||
if HITS=$(grep -nE "^\s*from (${RUNTIME_MODULES}) import" *.py 2>/dev/null); then
|
||||
echo "::error::Bare imports of runtime modules found — use 'from molecule_runtime.<module> import'"
|
||||
echo "$HITS" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::no bare imports of runtime modules in *.py files"
|
||||
|
||||
- name: Log in to ECR
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
|
||||
- name: Verify Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker info >/dev/null 2>&1 || {
|
||||
echo "::error::Docker daemon is not accessible — check runner sock mount"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Ensure ECR repository exists
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo_path="${IMAGE_NAME#*/}"
|
||||
repo_path="${repo_path#*/}" # strip registry host + first slash → molecule-ai/workspace-template-claude-code
|
||||
if ! aws ecr describe-repositories --repository-names "${repo_path}" --region us-east-2 >/dev/null 2>&1; then
|
||||
aws ecr create-repository \
|
||||
--repository-name "${repo_path}" \
|
||||
--image-scanning-configuration scanOnPush=true \
|
||||
--region us-east-2 >/dev/null
|
||||
echo "::notice::created ECR repository ${repo_path}"
|
||||
else
|
||||
echo "ECR repository ${repo_path} already exists"
|
||||
fi
|
||||
|
||||
- name: Build image (load for smoke test, do not push yet)
|
||||
# Build into runner-local docker first. Smoke test runs before push so
|
||||
# a broken adapter.py never poisons :latest.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
|
||||
build-args: |
|
||||
RUNTIME_VERSION=${{ needs.resolve-version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.description=Molecule AI workspace template — claude-code runtime
|
||||
|
||||
- name: Smoke test — import every /app/*.py
|
||||
# Boot the locally-loaded image and import each *.py module to verify
|
||||
# all module-level imports resolve against the pip-installed runtime.
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm \
|
||||
-e WORKSPACE_ID=smoke-test \
|
||||
-e CLAUDE_CODE_OAUTH_TOKEN=sk-fake-smoke-token \
|
||||
-e ANTHROPIC_API_KEY=sk-fake-smoke-key \
|
||||
-e OPENAI_API_KEY=sk-fake-smoke-key \
|
||||
--entrypoint sh "${IMAGE}" -c '
|
||||
set -e
|
||||
cd /app
|
||||
for f in *.py; do
|
||||
[ "$f" = "__init__.py" ] && continue
|
||||
mod="${f%.py}"
|
||||
python3 -c "import $mod" || { echo "::error::failed to import $mod"; exit 1; }
|
||||
echo " import $mod OK"
|
||||
done
|
||||
'
|
||||
echo "::notice::${IMAGE}: all /app/*.py modules import cleanly"
|
||||
|
||||
- name: Push image to ECR (post-smoke)
|
||||
# Smoke passed — push both :latest and :sha-<7>. build-push-action
|
||||
# reuses the cached layers so this is a layer-push, not a rebuild.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
|
||||
build-args: |
|
||||
RUNTIME_VERSION=${{ needs.resolve-version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.description=Molecule AI workspace template — claude-code runtime
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
name: Secret scan
|
||||
|
||||
# Hard CI gate. Refuses any PR / push whose diff additions contain a
|
||||
# recognisable credential. Defense-in-depth for the #2090-class incident
|
||||
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
|
||||
# installation token into tenant-proxy/package.json via `npm init`
|
||||
# slurping the URL from a token-embedded origin remote. We can't fix
|
||||
# upstream's clone hygiene, so we gate here.
|
||||
#
|
||||
# Same regex set as the runtime's bundled pre-commit hook
|
||||
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
|
||||
# Keep the two sides aligned when adding patterns.
|
||||
#
|
||||
# Ported from .github/workflows/secret-scan.yml so the gate actually
|
||||
# fires on Gitea Actions. Differences from the GitHub version:
|
||||
# - drops `merge_group` event (Gitea has no merge queue)
|
||||
# - drops `workflow_call` (no cross-repo reusable invocation on Gitea)
|
||||
# - SELF path updated to .gitea/workflows/secret-scan.yml
|
||||
# The job name + step name are identical to the GitHub workflow so the
|
||||
# status-check context (`Secret scan / Scan diff for credential-shaped
|
||||
# strings (pull_request)`) matches branch protection on this template
|
||||
# repo's main branch. Before this port, the required-status was satisfied
|
||||
# only via a compensating signed POST /statuses/{SHA} because the
|
||||
# .github/ workflow was silently shadowed by the .gitea/ directory taking
|
||||
# precedence on this repo
|
||||
# (reference_molecule_core_actions_gitea_only — same applies here).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2 # need previous commit to diff against on push events
|
||||
|
||||
# For pull_request events the diff base may be many commits behind
|
||||
# HEAD and absent from the shallow clone. Fetch it explicitly.
|
||||
- name: Fetch PR base SHA (pull_request events only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Refuse if credential-shaped strings appear in diff additions
|
||||
env:
|
||||
# Plumb event-specific SHAs through env so the script doesn't
|
||||
# need conditional `${{ ... }}` interpolation per event type.
|
||||
# github.event.before/after only exist on push events;
|
||||
# pull_request has pull_request.base.sha / pull_request.head.sha.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
PUSH_AFTER: ${{ github.event.after }}
|
||||
run: |
|
||||
# Pattern set covers GitHub family (the actual #2090 vector),
|
||||
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
|
||||
# false-positive rates against agent-generated content. Mirror of
|
||||
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||
# — keep aligned.
|
||||
SECRET_PATTERNS=(
|
||||
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
|
||||
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
|
||||
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
|
||||
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
|
||||
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
|
||||
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
|
||||
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
||||
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
|
||||
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
|
||||
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
|
||||
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
|
||||
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
|
||||
)
|
||||
|
||||
# Determine the diff base. Each event type stores its SHAs in
|
||||
# a different place — see the env block above.
|
||||
case "${{ github.event_name }}" in
|
||||
pull_request)
|
||||
BASE="$PR_BASE_SHA"
|
||||
HEAD="$PR_HEAD_SHA"
|
||||
;;
|
||||
*)
|
||||
BASE="$PUSH_BEFORE"
|
||||
HEAD="$PUSH_AFTER"
|
||||
;;
|
||||
esac
|
||||
|
||||
# On push events with shallow clones, BASE may be present in
|
||||
# the event payload but absent from the local object DB
|
||||
# (fetch-depth=2 doesn't always reach the previous commit
|
||||
# across true merges). Try fetching it on demand. If the
|
||||
# fetch fails — e.g. the SHA was force-overwritten — we fall
|
||||
# through to the empty-BASE branch below, which scans the
|
||||
# entire tree as if every file were new. Correct, just slow.
|
||||
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Files added or modified in this change.
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
# New branch / no previous SHA / BASE unreachable — check the
|
||||
# entire tree as added content. Slower, but correct on first
|
||||
# push.
|
||||
CHANGED=$(git ls-tree -r --name-only HEAD)
|
||||
DIFF_RANGE=""
|
||||
else
|
||||
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
|
||||
DIFF_RANGE="$BASE $HEAD"
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No changed files to inspect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Self-exclude: this workflow file legitimately contains the
|
||||
# pattern strings as regex literals. Without an exclude it would
|
||||
# block its own merge. Both the .github/ original and this
|
||||
# .gitea/ port are excluded so a sync between them stays clean.
|
||||
SELF_GITHUB=".github/workflows/secret-scan.yml"
|
||||
SELF_GITEA=".gitea/workflows/secret-scan.yml"
|
||||
|
||||
OFFENDING=""
|
||||
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
|
||||
# containing whitespace don't word-split silently — a path
|
||||
# with a space would otherwise produce two iterations on
|
||||
# tokens that aren't real filenames, breaking the
|
||||
# self-exclude + diff lookup.
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
[ "$f" = "$SELF_GITHUB" ] && continue
|
||||
[ "$f" = "$SELF_GITEA" ] && continue
|
||||
if [ -n "$DIFF_RANGE" ]; then
|
||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
else
|
||||
# No diff range (new branch first push) — scan the full file
|
||||
# contents as if every line were new.
|
||||
ADDED=$(cat "$f" 2>/dev/null || true)
|
||||
fi
|
||||
[ -z "$ADDED" ] && continue
|
||||
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||
if echo "$ADDED" | grep -qE "$pattern"; then
|
||||
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ -n "$OFFENDING" ]; then
|
||||
echo "::error::Credential-shaped strings detected in diff additions:"
|
||||
# `printf '%b' "$OFFENDING"` interprets backslash escapes
|
||||
# (the literal `\n` we appended above becomes a newline)
|
||||
# WITHOUT treating OFFENDING as a format string. Plain
|
||||
# `printf "$OFFENDING"` is a format-string sink: a filename
|
||||
# containing `%` would be interpreted as a conversion
|
||||
# specifier, corrupting the error message (or printing
|
||||
# `%(missing)` artifacts).
|
||||
printf '%b' "$OFFENDING"
|
||||
echo ""
|
||||
echo "The actual matched values are NOT echoed here, deliberately —"
|
||||
echo "round-tripping a leaked credential into CI logs widens the blast"
|
||||
echo "radius (logs are searchable + retained)."
|
||||
echo ""
|
||||
echo "Recovery:"
|
||||
echo " 1. Remove the secret from the file. Replace with an env var"
|
||||
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
|
||||
echo " process.env.X in code)."
|
||||
echo " 2. If the credential was already pushed (this PR's commit"
|
||||
echo " history reaches a public ref), treat it as compromised —"
|
||||
echo " ROTATE it immediately, do not just remove it. The token"
|
||||
echo " remains valid in git history forever and may be in any"
|
||||
echo " log/cache that consumed this branch."
|
||||
echo " 3. Force-push the cleaned commit (or stack a revert) and"
|
||||
echo " re-run CI."
|
||||
echo ""
|
||||
echo "If the match is a false positive (test fixture, docs example,"
|
||||
echo "or this workflow's own regex literals): use a clearly-fake"
|
||||
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
|
||||
echo "the length suffix, OR add the file path to the SELF exclude"
|
||||
echo "list in this workflow with a short reason."
|
||||
echo ""
|
||||
echo "Mirror of the regex set lives in the runtime's bundled"
|
||||
echo "pre-commit hook (molecule-ai-workspace-runtime:"
|
||||
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ No credential-shaped strings in this change."
|
||||
@@ -72,6 +72,27 @@ Skills persist across restarts. Use them to codify best practices, coding standa
|
||||
## Language
|
||||
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
|
||||
|
||||
## Workspace ownership tier — T4 (top-tier)
|
||||
|
||||
**T4 (top-tier) workspaces grant full host control.** The Docker socket is mounted into the container; the runtime is started with `--privileged`; the workspace owner can `docker exec` into siblings on the same host and read/write anything the host kernel will let it touch.
|
||||
|
||||
What this means in practice:
|
||||
|
||||
- The container's home directory `~/.claude/` (and its `sessions/`, `session-env/`, `settings.json`) is persisted across restarts via a host bind mount. Anything you (or the SDK) write there survives container churn — but ownership can drift.
|
||||
- If you see `EPERM` / "permission denied" on `~/.claude/` writes after a restart — particularly on `settings.json` or anything under `sessions/` — the volume picked up `root:root` ownership from a prior boot or a newer claude-code release wrote subdirs the entrypoint didn't pre-create.
|
||||
- Recovery (do this yourself; you have the rights): `sudo chown -R agent:agent ~/.claude/`. The entrypoint already runs a recursive chown unconditionally on every boot, so a restart also clears it — but `sudo chown` is faster and doesn't drop the active session.
|
||||
- The provisioned `~/.claude/settings.json` is informational: it documents that `permission_mode='bypassPermissions'` is the canonical operating mode, which is also set programmatically in `claude_sdk_executor.py` (the file is NOT the source of truth — the SDK kwargs are).
|
||||
|
||||
If `cat ~/.claude/settings.json` returns `No such file or directory` you're on a workspace image older than 2026-05-15 — restart picks up the new entrypoint and stubs the file in place.
|
||||
|
||||
## Knowing your own model
|
||||
|
||||
Use the `get_runtime_identity` MCP tool to know what model you actually are. It reads the live process env (`MODEL`, `MODEL_PROVIDER`, `MOLECULE_MODEL`, `ANTHROPIC_BASE_URL`, `TIER`, `WORKSPACE_ID`, `ADAPTER_MODULE`) and returns the resolved values — no HTTP call, always works, always permitted by RBAC. Do NOT guess from your system prompt or from `requirements.txt`; the operator may have routed you to a different model via persona env between boots.
|
||||
|
||||
## Editing your own agent_card
|
||||
|
||||
Use the `update_agent_card` MCP tool to update this workspace's `agent_card` on the platform. Pass a JSON object — the platform validates required fields server-side. The change is broadcast as an `agent_card_updated` event so the canvas reflects the new card live. The tool is gated on `memory.write` capability, so read-only agents won't accidentally rewrite the card; T4 owners always have this capability.
|
||||
|
||||
## Runtime wedge integration
|
||||
|
||||
The `runtime_wedge` module (in `molecule_runtime`) is the universal cross-cutting holder for "this Python process can no longer serve queries — only a workspace restart will recover." It surfaces unrecoverable wedges to two consumers:
|
||||
|
||||
+61
-2
@@ -5,8 +5,23 @@ FROM python:3.11-slim
|
||||
# --add-assignee`, `git clone`, etc. per their idle/cron prompts).
|
||||
# Without these the team's claim-and-ship loop silently returns
|
||||
# "(no response generated)" because tools error out.
|
||||
#
|
||||
# T4 escalation leg (RFC internal#456 §9 / PR#474):
|
||||
# sudo + util-linux(nsenter) + docker.io(CLI) are baked here so the
|
||||
# uid-1000 `agent` (see useradd below — UNCHANGED, agent stays
|
||||
# uid-1000) has a wired, audited path to host root inside the
|
||||
# provisioner's `--privileged --pid=host -v /:/host
|
||||
# -v /var/run/docker.sock:/var/run/docker.sock` container. Without
|
||||
# sudo, a uid-1000 process in --privileged CANNOT nsenter/chroot
|
||||
# /host (--privileged grants caps to root, not uid-1000) and cannot
|
||||
# use the root:docker 0660 docker.sock — T4 would be
|
||||
# provisioner-shape-only (the documented ABSENT-escalation-leg gap).
|
||||
# The sudoers drop-in + docker-group add are below, after useradd,
|
||||
# so `agent` exists. This is ADDITIVE: it does NOT change the agent
|
||||
# uid and does NOT change /configs token ownership (still uid-1000,
|
||||
# enforced by entrypoint.sh + the Layer-3 conformance gate).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl gosu nodejs npm ca-certificates git \
|
||||
curl gosu nodejs npm ca-certificates git sudo util-linux docker.io \
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
@@ -17,8 +32,31 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Install claude-code CLI via npm
|
||||
RUN npm install -g @anthropic-ai/claude-code 2>/dev/null || true
|
||||
|
||||
# Create agent user
|
||||
# Create agent user — UNCHANGED. The agent runs as uid-1000; the T4
|
||||
# escalation leg below is additive and does NOT promote the agent to
|
||||
# root. claude-code still refuses --dangerously-skip-permissions as
|
||||
# root, and /configs/.auth_token must stay agent-owned (Hermes
|
||||
# list_peers 401 class — RFC internal#456 §10).
|
||||
RUN useradd -u 1000 -m -s /bin/bash agent
|
||||
|
||||
# --- T4 escalation leg (RFC internal#456 §9.3 / PR#474) ---
|
||||
# Wired path: uid-1000 agent -> host root inside the provisioner's
|
||||
# --privileged --pid=host -v /:/host -v docker.sock container.
|
||||
# 1. NOPASSWD sudoers drop-in (mode 0440, visudo-validated at build
|
||||
# so a malformed sudoers can never ship a broken-sudo image).
|
||||
# 2. agent in the `docker` group so the bind-mounted root:docker
|
||||
# 0660 /var/run/docker.sock is usable without sudo.
|
||||
# Atomic co-sequencing (RFC §10): this ships in the SAME image
|
||||
# revision as the uid-1000 + agent-owned-token entrypoint contract;
|
||||
# the Layer-3 conformance gate asserts BOTH on the running container.
|
||||
RUN set -eux; \
|
||||
printf 'agent ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/agent-t4; \
|
||||
chmod 0440 /etc/sudoers.d/agent-t4; \
|
||||
visudo -cf /etc/sudoers.d/agent-t4; \
|
||||
groupadd -f docker; \
|
||||
usermod -aG docker agent; \
|
||||
id agent
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# RUNTIME_VERSION is forwarded from the reusable publish workflow as
|
||||
@@ -81,6 +119,27 @@ COPY scripts/molecule-git-token-helper.sh /app/scripts/molecule-git-token-helper
|
||||
COPY scripts/molecule-gh-token-refresh.sh /app/scripts/molecule-gh-token-refresh.sh
|
||||
RUN chmod +x /app/scripts/molecule-git-token-helper.sh /app/scripts/molecule-gh-token-refresh.sh
|
||||
|
||||
# Generic GIT_ASKPASS helper — image-side companion to molecule-core PR
|
||||
# #1525 (workspace-server applyAgentGitIdentity, merge_sha 73a09443a086).
|
||||
# Reads HTTPS Basic-Auth credentials from env vars (GIT_HTTP_USERNAME /
|
||||
# GIT_HTTP_PASSWORD, with GITEA_USER / GITEA_TOKEN as fallback) and emits
|
||||
# them on the git credential-prompt protocol, so container-side `git` can
|
||||
# authenticate to any private HTTPS remote without on-disk ~/.gitconfig
|
||||
# or ~/.git-credentials mutation. The platform provisioner sets
|
||||
# GIT_ASKPASS=/usr/local/bin/molecule-askpass via applyAgentGitIdentity;
|
||||
# until this binary ships in the runtime image, git invocations error
|
||||
# with "exec: /usr/local/bin/molecule-askpass: not found" (forward-only
|
||||
# pin gap — same class as Hermes list_peers and codex template breakage,
|
||||
# fixed image-side here).
|
||||
#
|
||||
# No hardcoded hostnames or vendor names — the script body is identical
|
||||
# to the one shipped in molecule-core workspace/scripts/molecule-askpass
|
||||
# and the parallel external workspace template repos, so any deployer
|
||||
# can fork this template and use it against their own git host without
|
||||
# editing.
|
||||
COPY scripts/molecule-askpass /usr/local/bin/molecule-askpass
|
||||
RUN chmod +x /usr/local/bin/molecule-askpass
|
||||
|
||||
# Drop-priv entrypoint — claude-code refuses --dangerously-skip-permissions
|
||||
# as root, so we run molecule-runtime as the agent user (uid 1000).
|
||||
# The script handles volume-ownership fix + session-dir symlink before
|
||||
|
||||
+93
-20
@@ -144,6 +144,20 @@ def _normalize_provider(entry: dict):
|
||||
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
|
||||
"base_url": entry.get("base_url") or None,
|
||||
"auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False),
|
||||
# Which env var the boot-time vendor-key projection writes the
|
||||
# vendor key INTO. Defaults to ANTHROPIC_AUTH_TOKEN (Bearer-style
|
||||
# — correct for MiniMax/GLM/DeepSeek Anthropic-compat shims).
|
||||
# Kimi For Coding's gateway authenticates with the x-api-key
|
||||
# header (per kimi.com's official Claude Code doc), which the
|
||||
# Anthropic SDK / claude CLI emits from ANTHROPIC_API_KEY — so
|
||||
# that provider's entry sets auth_token_env: ANTHROPIC_API_KEY.
|
||||
# Env-var names are case-sensitive; preserve case.
|
||||
"auth_token_env": (
|
||||
entry.get("auth_token_env")
|
||||
if isinstance(entry.get("auth_token_env"), str)
|
||||
and entry.get("auth_token_env").strip()
|
||||
else "ANTHROPIC_AUTH_TOKEN"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +292,26 @@ def _load_providers(config_path: str) -> tuple:
|
||||
return tuple(parsed)
|
||||
|
||||
|
||||
# Aliases for `MODEL_PROVIDER` env values that should map to a registry
|
||||
# provider name. The persona env files use shorter / friendlier slugs
|
||||
# than the registry's canonical names — without this alias map a value
|
||||
# like ``MODEL_PROVIDER=claude-code`` would fall through to YAML-based
|
||||
# resolution and (when the YAML doesn't pin a provider) hit the
|
||||
# model-prefix matcher with the operator-picked MODEL, mis-routing a
|
||||
# lead workspace through MiniMax even though its CLAUDE_CODE_OAUTH_TOKEN
|
||||
# was clearly meant to be used.
|
||||
#
|
||||
# Maintain this list in sync with the persona env file convention:
|
||||
# - ``claude-code`` → ``anthropic-oauth`` (Claude Code subscription path)
|
||||
# - ``anthropic`` → ``anthropic-api`` (direct Anthropic API key)
|
||||
# Provider names already in the registry alias to themselves implicitly
|
||||
# (the ``in registry`` check catches them before this map is consulted).
|
||||
_PROVIDER_SLUG_ALIASES = {
|
||||
"claude-code": "anthropic-oauth",
|
||||
"anthropic": "anthropic-api",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_model_and_provider_from_env(
|
||||
yaml_model: str,
|
||||
yaml_provider: str,
|
||||
@@ -331,8 +365,20 @@ def _resolve_model_and_provider_from_env(
|
||||
# (provider name) vs. the legacy convention (model id). Persona-
|
||||
# convention wins when the value matches a registered provider; we
|
||||
# fall back to legacy interpretation only when it doesn't.
|
||||
#
|
||||
# First, apply the alias map so persona-friendly slugs like
|
||||
# ``claude-code`` resolve to the canonical registry name
|
||||
# ``anthropic-oauth``. Without this, a lead workspace's
|
||||
# ``MODEL_PROVIDER=claude-code`` env would fall through to the model-
|
||||
# prefix matcher, see ``MODEL=MiniMax-M2.7`` and mis-route to MiniMax
|
||||
# even though the operator's intent (and the OAuth token they set)
|
||||
# was the OAuth subscription path.
|
||||
env_provider_resolved = _PROVIDER_SLUG_ALIASES.get(
|
||||
env_provider.lower(), env_provider,
|
||||
) if env_provider else ""
|
||||
env_provider_is_slug = (
|
||||
bool(env_provider) and env_provider.lower() in provider_names_lower
|
||||
bool(env_provider_resolved)
|
||||
and env_provider_resolved.lower() in provider_names_lower
|
||||
)
|
||||
|
||||
# Picked model resolution
|
||||
@@ -345,12 +391,30 @@ def _resolve_model_and_provider_from_env(
|
||||
else:
|
||||
picked_model = yaml_model or ""
|
||||
|
||||
# Explicit provider resolution — env wins when it's a registered slug,
|
||||
# otherwise fall back to YAML.
|
||||
# Explicit provider resolution — env wins when it's a registered slug
|
||||
# (after alias mapping), otherwise fall back to YAML.
|
||||
#
|
||||
# YAML aliasing: the molecule-runtime wheel (config.py) auto-derives
|
||||
# ``runtime_config.provider`` from the YAML/default model slug — the
|
||||
# default model ``anthropic:claude-opus-4-7`` yields ``anthropic`` as
|
||||
# the inferred provider. Without applying the alias map here, that
|
||||
# auto-derived ``anthropic`` slug fails registry lookup and the
|
||||
# adapter raises ValueError ("provider='anthropic' but it is not in
|
||||
# the providers registry"), wedging the workspace at boot. The alias
|
||||
# map already handles this for the env-var path above; mirror the
|
||||
# same treatment for the YAML path so the runtime-wheel default
|
||||
# produces a registered provider name in both cases. Caught
|
||||
# 2026-05-09 on staging-cplead-2 — every workspace booted with
|
||||
# ``configuration_status=not_configured`` because the YAML provider
|
||||
# ``anthropic`` was passed through verbatim instead of being aliased
|
||||
# to ``anthropic-api``.
|
||||
if env_provider_is_slug:
|
||||
explicit_provider = env_provider
|
||||
explicit_provider = env_provider_resolved
|
||||
elif yaml_provider:
|
||||
yp_lower = yaml_provider.lower()
|
||||
explicit_provider = _PROVIDER_SLUG_ALIASES.get(yp_lower, yaml_provider)
|
||||
else:
|
||||
explicit_provider = yaml_provider or None
|
||||
explicit_provider = None
|
||||
|
||||
return picked_model, explicit_provider
|
||||
|
||||
@@ -396,12 +460,18 @@ _VENDOR_KEY_NAMES = frozenset({
|
||||
|
||||
|
||||
def _project_vendor_auth(provider: dict) -> None:
|
||||
"""Project a per-vendor API key onto ANTHROPIC_AUTH_TOKEN at boot.
|
||||
"""Project a per-vendor API key onto the provider's auth-token env at boot.
|
||||
|
||||
Third-party Anthropic-compat providers (MiniMax, Z.ai, DeepSeek)
|
||||
reuse the Anthropic SDK's wire format with a Bearer token, which the
|
||||
``claude`` CLI / claude-code-sdk reads from ``ANTHROPIC_AUTH_TOKEN``.
|
||||
Kimi For Coding's gateway instead authenticates with the
|
||||
``x-api-key`` header (per kimi.com's official Claude Code
|
||||
integration doc), which the SDK emits from ``ANTHROPIC_API_KEY`` —
|
||||
so the projection target is per-provider, declared as
|
||||
``auth_token_env`` in the registry (default ``ANTHROPIC_AUTH_TOKEN``
|
||||
preserves the existing MiniMax/GLM/DeepSeek behavior unchanged).
|
||||
|
||||
Third-party Anthropic-compat providers (MiniMax, Z.ai, Moonshot,
|
||||
DeepSeek) all reuse the Anthropic SDK's wire format, which means the
|
||||
``claude`` CLI / claude-code-sdk reads the bearer token from
|
||||
``ANTHROPIC_AUTH_TOKEN`` no matter which vendor is being talked to.
|
||||
Pre-#244 the canvas surfaced the vendor-specific name
|
||||
(``MINIMAX_API_KEY``, etc.) to the user — so a user who saved only
|
||||
that name hit a silent 401 on first call while the boot audit said
|
||||
@@ -409,21 +479,24 @@ def _project_vendor_auth(provider: dict) -> None:
|
||||
/ hermes PR #38.
|
||||
|
||||
Behavior:
|
||||
* Let ``target`` = the provider's ``auth_token_env`` (default
|
||||
``ANTHROPIC_AUTH_TOKEN``).
|
||||
* If the matched provider's ``auth_env`` lists any of
|
||||
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
|
||||
``ANTHROPIC_AUTH_TOKEN`` so the SDK finds it.
|
||||
* **Idempotent**: if ``ANTHROPIC_AUTH_TOKEN`` is already set we
|
||||
do NOT overwrite — an explicit operator value (workspace
|
||||
secret) always wins over auto-projection.
|
||||
* Logs the projection by NAME (e.g. ``MINIMAX_API_KEY ->
|
||||
ANTHROPIC_AUTH_TOKEN``); never logs the secret VALUE. Same
|
||||
``target`` so the SDK finds it.
|
||||
* **Idempotent**: if ``target`` is already set we do NOT
|
||||
overwrite — an explicit operator value (workspace secret)
|
||||
always wins over auto-projection.
|
||||
* Logs the projection by NAME (e.g. ``KIMI_API_KEY ->
|
||||
ANTHROPIC_API_KEY``); never logs the secret VALUE. Same
|
||||
contract as ``_audit_auth_env_presence``.
|
||||
* No-op for providers whose ``auth_env`` doesn't reference a
|
||||
vendor-specific name (oauth, anthropic-api, or a third-party
|
||||
entry that hasn't been added to the registry yet).
|
||||
"""
|
||||
auth_env = provider.get("auth_env") or ()
|
||||
if os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
target = provider.get("auth_token_env") or "ANTHROPIC_AUTH_TOKEN"
|
||||
if os.environ.get(target):
|
||||
# Operator override wins — never clobber an explicit value.
|
||||
return
|
||||
for name in auth_env:
|
||||
@@ -432,10 +505,10 @@ def _project_vendor_auth(provider: dict) -> None:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
continue
|
||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = value
|
||||
os.environ[target] = value
|
||||
logger.info(
|
||||
"auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)",
|
||||
name, provider.get("name", "<unknown>"),
|
||||
"auth env projection: %s -> %s (provider=%s)",
|
||||
name, target, provider.get("name", "<unknown>"),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
+10
-1
@@ -535,7 +535,16 @@ class ClaudeSDKExecutor(AgentExecutor):
|
||||
# claude session renders inbound messages as `<channel>` tags
|
||||
# inline (no inbox poll needed). Drop once channels graduate
|
||||
# to the default allowlist.
|
||||
extra_args={"dangerously-load-development-channels": "server:molecule"},
|
||||
#
|
||||
# Task #214 — CLI 2.1.143 made the flag variadic (nargs='+').
|
||||
# The `{flag: value}` shape renders as TWO argv elements (see
|
||||
# claude_agent_sdk subprocess_cli.py:340) and the channels
|
||||
# parser then greedily absorbs the SDK's downstream `--print
|
||||
# <prompt>` argv pair, wedging the SDK at initialize. Fix:
|
||||
# pack `=value` into the key so the renderer's None-value
|
||||
# path emits a single argv element which the variadic parser
|
||||
# cannot reach across.
|
||||
extra_args={"dangerously-load-development-channels=server:molecule": None},
|
||||
)
|
||||
|
||||
# --- output_config: effort + task_budget (issue #652) ---
|
||||
|
||||
+42
-10
@@ -31,6 +31,16 @@ tier: 2
|
||||
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
|
||||
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
|
||||
# auth_env : env vars accepted; any one being set satisfies auth
|
||||
# auth_token_env : (optional) the env var the boot-time vendor-key
|
||||
# projection writes the vendor key INTO. Defaults to
|
||||
# ANTHROPIC_AUTH_TOKEN (Bearer-style; correct for
|
||||
# MiniMax/GLM/DeepSeek Anthropic-compat shims). Kimi
|
||||
# For Coding's gateway authenticates with the
|
||||
# x-api-key header per kimi.com's official Claude Code
|
||||
# integration doc, which the Anthropic SDK / claude
|
||||
# CLI emits from ANTHROPIC_API_KEY (NOT the Bearer
|
||||
# ANTHROPIC_AUTH_TOKEN) — so its entry sets
|
||||
# auth_token_env: ANTHROPIC_API_KEY.
|
||||
providers:
|
||||
- name: anthropic-oauth
|
||||
auth_mode: oauth
|
||||
@@ -73,13 +83,27 @@ providers:
|
||||
base_url: https://api.z.ai/api/anthropic
|
||||
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
|
||||
- name: moonshot
|
||||
# Kimi For Coding — Moonshot's coding-agent tier (K2.6 / "Kimi for
|
||||
# Coding"). Per kimi.com's OFFICIAL Claude Code integration doc
|
||||
# (kimi.com/code/docs/en/third-party-tools/other-coding-agents.html,
|
||||
# "Claude Code" section) the contract is:
|
||||
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ (trailing slash)
|
||||
# ANTHROPIC_API_KEY=<the Kimi key> (x-api-key header)
|
||||
# The `sk-kimi-*` key (KIMI_API_KEY in SSOT) authenticates ONLY against
|
||||
# this gateway — the legacy api.moonshot.ai/anthropic surface 401s it.
|
||||
# The gateway routes to the served K2.6 model regardless of the Claude
|
||||
# model name on the wire (proven end-to-end via the OpenClaw template's
|
||||
# api.kimi.com/coding path, winnerProvider=custom-api-kimi-com).
|
||||
# auth_token_env pins the projection to ANTHROPIC_API_KEY (x-api-key)
|
||||
# rather than the default ANTHROPIC_AUTH_TOKEN (Bearer), which this
|
||||
# gateway rejects.
|
||||
- name: kimi-coding
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [kimi-]
|
||||
model_aliases: []
|
||||
base_url: https://api.moonshot.ai/anthropic
|
||||
auth_env: [KIMI_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
base_url: https://api.kimi.com/coding/
|
||||
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
auth_token_env: ANTHROPIC_API_KEY
|
||||
|
||||
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
|
||||
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
|
||||
@@ -175,15 +199,23 @@ runtime_config:
|
||||
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
|
||||
required_env: [GLM_API_KEY]
|
||||
|
||||
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
|
||||
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
|
||||
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
|
||||
# K2 stays as a cheaper option.
|
||||
# --- Kimi For Coding (third-party, Anthropic-API-compatible) ---
|
||||
# Routed via the `kimi-coding` provider entry above: the adapter
|
||||
# auto-sets ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ and
|
||||
# projects KIMI_API_KEY → ANTHROPIC_API_KEY (x-api-key) per
|
||||
# kimi.com's official Claude Code integration doc. The gateway
|
||||
# serves the K2.6 model regardless of the wire model id; the id
|
||||
# below is the gateway's own served-model name (mirrors the proven
|
||||
# OpenClaw `kimi-for-coding` route). K2.5 / K2 stay as aliases for
|
||||
# workspaces pinned to the older labels — they hit the same gateway.
|
||||
- id: kimi-for-coding
|
||||
name: Kimi K2.6 (Kimi For Coding, third-party Anthropic-API-compatible)
|
||||
required_env: [KIMI_API_KEY]
|
||||
- id: kimi-k2.5
|
||||
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
|
||||
name: Kimi K2.5 (Kimi For Coding, third-party Anthropic-API-compatible)
|
||||
required_env: [KIMI_API_KEY]
|
||||
- id: kimi-k2
|
||||
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
|
||||
name: Kimi K2 (Kimi For Coding, third-party Anthropic-API-compatible)
|
||||
required_env: [KIMI_API_KEY]
|
||||
|
||||
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
|
||||
|
||||
+38
-2
@@ -42,6 +42,15 @@ log_boot_context
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Configs volume is created by Docker as root; agent needs write access
|
||||
# for plugin installs, memory writes, .auth_token rotation, etc.
|
||||
#
|
||||
# T4 atomic-co-sequencing contract (RFC internal#456 §10): the T4
|
||||
# escalation leg (sudo NOPASSWD + docker group, baked in the
|
||||
# Dockerfile) is ADDITIVE. The agent still runs uid-1000 and
|
||||
# /configs/.auth_token MUST remain agent-owned — escalation must
|
||||
# NOT regress the Hermes list_peers-401 token-ownership class.
|
||||
# This chown -R is the agent-ownership half of that contract; the
|
||||
# Layer-3 conformance gate asserts owner_uid==1000 on the running
|
||||
# container alongside the host-root-reach assertion.
|
||||
chown -R agent:agent /configs 2>/dev/null
|
||||
# /workspace handling — only chown when the contents are root-owned
|
||||
# (typical on Docker Desktop on Windows where host uid maps to 0).
|
||||
@@ -70,9 +79,36 @@ if [ "$(id -u)" = "0" ]; then
|
||||
# finds it when running as agent. The provisioner's mount point is
|
||||
# hardcoded to /root/.claude/sessions; we don't want to change the
|
||||
# platform contract just for this template.
|
||||
mkdir -p /home/agent/.claude
|
||||
#
|
||||
# NOTE (T4 perms regression): on FIRST boot the host volume mount for
|
||||
# /home/agent/.claude doesn't exist yet — entrypoint creates it and
|
||||
# the chown lands inside the `if -d /root/.claude/sessions` guard.
|
||||
# On SECOND boot with a populated /home/agent/.claude (sessions/,
|
||||
# session-env/, settings.json — any of which the SDK or agent has
|
||||
# written between boots) the dir may already be root-owned because
|
||||
# the SDK's working files inherited root's uid when written under
|
||||
# the prior root segment of an earlier entrypoint, OR because a
|
||||
# newer claude-code release writes new subdirs we don't create here.
|
||||
# That leaves uid-1000 agent EPERMing on every settings/session write
|
||||
# ("permission restrictions" surfaced to the canvas as a generic
|
||||
# Bash failure). Fix: create the well-known subdirs idempotently
|
||||
# and run the chown unconditionally (no-op when ownership is already
|
||||
# correct, fast on small trees). Stub ~/.claude/settings.json too so
|
||||
# the agent's introspection (cat ~/.claude/settings.json) succeeds
|
||||
# and shows operating mode — bypassPermissions is the canonical
|
||||
# mode set programmatically by claude_sdk_executor.py.
|
||||
mkdir -p /home/agent/.claude/sessions /home/agent/.claude/session-env
|
||||
if [ ! -f /home/agent/.claude/settings.json ]; then
|
||||
cat > /home/agent/.claude/settings.json <<'EOF'
|
||||
{
|
||||
"permissions": {"defaultMode": "bypassPermissions"},
|
||||
"_note": "Mode is also set programmatically by claude_sdk_executor.py (permission_mode='bypassPermissions'); this file is informational and lets `cat ~/.claude/settings.json` succeed."
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
chown -R agent:agent /home/agent/.claude 2>/dev/null
|
||||
if [ -d /root/.claude/sessions ]; then
|
||||
chown -R agent:agent /root/.claude /home/agent/.claude 2>/dev/null
|
||||
chown -R agent:agent /root/.claude 2>/dev/null
|
||||
ln -sfn /root/.claude/sessions /home/agent/.claude/sessions
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Molecule AI workspace runtime — shared infrastructure
|
||||
molecule-ai-workspace-runtime>=0.1.22
|
||||
|
||||
# P0 band-aid for canvas-chat upload 400 "failed to parse multipart form"
|
||||
# (task #256; forensic a5bb950f). Starlette `Request.form()` raises
|
||||
# AssertionError parsing multipart bodies when python-multipart is absent.
|
||||
# Pinned in molecule-core mc#1578 (SSOT, MERGED 2026-05-19T21:41Z) but the
|
||||
# PyPI publish of the updated runtime wheel is gated on the Gitea middleman
|
||||
# rename + PyPI abuse-block recovery. This direct pin in each template is
|
||||
# REDUNDANT (and harmless) once mc#1578's runtime tag publishes — at that
|
||||
# point the runtime wheel itself will carry python-multipart as a transitive.
|
||||
python-multipart>=0.0.27
|
||||
|
||||
# Claude Code adapter specific deps
|
||||
# Claude Agent SDK — programmatic API to Claude Code engine.
|
||||
# Replaces CLI subprocess approach (no more --print, --resume, json parsing).
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# git-askpass helper. Reads HTTPS Basic-Auth credentials from env vars so
|
||||
# the deployer can wire git authentication for any private remote without
|
||||
# touching ~/.gitconfig or ~/.git-credentials inside the container.
|
||||
#
|
||||
# Wire-up: set GIT_ASKPASS=/usr/local/bin/molecule-askpass in the
|
||||
# container env, then export GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (or the
|
||||
# GITEA_USER / GITEA_TOKEN fallback pair). When git encounters an HTTPS
|
||||
# auth challenge on a host that has no credential.helper configured for
|
||||
# it, git invokes GIT_ASKPASS twice — once with a "Username for ..."
|
||||
# prompt and once with a "Password for ..." prompt. We pattern-match on
|
||||
# that prompt and emit the matching env var.
|
||||
#
|
||||
# No hardcoded hostnames or vendor names — the deployer decides which
|
||||
# host these credentials apply to by virtue of setting GIT_ASKPASS only
|
||||
# when the target remote is in scope. The helper itself is reusable for
|
||||
# any HTTPS git remote.
|
||||
#
|
||||
# Failure mode: if the env vars are unset, we emit an empty string and
|
||||
# let git surface "Authentication failed" — this is intentional, so a
|
||||
# misconfigured deployment fails loudly at first push instead of silently
|
||||
# falling through to an unrelated credential chain.
|
||||
|
||||
case "$1" in
|
||||
Username*)
|
||||
printf '%s\n' "${GIT_HTTP_USERNAME:-${GITEA_USER:-}}"
|
||||
;;
|
||||
Password*)
|
||||
printf '%s\n' "${GIT_HTTP_PASSWORD:-${GITEA_TOKEN:-}}"
|
||||
;;
|
||||
*)
|
||||
# Unknown prompt — emit empty and let git decide.
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
@@ -129,12 +129,13 @@ _FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
|
||||
base_url: https://api.z.ai/api/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
|
||||
- name: moonshot
|
||||
- name: kimi-coding
|
||||
auth_mode: third_party_anthropic_compat
|
||||
model_prefixes: [kimi-]
|
||||
model_aliases: []
|
||||
base_url: https://api.moonshot.ai/anthropic
|
||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||
base_url: https://api.kimi.com/coding/
|
||||
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||
auth_token_env: ANTHROPIC_API_KEY
|
||||
|
||||
- name: deepseek
|
||||
auth_mode: third_party_anthropic_compat
|
||||
@@ -554,7 +555,7 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
||||
names = [p["name"] for p in result]
|
||||
assert names == [
|
||||
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
|
||||
"zai", "moonshot", "deepseek",
|
||||
"zai", "kimi-coding", "deepseek",
|
||||
]
|
||||
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
|
||||
assert isinstance(result[0]["model_aliases"], tuple)
|
||||
@@ -564,15 +565,16 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
||||
@pytest.mark.parametrize("model,expected_provider,expected_url", [
|
||||
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
|
||||
("glm-4.5", "zai", "https://api.z.ai/api/anthropic"),
|
||||
("kimi-k2.5", "moonshot", "https://api.moonshot.ai/anthropic"),
|
||||
("kimi-k2.5", "kimi-coding", "https://api.kimi.com/coding/"),
|
||||
("kimi-for-coding", "kimi-coding", "https://api.kimi.com/coding/"),
|
||||
("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"),
|
||||
])
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_routes_extra_providers(
|
||||
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
|
||||
):
|
||||
"""The Z.ai / Moonshot / DeepSeek providers added in this PR must
|
||||
route correctly: model id → provider entry → ANTHROPIC_BASE_URL.
|
||||
"""The Z.ai / Kimi-For-Coding / DeepSeek providers must route
|
||||
correctly: model id → provider entry → ANTHROPIC_BASE_URL.
|
||||
Parametrized to keep the matrix coverage tight without 3 near-identical
|
||||
test bodies. Locks in the per-vendor base_url so a future YAML edit
|
||||
that mistypes z.ai's `/api/anthropic` suffix gets caught.
|
||||
|
||||
@@ -110,6 +110,18 @@ def _load_executor():
|
||||
return claude_sdk_executor
|
||||
|
||||
|
||||
def _channels_entry(extra_args):
|
||||
"""Return (key, value) for the dev-channels flag, tolerating both shapes.
|
||||
|
||||
- separate-value shape: {"dangerously-load-development-channels": "server:X"}
|
||||
- packed `=` shape (task #214 fix): {"dangerously-load-development-channels=server:X": None}
|
||||
"""
|
||||
for k, v in extra_args.items():
|
||||
if k.split("=", 1)[0] == "dangerously-load-development-channels":
|
||||
return k, v
|
||||
return None, None
|
||||
|
||||
|
||||
def test_build_options_forwards_tagged_dev_channels_flag(tmp_path):
|
||||
"""``_build_options`` must pass the tagged ``server:molecule`` entry to
|
||||
``--dangerously-load-development-channels``. The Claude Code 2.1.x CLI
|
||||
@@ -142,25 +154,28 @@ def test_build_options_forwards_tagged_dev_channels_flag(tmp_path):
|
||||
"extra_args missing — host claude CLI will never see the dev-channels "
|
||||
"flag and notifications/claude/channel will be filtered at the allowlist"
|
||||
)
|
||||
flag_value = kwargs["extra_args"].get("dangerously-load-development-channels")
|
||||
assert flag_value == "server:molecule", (
|
||||
f"dev-channels entry must be tagged 'server:molecule' to match the "
|
||||
f"workspace's MCP-server registration. The CLI rejects bare server "
|
||||
key, value = _channels_entry(kwargs["extra_args"])
|
||||
# Resolve the tagged payload from whichever shape the executor used.
|
||||
tagged = value if value is not None else (key.split("=", 1)[1] if "=" in key else None)
|
||||
assert tagged == "server:molecule", (
|
||||
f"dev-channels entry must resolve to tagged 'server:molecule' to match "
|
||||
f"the workspace's MCP-server registration. The CLI rejects bare server "
|
||||
f"names with `entries must be tagged` and bare-switch values (None) "
|
||||
f"with `argument missing`; the latter wedges SDK initialize. "
|
||||
f"got {flag_value!r}"
|
||||
f"got key={key!r} value={value!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_build_options_dev_channels_value_is_not_bare_none(tmp_path):
|
||||
"""Defense in depth against the original PR #25 bare-switch shape.
|
||||
|
||||
``{flag: None}`` in claude-agent-sdk's extra_args forwarding renders
|
||||
as a bare ``--flag`` with no value, which the post-2.1.x CLI rejects.
|
||||
Pin the invariant (non-None, non-empty, contains a tag colon) so a
|
||||
regression to the old shape fails immediately at unit-test time
|
||||
instead of surfacing as a live `Control request timeout: initialize`
|
||||
wedge in production.
|
||||
A bare ``--dangerously-load-development-channels`` (no value, no
|
||||
``=value`` packed into the key) renders as an argument-less flag,
|
||||
which the post-2.1.x CLI rejects with `argument missing`. Pin the
|
||||
invariant (the rendered payload is non-empty and tag-colon-shaped)
|
||||
so a regression to the old shape fails immediately at unit-test
|
||||
time instead of surfacing as a live `Control request timeout:
|
||||
initialize` wedge in production.
|
||||
"""
|
||||
mod = _load_executor()
|
||||
sdk = sys.modules["claude_agent_sdk"]
|
||||
@@ -174,17 +189,56 @@ def test_build_options_dev_channels_value_is_not_bare_none(tmp_path):
|
||||
)
|
||||
executor._build_options()
|
||||
|
||||
flag_value = (
|
||||
key, value = _channels_entry(
|
||||
sdk.ClaudeAgentOptions.call_args.kwargs["extra_args"]
|
||||
["dangerously-load-development-channels"]
|
||||
)
|
||||
assert flag_value is not None, (
|
||||
"flag value must not be None — bare switch wedges SDK initialize"
|
||||
payload = key if value is None else f"{key}={value}"
|
||||
assert ":" in payload.split("=", 1)[-1], (
|
||||
f"flag payload must be tagged (server:<name> or plugin:<name>@<marketplace>); "
|
||||
f"got key={key!r} value={value!r} which the CLI rejects with "
|
||||
f"`entries must be tagged` or `argument missing`"
|
||||
)
|
||||
assert isinstance(flag_value, str) and flag_value, (
|
||||
f"flag value must be a non-empty string; got {flag_value!r}"
|
||||
|
||||
|
||||
def test_dev_channels_does_not_swallow_print_prompt_cli_2_1_143(tmp_path):
|
||||
"""Task #214 regression — claude-code CLI 2.1.143.
|
||||
|
||||
CLI 2.1.143 made ``--dangerously-load-development-channels`` variadic
|
||||
(``nargs='+'``). claude-agent-sdk's renderer (subprocess_cli.py:340)
|
||||
emits ``{flag: value}`` as TWO argv elements, so the channels parser
|
||||
greedily absorbs the following ``--print <prompt>`` argv pair as
|
||||
channel entries and the SDK wedges at initialize. Fix: pack ``=``
|
||||
into the key so the renderer's ``None``-value path emits ONE argv —
|
||||
``--dangerously-load-development-channels=server:molecule`` — that
|
||||
the variadic parser cannot reach across. Both argv orderings
|
||||
around ``--print <prompt>`` (channels-then-print, print-then-
|
||||
channels) must keep the prompt argv adjacent to ``--print``.
|
||||
"""
|
||||
mod = _load_executor()
|
||||
sdk = sys.modules["claude_agent_sdk"]
|
||||
sdk.ClaudeAgentOptions.reset_mock()
|
||||
executor = mod.ClaudeSDKExecutor(
|
||||
system_prompt=None, config_path=str(tmp_path), heartbeat=None, model="sonnet",
|
||||
)
|
||||
assert ":" in flag_value, (
|
||||
f"flag value must be tagged (server:<name> or plugin:<name>@<marketplace>); "
|
||||
f"got {flag_value!r} which the CLI rejects with `entries must be tagged`"
|
||||
executor._build_options()
|
||||
extra_args = sdk.ClaudeAgentOptions.call_args.kwargs["extra_args"]
|
||||
|
||||
# Mirror claude_agent_sdk/_internal/transport/subprocess_cli.py:340.
|
||||
channels_argv = []
|
||||
for flag, val in extra_args.items():
|
||||
channels_argv.append(f"--{flag}") if val is None else channels_argv.extend([f"--{flag}", str(val)])
|
||||
|
||||
slots = [a for a in channels_argv if a.startswith("--dangerously-load-development-channels")]
|
||||
assert len(slots) == 1 and "=" in slots[0] and channels_argv == slots, (
|
||||
f"channels flag must render as a single argv with `=value` packed in so "
|
||||
f"CLI 2.1.143's nargs='+' parser cannot swallow --print <prompt>; "
|
||||
f"got channels_argv={channels_argv!r}"
|
||||
)
|
||||
for orientation, full_argv in (
|
||||
("channels_then_print", channels_argv + ["--print", "hello world"]),
|
||||
("print_then_channels", ["--print", "hello world"] + channels_argv),
|
||||
):
|
||||
idx = full_argv.index("--print")
|
||||
assert full_argv[idx + 1] == "hello world", (
|
||||
f"--print prompt argv must stay adjacent ({orientation}); got {full_argv!r}"
|
||||
)
|
||||
|
||||
@@ -73,10 +73,11 @@ def test_persona_env_minimax_resolves_correctly(monkeypatch):
|
||||
|
||||
def test_persona_env_lead_claude_code_resolves_correctly(monkeypatch):
|
||||
"""Lead persona env (MODEL=opus, MODEL_PROVIDER=claude-code) —
|
||||
``claude-code`` isn't a registered provider name (registry uses
|
||||
``anthropic-oauth``), so it falls back to legacy interpretation
|
||||
and yields no explicit provider, letting the model-based
|
||||
fall-through to providers[0]=anthropic-oauth do the right thing."""
|
||||
``claude-code`` is the persona-friendly alias for the canonical
|
||||
``anthropic-oauth`` registry name. Must resolve via the alias map
|
||||
so the lead boots through the OAuth subscription path even when
|
||||
MODEL is a non-Anthropic model id (e.g. an operator who picked
|
||||
MiniMax in canvas but whose persona env still pins claude-code)."""
|
||||
_clear_env(monkeypatch)
|
||||
monkeypatch.setenv("MODEL", "opus")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
@@ -84,10 +85,38 @@ def test_persona_env_lead_claude_code_resolves_correctly(monkeypatch):
|
||||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||
)
|
||||
assert model == "opus"
|
||||
# claude-code is not a registered slug, so this falls back —
|
||||
# provider is None and the caller will model-resolve to
|
||||
# anthropic-oauth via the alias match on "opus".
|
||||
assert provider is None
|
||||
# claude-code → anthropic-oauth via the alias map
|
||||
assert provider == "anthropic-oauth"
|
||||
|
||||
|
||||
def test_persona_env_lead_with_minimax_model_routes_via_oauth(monkeypatch):
|
||||
"""Lead workspace whose persona pins MODEL_PROVIDER=claude-code but
|
||||
whose YAML/canvas selection happens to be a MiniMax model still
|
||||
routes via OAuth — the persona's provider pin wins over the
|
||||
model-prefix matcher. Without the alias map, the fall-through
|
||||
mis-routed leads to MiniMax even when their CLAUDE_CODE_OAUTH_TOKEN
|
||||
was set."""
|
||||
_clear_env(monkeypatch)
|
||||
monkeypatch.setenv("MODEL", "MiniMax-M2.7")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
|
||||
model, provider = _resolve_model_and_provider_from_env(
|
||||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||
)
|
||||
assert model == "MiniMax-M2.7"
|
||||
assert provider == "anthropic-oauth"
|
||||
|
||||
|
||||
def test_anthropic_alias_resolves_to_anthropic_api(monkeypatch):
|
||||
"""``MODEL_PROVIDER=anthropic`` alias → ``anthropic-api`` (direct
|
||||
Anthropic API key path)."""
|
||||
_clear_env(monkeypatch)
|
||||
monkeypatch.setenv("MODEL", "claude-opus-4-7")
|
||||
monkeypatch.setenv("MODEL_PROVIDER", "anthropic")
|
||||
model, provider = _resolve_model_and_provider_from_env(
|
||||
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||
)
|
||||
assert model == "claude-opus-4-7"
|
||||
assert provider == "anthropic-api"
|
||||
|
||||
|
||||
def test_persona_env_glm_resolves_correctly(monkeypatch):
|
||||
@@ -184,6 +213,54 @@ def test_no_env_no_yaml_returns_empty(monkeypatch):
|
||||
assert provider is None
|
||||
|
||||
|
||||
def test_yaml_provider_anthropic_is_aliased_to_anthropic_api(monkeypatch):
|
||||
"""Regression for 2026-05-09 staging-cplead-2 incident: every
|
||||
workspace booted ``configuration_status=not_configured`` because the
|
||||
molecule-runtime wheel auto-derives ``runtime_config.provider =
|
||||
"anthropic"`` from the default model slug ``anthropic:claude-opus-4-7``.
|
||||
The adapter received ``yaml_provider="anthropic"`` from the wheel and
|
||||
rejected it with ``ValueError: provider='anthropic' but it is not in
|
||||
the providers registry`` — but ``anthropic`` is already in
|
||||
``_PROVIDER_SLUG_ALIASES`` for the env-var path. Mirror the alias map
|
||||
on the YAML path so the wheel default produces a registered provider
|
||||
name."""
|
||||
_clear_env(monkeypatch)
|
||||
_, provider = _resolve_model_and_provider_from_env(
|
||||
yaml_model="", yaml_provider="anthropic", providers=_REGISTRY,
|
||||
)
|
||||
assert provider == "anthropic-api", (
|
||||
f"yaml_provider='anthropic' must resolve through the alias map to "
|
||||
f"'anthropic-api'; got {provider!r}. Without this aliasing the "
|
||||
f"wheel-default workspace boot wedges at adapter.setup()."
|
||||
)
|
||||
|
||||
|
||||
def test_yaml_provider_claude_code_is_aliased_to_anthropic_oauth(monkeypatch):
|
||||
"""Symmetric coverage: persona-friendly ``claude-code`` slug from the
|
||||
YAML ``provider:`` field must alias to ``anthropic-oauth``, the same
|
||||
way the env-var path resolves it. Lead workspaces that pin the OAuth
|
||||
path in YAML (instead of via env) must not wedge."""
|
||||
_clear_env(monkeypatch)
|
||||
_, provider = _resolve_model_and_provider_from_env(
|
||||
yaml_model="", yaml_provider="claude-code", providers=_REGISTRY,
|
||||
)
|
||||
assert provider == "anthropic-oauth"
|
||||
|
||||
|
||||
def test_yaml_provider_unknown_passes_through_for_actionable_error(monkeypatch):
|
||||
"""An unaliased, unknown YAML provider (e.g. ``yaml_provider="mystery"``)
|
||||
must NOT be silently swapped to providers[0] — it must reach
|
||||
``_resolve_provider`` so the adapter raises the actionable
|
||||
``Known providers: ...`` error message. The alias map is a
|
||||
convenience for the two persona-convention slugs only; everything
|
||||
else must keep its original semantics."""
|
||||
_clear_env(monkeypatch)
|
||||
_, provider = _resolve_model_and_provider_from_env(
|
||||
yaml_model="", yaml_provider="mystery", providers=_REGISTRY,
|
||||
)
|
||||
assert provider == "mystery"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Whitespace / empty-value defensive cases
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -219,7 +219,6 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
|
||||
"""
|
||||
cases = [
|
||||
("zai", "GLM_API_KEY"),
|
||||
("moonshot", "KIMI_API_KEY"),
|
||||
("deepseek", "DEEPSEEK_API_KEY"),
|
||||
]
|
||||
for provider_name, env_name in cases:
|
||||
@@ -242,3 +241,83 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
|
||||
f"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for "
|
||||
f"provider={provider_name}"
|
||||
)
|
||||
|
||||
|
||||
def test_kimi_coding_projects_into_anthropic_api_key(adapter_module, monkeypatch):
|
||||
"""Kimi For Coding's gateway authenticates with the x-api-key header
|
||||
(kimi.com official Claude Code doc), which the Anthropic SDK / claude
|
||||
CLI emits from ANTHROPIC_API_KEY — NOT the Bearer ANTHROPIC_AUTH_TOKEN
|
||||
used by MiniMax/GLM/DeepSeek. The kimi-coding provider sets
|
||||
auth_token_env: ANTHROPIC_API_KEY so KIMI_API_KEY projects there.
|
||||
|
||||
Regression guard for the original mis-route: KIMI_API_KEY landing in
|
||||
ANTHROPIC_AUTH_TOKEN against api.kimi.com/coding 401s.
|
||||
"""
|
||||
import os
|
||||
_clear_all_auth_env(monkeypatch, adapter_module)
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
|
||||
provider = {
|
||||
"name": "kimi-coding",
|
||||
"auth_mode": "third_party_anthropic_compat",
|
||||
"model_prefixes": ("kimi-",),
|
||||
"model_aliases": (),
|
||||
"base_url": "https://api.kimi.com/coding/",
|
||||
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
||||
"auth_token_env": "ANTHROPIC_API_KEY",
|
||||
}
|
||||
|
||||
adapter_module._project_vendor_auth(provider)
|
||||
|
||||
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-kimi-sentinel", (
|
||||
"KIMI_API_KEY must project onto ANTHROPIC_API_KEY (x-api-key) for "
|
||||
"the kimi-coding provider per kimi.com's official Claude Code doc"
|
||||
)
|
||||
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, (
|
||||
"KIMI_API_KEY must NOT land in ANTHROPIC_AUTH_TOKEN — the Bearer "
|
||||
"header 401s against api.kimi.com/coding (the original mis-route)"
|
||||
)
|
||||
|
||||
|
||||
def test_kimi_coding_operator_anthropic_api_key_wins(adapter_module, monkeypatch):
|
||||
"""Idempotency holds for the per-provider target too: an explicit
|
||||
operator ANTHROPIC_API_KEY is never clobbered by the projection."""
|
||||
import os
|
||||
_clear_all_auth_env(monkeypatch, adapter_module)
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "operator-value")
|
||||
provider = {
|
||||
"name": "kimi-coding",
|
||||
"auth_mode": "third_party_anthropic_compat",
|
||||
"model_prefixes": ("kimi-",),
|
||||
"model_aliases": (),
|
||||
"base_url": "https://api.kimi.com/coding/",
|
||||
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
|
||||
"auth_token_env": "ANTHROPIC_API_KEY",
|
||||
}
|
||||
|
||||
adapter_module._project_vendor_auth(provider)
|
||||
|
||||
assert os.environ.get("ANTHROPIC_API_KEY") == "operator-value", (
|
||||
"explicit operator ANTHROPIC_API_KEY must win over auto-projection"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_provider_parses_auth_token_env(adapter_module):
|
||||
"""_normalize_provider surfaces auth_token_env; absent → the
|
||||
ANTHROPIC_AUTH_TOKEN default (preserves MiniMax/GLM/DeepSeek)."""
|
||||
with_override = adapter_module._normalize_provider({
|
||||
"name": "kimi-coding",
|
||||
"auth_mode": "third_party_anthropic_compat",
|
||||
"base_url": "https://api.kimi.com/coding/",
|
||||
"auth_env": ["KIMI_API_KEY", "ANTHROPIC_API_KEY"],
|
||||
"auth_token_env": "ANTHROPIC_API_KEY",
|
||||
})
|
||||
assert with_override["auth_token_env"] == "ANTHROPIC_API_KEY"
|
||||
|
||||
default = adapter_module._normalize_provider({
|
||||
"name": "minimax",
|
||||
"auth_mode": "third_party_anthropic_compat",
|
||||
"base_url": "https://api.minimax.io/anthropic",
|
||||
"auth_env": ["MINIMAX_API_KEY"],
|
||||
})
|
||||
assert default["auth_token_env"] == "ANTHROPIC_AUTH_TOKEN"
|
||||
|
||||
Reference in New Issue
Block a user