Compare commits

...

12 Commits

Author SHA1 Message Date
claude-ceo-assistant 3c849b3ba7 Merge pull request 'fix(adapter): honor explicit provider config — fail fast when not in registry (#180)' (#4) from fix/180-explicit-provider-validation into main
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
CI / Adapter unit tests (push) Successful in 1m14s
CI / validate (push) Successful in 4m25s
2026-05-07 18:09:01 +00:00
claude-ceo-assistant f8d7f8f3a8 test(adapter): install adapter import shims via conftest
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
CI / Adapter unit tests (push) Successful in 58s
CI / Adapter unit tests (pull_request) Successful in 58s
CI / validate (pull_request) Successful in 2m59s
CI / validate (push) Successful in 3m0s
CI runner installs only `pytest pytest-asyncio pyyaml`; without the
molecule_runtime/a2a/claude_sdk_executor stubs, the new
test_provider_resolution.py fails to collect with
ModuleNotFoundError. test_adapter_prevalidate.py owned the same
shims via a per-file _install_stubs(), but two files maintaining
parallel stub copies eventually disagree on shape (BaseAdapter
needing install_plugins_via_registry, etc.).

Move the shim install + sys.path bump into tests/conftest.py so
every test module shares a single canonical stub set, collected
before any test imports adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:58:51 -07:00
claude-ceo-assistant a2c7bf3d3b fix(adapter): honor explicit provider config — fail fast when not in registry (#180)
Workspace operators set 'provider: minimax' in /configs/config.yaml
expecting the adapter to route to MiniMax. Pre-fix behavior: adapter
ignored 'provider:' entirely, _resolve_provider model-matched against
_BUILTIN_PROVIDERS (anthropic-oauth + anthropic-api only), no model_prefix
matched 'MiniMax-M2.7-highspeed', silent fallback to providers[0]
(anthropic-oauth) — SDK kept using CLAUDE_CODE_OAUTH_TOKEN, hit OAuth
quota under a name the operator never asked for.

Fix: _resolve_provider now takes an explicit_provider arg. setup() reads
it from runtime_config.provider OR top-level config.yaml provider:.
Explicit name in registry → returned. Not in registry → ValueError with
the two paths to fix (add provider entry, or switch runtime template).

10 new tests cover: explicit-in-registry returns match, case-insensitive,
not-in-registry raises with actionable message, defense-in-depth against
silent fallback regression, custom-registry lookup, empty/None treated as
no-explicit (back-compat).

Closes #180.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:58:51 -07:00
claude-ceo-assistant a5c9acd950 Merge pull request 'chore(ci): adopt .runtime-version push-mode cascade signal' (#3) from chore/runtime-version-file into main
CI / Adapter unit tests (push) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
CI / validate (push) Successful in 11m48s
2026-05-07 10:12:38 +00:00
claude-ceo-assistant 3e491c673b chore(ci): adopt .runtime-version push-mode cascade signal
CI / Adapter unit tests (pull_request) Successful in 21s
CI / validate (push) Successful in 11m50s
CI / validate (pull_request) Successful in 11m38s
CI / Adapter unit tests (push) Successful in 20s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Background: post-2026-05-06 SCM is Gitea, not GitHub. Gitea 1.22.6 has
no repository_dispatch / workflow_dispatch trigger API (empirically
verified across 6 candidate paths in molecule-core#20 issuecomment-913).
The molecule-core/publish-runtime.yml cascade therefore cannot fire
templates via curl-dispatch — pivots to push-mode instead.

This PR is the consumer side of that pivot:

- `.runtime-version` file at repo root — single line, plain version
  string. Currently 0.1.129 (latest published as of 2026-05-07).
  publish-runtime overwrites this on each cascade.

- publish-image.yml gains a `resolve-version` job that reads the file
  and forwards the value to the reusable build workflow as the
  third-priority source in the resolution chain:
    1. client_payload.runtime_version (forward-compat with future
       GitHub-style dispatch if Gitea ever adds it)
    2. inputs.runtime_version (manual workflow_dispatch override)
    3. .runtime-version file (push-mode cascade — the new path)
    4. '' (Dockerfile requirements.txt default)

No behavioural change for PRs / manual dispatches; only fills in the
on-push case where previously the version was empty.

Sequencing context: this PR (and 8 sibling PRs to the other template
repos) MUST land before molecule-core#20 v2 is merged — otherwise the
first cascade push would trigger an on-push rebuild that pins the OLD
requirements.txt floor instead of the freshly-published version.

Refs molecule-core#14, molecule-core#20, molecule-core/issues/20.
2026-05-07 03:03:02 -07:00
security-auditor 91e5010888 ci: re-trigger after orchestrator restarted runners 1-8
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
CI / Adapter unit tests (push) Successful in 50s
CI / validate (push) Successful in 12m11s
Per saved memory feedback_runner_config_partial_deploy: orchestrator
identified that runners 1-8 last restarted before AGENT_TOOLSDIRECTORY
+ RUNNER_TOOL_CACHE were added; cycle 7 retrigger landed ~50% on stale
runners. Orchestrator restarted 1-8 at ~09:37; this empty commit
re-triggers CI on the now-consistent runner pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:40:53 -07:00
security-auditor b91f1ab694 fix(ci): inline secret-scan body, drop cross-repo uses: of private molecule-core
Secret scan / Scan diff for credential-shaped strings (push) Successful in 7s
CI / Adapter unit tests (push) Failing after 16s
CI / validate (push) Failing after 18s
The 3-line wrapper at .github/workflows/secret-scan.yml referenced
`uses: molecule-ai/molecule-core/.github/workflows/secret-scan.yml@staging`.
molecule-core is private; act_runner clones cross-repo reusable
workflows anonymously, so the resolve fails at 0s with no logs.

Same root cause + same fix that molecule-controlplane already shipped
(see its secret-scan.yml comment block lines 10-22). Inlining keeps
the gate functional until Gitea is upgraded or the canonical scanner
moves to a public repo. When either lands, this file reverts to the
3-line wrapper.

Refs: internal#46 Phase 3 Class 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:29:04 -07:00
security-auditor cd68aae474 ci: re-trigger after runner-config v2 (AGENT_TOOLSDIRECTORY etc.)
Secret scan / secret-scan (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 15s
CI / validate (push) Failing after 18s
Empty commit to re-run CI against the act_runner config that landed
in /opt/molecule/runners/config.yaml (cycle ~58 internal#46 Phase 3).
No source change. CI now runs setup-python with /tmp/hostedtoolcache,
which works (verified in cycle 6 task 1022 log, careful-bash#2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:27:50 -07:00
claude-ceo-assistant f549d0e4f3 Merge pull request 'docs(install): migrate git clone URL to git.moleculesai.app (#37)' (#1) from fix/install-path-gitea into main
Secret scan / secret-scan (push) Failing after 0s
CI / validate (push) Failing after 11s
CI / Adapter unit tests (push) Successful in 18s
2026-05-07 09:24:04 +00:00
claude-ceo-assistant 09c95308fd Merge pull request 'fix(ci): lowercase 'molecule-ai/' in cross-repo workflow refs' (#2) from fix/lowercase-org-slug into main
Secret scan / secret-scan (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 17s
CI / validate (push) Failing after 23s
2026-05-07 08:59:12 +00:00
security-auditor fb450b0758 fix(ci): lowercase 'molecule-ai/' in cross-repo workflow refs
CI / validate (pull_request) Failing after 0s
Secret scan / secret-scan (pull_request) Failing after 0s
CI / validate (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 13s
CI / Adapter unit tests (pull_request) Failing after 13s
Gitea is case-sensitive on owner slugs; canonical is lowercase
`molecule-ai/...`. Mixed-case `Molecule-AI/...` refs fail-at-0s
when the runner tries to resolve the cross-repo workflow / checkout.

Same fix as molecule-controlplane#12. Mechanical case-correction;
no behavior change beyond making CI resolve again.

Refs: internal#46

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:59:45 -07:00
documentation-specialist e28c2d0fd7 docs(install): migrate git clone URL to git.moleculesai.app (#37)
CI / Adapter unit tests (push) Failing after 10s
CI / Adapter unit tests (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 0s
Secret scan / secret-scan (pull_request) Failing after 0s
CI / validate (push) Failing after 0s
One anonymous git-clone ref in runbooks/local-dev-setup.md:27.
Public repo, no auth-shape change.

Refs: molecule-ai/internal#37, molecule-ai/internal#38
2026-05-07 00:31:16 -07:00
8 changed files with 565 additions and 28 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: CI
on: [push, pull_request]
jobs:
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:
name: Adapter unit tests
+41 -8
View File
@@ -32,14 +32,47 @@ permissions:
packages: write
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:
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
with:
# When the cascade fires, client_payload.runtime_version is the
# exact version PyPI just published. Forwarded to the reusable
# workflow as a docker --build-arg so the cache key changes
# per-version and pip install resolves freshly.
# On other events (push to main / manual without input), this is
# empty and the Dockerfile's default (requirements.txt pin) applies.
runtime_version: ${{ github.event.client_payload.runtime_version || inputs.runtime_version || '' }}
# Resolution chain (highest priority first):
# 1. client_payload.runtime_version — legacy GitHub
# repository_dispatch path (will return if Gitea ever adds
# the dispatch API; left in place for forward-compat).
# 2. inputs.runtime_version manual workflow_dispatch run from
# the Actions UI for ad-hoc rebuilds against a specific
# 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 || '' }}
+191 -12
View File
@@ -1,22 +1,201 @@
name: Secret scan
# Calls the canonical reusable workflow in molecule-core. Defense
# against the #2090-class leak (a hosted-agent commit slipping a
# credential-shaped string into a PR). Pattern set lives in
# molecule-core so we do not maintain a parallel copy here.
# 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.
#
# Pinned to @staging because that is the active default branch on the
# upstream repo (main lags behind via the staging-promotion workflow).
# Updates ride along automatically as the upstream regex set evolves.
# Inlined copy from molecule-ai/molecule-core/.github/workflows/secret-scan.yml.
# Cross-repo workflow_call to a private repo doesn't fully work on Gitea 1.22.6
# (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:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging, master]
merge_group:
types: [checks_requested]
branches: [main, staging]
jobs:
secret-scan:
uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
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 }}
# 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
View File
@@ -0,0 +1 @@
0.1.129
+95 -6
View File
@@ -280,13 +280,28 @@ def _project_vendor_auth(provider: dict) -> None:
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.
Match is case-insensitive: prefix wins over alias when both could
apply. 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).
If ``explicit_provider`` is given (set via the ``provider:`` field in
workspace config.yaml or runtime_config), look up by name first. If the
named provider is not in the registry, RAISE ``ValueError`` with an
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
returns at least one entry (built-ins when YAML is missing or every
@@ -298,6 +313,44 @@ def _resolve_provider(model: str, providers: tuple) -> dict:
"_load_providers must always return at least one entry "
"(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:
return providers[0]
m = model.lower()
@@ -401,8 +454,36 @@ class ClaudeCodeAdapter(BaseAdapter):
rc = config.runtime_config
if isinstance(rc, dict):
picked_model = rc.get("model") or "sonnet"
explicit_provider_name = rc.get("provider")
else:
picked_model = getattr(rc, "model", None) or "sonnet"
explicit_provider_name = getattr(rc, "provider", None)
# 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 explicit_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():
explicit_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,
)
# NOTE: do NOT strip the provider prefix here. The pre-fix routing
# behavior — `anthropic:claude-opus-4-7` falls through to
# providers[0] (anthropic-oauth) when no model_prefixes match — is
@@ -411,7 +492,15 @@ class ClaudeCodeAdapter(BaseAdapter):
# `anthropic-api` provider and the CLI then hangs at `initialize`
# because ANTHROPIC_API_KEY isn't set. The strip belongs only at
# 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"]
# Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY,
+1 -1
View File
@@ -24,7 +24,7 @@ common problems.
## Step 1 — Clone the Repository
```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
```
+89
View 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)
+146
View 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"