Compare commits

..

17 Commits

Author SHA1 Message Date
f8d7f8f3a8 test(adapter): install adapter import shims via conftest
All checks were successful
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
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
a5c9acd950 Merge pull request 'chore(ci): adopt .runtime-version push-mode cascade signal' (#3) from chore/runtime-version-file into main
All checks were successful
CI / validate (push) Successful in 11m48s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
CI / Adapter unit tests (push) Successful in 20s
2026-05-07 10:12:38 +00:00
3e491c673b chore(ci): adopt .runtime-version push-mode cascade signal
All checks were successful
CI / Adapter unit tests (push) Successful in 20s
CI / Adapter unit tests (pull_request) Successful in 21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
CI / validate (push) Successful in 11m50s
CI / validate (pull_request) Successful in 11m38s
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
All checks were successful
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
Some checks failed
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.)
Some checks failed
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
f549d0e4f3 Merge pull request 'docs(install): migrate git clone URL to git.moleculesai.app (#37)' (#1) from fix/install-path-gitea into main
Some checks failed
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
09c95308fd Merge pull request 'fix(ci): lowercase 'molecule-ai/' in cross-repo workflow refs' (#2) from fix/lowercase-org-slug into main
Some checks failed
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
Some checks failed
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)
Some checks failed
CI / Adapter unit tests (push) Failing after 10s
CI / Adapter unit tests (pull_request) Failing after 10s
CI / validate (push) Failing after 0s
CI / validate (pull_request) Failing after 0s
Secret scan / secret-scan (pull_request) 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
Hongming Wang
50e16c5c73
Merge pull request #33 from Molecule-AI/feat/per-vendor-env-routing-task-244
Some checks failed
CI / validate (push) Failing after 0s
Secret scan / secret-scan (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 7s
feat: per-vendor env routing for third-party providers (task #244)
2026-05-02 22:24:03 -07:00
Hongming Wang
b9a1fa1b1f feat: per-vendor env routing for third-party providers (task #244)
Some checks failed
CI / validate (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 6s
Third-party Anthropic-compat providers (MiniMax, GLM, Kimi, DeepSeek)
all reuse the Anthropic SDK's wire format, which means the claude CLI
and claude-code-sdk read the bearer token from ANTHROPIC_AUTH_TOKEN no
matter which vendor is being talked to. Pre-#244:

  * Canvas surfaced the vendor-specific name (MINIMAX_API_KEY, etc.)
    to the user — so a user who saved only MINIMAX_API_KEY hit a
    silent 401 on first call.
  * The boot audit said `MINIMAX_API_KEY=set`, making it look like an
    SDK bug rather than a routing gap.
  * A user with multiple vendor keys could only run one workspace at a
    time because they all fought over the shared ANTHROPIC_AUTH_TOKEN
    slot.

Diagnostic-only audit logging shipped earlier (#32) but the actual
routing was never written — task #244 was mismarked complete.

Changes:
  * config.yaml: third-party model `required_env` now references the
    per-vendor name (MINIMAX_API_KEY, GLM_API_KEY, KIMI_API_KEY,
    DEEPSEEK_API_KEY) so canvas asks the user for the right key.
    First-party Anthropic models still use ANTHROPIC_AUTH_TOKEN /
    CLAUDE_CODE_OAUTH_TOKEN.
  * config.yaml: each third-party provider's `auth_env` lists the
    vendor name FIRST (priority order) so projection picks the
    vendor key over a stale ANTHROPIC_AUTH_TOKEN.
  * adapter.py: new `_project_vendor_auth(provider)` helper, called
    from `setup()` right after `_resolve_provider`. Idempotent — only
    projects when ANTHROPIC_AUTH_TOKEN is unset (operator override
    always wins). Logs the projection by NAME, never by VALUE
    (mirrors `_audit_auth_env_presence`).
  * tests/test_provider_routing.py: 6 new tests pin the contract —
    vendor-key-set projects, AUTH_TOKEN-already-set is never
    clobbered, first-party providers skip projection, secret value
    never leaks into a log record, empty-string vendor env doesn't
    trigger projection, and the same routing fires for GLM / Kimi /
    DeepSeek.

Mirrors the parallel hermes-side fix from task #249 / hermes PR #38;
keeps the two runtimes' multi-vendor UX in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:20:03 -07:00
Hongming Wang
c09a268ae4
Merge pull request #32 from Molecule-AI/fix/canvas-picked-model-and-boot-debug-logging
fix: canvas-picked model + boot debug logging + restore claude_sdk_executor.py
2026-05-02 21:44:33 -07:00
Hongming Wang
78ae139609 feat(adapter,entrypoint): boot env audit + crash-loop diagnosis logging
Adds two operator-visible boot diagnostics that close the diagnosis gap
exposed by the 2026-05-02 MiniMax E2E crash-loop. The universal
canvas-picked-model fix (Bug B) and per-model required_env (Bug D) live
in molecule-core PR #2538 — this PR adds the per-template visibility
that complements them so operators can answer "is the key missing or is
routing wrong?" from `docker logs` alone.

Changes
-------
adapter.py:
- _AUTH_ENV_AUDIT tuple of 8 vendor env names (CLAUDE_CODE_OAUTH_TOKEN,
  ANTHROPIC_API_KEY/AUTH_TOKEN/BASE_URL, MINIMAX/GLM/KIMI/DEEPSEEK_API_KEY).
- _audit_auth_env_presence() helper — single INFO line of NAME=set/unset
  pairs. NEVER logs values; the test pins this with a "fake-secret-MUST-
  NOT-LEAK" sentinel that must never appear in the log message.
- One call site at the end of setup()'s boot banner so every workspace
  start emits both "which provider got picked" and "which envs are present"
  in adjacent log lines.

entrypoint.sh:
- log_boot_context() function fired once before the gosu drop (as root)
  and once after (as agent) so an operator can spot env values lost
  across the privilege drop. Emits uid/gid/user/hostname/workspace_id/
  platform_url/configs_dir/workspace_dir + the same 8 env names as
  NAME=set/unset. Mirror of _AUTH_ENV_AUDIT — list pinned in sync by a
  new AST-style test (test_audit_env_list_matches_entrypoint_sh) that
  parses entrypoint.sh and asserts set-equality with adapter.py's tuple.

tests/test_adapter_logging.py (new):
- 4 tests covering the audit contract: every name appears, all-unset
  scenario, empty-string treated as unset (matches routing semantics),
  and the cross-file sync gate against entrypoint.sh's for-loop.
- Stubs molecule_runtime + a2a so the helpers can be imported without
  the real wheel installed in CI (mirrors test_adapter_prevalidate.py's
  scaffolding pattern).

Why this complements molecule-core PR #2538
-------------------------------------------
- PR #2538 makes Bug B (canvas-picked model silently dropped) impossible
  by resolving model centrally in workspace/config.py:load_config —
  every adapter (claude-code, hermes, codex, future ones) gets the
  passthrough for free.
- PR #2538 makes Bug D (preflight rejects valid auth for non-default
  models) impossible by REPLACE-not-union per-entry required_env.
- This template PR is the per-template observability layer: when one
  of those universal fixes regresses (or when an operator misconfigs a
  vendor key), the boot logs say exactly which env was present at each
  tier. Validated end-to-end on workspace
  be27badd-00a7-4cef-91e8-af428175c76f (clean boot, MINIMAX_API_KEY=set
  audited, no crash-loop).

Closes part of molecule-monorepo task #248. Sibling of #2538 for
molecule-core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:41:05 -07:00
Hongming Wang
863a1e6f87
Merge pull request #31 from Molecule-AI/docs/claude-md-runtime-wedge-channels
docs(claude): add runtime_wedge + dev-channels CLI flag sections
2026-05-01 20:10:02 -07:00
Hongming Wang
7c23ea65a5 docs(claude): document runtime_wedge integration + dev-channels server tag 2026-05-01 20:04:11 -07:00
11 changed files with 842 additions and 46 deletions

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

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 || '' }}

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
.runtime-version Normal file
View File

@ -0,0 +1 @@
0.1.129

View File

@ -71,3 +71,22 @@ Skills persist across restarts. Use them to codify best practices, coding standa
## Language
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
## 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:
- **Heartbeat** — reads `runtime_wedge.is_wedged()` on each beat and reports `runtime_state="wedged"` to the platform, which flips the workspace card to `degraded` so the canvas surfaces a Restart hint instead of leaving the user staring at a green dot while every chat hangs.
- **Boot smoke (`smoke_mode`)** — when the publish-image workflow boots the image with `MOLECULE_SMOKE_MODE=1`, the smoke runner consults `runtime_wedge.is_wedged()` at the end of every result path and upgrades a provisional PASS to FAIL when the flag is set. Catches PR-25-class regressions (malformed CLI argv → SDK init wedge) BEFORE the broken image ships to GHCR.
The executor sets the flag in its catch arm in `claude_sdk_executor.py` (`_mark_sdk_wedged`) when `claude_agent_sdk` raises `Control request timeout: initialize` — that wedge corrupts the SDK's internal client-process state for the rest of the Python process, so every subsequent `_run_query()` call would hit the same wedge and re-throw without intervention. The flag is cleared automatically on the next successful query (`_clear_sdk_wedge_on_success`) so a transient handshake blip self-heals to `online` without a manual restart.
## Channels CLI flag
The executor passes `extra_args={"dangerously-load-development-channels": "server:molecule"}` to `claude-agent-sdk` when building `ClaudeAgentOptions` (see `_build_options` in `claude_sdk_executor.py`). This forwards `--dangerously-load-development-channels server:molecule` to the spawned `claude` CLI so the host registers the experimental `experimental.claude/channel` capability instead of dropping the notification on the allowlist check.
The flag's value MUST be in tagged form — `server:<name>` for manually-configured MCP servers, `plugin:<name>@<marketplace>` for plugin channels. Claude Code 2.1.x+ rejects the bare flag with `argument missing` and the SDK times out at `initialize`, surfacing as `Control request timeout: initialize` upstream (which then trips the wedge path described above).
Why this is needed: the in-workspace MCP server (the `a2a` server) emits `experimental.claude/channel` notifications so inbound peer/canvas messages render as `<channel>` push tags inline in the host claude session, without the agent having to poll an inbox. The wheel ships the gates and the inbox bridge fires the notification, but without this flag the CLI silently filters it during the channels research preview.
Drop this flag once channels graduate from research preview to the default allowlist.

View File

@ -15,6 +15,41 @@ logger = logging.getLogger(__name__)
# the workspace by polling /transcript?limit=999999.
_TRANSCRIPT_MAX_LIMIT = 1000
# Auth env names to audit at boot. Order is informational; presence/absence
# of each is logged so the operator can see at a glance which key the
# workspace was started with vs which is missing. NEVER log values — just
# the boolean "set"/"unset" per name. Adding a new vendor: add its env
# name here so the audit reports it too. Keep in sync with the matching
# list in entrypoint.sh's log_boot_context().
_AUTH_ENV_AUDIT = (
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"MINIMAX_API_KEY",
"GLM_API_KEY",
"KIMI_API_KEY",
"DEEPSEEK_API_KEY",
)
def _audit_auth_env_presence() -> None:
"""Log a one-line snapshot of which auth env names are set.
Logs NAMES + presence ("set"/"unset"), never VALUES. Lets an
operator reading docker logs answer "is this a missing key
problem or a routing problem?" in one glance. The boot-banner in
setup() answers "which provider got picked"; this audit answers
"is the env even there for it." Together they make the
crash-loop diagnosis path that bit us 2026-05-02 a one-line read.
"""
snapshot = ", ".join(
f"{name}={'set' if os.environ.get(name) else 'unset'}"
for name in _AUTH_ENV_AUDIT
)
logger.info("auth env audit: %s", snapshot)
# Auth-mode constants — provider entries use one of these strings.
# Drives validation behavior in setup() (third-party requires base_url
# resolution; oauth/anthropic-api leave base_url=None for CLI defaults).
@ -188,6 +223,63 @@ def _strip_provider_prefix(model: str) -> str:
return model
# Vendor-specific env names that are SAFE to copy into ANTHROPIC_AUTH_TOKEN
# at boot. Limited to per-vendor names so a stray ANTHROPIC_API_KEY (which
# the SDK reads on its own path) is never misrouted into the AUTH_TOKEN
# slot. Keep in sync with the canvas-side env name suggestions.
_VENDOR_KEY_NAMES = frozenset({
"MINIMAX_API_KEY",
"GLM_API_KEY",
"KIMI_API_KEY",
"DEEPSEEK_API_KEY",
})
def _project_vendor_auth(provider: dict) -> None:
"""Project a per-vendor API key onto ANTHROPIC_AUTH_TOKEN at boot.
Third-party Anthropic-compat providers (MiniMax, Z.ai, Moonshot,
DeepSeek) all reuse the Anthropic SDK's wire format, which means the
``claude`` CLI / claude-code-sdk reads the bearer token from
``ANTHROPIC_AUTH_TOKEN`` no matter which vendor is being talked to.
Pre-#244 the canvas surfaced the vendor-specific name
(``MINIMAX_API_KEY``, etc.) to the user so a user who saved only
that name hit a silent 401 on first call while the boot audit said
``MINIMAX_API_KEY=set``. Mirrors the hermes-side fix from task #249
/ hermes PR #38.
Behavior:
* If the matched provider's ``auth_env`` lists any of
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
``ANTHROPIC_AUTH_TOKEN`` so the SDK finds it.
* **Idempotent**: if ``ANTHROPIC_AUTH_TOKEN`` is already set we
do NOT overwrite an explicit operator value (workspace
secret) always wins over auto-projection.
* Logs the projection by NAME (e.g. ``MINIMAX_API_KEY ->
ANTHROPIC_AUTH_TOKEN``); never logs the secret VALUE. Same
contract as ``_audit_auth_env_presence``.
* No-op for providers whose ``auth_env`` doesn't reference a
vendor-specific name (oauth, anthropic-api, or a third-party
entry that hasn't been added to the registry yet).
"""
auth_env = provider.get("auth_env") or ()
if os.environ.get("ANTHROPIC_AUTH_TOKEN"):
# Operator override wins — never clobber an explicit value.
return
for name in auth_env:
if name not in _VENDOR_KEY_NAMES:
continue
value = os.environ.get(name)
if not value:
continue
os.environ["ANTHROPIC_AUTH_TOKEN"] = value
logger.info(
"auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)",
name, provider.get("name", "<unknown>"),
)
return
def _resolve_provider(
model: str,
providers: tuple,
@ -411,6 +503,15 @@ class ClaudeCodeAdapter(BaseAdapter):
)
auth_env_options = provider["auth_env"]
# Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY,
# KIMI_API_KEY, DEEPSEEK_API_KEY) onto ANTHROPIC_AUTH_TOKEN so the
# claude-code-sdk finds the bearer token. Idempotent: explicit
# ANTHROPIC_AUTH_TOKEN (operator override) is never clobbered.
# Must run BEFORE the auth audit + auth check below so the audit
# reflects the post-projection state and the check sees the right
# value. Task #244; mirrors hermes PR #38 (task #249).
_project_vendor_auth(provider)
# Endpoint precedence: operator-set ANTHROPIC_BASE_URL wins (escape
# hatch for custom regional endpoints — e.g. token-plan-sgp.* for
# Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the
@ -448,6 +549,16 @@ class ClaudeCodeAdapter(BaseAdapter):
"/".join(auth_env_options),
)
# Audit which auth-relevant env vars are actually present (NAMES
# ONLY — never values). Boot-time visibility into "is the key
# missing or wrong" was the #1 ask after the 2026-05-02
# crash-loop incident: docker logs showed "missing X" with no
# hint about which vendor envs WERE set, so an operator with
# MINIMAX_API_KEY couldn't tell at a glance whether the
# ANTHROPIC_AUTH_TOKEN gap was the cause. This one-line audit
# closes that gap. See _audit_auth_env_presence above.
_audit_auth_env_presence()
# Auth check — any of the provider's accepted env vars satisfies.
# Warning (not raise) so a workspace can still boot for non-LLM
# work (terminal, file editing) while the operator sets the key.

View File

@ -58,7 +58,10 @@ providers:
model_prefixes: [minimax-]
model_aliases: []
base_url: https://api.minimax.io/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
# Vendor-specific name FIRST so the boot-time projection helper
# (_project_vendor_auth in adapter.py) picks it over a stale
# ANTHROPIC_AUTH_TOKEN belonging to a sibling vendor.
auth_env: [MINIMAX_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
# Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude.
# Model ids are uppercase (GLM-4.6) but the registry lowercases for
@ -68,7 +71,7 @@ providers:
model_prefixes: [glm-]
model_aliases: []
base_url: https://api.z.ai/api/anthropic
auth_env: [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.
- name: moonshot
@ -76,7 +79,7 @@ providers:
model_prefixes: [kimi-]
model_aliases: []
base_url: https://api.moonshot.ai/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
auth_env: [KIMI_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
@ -87,7 +90,7 @@ providers:
model_prefixes: [deepseek-]
model_aliases: []
base_url: https://api.deepseek.com/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
auth_env: [DEEPSEEK_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
runtime: claude-code
runtime_config:
@ -145,50 +148,54 @@ runtime_config:
required_env: [ANTHROPIC_API_KEY]
# --- MiniMax (third-party, Anthropic-API-compatible) ---
# Routed via the `minimax` provider entry above. MiniMax docs prefer
# ANTHROPIC_AUTH_TOKEN (Bearer-style) — see platform.minimax.io/docs/token-plan/claude-code.
# ANTHROPIC_API_KEY also works (the claude CLI accepts both).
# Vendor-specific env var so a user with multiple third-party Anthropic-
# compat keys can run multiple workspaces simultaneously without them
# fighting over a shared ANTHROPIC_AUTH_TOKEN slot. The adapter projects
# MINIMAX_API_KEY → ANTHROPIC_AUTH_TOKEN at boot (only when
# ANTHROPIC_AUTH_TOKEN is unset, so an explicit operator override wins).
# Mirrors hermes-side fix from task #249 / hermes PR #38.
- id: MiniMax-M2
name: MiniMax M2 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [MINIMAX_API_KEY]
- id: MiniMax-M2.7
name: MiniMax M2.7 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [MINIMAX_API_KEY]
- id: MiniMax-M2.7-highspeed
name: MiniMax M2.7 High-Speed (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [MINIMAX_API_KEY]
# --- Z.ai GLM family (third-party, Anthropic-API-compatible) ---
# Routed via the `zai` provider entry. docs.z.ai for the full
# Anthropic-compat docs. GLM-4.6 is the current-gen flagship; 4.5
# remains for users on legacy quotas.
# GLM_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot. docs.z.ai for
# the full Anthropic-compat docs. GLM-4.6 is the current-gen flagship;
# 4.5 remains for users on legacy quotas.
- id: GLM-4.6
name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [GLM_API_KEY]
- id: GLM-4.5
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [GLM_API_KEY]
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
# Routed via the `moonshot` provider entry. platform.kimi.ai for docs.
# K2.5 is the latest agentic-coding tier; K2 stays as a cheaper option.
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
# K2 stays as a cheaper option.
- id: kimi-k2.5
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [KIMI_API_KEY]
- id: kimi-k2
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [KIMI_API_KEY]
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
# Routed via the `deepseek` provider entry. api-docs.deepseek.com.
# Note: unknown deepseek-* ids silently fall back to v4-flash on
# DeepSeek's side — pick the exact tier you mean.
# DEEPSEEK_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
# api-docs.deepseek.com. Note: unknown deepseek-* ids silently fall
# back to v4-flash on DeepSeek's side — pick the exact tier you mean.
- id: deepseek-v4-pro
name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [DEEPSEEK_API_KEY]
- id: deepseek-v4-flash
name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible)
required_env: [ANTHROPIC_AUTH_TOKEN]
required_env: [DEEPSEEK_API_KEY]
# Default required_env — per-model entries above override this once a
# model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so

View File

@ -9,6 +9,36 @@
# Pattern matches the legacy monorepo workspace-template/entrypoint.sh:
# fix volume ownership as root, then re-exec via gosu as agent (uid 1000).
# Boot-context snapshot — emitted on EVERY container start, including
# every restart of a crash-loop. Lets `docker logs` answer "what env
# was actually present?" without having to docker exec into a dying
# container. Logs NAMES of auth-relevant env vars, never VALUES. Fires
# twice (once as root pre-gosu, once as agent post-gosu) so an operator
# can see whether a value was lost across the privilege drop.
# Keep the env-name list in sync with adapter.py's _AUTH_ENV_AUDIT —
# the same set of vendors should be audited from both sides.
log_boot_context() {
echo "----- entrypoint boot $(date -u +%Y-%m-%dT%H:%M:%SZ) -----"
echo "uid=$(id -u) gid=$(id -g) user=$(id -un 2>/dev/null || echo unknown)"
echo "hostname=$(hostname) workspace_id=${WORKSPACE_ID:-<unset>}"
echo "platform_url=${PLATFORM_URL:-<unset>}"
echo "configs_dir: $(ls -ld /configs 2>/dev/null || echo MISSING)"
echo "configs_contents: $(ls /configs 2>/dev/null | tr '\n' ' ' || echo MISSING)"
echo "workspace_dir: $(ls -ld /workspace 2>/dev/null || echo MISSING)"
# Auth env presence (NAMES + set/unset only — never the values).
# Mirror of _AUTH_ENV_AUDIT in adapter.py — keep in sync if you add a vendor.
for var in CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL MINIMAX_API_KEY GLM_API_KEY KIMI_API_KEY DEEPSEEK_API_KEY; do
eval "val=\$$var"
if [ -n "$val" ]; then
echo "env $var=set"
else
echo "env $var=unset"
fi
done
echo "------------------------------------------------"
}
log_boot_context
if [ "$(id -u)" = "0" ]; then
# Configs volume is created by Docker as root; agent needs write access
# for plugin installs, memory writes, .auth_token rotation, etc.

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
```

View File

@ -0,0 +1,172 @@
"""Tests for the adapter-side boot debug logging helpers.
The 2026-05-02 crash-loop diagnosis hinged on operators being able to see,
from `docker logs` alone, *which* auth env names were set vs unset at boot.
This test pins that contract `_audit_auth_env_presence` must emit a
single INFO line listing every name in `_AUTH_ENV_AUDIT` with its presence
status, and must NEVER include the value.
Test isolation: adapter.py imports molecule_runtime + a2a at module load.
Neither is installed in this template's test env (the template ships its
own stripped-down test set so CI doesn't pull a heavy runtime wheel just
to lint the adapter helpers). We stub both with empty modules so the
audit helpers can import cleanly.
"""
from __future__ import annotations
import importlib.util
import logging
import sys
import types
from pathlib import Path
import pytest
@pytest.fixture
def adapter_module(monkeypatch):
"""Load the template's adapter module without its molecule_runtime + a2a deps.
The full adapter requires a2a-sdk + molecule_runtime at import time,
which aren't installed in the lean test env. We stub them with empty
modules so the module-level helpers (_AUTH_ENV_AUDIT,
_audit_auth_env_presence) can be imported in isolation.
"""
# Stub molecule_runtime.adapters.base.BaseAdapter / AdapterConfig /
# RuntimeCapabilities (all referenced at adapter.py module load).
pkg = types.ModuleType("molecule_runtime")
sub = types.ModuleType("molecule_runtime.adapters")
base = types.ModuleType("molecule_runtime.adapters.base")
base.BaseAdapter = type("BaseAdapter", (), {})
base.AdapterConfig = type("AdapterConfig", (), {})
base.RuntimeCapabilities = type("RuntimeCapabilities", (), {})
monkeypatch.setitem(sys.modules, "molecule_runtime", pkg)
monkeypatch.setitem(sys.modules, "molecule_runtime.adapters", sub)
monkeypatch.setitem(sys.modules, "molecule_runtime.adapters.base", base)
# Stub a2a.server.agent_execution.AgentExecutor
a2a = types.ModuleType("a2a")
a2a_server = types.ModuleType("a2a.server")
a2a_ax = types.ModuleType("a2a.server.agent_execution")
a2a_ax.AgentExecutor = type("AgentExecutor", (), {})
monkeypatch.setitem(sys.modules, "a2a", a2a)
monkeypatch.setitem(sys.modules, "a2a.server", a2a_server)
monkeypatch.setitem(sys.modules, "a2a.server.agent_execution", a2a_ax)
template_dir = Path(__file__).resolve().parent.parent
monkeypatch.syspath_prepend(str(template_dir))
# Force-reload so the stubs take effect even if a sibling test
# already imported the real (or partially-stubbed) module first.
sys.modules.pop("adapter", None)
spec = importlib.util.spec_from_file_location("adapter", template_dir / "adapter.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_audit_lists_every_name_with_presence(adapter_module, monkeypatch, caplog):
"""The audit log must enumerate every name in _AUTH_ENV_AUDIT, set or unset."""
monkeypatch.setenv("MINIMAX_API_KEY", "fake-secret-MUST-NOT-LEAK")
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
monkeypatch.delenv("GLM_API_KEY", raising=False)
monkeypatch.delenv("KIMI_API_KEY", raising=False)
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
with caplog.at_level(logging.INFO, logger="adapter"):
adapter_module._audit_auth_env_presence()
# Single log record, INFO level, prefix "auth env audit:"
matching = [r for r in caplog.records if "auth env audit" in r.getMessage()]
assert len(matching) == 1, f"expected exactly one audit record, got {len(matching)}"
msg = matching[0].getMessage()
# Every audited name appears with set/unset
for name in adapter_module._AUTH_ENV_AUDIT:
assert f"{name}=" in msg, f"audit message missing {name}: {msg!r}"
# MINIMAX_API_KEY is set, others unset
assert "MINIMAX_API_KEY=set" in msg
assert "CLAUDE_CODE_OAUTH_TOKEN=unset" in msg
assert "ANTHROPIC_API_KEY=unset" in msg
# Critical security assertion: the SECRET VALUE itself must NOT appear.
# If this regresses, the audit is leaking secrets to operator-visible
# docker logs and (worse) to the platform's central log aggregator.
assert "fake-secret-MUST-NOT-LEAK" not in msg, (
"audit log leaked the env VALUE — must be names + set/unset only"
)
def test_audit_with_all_unset(adapter_module, monkeypatch, caplog):
"""All names report 'unset' when no auth env is configured (the crash-loop scenario)."""
for name in adapter_module._AUTH_ENV_AUDIT:
monkeypatch.delenv(name, raising=False)
with caplog.at_level(logging.INFO, logger="adapter"):
adapter_module._audit_auth_env_presence()
matching = [r for r in caplog.records if "auth env audit" in r.getMessage()]
assert len(matching) == 1
msg = matching[0].getMessage()
for name in adapter_module._AUTH_ENV_AUDIT:
assert f"{name}=unset" in msg
def test_audit_treats_empty_string_as_unset(adapter_module, monkeypatch, caplog):
"""Empty-string env values report as 'unset' — matches routing semantics.
workspace-server's nil/empty handling could plausibly export
MINIMAX_API_KEY="" instead of omitting it; the audit must report
that as unset (it is, semantically) so the operator's "is the key
present?" question gets the same answer as the routing layer's.
"""
monkeypatch.setenv("MINIMAX_API_KEY", "")
for name in adapter_module._AUTH_ENV_AUDIT:
if name != "MINIMAX_API_KEY":
monkeypatch.delenv(name, raising=False)
with caplog.at_level(logging.INFO, logger="adapter"):
adapter_module._audit_auth_env_presence()
msg = [r.getMessage() for r in caplog.records if "auth env audit" in r.getMessage()][0]
assert "MINIMAX_API_KEY=unset" in msg
def test_audit_env_list_matches_entrypoint_sh(adapter_module):
"""_AUTH_ENV_AUDIT in adapter.py must mirror the for-loop in entrypoint.sh.
The entrypoint emits the same set of NAME=set/unset lines BEFORE the
Python adapter ever runs (including the pre-gosu and post-gosu boot
contexts), so an operator can correlate a missing key across the
privilege drop. If the two lists drift, an env name added in one
place but not the other becomes invisible at one tier exactly the
crash-loop diagnosis gap we just closed.
Pin the union by parsing the shell loop and asserting set-equality.
"""
template_dir = Path(__file__).resolve().parent.parent
entrypoint = (template_dir / "entrypoint.sh").read_text()
# The for-loop has the form: `for var in NAME1 NAME2 ... NAMEN; do`
# Extract NAME1..NAMEN by finding the `for var in ... ; do` line that
# references CLAUDE_CODE_OAUTH_TOKEN (so we don't grab unrelated loops).
loop_line = next(
(line for line in entrypoint.splitlines()
if "for var in" in line and "CLAUDE_CODE_OAUTH_TOKEN" in line),
None,
)
assert loop_line, "entrypoint.sh missing the auth-env audit for-loop"
# ` for var in A B C; do` → ['A', 'B', 'C']
names_in_shell = (
loop_line.split("for var in", 1)[1]
.split(";", 1)[0]
.split()
)
assert set(names_in_shell) == set(adapter_module._AUTH_ENV_AUDIT), (
f"adapter.py _AUTH_ENV_AUDIT ({set(adapter_module._AUTH_ENV_AUDIT)}) "
f"and entrypoint.sh for-loop ({set(names_in_shell)}) disagree on the "
"audit set — keep them in sync (see the comment in adapter.py)."
)

View File

@ -0,0 +1,244 @@
"""Tests for the per-vendor env routing helper (_project_vendor_auth).
Task #244 — third-party Anthropic-compat providers (MiniMax, GLM, Kimi,
DeepSeek) used to share ANTHROPIC_AUTH_TOKEN, so a user with multiple
vendor keys could only run one workspace at a time, AND a user who saved
only the canvas-shown vendor name (e.g. MINIMAX_API_KEY) hit a silent
401 on first call. The boot audit log even said ``MINIMAX_API_KEY=set``
which made root-causing this look like an SDK bug.
This file pins the projection contract:
1. Vendor key set + AUTH_TOKEN unset -> projection happens
2. AUTH_TOKEN already set -> never clobbered (operator override wins)
3. First-party (oauth / anthropic-api) provider picked -> no
projection (vendor names ignored even if set)
4. The secret VALUE is never logged (mirrors the
_audit_auth_env_presence guarantee from PR #32).
"""
from __future__ import annotations
import importlib.util
import logging
import sys
import types
from pathlib import Path
import pytest
@pytest.fixture
def adapter_module(monkeypatch):
"""Load adapter.py with molecule_runtime + a2a stubbed.
Same isolation strategy as test_adapter_logging.py see that file's
fixture comment for the rationale. We stub the heavy import deps so
the module-level helpers can be exercised without installing the
runtime wheel.
"""
pkg = types.ModuleType("molecule_runtime")
sub = types.ModuleType("molecule_runtime.adapters")
base = types.ModuleType("molecule_runtime.adapters.base")
base.BaseAdapter = type("BaseAdapter", (), {})
base.AdapterConfig = type("AdapterConfig", (), {})
base.RuntimeCapabilities = type("RuntimeCapabilities", (), {})
monkeypatch.setitem(sys.modules, "molecule_runtime", pkg)
monkeypatch.setitem(sys.modules, "molecule_runtime.adapters", sub)
monkeypatch.setitem(sys.modules, "molecule_runtime.adapters.base", base)
a2a = types.ModuleType("a2a")
a2a_server = types.ModuleType("a2a.server")
a2a_ax = types.ModuleType("a2a.server.agent_execution")
a2a_ax.AgentExecutor = type("AgentExecutor", (), {})
monkeypatch.setitem(sys.modules, "a2a", a2a)
monkeypatch.setitem(sys.modules, "a2a.server", a2a_server)
monkeypatch.setitem(sys.modules, "a2a.server.agent_execution", a2a_ax)
template_dir = Path(__file__).resolve().parent.parent
monkeypatch.syspath_prepend(str(template_dir))
sys.modules.pop("adapter", None)
spec = importlib.util.spec_from_file_location("adapter", template_dir / "adapter.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# Sentinel used across tests to verify the secret value never leaks
# into a log record. Distinctive enough that any substring match
# unambiguously means a regression.
_SENTINEL = "fake-vendor-secret-MUST-NOT-LEAK-244"
def _minimax_provider():
"""Return a minimax-shaped provider dict matching config.yaml's entry.
Built inline (not loaded from YAML) so the test doesn't depend on
config.yaml's exact contents — that keeps the test green if a
reviewer reorders the YAML or renames the provider entry, while
still pinning the routing contract on the helper itself.
"""
return {
"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", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"),
}
def _oauth_provider():
return {
"name": "anthropic-oauth",
"auth_mode": "oauth",
"model_prefixes": (),
"model_aliases": ("sonnet", "opus", "haiku"),
"base_url": None,
"auth_env": ("CLAUDE_CODE_OAUTH_TOKEN",),
}
def _clear_all_auth_env(monkeypatch, adapter_module):
"""Strip every auth-relevant env var so the test starts from a clean slate."""
for name in adapter_module._AUTH_ENV_AUDIT:
monkeypatch.delenv(name, raising=False)
def test_vendor_key_projects_when_auth_token_unset(adapter_module, monkeypatch):
"""The headline #244 fix: MINIMAX_API_KEY set, AUTH_TOKEN unset -> projection."""
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL)
adapter_module._project_vendor_auth(_minimax_provider())
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == _SENTINEL, (
"MINIMAX_API_KEY value must be projected onto ANTHROPIC_AUTH_TOKEN "
"so the claude-code-sdk finds the bearer token"
)
def test_existing_auth_token_not_clobbered(adapter_module, monkeypatch):
"""Idempotency: an explicit ANTHROPIC_AUTH_TOKEN is the operator override and wins."""
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("MINIMAX_API_KEY", "vendor-value")
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "operator-value")
adapter_module._project_vendor_auth(_minimax_provider())
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == "operator-value", (
"operator-set ANTHROPIC_AUTH_TOKEN must NEVER be overwritten by the "
"vendor-key projection — that's the explicit-override escape hatch"
)
def test_first_party_provider_skips_projection(adapter_module, monkeypatch):
"""OAuth/anthropic-api providers don't project even if a vendor key is set.
A workspace running on Claude Code OAuth that *also* happens to have
MINIMAX_API_KEY exported (e.g. a multi-vendor power user) must NOT
have that vendor key bleed into ANTHROPIC_AUTH_TOKEN the OAuth
path uses a totally different token and projection would only cause
confusion (and a confusing audit-log line).
"""
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "oauth-token")
adapter_module._project_vendor_auth(_oauth_provider())
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, (
"first-party provider (oauth) must not consume vendor-specific "
"env keys — projection should be a no-op for non-third-party paths"
)
def test_projection_logs_name_not_value(adapter_module, monkeypatch, caplog):
"""The secret value must NEVER appear in any log record.
Mirrors the safety guarantee on _audit_auth_env_presence (pinned by
test_adapter_logging.py::test_audit_lists_every_name_with_presence).
Same threat model: docker logs + central log aggregator must not
leak the bearer token.
"""
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("MINIMAX_API_KEY", _SENTINEL)
with caplog.at_level(logging.INFO, logger="adapter"):
adapter_module._project_vendor_auth(_minimax_provider())
# The projection happened (precondition for the leak check to be meaningful).
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == _SENTINEL
for record in caplog.records:
msg = record.getMessage()
assert _SENTINEL not in msg, (
f"projection logged the secret VALUE: {msg!r} — must log the "
"env NAME only (mirrors _audit_auth_env_presence contract)"
)
# Sanity: at least one log record mentioned the projection by NAME.
assert any(
"MINIMAX_API_KEY" in r.getMessage() and "ANTHROPIC_AUTH_TOKEN" in r.getMessage()
for r in caplog.records
), "expected an INFO log line documenting the MINIMAX_API_KEY -> ANTHROPIC_AUTH_TOKEN projection"
def test_empty_vendor_key_treated_as_unset(adapter_module, monkeypatch):
"""Empty-string vendor env doesn't trigger projection.
workspace-server's nil/empty handling can plausibly export
MINIMAX_API_KEY="" instead of omitting it (matches the audit
helper's empty-string handling — see test_adapter_logging.py).
Projecting an empty string would silently corrupt
ANTHROPIC_AUTH_TOKEN and turn a missing-key error into a 401 with
no diagnostic trail.
"""
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("MINIMAX_API_KEY", "")
adapter_module._project_vendor_auth(_minimax_provider())
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, (
"empty-string vendor env must not trigger projection — the right "
"failure mode is the existing 'no auth env set' warning, not a "
"silently-projected empty bearer token"
)
def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
"""The other three vendor names project too — not just MiniMax.
Parametrize-style coverage in one test so a future contributor adding
a new vendor sees the pattern in one place. Each iteration uses an
isolated provider dict + a freshly-cleared env.
"""
cases = [
("zai", "GLM_API_KEY"),
("moonshot", "KIMI_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
]
for provider_name, env_name in cases:
_clear_all_auth_env(monkeypatch, adapter_module)
sentinel = f"{env_name}-sentinel"
monkeypatch.setenv(env_name, sentinel)
provider = {
"name": provider_name,
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": (),
"model_aliases": (),
"base_url": "https://example.invalid/anthropic",
"auth_env": (env_name, "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"),
}
adapter_module._project_vendor_auth(provider)
import os
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == sentinel, (
f"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for "
f"provider={provider_name}"
)