Compare commits
35 Commits
test/log-b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bbc2daea4a | |||
| 12dd60413d | |||
| c93214e4e0 | |||
| 66e3b7edb3 | |||
| 5bc87ea75d | |||
| 73827045bc | |||
| 38353e9a4f | |||
| 8bcc19c38e | |||
|
|
47263db7ad | ||
| 43a86d44da | |||
| c2a0bdea96 | |||
| d2585700f5 | |||
|
|
aaa2a79e81 | ||
| 4b038f2947 | |||
| 8adc3576fd | |||
| 134ba7f82c | |||
| 1742b60e62 | |||
| 56a045f38e | |||
|
|
291f356dab | ||
| 91022654cd | |||
|
|
b96a6d2569 | ||
| 2edd78c154 | |||
|
|
ad4241cebb | ||
| 3c849b3ba7 | |||
| f8d7f8f3a8 | |||
| a2c7bf3d3b | |||
| a5c9acd950 | |||
| 3e491c673b | |||
|
|
91e5010888 | ||
|
|
b91f1ab694 | ||
|
|
cd68aae474 | ||
| f549d0e4f3 | |||
| 09c95308fd | |||
|
|
fb450b0758 | ||
|
|
e28c2d0fd7 |
329
.gitea/workflows/ci.yml
Normal file
329
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
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]
|
||||||
|
|
||||||
|
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
|
||||||
|
# -v /:/host -v /var/run/docker.sock:/var/run/docker.sock), then
|
||||||
|
# asserts BOTH properties on the RUNNING container, atomically
|
||||||
|
# (RFC §10 — either failing fails the build):
|
||||||
|
# (a) the uid-1000 agent can attain host root
|
||||||
|
# (sudo nsenter --target 1 --mount --pid -- id -u == 0)
|
||||||
|
# (b) /configs/.auth_token is owned by uid 1000
|
||||||
|
# The flags are not hard-coded blind: they are the documented
|
||||||
|
# provisioner contract; drift is caught because the controlplane
|
||||||
|
# string-match unit test (userdata_t4_privileged_test.go) guards the
|
||||||
|
# emission side and this gate guards the runtime side.
|
||||||
|
t4-conformance:
|
||||||
|
name: T4 tier-4 conformance (live)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
needs: validate-static
|
||||||
|
# Untrusted-by-design: builds + runs the PR's Dockerfile. Skip on
|
||||||
|
# fork PRs exactly like validate-runtime.
|
||||||
|
if: github.event.pull_request.head.repo.fork != true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- name: Build the runtime image
|
||||||
|
id: build
|
||||||
|
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
|
||||||
|
docker build -t t4-conformance-test . --no-cache 2>&1 | tail -5
|
||||||
|
- name: Run under EXACT tier-4 provisioner flags + assert host-root reach AND token agent-ownership
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# EXACT flags from controlplane userdata_containerized.go
|
||||||
|
# (tier-4 emission @ec2384c). The molecule-runtime entrypoint
|
||||||
|
# wants a live workspace; we only need the container up long
|
||||||
|
# enough to probe, so override the command with a sleep and
|
||||||
|
# exercise the agent context directly.
|
||||||
|
CID=$(docker run -d \
|
||||||
|
--name t4probe \
|
||||||
|
--network host \
|
||||||
|
--privileged \
|
||||||
|
--pid=host \
|
||||||
|
-v /:/host \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
--entrypoint /bin/sh \
|
||||||
|
t4-conformance-test -c 'sleep 600')
|
||||||
|
trap 'docker rm -f t4probe >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
echo "=== Reproduce the agent-owned-token half of the entrypoint contract ==="
|
||||||
|
# The real entrypoint chowns /configs to agent before gosu;
|
||||||
|
# /configs is an unmounted VOLUME in this probe, so reproduce
|
||||||
|
# the exact contract step the entrypoint performs, then assert.
|
||||||
|
docker exec t4probe sh -c 'mkdir -p /configs && touch /configs/.auth_token && chown -R agent:agent /configs'
|
||||||
|
|
||||||
|
echo "=== (b) token agent-ownership: stat /configs/.auth_token ==="
|
||||||
|
OWNER_UID=$(docker exec t4probe stat -c '%u' /configs/.auth_token)
|
||||||
|
echo "owner_uid=$OWNER_UID"
|
||||||
|
if [ "$OWNER_UID" != "1000" ]; then
|
||||||
|
echo "::error::T4 contract violated: /configs/.auth_token owner_uid=$OWNER_UID (expected 1000). Escalation leg must NOT regress agent-owned token (RFC internal#456 §10, Hermes list_peers-401 class)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== (a) host-root reach AS THE uid-1000 AGENT (not root) ==="
|
||||||
|
# Run as the agent user (uid 1000), exactly as gosu would.
|
||||||
|
AGENT_HOSTROOT_UID=$(docker exec -u agent t4probe sudo -n nsenter --target 1 --mount --pid -- id -u)
|
||||||
|
echo "agent->host-root id -u = $AGENT_HOSTROOT_UID"
|
||||||
|
if [ "$AGENT_HOSTROOT_UID" != "0" ]; then
|
||||||
|
echo "::error::T4 contract violated: uid-1000 agent could NOT attain host root via 'sudo nsenter --target 1' (got uid=$AGENT_HOSTROOT_UID). T4 escalation leg ABSENT/broken."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Defense-in-depth: host-filesystem write+readback through /host
|
||||||
|
# from the agent, proving real host reach (not just a namespace
|
||||||
|
# trick on an isolated PID 1).
|
||||||
|
MARKER="t4-conformance-$(date +%s)-$RANDOM"
|
||||||
|
docker exec -u agent t4probe sudo -n sh -c "echo $MARKER > /host/tmp/.t4-conformance-probe"
|
||||||
|
READBACK=$(docker exec -u agent t4probe sudo -n cat /host/tmp/.t4-conformance-probe)
|
||||||
|
docker exec -u agent t4probe sudo -n rm -f /host/tmp/.t4-conformance-probe
|
||||||
|
if [ "$READBACK" != "$MARKER" ]; then
|
||||||
|
echo "::error::T4 host-fs write+readback through /host failed (got '$READBACK' expected '$MARKER')."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "::notice::T4 tier-4 conformance PASS — uid-1000 agent reaches host root AND /configs/.auth_token is agent-owned (both, atomically)."
|
||||||
|
|
||||||
|
# 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
|
||||||
214
.gitea/workflows/publish-image.yml
Normal file
214
.gitea/workflows/publish-image.yml
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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
|
||||||
|
|
||||||
196
.gitea/workflows/secret-scan.yml
Normal file
196
.gitea/workflows/secret-scan.yml
Normal file
@ -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."
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -2,7 +2,7 @@ name: CI
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
uses: Molecule-AI/molecule-ci/.github/workflows/validate-workspace-template.yml@main
|
uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
name: Adapter unit tests
|
name: Adapter unit tests
|
||||||
|
|||||||
49
.github/workflows/publish-image.yml
vendored
49
.github/workflows/publish-image.yml
vendored
@ -32,14 +32,47 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# The `.runtime-version` file is the push-mode cascade signal post-
|
||||||
|
# 2026-05-06: when molecule-core/publish-runtime.yml ships a new
|
||||||
|
# version to PyPI, it does NOT call repository_dispatch (Gitea 1.22.6
|
||||||
|
# has no such endpoint — empirically verified molecule-core#20).
|
||||||
|
# Instead it git-pushes an updated `.runtime-version` to each template,
|
||||||
|
# which trips this workflow's `on: push: branches: [main]` trigger.
|
||||||
|
# This job reads that file and forwards the version to the reusable
|
||||||
|
# build workflow so the Dockerfile pip-installs the exact published
|
||||||
|
# version, not whatever requirements.txt currently bounds.
|
||||||
|
resolve-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 2
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.read.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- id: read
|
||||||
|
run: |
|
||||||
|
if [ -f .runtime-version ]; then
|
||||||
|
v="$(head -n1 .runtime-version | tr -d '[:space:]')"
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "resolved runtime version: $v"
|
||||||
|
else
|
||||||
|
echo "no .runtime-version file present — falling through to Dockerfile default"
|
||||||
|
fi
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
uses: Molecule-AI/molecule-ci/.github/workflows/publish-template-image.yml@main
|
needs: resolve-version
|
||||||
|
uses: molecule-ai/molecule-ci/.github/workflows/publish-template-image.yml@main
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
# When the cascade fires, client_payload.runtime_version is the
|
# Resolution chain (highest priority first):
|
||||||
# exact version PyPI just published. Forwarded to the reusable
|
# 1. client_payload.runtime_version — legacy GitHub
|
||||||
# workflow as a docker --build-arg so the cache key changes
|
# repository_dispatch path (will return if Gitea ever adds
|
||||||
# per-version and pip install resolves freshly.
|
# the dispatch API; left in place for forward-compat).
|
||||||
# On other events (push to main / manual without input), this is
|
# 2. inputs.runtime_version — manual workflow_dispatch run from
|
||||||
# empty and the Dockerfile's default (requirements.txt pin) applies.
|
# the Actions UI for ad-hoc rebuilds against a specific
|
||||||
runtime_version: ${{ github.event.client_payload.runtime_version || inputs.runtime_version || '' }}
|
# version.
|
||||||
|
# 3. needs.resolve-version.outputs.version — the
|
||||||
|
# `.runtime-version` file in this repo, written by
|
||||||
|
# molecule-core/publish-runtime.yml's push-mode cascade.
|
||||||
|
# 4. '' — fall through to the Dockerfile default
|
||||||
|
# (requirements.txt pin).
|
||||||
|
runtime_version: ${{ github.event.client_payload.runtime_version || inputs.runtime_version || needs.resolve-version.outputs.version || '' }}
|
||||||
|
|||||||
203
.github/workflows/secret-scan.yml
vendored
203
.github/workflows/secret-scan.yml
vendored
@ -1,22 +1,201 @@
|
|||||||
name: Secret scan
|
name: Secret scan
|
||||||
|
|
||||||
# Calls the canonical reusable workflow in molecule-core. Defense
|
# Hard CI gate. Refuses any PR / push whose diff additions contain a
|
||||||
# against the #2090-class leak (a hosted-agent commit slipping a
|
# recognisable credential. Defense-in-depth for the #2090-class incident
|
||||||
# credential-shaped string into a PR). Pattern set lives in
|
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
|
||||||
# molecule-core so we do not maintain a parallel copy here.
|
# 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.
|
||||||
#
|
#
|
||||||
# Pinned to @staging because that is the active default branch on the
|
# Inlined copy from molecule-ai/molecule-core/.github/workflows/secret-scan.yml.
|
||||||
# upstream repo (main lags behind via the staging-promotion workflow).
|
# Cross-repo workflow_call to a private repo doesn't fully work on Gitea 1.22.6
|
||||||
# Updates ride along automatically as the upstream regex set evolves.
|
# (workflow file fails parse-time at 0s with no logs); inline keeps the gate
|
||||||
|
# functional until Gitea is upgraded or the canonical scanner moves to a public
|
||||||
|
# repo. When that lands, this file reverts to the 3-line wrapper:
|
||||||
|
#
|
||||||
|
# jobs:
|
||||||
|
# secret-scan:
|
||||||
|
# uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||||
|
#
|
||||||
|
# Pin to @staging not @main — staging is the active default branch,
|
||||||
|
# main lags via the staging-promotion workflow. Updates ride along
|
||||||
|
# automatically on the next consumer workflow run.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
push:
|
push:
|
||||||
branches: [main, staging, master]
|
branches: [main, staging]
|
||||||
merge_group:
|
|
||||||
types: [checks_requested]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secret-scan:
|
scan:
|
||||||
uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
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 }}
|
||||||
|
|
||||||
|
# For merge_group events the queue's pre-merge ref is a commit on
|
||||||
|
# `gh-readonly-queue/...` whose parent is the queue's base_sha.
|
||||||
|
# That parent isn't part of the queue branch's shallow clone, so
|
||||||
|
# we fetch it explicitly. Without this the diff falls through to
|
||||||
|
# "no BASE → scan entire tree" mode and false-positives on legit
|
||||||
|
# test fixtures (e.g. canvas/src/lib/validation/__tests__/secret-formats.test.ts).
|
||||||
|
|
||||||
|
- 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;
|
||||||
|
# merge_group has its own base_sha/head_sha; 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.
|
||||||
|
SELF=".github/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" ] && 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."
|
||||||
|
|||||||
1
.runtime-version
Normal file
1
.runtime-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.129
|
||||||
21
CLAUDE.md
21
CLAUDE.md
@ -72,6 +72,27 @@ Skills persist across restarts. Use them to codify best practices, coding standa
|
|||||||
## Language
|
## Language
|
||||||
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
|
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
|
## 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:
|
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:
|
||||||
|
|||||||
55
Dockerfile
55
Dockerfile
@ -5,8 +5,23 @@ FROM python:3.11-slim
|
|||||||
# --add-assignee`, `git clone`, etc. per their idle/cron prompts).
|
# --add-assignee`, `git clone`, etc. per their idle/cron prompts).
|
||||||
# Without these the team's claim-and-ship loop silently returns
|
# Without these the team's claim-and-ship loop silently returns
|
||||||
# "(no response generated)" because tools error out.
|
# "(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 \
|
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 \
|
&& 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 \
|
&& 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 \
|
&& 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
|
# Install claude-code CLI via npm
|
||||||
RUN npm install -g @anthropic-ai/claude-code 2>/dev/null || true
|
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
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# RUNTIME_VERSION is forwarded from the reusable publish workflow as
|
# RUNTIME_VERSION is forwarded from the reusable publish workflow as
|
||||||
@ -43,6 +81,19 @@ RUN pip install --no-cache-dir -r requirements.txt && \
|
|||||||
# Copy adapter code
|
# Copy adapter code
|
||||||
COPY adapter.py .
|
COPY adapter.py .
|
||||||
COPY __init__.py .
|
COPY __init__.py .
|
||||||
|
# Provider registry. The adapter's _load_providers walks 4 paths:
|
||||||
|
# 1. /opt/adapter/config.yaml — provisioner-managed canonical
|
||||||
|
# 2. os.path.dirname(__file__)/config.yaml — alongside adapter.py (this image)
|
||||||
|
# 3. ${WORKSPACE_CONFIG_PATH}/config.yaml — workspace per-instance overrides
|
||||||
|
# 4. _BUILTIN_PROVIDERS — oauth + anthropic-api only
|
||||||
|
# On this image /opt/adapter/ is never populated by the platform
|
||||||
|
# provisioner, so path 2 (/app/config.yaml) is the load-bearing one.
|
||||||
|
# Without this COPY the file isn't in the image, all 3 file paths fail,
|
||||||
|
# and _load_providers falls through to _BUILTIN_PROVIDERS — every
|
||||||
|
# MiniMax/GLM/Kimi/DeepSeek model silently routes to anthropic-oauth →
|
||||||
|
# "Not logged in. Please run /login" at first LLM call. Caused the
|
||||||
|
# canary's 38h chronic red on 2026-05-07/08 (molecule-core#129).
|
||||||
|
COPY config.yaml .
|
||||||
# Adapter-specific executor — owned by THIS template (universal-runtime
|
# Adapter-specific executor — owned by THIS template (universal-runtime
|
||||||
# refactor, molecule-core task #87). Lives alongside adapter.py so
|
# refactor, molecule-core task #87). Lives alongside adapter.py so
|
||||||
# Python's import system picks the local /app/claude_sdk_executor.py
|
# Python's import system picks the local /app/claude_sdk_executor.py
|
||||||
|
|||||||
446
adapter.py
446
adapter.py
@ -144,39 +144,135 @@ def _normalize_provider(entry: dict):
|
|||||||
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
|
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
|
||||||
"base_url": entry.get("base_url") or None,
|
"base_url": entry.get("base_url") or None,
|
||||||
"auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False),
|
"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"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical install path the platform provisioner is contracted to clone
|
||||||
|
# the template repo into. Hardcoded so the adapter's config.yaml lookup
|
||||||
|
# is invariant across Docker (mounted /app→/opt/adapter) and EC2-host
|
||||||
|
# (cloned by molecule-controlplane's ec2.go) install paths — robust
|
||||||
|
# against the site-packages copy that bit us 2026-05-04 11:08Z.
|
||||||
|
_CANONICAL_ADAPTER_DIR = "/opt/adapter"
|
||||||
|
|
||||||
|
# Adjacent-to-adapter.py path. Module-level so tests can monkeypatch it
|
||||||
|
# to redirect the path-2 lookup at a controlled tmp dir. Production code
|
||||||
|
# resolves this once at import time and never touches it again — same
|
||||||
|
# semantics as before.
|
||||||
|
_TEMPLATE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
def _load_providers(config_path: str) -> tuple:
|
def _load_providers(config_path: str) -> tuple:
|
||||||
"""Load the provider registry from /configs/config.yaml.
|
"""Load the provider registry from the template's bundled config.yaml.
|
||||||
|
|
||||||
The YAML's top-level ``providers:`` list is the canonical source —
|
The providers list is a TEMPLATE concern — it describes which
|
||||||
canvas Config tab reads the same list to populate its Provider
|
models/auth-modes this runtime image supports — and ships in the
|
||||||
dropdown so the UI and the adapter never disagree on what's
|
template's own config.yaml alongside adapter.py. The per-workspace
|
||||||
available. Falls back to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api)
|
``${WORKSPACE_CONFIG_PATH}/config.yaml`` (default ``/configs/``)
|
||||||
if the file is missing, malformed, or has no providers section, so a
|
only contains workspace-specific overrides (model, runtime, skills,
|
||||||
bare-bones workspace still boots with the historical defaults.
|
prompt files) and does NOT carry a providers section.
|
||||||
|
|
||||||
Per-entry isolation: a single bad provider entry is dropped with a
|
Two-step incident history:
|
||||||
warning; the rest of the registry survives. Used to be a generator
|
• Pre-2026-05-04 09:00Z: only checked ``config_path``, fell back
|
||||||
inside tuple(...) that propagated any AttributeError out and reverted
|
to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api). Every
|
||||||
the whole registry to builtins — exactly the silent-fallback failure
|
MiniMax / GLM / Kimi / DeepSeek model resolved to
|
||||||
mode this file's existence was meant to fix.
|
``anthropic-oauth`` and crashed at first LLM call with
|
||||||
|
"Not logged in. Please run /login". Fixed by adding a
|
||||||
|
template-bundled lookup using
|
||||||
|
``os.path.dirname(os.path.abspath(__file__))``.
|
||||||
|
• 2026-05-04 11:08Z: that ``__file__`` lookup misses on EC2-host
|
||||||
|
installs because the provisioner copies adapter.py to
|
||||||
|
``/opt/molecule-venv/lib/python3.12/site-packages/`` —
|
||||||
|
site-packages wins over PYTHONPATH=/opt/adapter (which the
|
||||||
|
host install doesn't set), so __file__ resolves to the venv
|
||||||
|
path WITHOUT an adjacent config.yaml. Same silent fallback
|
||||||
|
to anthropic-oauth + same "Not logged in" symptom.
|
||||||
|
• 2026-05-08 (#129): the multi-path lookup that fixed both of
|
||||||
|
the above was lost in a post-suspension migration cycle (the
|
||||||
|
Gitea main branch never carried the fix even though the
|
||||||
|
:latest image had it baked in from a prior build). Canary
|
||||||
|
chronic red for 38h before this commit restored the lookup.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``/opt/adapter/config.yaml`` — canonical provisioner-managed
|
||||||
|
install dir. Hardcoded because the platform contract is
|
||||||
|
"provisioner clones template repo into /opt/adapter"; this
|
||||||
|
is invariant across Docker (mounted /app→/opt/adapter) and
|
||||||
|
EC2-host (cloned by ec2.go) install paths. Robust against
|
||||||
|
site-packages copy.
|
||||||
|
2. Adjacent to ``adapter.__file__`` — works in dev/test where
|
||||||
|
the canonical path doesn't exist. Also covers the Docker
|
||||||
|
image's /app/config.yaml (bundled by Dockerfile #6).
|
||||||
|
3. Per-workspace ``${config_path}/config.yaml`` — fallback for
|
||||||
|
operator-shipped overrides on a private deployment that
|
||||||
|
wants a custom providers list.
|
||||||
|
4. ``_BUILTIN_PROVIDERS`` — oauth + anthropic-api defaults so a
|
||||||
|
bare-bones workspace still boots even with no config.yaml
|
||||||
|
anywhere.
|
||||||
|
|
||||||
|
Per-entry isolation: a single bad provider entry is dropped with
|
||||||
|
a warning; the rest of the registry survives.
|
||||||
"""
|
"""
|
||||||
yaml_path = os.path.join(config_path, "config.yaml")
|
canonical_yaml = os.path.join(_CANONICAL_ADAPTER_DIR, "config.yaml")
|
||||||
|
template_yaml = os.path.join(_TEMPLATE_DIR, "config.yaml")
|
||||||
|
workspace_yaml = os.path.join(config_path, "config.yaml")
|
||||||
|
# Deduplicate while preserving order — _CANONICAL_ADAPTER_DIR and
|
||||||
|
# the __file__ dir collide in dev/test (when imported from
|
||||||
|
# /opt/adapter directly), and workspace_yaml may also collide if
|
||||||
|
# config_path == /opt/adapter in tests.
|
||||||
|
seen = set()
|
||||||
|
candidates = []
|
||||||
|
for path in (canonical_yaml, template_yaml, workspace_yaml):
|
||||||
|
if path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
candidates.append(path)
|
||||||
|
|
||||||
|
raw = None
|
||||||
|
chosen_path = None
|
||||||
try:
|
try:
|
||||||
import yaml # transitive dep via molecule-ai-workspace-runtime
|
import yaml # transitive dep via molecule-ai-workspace-runtime
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("providers: yaml import failed; using builtins")
|
||||||
|
return _BUILTIN_PROVIDERS
|
||||||
|
|
||||||
|
for yaml_path in candidates:
|
||||||
|
try:
|
||||||
with open(yaml_path, "r") as f:
|
with open(yaml_path, "r") as f:
|
||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.info("providers: %s not found, using builtin defaults", yaml_path)
|
logger.info("providers: %s not found, trying next candidate", yaml_path)
|
||||||
return _BUILTIN_PROVIDERS
|
continue
|
||||||
except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML
|
except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML
|
||||||
logger.warning("providers: failed to load from %s (%s); using builtins", yaml_path, exc)
|
logger.warning(
|
||||||
return _BUILTIN_PROVIDERS
|
"providers: failed to load from %s (%s); trying next candidate",
|
||||||
|
yaml_path, exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
raw = data.get("providers") if isinstance(data, dict) else None
|
candidate_raw = data.get("providers") if isinstance(data, dict) else None
|
||||||
if not isinstance(raw, list) or not raw:
|
if isinstance(candidate_raw, list) and candidate_raw:
|
||||||
|
raw = candidate_raw
|
||||||
|
chosen_path = yaml_path
|
||||||
|
break
|
||||||
|
|
||||||
|
if raw is None:
|
||||||
|
logger.info(
|
||||||
|
"providers: no providers section found in %s; using builtin defaults",
|
||||||
|
" or ".join(candidates),
|
||||||
|
)
|
||||||
return _BUILTIN_PROVIDERS
|
return _BUILTIN_PROVIDERS
|
||||||
|
|
||||||
parsed = []
|
parsed = []
|
||||||
@ -190,11 +286,139 @@ def _load_providers(config_path: str) -> tuple:
|
|||||||
parsed.append(normalized)
|
parsed.append(normalized)
|
||||||
|
|
||||||
if not parsed:
|
if not parsed:
|
||||||
logger.warning("providers: no valid entries in %s; using builtins", yaml_path)
|
logger.warning("providers: no valid entries in %s; using builtins", chosen_path)
|
||||||
return _BUILTIN_PROVIDERS
|
return _BUILTIN_PROVIDERS
|
||||||
|
logger.info("providers: loaded %d entries from %s", len(parsed), chosen_path)
|
||||||
return tuple(parsed)
|
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,
|
||||||
|
providers: tuple,
|
||||||
|
) -> tuple:
|
||||||
|
"""Reconcile model + provider from env vars vs YAML, with the persona-env
|
||||||
|
convention winning over the legacy ``MODEL_PROVIDER``-as-model-id usage.
|
||||||
|
|
||||||
|
The persona env files (``~/.molecule-ai/personas/<name>/env`` on the host,
|
||||||
|
sourced into each workspace container at provision time) declare TWO env
|
||||||
|
vars with distinct semantics:
|
||||||
|
|
||||||
|
* ``MODEL`` — the model id (e.g. ``MiniMax-M2.7-highspeed``, ``opus``).
|
||||||
|
* ``MODEL_PROVIDER`` — the provider slug (e.g. ``minimax``,
|
||||||
|
``claude-code``, ``anthropic``).
|
||||||
|
|
||||||
|
The legacy ``workspace/config.py`` (in molecule-ai-workspace-runtime)
|
||||||
|
historically interpreted ``MODEL_PROVIDER`` as the *model id* — a name
|
||||||
|
chosen before there was a separate ``MODEL`` env var. When both env vars
|
||||||
|
are set with the persona convention, the legacy code reads
|
||||||
|
``MODEL_PROVIDER=minimax`` into ``runtime_config.model``, which then
|
||||||
|
fails to match any registry prefix (``minimax-`` requires a hyphen
|
||||||
|
suffix) and silently falls through to providers[0] (``anthropic-oauth``).
|
||||||
|
OAuth-token-less workspaces then wedge at ``query.initialize()`` because
|
||||||
|
the claude CLI can't authenticate. This is the 2026-05-08 dev-tree
|
||||||
|
incident — 22/27 non-lead workspaces stuck in ``degraded``.
|
||||||
|
|
||||||
|
Resolution order (this function):
|
||||||
|
1. ``MODEL`` env var → picked_model. Authoritative when set; the
|
||||||
|
persona env always sets it alongside ``MODEL_PROVIDER`` so the
|
||||||
|
model id never has to be inferred.
|
||||||
|
2. ``MODEL_PROVIDER`` env var → explicit_provider, BUT only when the
|
||||||
|
value matches a known provider name in the registry. This guards
|
||||||
|
against the legacy case where some callers still set
|
||||||
|
``MODEL_PROVIDER`` to a model id (e.g. canvas Save+Restart prior to
|
||||||
|
this fix). If the value isn't a registered provider name and YAML
|
||||||
|
didn't supply a model, treat it as a model id for back-compat.
|
||||||
|
3. YAML ``runtime_config.model`` / ``provider`` — used for any field
|
||||||
|
the env didn't supply. Carries the operator's canvas selection
|
||||||
|
on workspaces that haven't yet adopted the persona env shape.
|
||||||
|
|
||||||
|
Returns ``(picked_model, explicit_provider_name)``. Either may be
|
||||||
|
empty/None — the caller (``setup``) handles the empty cases via
|
||||||
|
``_resolve_provider``'s registry fallback.
|
||||||
|
"""
|
||||||
|
env_model = (os.environ.get("MODEL") or "").strip()
|
||||||
|
env_provider = (os.environ.get("MODEL_PROVIDER") or "").strip()
|
||||||
|
provider_names_lower = {p.get("name", "").lower() for p in providers}
|
||||||
|
|
||||||
|
# Detect whether MODEL_PROVIDER carries the persona-convention slug
|
||||||
|
# (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_resolved)
|
||||||
|
and env_provider_resolved.lower() in provider_names_lower
|
||||||
|
)
|
||||||
|
|
||||||
|
# Picked model resolution
|
||||||
|
if env_model:
|
||||||
|
picked_model = env_model
|
||||||
|
elif env_provider and not env_provider_is_slug:
|
||||||
|
# Legacy: MODEL_PROVIDER env carried the model id. Honor it so
|
||||||
|
# canvas Save+Restart workflows that predate this fix keep working.
|
||||||
|
picked_model = env_provider
|
||||||
|
else:
|
||||||
|
picked_model = yaml_model or ""
|
||||||
|
|
||||||
|
# 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_resolved
|
||||||
|
elif yaml_provider:
|
||||||
|
yp_lower = yaml_provider.lower()
|
||||||
|
explicit_provider = _PROVIDER_SLUG_ALIASES.get(yp_lower, yaml_provider)
|
||||||
|
else:
|
||||||
|
explicit_provider = None
|
||||||
|
|
||||||
|
return picked_model, explicit_provider
|
||||||
|
|
||||||
|
|
||||||
def _strip_provider_prefix(model: str) -> str:
|
def _strip_provider_prefix(model: str) -> str:
|
||||||
"""Strip LangChain-style "<provider>:<model>" prefix from a model id.
|
"""Strip LangChain-style "<provider>:<model>" prefix from a model id.
|
||||||
|
|
||||||
@ -236,12 +460,18 @@ _VENDOR_KEY_NAMES = frozenset({
|
|||||||
|
|
||||||
|
|
||||||
def _project_vendor_auth(provider: dict) -> None:
|
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
|
Pre-#244 the canvas surfaced the vendor-specific name
|
||||||
(``MINIMAX_API_KEY``, etc.) to the user — so a user who saved only
|
(``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
|
that name hit a silent 401 on first call while the boot audit said
|
||||||
@ -249,21 +479,24 @@ def _project_vendor_auth(provider: dict) -> None:
|
|||||||
/ hermes PR #38.
|
/ hermes PR #38.
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
* Let ``target`` = the provider's ``auth_token_env`` (default
|
||||||
|
``ANTHROPIC_AUTH_TOKEN``).
|
||||||
* If the matched provider's ``auth_env`` lists any of
|
* If the matched provider's ``auth_env`` lists any of
|
||||||
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
|
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
|
||||||
``ANTHROPIC_AUTH_TOKEN`` so the SDK finds it.
|
``target`` so the SDK finds it.
|
||||||
* **Idempotent**: if ``ANTHROPIC_AUTH_TOKEN`` is already set we
|
* **Idempotent**: if ``target`` is already set we do NOT
|
||||||
do NOT overwrite — an explicit operator value (workspace
|
overwrite — an explicit operator value (workspace secret)
|
||||||
secret) always wins over auto-projection.
|
always wins over auto-projection.
|
||||||
* Logs the projection by NAME (e.g. ``MINIMAX_API_KEY ->
|
* Logs the projection by NAME (e.g. ``KIMI_API_KEY ->
|
||||||
ANTHROPIC_AUTH_TOKEN``); never logs the secret VALUE. Same
|
ANTHROPIC_API_KEY``); never logs the secret VALUE. Same
|
||||||
contract as ``_audit_auth_env_presence``.
|
contract as ``_audit_auth_env_presence``.
|
||||||
* No-op for providers whose ``auth_env`` doesn't reference a
|
* No-op for providers whose ``auth_env`` doesn't reference a
|
||||||
vendor-specific name (oauth, anthropic-api, or a third-party
|
vendor-specific name (oauth, anthropic-api, or a third-party
|
||||||
entry that hasn't been added to the registry yet).
|
entry that hasn't been added to the registry yet).
|
||||||
"""
|
"""
|
||||||
auth_env = provider.get("auth_env") or ()
|
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.
|
# Operator override wins — never clobber an explicit value.
|
||||||
return
|
return
|
||||||
for name in auth_env:
|
for name in auth_env:
|
||||||
@ -272,21 +505,36 @@ def _project_vendor_auth(provider: dict) -> None:
|
|||||||
value = os.environ.get(name)
|
value = os.environ.get(name)
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
os.environ["ANTHROPIC_AUTH_TOKEN"] = value
|
os.environ[target] = value
|
||||||
logger.info(
|
logger.info(
|
||||||
"auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)",
|
"auth env projection: %s -> %s (provider=%s)",
|
||||||
name, provider.get("name", "<unknown>"),
|
name, target, provider.get("name", "<unknown>"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
def _resolve_provider(model: str, providers: tuple) -> dict:
|
def _resolve_provider(
|
||||||
|
model: str,
|
||||||
|
providers: tuple,
|
||||||
|
explicit_provider: str = None,
|
||||||
|
) -> dict:
|
||||||
"""Return the provider entry matching this model id.
|
"""Return the provider entry matching this model id.
|
||||||
|
|
||||||
Match is case-insensitive: prefix wins over alias when both could
|
If ``explicit_provider`` is given (set via the ``provider:`` field in
|
||||||
apply. Unknown ids fall back to the first provider in the registry
|
workspace config.yaml or runtime_config), look up by name first. If the
|
||||||
(by convention, the OAuth/safest default — anthropic-oauth in both
|
named provider is not in the registry, RAISE ``ValueError`` with an
|
||||||
_BUILTIN_PROVIDERS and the shipped config.yaml).
|
actionable message — silent fallback to ``providers[0]`` is the bug
|
||||||
|
that motivated #180 (workspace operator picks ``provider: minimax``
|
||||||
|
in the canvas Config tab, the adapter ignores it, the Claude SDK
|
||||||
|
silently keeps using ``CLAUDE_CODE_OAUTH_TOKEN`` and the operator has
|
||||||
|
no way to tell from the canvas that their provider switch did
|
||||||
|
nothing).
|
||||||
|
|
||||||
|
Without an explicit name: match is case-insensitive, prefix wins over
|
||||||
|
alias when both could apply, and unknown ids fall back to the first
|
||||||
|
provider in the registry (by convention, the OAuth/safest default —
|
||||||
|
``anthropic-oauth`` in both _BUILTIN_PROVIDERS and the shipped
|
||||||
|
config.yaml).
|
||||||
|
|
||||||
Pre-condition: ``providers`` is non-empty. _load_providers always
|
Pre-condition: ``providers`` is non-empty. _load_providers always
|
||||||
returns at least one entry (built-ins when YAML is missing or every
|
returns at least one entry (built-ins when YAML is missing or every
|
||||||
@ -298,6 +546,44 @@ def _resolve_provider(model: str, providers: tuple) -> dict:
|
|||||||
"_load_providers must always return at least one entry "
|
"_load_providers must always return at least one entry "
|
||||||
"(falling back to _BUILTIN_PROVIDERS when needed)"
|
"(falling back to _BUILTIN_PROVIDERS when needed)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Explicit provider name takes precedence — fail fast if it's not in
|
||||||
|
# the registry. Anything else would silently route the operator's
|
||||||
|
# picked provider through the wrong auth/base_url path. The error
|
||||||
|
# message tells them exactly which two paths fix it.
|
||||||
|
if explicit_provider:
|
||||||
|
ep_lower = explicit_provider.lower()
|
||||||
|
for provider in providers:
|
||||||
|
if provider["name"].lower() == ep_lower:
|
||||||
|
return provider
|
||||||
|
names = ", ".join(p["name"] for p in providers)
|
||||||
|
raise ValueError(
|
||||||
|
f"claude-code adapter: workspace config picks "
|
||||||
|
f"provider='{explicit_provider}' but it is not in the "
|
||||||
|
f"providers registry.\n"
|
||||||
|
f"\n"
|
||||||
|
f"Known providers: {names}\n"
|
||||||
|
f"\n"
|
||||||
|
f"Two ways to fix:\n"
|
||||||
|
f" (a) Add '{explicit_provider}' to /configs/config.yaml as a "
|
||||||
|
f"providers: entry. Required keys:\n"
|
||||||
|
f" providers:\n"
|
||||||
|
f" - name: {explicit_provider}\n"
|
||||||
|
f" auth_mode: third_party_anthropic_compat\n"
|
||||||
|
f" base_url: https://... # provider's Anthropic-compat endpoint\n"
|
||||||
|
f" auth_env: [{explicit_provider.upper()}_API_KEY]\n"
|
||||||
|
f" model_prefixes: [...]\n"
|
||||||
|
f" (b) Switch the workspace runtime template to one that "
|
||||||
|
f"natively supports {explicit_provider} (CrewAI, LangGraph, or "
|
||||||
|
f"DeepAgents read provider/model from runtime_config and route "
|
||||||
|
f"directly without needing an Anthropic-compat shim).\n"
|
||||||
|
f"\n"
|
||||||
|
f"Note: claude-code SDK speaks the Anthropic API protocol. "
|
||||||
|
f"Providers that only expose OpenAI-compatible endpoints "
|
||||||
|
f"(MiniMax, GLM, Kimi, DeepSeek native APIs) need either an "
|
||||||
|
f"Anthropic-compat proxy in front, or option (b)."
|
||||||
|
)
|
||||||
|
|
||||||
if not model:
|
if not model:
|
||||||
return providers[0]
|
return providers[0]
|
||||||
m = model.lower()
|
m = model.lower()
|
||||||
@ -400,9 +686,52 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
# validation + ANTHROPIC_BASE_URL routing from that single decision.
|
# validation + ANTHROPIC_BASE_URL routing from that single decision.
|
||||||
rc = config.runtime_config
|
rc = config.runtime_config
|
||||||
if isinstance(rc, dict):
|
if isinstance(rc, dict):
|
||||||
picked_model = rc.get("model") or "sonnet"
|
yaml_model = rc.get("model") or ""
|
||||||
|
yaml_provider_name = rc.get("provider") or ""
|
||||||
else:
|
else:
|
||||||
picked_model = getattr(rc, "model", None) or "sonnet"
|
yaml_model = getattr(rc, "model", None) or ""
|
||||||
|
yaml_provider_name = getattr(rc, "provider", None) or ""
|
||||||
|
|
||||||
|
# Also honor the top-level `provider:` field in /configs/config.yaml.
|
||||||
|
# The canvas Config-tab Provider dropdown writes there (not into
|
||||||
|
# runtime_config) on some legacy paths. Either source is canonical;
|
||||||
|
# whichever is set wins. Root cause of #180: the adapter used to
|
||||||
|
# ignore both, silently routing every non-Anthropic provider pick
|
||||||
|
# through anthropic-oauth.
|
||||||
|
if not yaml_provider_name:
|
||||||
|
yaml_path = os.path.join(config.config_path, "config.yaml")
|
||||||
|
try:
|
||||||
|
import yaml # transitive dep via molecule-ai-workspace-runtime
|
||||||
|
with open(yaml_path, "r") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
val = data.get("provider")
|
||||||
|
if isinstance(val, str) and val.strip():
|
||||||
|
yaml_provider_name = val.strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # noqa: BLE001 — defensive: never block boot
|
||||||
|
logger.warning(
|
||||||
|
"providers: failed to read top-level provider: from %s (%s); "
|
||||||
|
"falling back to model-based resolution",
|
||||||
|
yaml_path, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reconcile env vars (persona convention: MODEL=<id>,
|
||||||
|
# MODEL_PROVIDER=<slug>) against YAML. Env wins over YAML — the
|
||||||
|
# persona env files are the canonical per-agent provider mapping
|
||||||
|
# (Phase 2 mapping 2026-05-08), and the workspace-runtime wheel's
|
||||||
|
# legacy ``MODEL_PROVIDER``-as-model-id reading would otherwise
|
||||||
|
# silently route non-leads to providers[0] = anthropic-oauth.
|
||||||
|
# Documented in detail at _resolve_model_and_provider_from_env.
|
||||||
|
picked_model, explicit_provider_name = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model=yaml_model,
|
||||||
|
yaml_provider=yaml_provider_name,
|
||||||
|
providers=providers,
|
||||||
|
)
|
||||||
|
if not picked_model:
|
||||||
|
picked_model = "sonnet"
|
||||||
|
|
||||||
# NOTE: do NOT strip the provider prefix here. The pre-fix routing
|
# NOTE: do NOT strip the provider prefix here. The pre-fix routing
|
||||||
# behavior — `anthropic:claude-opus-4-7` falls through to
|
# behavior — `anthropic:claude-opus-4-7` falls through to
|
||||||
# providers[0] (anthropic-oauth) when no model_prefixes match — is
|
# providers[0] (anthropic-oauth) when no model_prefixes match — is
|
||||||
@ -411,7 +740,15 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
# `anthropic-api` provider and the CLI then hangs at `initialize`
|
# `anthropic-api` provider and the CLI then hangs at `initialize`
|
||||||
# because ANTHROPIC_API_KEY isn't set. The strip belongs only at
|
# because ANTHROPIC_API_KEY isn't set. The strip belongs only at
|
||||||
# the CLI invocation site (create_executor below).
|
# the CLI invocation site (create_executor below).
|
||||||
provider = _resolve_provider(picked_model, providers)
|
#
|
||||||
|
# Pass the explicit provider name through so _resolve_provider
|
||||||
|
# raises ValueError with an actionable message (instead of silently
|
||||||
|
# routing to providers[0]) when an operator picks a provider that
|
||||||
|
# isn't in the registry. See #180.
|
||||||
|
provider = _resolve_provider(
|
||||||
|
picked_model, providers,
|
||||||
|
explicit_provider=explicit_provider_name,
|
||||||
|
)
|
||||||
auth_env_options = provider["auth_env"]
|
auth_env_options = provider["auth_env"]
|
||||||
|
|
||||||
# Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY,
|
# Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY,
|
||||||
@ -522,9 +859,26 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
# RuntimeConfig dataclass. Read `model` defensively from either shape.
|
# RuntimeConfig dataclass. Read `model` defensively from either shape.
|
||||||
rc = config.runtime_config
|
rc = config.runtime_config
|
||||||
if isinstance(rc, dict):
|
if isinstance(rc, dict):
|
||||||
explicit_model = rc.get("model") or ""
|
yaml_model = rc.get("model") or ""
|
||||||
|
yaml_provider = rc.get("provider") or ""
|
||||||
else:
|
else:
|
||||||
explicit_model = getattr(rc, "model", None) or ""
|
yaml_model = getattr(rc, "model", None) or ""
|
||||||
|
yaml_provider = getattr(rc, "provider", None) or ""
|
||||||
|
|
||||||
|
# Reconcile against env vars (persona convention: MODEL=<id>,
|
||||||
|
# MODEL_PROVIDER=<slug>) using the same helper that ``setup`` uses,
|
||||||
|
# so the executor and the boot banner agree on the picked model.
|
||||||
|
# Without this, a workspace whose env says ``MODEL=MiniMax-M2.7``
|
||||||
|
# but whose runtime wheel pre-dates the persona-env fix would set
|
||||||
|
# runtime_config.model="minimax" (the slug, mistakenly read by the
|
||||||
|
# legacy ``MODEL_PROVIDER``-as-model-id path); this helper restores
|
||||||
|
# the correct model id before it reaches the SDK.
|
||||||
|
providers = _load_providers(config.config_path)
|
||||||
|
explicit_model, _ = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model=yaml_model,
|
||||||
|
yaml_provider=yaml_provider,
|
||||||
|
providers=providers,
|
||||||
|
)
|
||||||
explicit_model = _strip_provider_prefix(explicit_model)
|
explicit_model = _strip_provider_prefix(explicit_model)
|
||||||
|
|
||||||
# Pre-validation: detect the misconfiguration combo that drove the
|
# Pre-validation: detect the misconfiguration combo that drove the
|
||||||
@ -555,7 +909,7 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
"The default fallback ('sonnet') is an Anthropic-native "
|
"The default fallback ('sonnet') is an Anthropic-native "
|
||||||
"alias; non-Anthropic shims (MiniMax, OpenAI gateways, "
|
"alias; non-Anthropic shims (MiniMax, OpenAI gateways, "
|
||||||
"etc.) won't recognize it and the SDK --print probe will "
|
"etc.) won't recognize it and the SDK --print probe will "
|
||||||
"hang for 30s before timing out. Fix: set MODEL_PROVIDER "
|
"hang for 30s before timing out. Fix: set MODEL "
|
||||||
"as a workspace secret (canvas: Save+Restart with model "
|
"as a workspace secret (canvas: Save+Restart with model "
|
||||||
"picked) or set runtime_config.model in /configs/config.yaml."
|
"picked) or set runtime_config.model in /configs/config.yaml."
|
||||||
)
|
)
|
||||||
|
|||||||
52
config.yaml
52
config.yaml
@ -31,6 +31,16 @@ tier: 2
|
|||||||
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
|
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
|
||||||
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
|
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
|
||||||
# auth_env : env vars accepted; any one being set satisfies auth
|
# 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:
|
providers:
|
||||||
- name: anthropic-oauth
|
- name: anthropic-oauth
|
||||||
auth_mode: oauth
|
auth_mode: oauth
|
||||||
@ -73,13 +83,27 @@ providers:
|
|||||||
base_url: https://api.z.ai/api/anthropic
|
base_url: https://api.z.ai/api/anthropic
|
||||||
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||||
|
|
||||||
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
|
# Kimi For Coding — Moonshot's coding-agent tier (K2.6 / "Kimi for
|
||||||
- name: moonshot
|
# 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
|
auth_mode: third_party_anthropic_compat
|
||||||
model_prefixes: [kimi-]
|
model_prefixes: [kimi-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.moonshot.ai/anthropic
|
base_url: https://api.kimi.com/coding/
|
||||||
auth_env: [KIMI_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
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
|
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
|
||||||
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
|
# 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)
|
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
|
||||||
required_env: [GLM_API_KEY]
|
required_env: [GLM_API_KEY]
|
||||||
|
|
||||||
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
|
# --- Kimi For Coding (third-party, Anthropic-API-compatible) ---
|
||||||
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
|
# Routed via the `kimi-coding` provider entry above: the adapter
|
||||||
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
|
# auto-sets ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ and
|
||||||
# K2 stays as a cheaper option.
|
# 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
|
- 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]
|
required_env: [KIMI_API_KEY]
|
||||||
- id: kimi-k2
|
- 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]
|
required_env: [KIMI_API_KEY]
|
||||||
|
|
||||||
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
|
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
|
||||||
|
|||||||
@ -42,6 +42,15 @@ log_boot_context
|
|||||||
if [ "$(id -u)" = "0" ]; then
|
if [ "$(id -u)" = "0" ]; then
|
||||||
# Configs volume is created by Docker as root; agent needs write access
|
# Configs volume is created by Docker as root; agent needs write access
|
||||||
# for plugin installs, memory writes, .auth_token rotation, etc.
|
# 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
|
chown -R agent:agent /configs 2>/dev/null
|
||||||
# /workspace handling — only chown when the contents are root-owned
|
# /workspace handling — only chown when the contents are root-owned
|
||||||
# (typical on Docker Desktop on Windows where host uid maps to 0).
|
# (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
|
# finds it when running as agent. The provisioner's mount point is
|
||||||
# hardcoded to /root/.claude/sessions; we don't want to change the
|
# hardcoded to /root/.claude/sessions; we don't want to change the
|
||||||
# platform contract just for this template.
|
# 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
|
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
|
ln -sfn /root/.claude/sessions /home/agent/.claude/sessions
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ common problems.
|
|||||||
## Step 1 — Clone the Repository
|
## Step 1 — Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Molecule-AI/molecule-ai-workspace-template-claude-code.git
|
git clone https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-claude-code.git
|
||||||
cd molecule-ai-workspace-template-claude-code
|
cd molecule-ai-workspace-template-claude-code
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
89
tests/conftest.py
Normal file
89
tests/conftest.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""Shared pytest fixtures + import shims for the adapter test suite.
|
||||||
|
|
||||||
|
`adapter.py` imports at module load:
|
||||||
|
- molecule_runtime.adapters.base (BaseAdapter, AdapterConfig, RuntimeCapabilities)
|
||||||
|
- molecule_runtime.plugins (lazy in setup(), but stubbed proactively)
|
||||||
|
- a2a.server.agent_execution (AgentExecutor)
|
||||||
|
- claude_sdk_executor (lazy in create_executor(), stubbed proactively)
|
||||||
|
|
||||||
|
In production those arrive transitively via molecule-ai-workspace-runtime.
|
||||||
|
The CI runner only installs `pytest pytest-asyncio pyyaml`, so the import
|
||||||
|
chain would fail with ModuleNotFoundError before any test collects —
|
||||||
|
exactly the failure that broke CI on the #180 fix branch (PR #4) and
|
||||||
|
caused the merge wall to block on a green local but red Gitea CI.
|
||||||
|
|
||||||
|
Putting the stub installer here (collected before any test module is
|
||||||
|
imported, per pytest semantics) means every test file can do
|
||||||
|
`from adapter import ...` at module top without a per-file boilerplate
|
||||||
|
copy. It also forces a single shape for the stubs so two files can't
|
||||||
|
silently disagree on whether `BaseAdapter` has
|
||||||
|
`install_plugins_via_registry` (see test_adapter_prevalidate's
|
||||||
|
async-setup tests, which need the method to exist on the parent class).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StubRuntimeCapabilities:
|
||||||
|
provides_native_session: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StubAdapterConfig:
|
||||||
|
runtime_config: object = None
|
||||||
|
config_path: str = "/tmp/configs"
|
||||||
|
system_prompt: str = ""
|
||||||
|
heartbeat: object = None
|
||||||
|
|
||||||
|
|
||||||
|
class _StubBaseAdapter:
|
||||||
|
async def install_plugins_via_registry(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _install_stubs() -> None:
|
||||||
|
"""Install the smallest set of import shims that adapter.py needs."""
|
||||||
|
if "molecule_runtime" not in sys.modules:
|
||||||
|
mr = types.ModuleType("molecule_runtime")
|
||||||
|
mr.adapters = types.ModuleType("molecule_runtime.adapters")
|
||||||
|
mr.adapters.base = types.ModuleType("molecule_runtime.adapters.base")
|
||||||
|
mr.adapters.base.BaseAdapter = _StubBaseAdapter
|
||||||
|
mr.adapters.base.AdapterConfig = _StubAdapterConfig
|
||||||
|
mr.adapters.base.RuntimeCapabilities = _StubRuntimeCapabilities
|
||||||
|
mr.plugins = types.ModuleType("molecule_runtime.plugins")
|
||||||
|
mr.plugins.load_plugins = lambda **_kwargs: []
|
||||||
|
sys.modules["molecule_runtime"] = mr
|
||||||
|
sys.modules["molecule_runtime.adapters"] = mr.adapters
|
||||||
|
sys.modules["molecule_runtime.adapters.base"] = mr.adapters.base
|
||||||
|
sys.modules["molecule_runtime.plugins"] = mr.plugins
|
||||||
|
if "a2a" not in sys.modules:
|
||||||
|
a2a = types.ModuleType("a2a")
|
||||||
|
a2a.server = types.ModuleType("a2a.server")
|
||||||
|
a2a.server.agent_execution = types.ModuleType("a2a.server.agent_execution")
|
||||||
|
a2a.server.agent_execution.AgentExecutor = type("AgentExecutor", (), {})
|
||||||
|
sys.modules["a2a"] = a2a
|
||||||
|
sys.modules["a2a.server"] = a2a.server
|
||||||
|
sys.modules["a2a.server.agent_execution"] = a2a.server.agent_execution
|
||||||
|
if "claude_sdk_executor" not in sys.modules:
|
||||||
|
mod = types.ModuleType("claude_sdk_executor")
|
||||||
|
mod.ClaudeSDKExecutor = MagicMock(name="ClaudeSDKExecutor")
|
||||||
|
sys.modules["claude_sdk_executor"] = mod
|
||||||
|
|
||||||
|
|
||||||
|
# Run at conftest import time — pytest collects conftest.py before any
|
||||||
|
# test module, so the stubs are in sys.modules before `from adapter
|
||||||
|
# import ...` ever executes.
|
||||||
|
_install_stubs()
|
||||||
|
|
||||||
|
# adapter.py lives in the parent dir of tests/ (template root). pytest's
|
||||||
|
# `--import-mode=importlib` + tests/pytest.ini anchoring rootdir at
|
||||||
|
# tests/ means the parent isn't on sys.path automatically. Add it here
|
||||||
|
# once so every test file can do `from adapter import ...` cleanly.
|
||||||
|
_PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _PARENT_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, _PARENT_DIR)
|
||||||
@ -129,12 +129,13 @@ _FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
|
|||||||
base_url: https://api.z.ai/api/anthropic
|
base_url: https://api.z.ai/api/anthropic
|
||||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||||
|
|
||||||
- name: moonshot
|
- name: kimi-coding
|
||||||
auth_mode: third_party_anthropic_compat
|
auth_mode: third_party_anthropic_compat
|
||||||
model_prefixes: [kimi-]
|
model_prefixes: [kimi-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.moonshot.ai/anthropic
|
base_url: https://api.kimi.com/coding/
|
||||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
|
||||||
|
auth_token_env: ANTHROPIC_API_KEY
|
||||||
|
|
||||||
- name: deepseek
|
- name: deepseek
|
||||||
auth_mode: third_party_anthropic_compat
|
auth_mode: third_party_anthropic_compat
|
||||||
@ -514,8 +515,15 @@ async def test_setup_auth_token_alone_satisfies_third_party_check(
|
|||||||
# ---- _load_providers / _resolve_provider unit tests ----
|
# ---- _load_providers / _resolve_provider unit tests ----
|
||||||
|
|
||||||
|
|
||||||
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path):
|
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path, monkeypatch):
|
||||||
"""FileNotFoundError path returns the in-code defaults verbatim."""
|
"""FileNotFoundError path returns the in-code defaults verbatim.
|
||||||
|
|
||||||
|
Monkeypatches the canonical + template paths to a non-existent dir
|
||||||
|
so only the workspace config_path is in scope. Without this, the
|
||||||
|
multi-path lookup picks up the repo-root config.yaml that ships
|
||||||
|
with the template (path 2 finds the bundled providers list and
|
||||||
|
returns it instead of falling through to builtins).
|
||||||
|
"""
|
||||||
_install_stubs()
|
_install_stubs()
|
||||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
if parent_dir not in sys.path:
|
if parent_dir not in sys.path:
|
||||||
@ -523,6 +531,10 @@ def test_load_providers_returns_builtin_when_yaml_missing(tmp_path):
|
|||||||
sys.modules.pop("adapter", None)
|
sys.modules.pop("adapter", None)
|
||||||
import adapter as adapter_module
|
import adapter as adapter_module
|
||||||
|
|
||||||
|
nonexistent = str(tmp_path / "_isolate_canonical")
|
||||||
|
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
|
||||||
|
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
|
||||||
|
|
||||||
result = adapter_module._load_providers(str(tmp_path))
|
result = adapter_module._load_providers(str(tmp_path))
|
||||||
assert result == adapter_module._BUILTIN_PROVIDERS
|
assert result == adapter_module._BUILTIN_PROVIDERS
|
||||||
|
|
||||||
@ -543,7 +555,7 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
|||||||
names = [p["name"] for p in result]
|
names = [p["name"] for p in result]
|
||||||
assert names == [
|
assert names == [
|
||||||
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
|
"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.
|
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
|
||||||
assert isinstance(result[0]["model_aliases"], tuple)
|
assert isinstance(result[0]["model_aliases"], tuple)
|
||||||
@ -553,15 +565,16 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
|
|||||||
@pytest.mark.parametrize("model,expected_provider,expected_url", [
|
@pytest.mark.parametrize("model,expected_provider,expected_url", [
|
||||||
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
|
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
|
||||||
("glm-4.5", "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"),
|
("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"),
|
||||||
])
|
])
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_setup_routes_extra_providers(
|
async def test_setup_routes_extra_providers(
|
||||||
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
|
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
|
||||||
):
|
):
|
||||||
"""The Z.ai / Moonshot / DeepSeek providers added in this PR must
|
"""The Z.ai / Kimi-For-Coding / DeepSeek providers must route
|
||||||
route correctly: model id → provider entry → ANTHROPIC_BASE_URL.
|
correctly: model id → provider entry → ANTHROPIC_BASE_URL.
|
||||||
Parametrized to keep the matrix coverage tight without 3 near-identical
|
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
|
test bodies. Locks in the per-vendor base_url so a future YAML edit
|
||||||
that mistypes z.ai's `/api/anthropic` suffix gets caught.
|
that mistypes z.ai's `/api/anthropic` suffix gets caught.
|
||||||
@ -576,8 +589,12 @@ async def test_setup_routes_extra_providers(
|
|||||||
assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url
|
assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url
|
||||||
|
|
||||||
|
|
||||||
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog):
|
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog, monkeypatch):
|
||||||
"""Malformed YAML → log warning + fallback (don't kill boot)."""
|
"""Malformed YAML → log warning + fallback (don't kill boot).
|
||||||
|
|
||||||
|
Isolated from the multi-path lookup by pinning canonical + template
|
||||||
|
dirs at a non-existent path; only the workspace config_path is read.
|
||||||
|
"""
|
||||||
_install_stubs()
|
_install_stubs()
|
||||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
if parent_dir not in sys.path:
|
if parent_dir not in sys.path:
|
||||||
@ -585,6 +602,10 @@ def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog):
|
|||||||
sys.modules.pop("adapter", None)
|
sys.modules.pop("adapter", None)
|
||||||
import adapter as adapter_module
|
import adapter as adapter_module
|
||||||
|
|
||||||
|
nonexistent = str(tmp_path / "_isolate_canonical")
|
||||||
|
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
|
||||||
|
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
|
||||||
|
|
||||||
(tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{")
|
(tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{")
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -622,7 +643,7 @@ def test_resolve_provider_minimax_prefix_matches_minimax_provider():
|
|||||||
assert result2["name"] == "minimax"
|
assert result2["name"] == "minimax"
|
||||||
|
|
||||||
|
|
||||||
def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
|
def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog, monkeypatch):
|
||||||
"""Per-entry isolation: one malformed entry shouldn't nuke the registry.
|
"""Per-entry isolation: one malformed entry shouldn't nuke the registry.
|
||||||
|
|
||||||
Pre-fix: ``_load_providers`` built the registry via a generator inside
|
Pre-fix: ``_load_providers`` built the registry via a generator inside
|
||||||
@ -634,6 +655,9 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
|
|||||||
|
|
||||||
Post-fix: per-entry try/except drops the bad entry with a warning,
|
Post-fix: per-entry try/except drops the bad entry with a warning,
|
||||||
rest of the registry survives.
|
rest of the registry survives.
|
||||||
|
|
||||||
|
Isolated from the multi-path lookup so only the test's tmp config.yaml
|
||||||
|
is read.
|
||||||
"""
|
"""
|
||||||
_install_stubs()
|
_install_stubs()
|
||||||
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@ -642,6 +666,10 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
|
|||||||
sys.modules.pop("adapter", None)
|
sys.modules.pop("adapter", None)
|
||||||
import adapter as adapter_module
|
import adapter as adapter_module
|
||||||
|
|
||||||
|
nonexistent = str(tmp_path / "_isolate_canonical")
|
||||||
|
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
|
||||||
|
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
|
||||||
|
|
||||||
yaml_with_typo = textwrap.dedent("""
|
yaml_with_typo = textwrap.dedent("""
|
||||||
providers:
|
providers:
|
||||||
- name: good-zai
|
- name: good-zai
|
||||||
@ -690,7 +718,7 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, caplog):
|
def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, caplog, monkeypatch):
|
||||||
"""A YAML field declared as list-of-strings but written as a bare
|
"""A YAML field declared as list-of-strings but written as a bare
|
||||||
string (operator forgot brackets) used to silently iterate over
|
string (operator forgot brackets) used to silently iterate over
|
||||||
characters → ``('m','i','m','o','-')``. Post-fix: non-list value
|
characters → ``('m','i','m','o','-')``. Post-fix: non-list value
|
||||||
@ -705,6 +733,10 @@ def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, cap
|
|||||||
sys.modules.pop("adapter", None)
|
sys.modules.pop("adapter", None)
|
||||||
import adapter as adapter_module
|
import adapter as adapter_module
|
||||||
|
|
||||||
|
nonexistent = str(tmp_path / "_isolate_canonical")
|
||||||
|
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
|
||||||
|
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
|
||||||
|
|
||||||
yaml_str_prefix = textwrap.dedent("""
|
yaml_str_prefix = textwrap.dedent("""
|
||||||
providers:
|
providers:
|
||||||
- name: typo-prefix
|
- name: typo-prefix
|
||||||
@ -723,7 +755,7 @@ def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, cap
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_load_providers_drops_entry_without_name(tmp_path, caplog):
|
def test_load_providers_drops_entry_without_name(tmp_path, caplog, monkeypatch):
|
||||||
"""An entry without ``name`` is operator error — no silent fallback
|
"""An entry without ``name`` is operator error — no silent fallback
|
||||||
to ``<unnamed>``. Drop the entry with a warning so the boot log
|
to ``<unnamed>``. Drop the entry with a warning so the boot log
|
||||||
surfaces the typo.
|
surfaces the typo.
|
||||||
@ -735,6 +767,10 @@ def test_load_providers_drops_entry_without_name(tmp_path, caplog):
|
|||||||
sys.modules.pop("adapter", None)
|
sys.modules.pop("adapter", None)
|
||||||
import adapter as adapter_module
|
import adapter as adapter_module
|
||||||
|
|
||||||
|
nonexistent = str(tmp_path / "_isolate_canonical")
|
||||||
|
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
|
||||||
|
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
|
||||||
|
|
||||||
yaml_no_name = textwrap.dedent("""
|
yaml_no_name = textwrap.dedent("""
|
||||||
providers:
|
providers:
|
||||||
- name: good
|
- name: good
|
||||||
|
|||||||
287
tests/test_env_model_provider_dispatch.py
Normal file
287
tests/test_env_model_provider_dispatch.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
"""Tests for ``_resolve_model_and_provider_from_env`` — the env-vs-YAML
|
||||||
|
reconciliation that fixes the 2026-05-08 dev-tree wedge incident.
|
||||||
|
|
||||||
|
Symptom: 22/27 non-lead workspaces (minimax tier) wedged on
|
||||||
|
``Control request timeout: initialize`` because the runtime wheel's
|
||||||
|
``workspace/config.py`` interpreted ``MODEL_PROVIDER=minimax`` as the
|
||||||
|
*model id* instead of the provider slug. ``model="minimax"`` failed to
|
||||||
|
match the ``minimax-`` registry prefix, fell through to providers[0]
|
||||||
|
(anthropic-oauth), demanded ``CLAUDE_CODE_OAUTH_TOKEN`` (unset on
|
||||||
|
non-leads), and the claude CLI hung at SDK init.
|
||||||
|
|
||||||
|
The persona env files (``~/.molecule-ai/personas/<name>/env``) declare
|
||||||
|
the new convention:
|
||||||
|
* ``MODEL`` — model id (e.g. ``MiniMax-M2.7-highspeed``)
|
||||||
|
* ``MODEL_PROVIDER`` — provider slug (e.g. ``minimax``)
|
||||||
|
|
||||||
|
These tests cover the matrix of (env shape) × (YAML shape) so a future
|
||||||
|
contributor can't silently regress the wedge fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from adapter import (
|
||||||
|
_BUILTIN_PROVIDERS,
|
||||||
|
_resolve_model_and_provider_from_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# A registry that contains both anthropic-oauth (providers[0]) and
|
||||||
|
# minimax/zai (third-party slugs) — matches the shipped config.yaml.
|
||||||
|
_REGISTRY = _BUILTIN_PROVIDERS + (
|
||||||
|
{
|
||||||
|
"name": "minimax",
|
||||||
|
"auth_mode": "third_party_anthropic_compat",
|
||||||
|
"model_prefixes": ("minimax-",),
|
||||||
|
"model_aliases": (),
|
||||||
|
"base_url": "https://api.minimax.io/anthropic",
|
||||||
|
"auth_env": ("MINIMAX_API_KEY",),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "zai",
|
||||||
|
"auth_mode": "third_party_anthropic_compat",
|
||||||
|
"model_prefixes": ("glm-",),
|
||||||
|
"model_aliases": (),
|
||||||
|
"base_url": "https://api.z.ai/api/anthropic",
|
||||||
|
"auth_env": ("GLM_API_KEY",),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("MODEL", raising=False)
|
||||||
|
monkeypatch.delenv("MODEL_PROVIDER", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Persona env convention: MODEL=<id>, MODEL_PROVIDER=<slug>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_persona_env_minimax_resolves_correctly(monkeypatch):
|
||||||
|
"""The 2026-05-08 wedge regression test: persona env shape must
|
||||||
|
yield model=MiniMax-M2.7-highspeed (not "minimax") and explicit
|
||||||
|
provider=minimax."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "minimax")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "MiniMax-M2.7-highspeed"
|
||||||
|
assert provider == "minimax"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persona_env_lead_claude_code_resolves_correctly(monkeypatch):
|
||||||
|
"""Lead persona env (MODEL=opus, MODEL_PROVIDER=claude-code) —
|
||||||
|
``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")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "opus"
|
||||||
|
# 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):
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "zai")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "GLM-4.6"
|
||||||
|
assert provider == "zai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_provider_slug_case_insensitive(monkeypatch):
|
||||||
|
"""Operator typos like ``MiniMax`` (mixed case) still resolve."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax") # mixed case
|
||||||
|
_, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert provider == "MiniMax" # caller compares case-insensitively
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Legacy convention: MODEL_PROVIDER=<model-id>, MODEL unset
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_legacy_model_provider_as_model_id_still_works(monkeypatch):
|
||||||
|
"""Pre-2026-05-08 canvas Save+Restart shape: MODEL_PROVIDER carried
|
||||||
|
the model id directly (e.g. ``MODEL_PROVIDER=MiniMax-M2.7``) and
|
||||||
|
no MODEL env. Must keep working so existing canvas users don't
|
||||||
|
break overnight."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
# MiniMax-M2.7-highspeed is not a registered provider name, so
|
||||||
|
# it's treated as a legacy model-id-in-MODEL_PROVIDER value.
|
||||||
|
assert model == "MiniMax-M2.7-highspeed"
|
||||||
|
assert provider is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Env wins over YAML
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_env_model_wins_over_yaml_model(monkeypatch):
|
||||||
|
"""When both env MODEL and YAML model are set, env wins."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||||||
|
model, _ = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="MiniMax-M2.7", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "GLM-4.6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_provider_wins_over_yaml_provider(monkeypatch):
|
||||||
|
"""Env MODEL_PROVIDER (when a registered slug) wins over YAML provider."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "GLM-4.6")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "zai")
|
||||||
|
_, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="minimax", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert provider == "zai"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# YAML fallback (no env)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_no_env_falls_back_to_yaml(monkeypatch):
|
||||||
|
"""Workspace whose env doesn't set MODEL/MODEL_PROVIDER falls back
|
||||||
|
to the YAML config — preserves existing operator workflows."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="claude-sonnet-4-6",
|
||||||
|
yaml_provider="anthropic-api",
|
||||||
|
providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "claude-sonnet-4-6"
|
||||||
|
assert provider == "anthropic-api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_env_no_yaml_returns_empty(monkeypatch):
|
||||||
|
"""Pure default path — caller (setup) substitutes ``sonnet``."""
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == ""
|
||||||
|
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
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_whitespace_only_env_treated_as_unset(monkeypatch):
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", " ")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", " ")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="opus", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "opus"
|
||||||
|
assert provider is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_env_value_treated_as_unset(monkeypatch):
|
||||||
|
_clear_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("MODEL", "")
|
||||||
|
monkeypatch.setenv("MODEL_PROVIDER", "")
|
||||||
|
model, provider = _resolve_model_and_provider_from_env(
|
||||||
|
yaml_model="sonnet", yaml_provider="", providers=_REGISTRY,
|
||||||
|
)
|
||||||
|
assert model == "sonnet"
|
||||||
|
assert provider is None
|
||||||
146
tests/test_provider_resolution.py
Normal file
146
tests/test_provider_resolution.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for the provider-resolution path that was silent-failing on #180.
|
||||||
|
|
||||||
|
Regression coverage: when an operator picks a provider in the canvas Config
|
||||||
|
tab that isn't in the registry, the adapter must raise ValueError with an
|
||||||
|
actionable message — NOT silently fall through to providers[0]
|
||||||
|
(anthropic-oauth) and then have the Claude SDK hit the user's OAuth quota
|
||||||
|
under a different name.
|
||||||
|
|
||||||
|
These tests mirror the production failure mode reported by Hongming
|
||||||
|
2026-05-07 17:35: workspace config.yaml had `provider: minimax` set, the
|
||||||
|
adapter ignored it entirely, the SDK kept calling the Anthropic API with
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN, hit the OAuth quota, and the canvas surfaced
|
||||||
|
"Agent error (Exception)" with no clue why.
|
||||||
|
|
||||||
|
Import-shim setup (sys.path + molecule_runtime / a2a / claude_sdk_executor
|
||||||
|
stubs) lives in tests/conftest.py — shared with test_adapter_prevalidate
|
||||||
|
so the two stub installers can't disagree on shape (e.g. BaseAdapter
|
||||||
|
having install_plugins_via_registry).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from adapter import (
|
||||||
|
_BUILTIN_PROVIDERS,
|
||||||
|
_resolve_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_no_explicit_provider_falls_back_to_model_match():
|
||||||
|
"""No explicit provider → model-based prefix/alias matching, default to providers[0]."""
|
||||||
|
p = _resolve_provider("claude-opus-4-7", _BUILTIN_PROVIDERS)
|
||||||
|
assert p["name"] == "anthropic-api" # matches model_prefixes=("claude-",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_no_explicit_provider_falls_back_to_default():
|
||||||
|
"""Unknown model + no explicit provider → providers[0] (anthropic-oauth)."""
|
||||||
|
p = _resolve_provider("unknown-model", _BUILTIN_PROVIDERS)
|
||||||
|
assert p["name"] == "anthropic-oauth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_explicit_provider_in_registry_returns_match():
|
||||||
|
"""Explicit name lookup wins over model-based resolution."""
|
||||||
|
# Even though "claude-opus-4-7" would normally resolve to anthropic-api
|
||||||
|
# via prefix matching, the explicit provider name wins.
|
||||||
|
p = _resolve_provider(
|
||||||
|
"claude-opus-4-7", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider="anthropic-oauth",
|
||||||
|
)
|
||||||
|
assert p["name"] == "anthropic-oauth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_explicit_provider_case_insensitive():
|
||||||
|
"""Provider name match is case-insensitive (operators write 'Anthropic-OAuth' etc)."""
|
||||||
|
p = _resolve_provider(
|
||||||
|
"sonnet", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider="ANTHROPIC-OAUTH",
|
||||||
|
)
|
||||||
|
assert p["name"] == "anthropic-oauth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_explicit_provider_not_in_registry_raises():
|
||||||
|
"""The #180 regression test: explicit non-registry provider must raise, not fall through."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
_resolve_provider(
|
||||||
|
"MiniMax-M2.7-highspeed", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider="minimax",
|
||||||
|
)
|
||||||
|
msg = str(exc_info.value)
|
||||||
|
# Must name the bad provider so operator knows what they typed
|
||||||
|
assert "minimax" in msg
|
||||||
|
# Must list known providers so operator knows what's available
|
||||||
|
assert "anthropic-oauth" in msg
|
||||||
|
assert "anthropic-api" in msg
|
||||||
|
# Must give actionable next steps — NOT just "not found"
|
||||||
|
assert "providers:" in msg or "Add" in msg
|
||||||
|
assert "Switch" in msg or "runtime" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_explicit_provider_does_not_silent_fallback():
|
||||||
|
"""Specifically: must not return providers[0] when explicit_provider is bogus.
|
||||||
|
|
||||||
|
This is the exact silent-fallback path that caused the user-visible
|
||||||
|
bug: operator picks 'minimax' → adapter returns anthropic-oauth →
|
||||||
|
SDK uses CLAUDE_CODE_OAUTH_TOKEN → hits quota.
|
||||||
|
"""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
result = _resolve_provider(
|
||||||
|
"anything", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider="minimax",
|
||||||
|
)
|
||||||
|
# If the implementation regresses to silent fallback, this would
|
||||||
|
# have returned providers[0] (anthropic-oauth) instead of raising.
|
||||||
|
# Defense-in-depth: guard against accidental "return" inside the
|
||||||
|
# error path.
|
||||||
|
assert result["name"] not in {"anthropic-oauth", "anthropic-api"}, (
|
||||||
|
"REGRESSION: silent fallback to default provider when explicit "
|
||||||
|
"provider name is not in registry — this is the #180 bug."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_with_explicit_provider_in_custom_registry():
|
||||||
|
"""When operator adds a third-party provider to the registry, explicit lookup finds it."""
|
||||||
|
custom_registry = _BUILTIN_PROVIDERS + (
|
||||||
|
{
|
||||||
|
"name": "minimax",
|
||||||
|
"auth_mode": "third_party_anthropic_compat",
|
||||||
|
"model_prefixes": ("minimax-",),
|
||||||
|
"model_aliases": (),
|
||||||
|
"base_url": "https://api.minimaxi.com/anthropic-compat",
|
||||||
|
"auth_env": ("MINIMAX_API_KEY",),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
p = _resolve_provider(
|
||||||
|
"MiniMax-M2.7-highspeed", custom_registry,
|
||||||
|
explicit_provider="minimax",
|
||||||
|
)
|
||||||
|
assert p["name"] == "minimax"
|
||||||
|
assert p["base_url"] == "https://api.minimaxi.com/anthropic-compat"
|
||||||
|
assert "MINIMAX_API_KEY" in p["auth_env"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_empty_providers_raises():
|
||||||
|
"""Pre-condition: providers must be non-empty (existing behavior preserved)."""
|
||||||
|
with pytest.raises(ValueError, match="empty providers tuple"):
|
||||||
|
_resolve_provider("anything", ())
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_explicit_empty_string_treated_as_no_explicit():
|
||||||
|
"""`provider: ''` (empty string) → fall back to model-based resolution, not raise."""
|
||||||
|
# This shape can happen when the canvas writes an empty provider field.
|
||||||
|
# Treating it as "no explicit pick" is more forgiving than raising,
|
||||||
|
# since the user clearly didn't intend to break their workspace.
|
||||||
|
p = _resolve_provider(
|
||||||
|
"claude-opus-4-7", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider="",
|
||||||
|
)
|
||||||
|
assert p["name"] == "anthropic-api" # fell through to model-based
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_explicit_none_treated_as_no_explicit():
|
||||||
|
"""`explicit_provider=None` (default) → fall back to model-based resolution."""
|
||||||
|
p = _resolve_provider(
|
||||||
|
"claude-opus-4-7", _BUILTIN_PROVIDERS,
|
||||||
|
explicit_provider=None,
|
||||||
|
)
|
||||||
|
assert p["name"] == "anthropic-api"
|
||||||
@ -219,7 +219,6 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
|
|||||||
"""
|
"""
|
||||||
cases = [
|
cases = [
|
||||||
("zai", "GLM_API_KEY"),
|
("zai", "GLM_API_KEY"),
|
||||||
("moonshot", "KIMI_API_KEY"),
|
|
||||||
("deepseek", "DEEPSEEK_API_KEY"),
|
("deepseek", "DEEPSEEK_API_KEY"),
|
||||||
]
|
]
|
||||||
for provider_name, env_name in cases:
|
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"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for "
|
||||||
f"provider={provider_name}"
|
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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user