Compare commits
17 Commits
de17e60d4a
...
f8d7f8f3a8
| Author | SHA1 | Date | |
|---|---|---|---|
| f8d7f8f3a8 | |||
| a2c7bf3d3b | |||
| a5c9acd950 | |||
| 3e491c673b | |||
|
|
91e5010888 | ||
|
|
b91f1ab694 | ||
|
|
cd68aae474 | ||
| f549d0e4f3 | |||
| 09c95308fd | |||
|
|
fb450b0758 | ||
|
|
e28c2d0fd7 | ||
|
|
50e16c5c73 | ||
|
|
b9a1fa1b1f | ||
|
|
c09a268ae4 | ||
|
|
78ae139609 | ||
|
|
863a1e6f87 | ||
|
|
7c23ea65a5 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -2,7 +2,7 @@ name: CI
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
validate:
|
||||||
uses: Molecule-AI/molecule-ci/.github/workflows/validate-workspace-template.yml@main
|
uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
|
||||||
|
|
||||||
tests:
|
tests:
|
||||||
name: Adapter unit tests
|
name: Adapter unit tests
|
||||||
|
|||||||
49
.github/workflows/publish-image.yml
vendored
49
.github/workflows/publish-image.yml
vendored
@ -32,14 +32,47 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# The `.runtime-version` file is the push-mode cascade signal post-
|
||||||
|
# 2026-05-06: when molecule-core/publish-runtime.yml ships a new
|
||||||
|
# version to PyPI, it does NOT call repository_dispatch (Gitea 1.22.6
|
||||||
|
# has no such endpoint — empirically verified molecule-core#20).
|
||||||
|
# Instead it git-pushes an updated `.runtime-version` to each template,
|
||||||
|
# which trips this workflow's `on: push: branches: [main]` trigger.
|
||||||
|
# This job reads that file and forwards the version to the reusable
|
||||||
|
# build workflow so the Dockerfile pip-installs the exact published
|
||||||
|
# version, not whatever requirements.txt currently bounds.
|
||||||
|
resolve-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 2
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.read.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- id: read
|
||||||
|
run: |
|
||||||
|
if [ -f .runtime-version ]; then
|
||||||
|
v="$(head -n1 .runtime-version | tr -d '[:space:]')"
|
||||||
|
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "resolved runtime version: $v"
|
||||||
|
else
|
||||||
|
echo "no .runtime-version file present — falling through to Dockerfile default"
|
||||||
|
fi
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
uses: Molecule-AI/molecule-ci/.github/workflows/publish-template-image.yml@main
|
needs: resolve-version
|
||||||
|
uses: molecule-ai/molecule-ci/.github/workflows/publish-template-image.yml@main
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
# When the cascade fires, client_payload.runtime_version is the
|
# Resolution chain (highest priority first):
|
||||||
# exact version PyPI just published. Forwarded to the reusable
|
# 1. client_payload.runtime_version — legacy GitHub
|
||||||
# workflow as a docker --build-arg so the cache key changes
|
# repository_dispatch path (will return if Gitea ever adds
|
||||||
# per-version and pip install resolves freshly.
|
# the dispatch API; left in place for forward-compat).
|
||||||
# On other events (push to main / manual without input), this is
|
# 2. inputs.runtime_version — manual workflow_dispatch run from
|
||||||
# empty and the Dockerfile's default (requirements.txt pin) applies.
|
# the Actions UI for ad-hoc rebuilds against a specific
|
||||||
runtime_version: ${{ github.event.client_payload.runtime_version || inputs.runtime_version || '' }}
|
# version.
|
||||||
|
# 3. needs.resolve-version.outputs.version — the
|
||||||
|
# `.runtime-version` file in this repo, written by
|
||||||
|
# molecule-core/publish-runtime.yml's push-mode cascade.
|
||||||
|
# 4. '' — fall through to the Dockerfile default
|
||||||
|
# (requirements.txt pin).
|
||||||
|
runtime_version: ${{ github.event.client_payload.runtime_version || inputs.runtime_version || needs.resolve-version.outputs.version || '' }}
|
||||||
|
|||||||
203
.github/workflows/secret-scan.yml
vendored
203
.github/workflows/secret-scan.yml
vendored
@ -1,22 +1,201 @@
|
|||||||
name: Secret scan
|
name: Secret scan
|
||||||
|
|
||||||
# Calls the canonical reusable workflow in molecule-core. Defense
|
# Hard CI gate. Refuses any PR / push whose diff additions contain a
|
||||||
# against the #2090-class leak (a hosted-agent commit slipping a
|
# recognisable credential. Defense-in-depth for the #2090-class incident
|
||||||
# credential-shaped string into a PR). Pattern set lives in
|
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
|
||||||
# molecule-core so we do not maintain a parallel copy here.
|
# installation token into tenant-proxy/package.json via `npm init`
|
||||||
|
# slurping the URL from a token-embedded origin remote. We can't fix
|
||||||
|
# upstream's clone hygiene, so we gate here.
|
||||||
#
|
#
|
||||||
# Pinned to @staging because that is the active default branch on the
|
# Inlined copy from molecule-ai/molecule-core/.github/workflows/secret-scan.yml.
|
||||||
# upstream repo (main lags behind via the staging-promotion workflow).
|
# Cross-repo workflow_call to a private repo doesn't fully work on Gitea 1.22.6
|
||||||
# Updates ride along automatically as the upstream regex set evolves.
|
# (workflow file fails parse-time at 0s with no logs); inline keeps the gate
|
||||||
|
# functional until Gitea is upgraded or the canonical scanner moves to a public
|
||||||
|
# repo. When that lands, this file reverts to the 3-line wrapper:
|
||||||
|
#
|
||||||
|
# jobs:
|
||||||
|
# secret-scan:
|
||||||
|
# uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||||
|
#
|
||||||
|
# Pin to @staging not @main — staging is the active default branch,
|
||||||
|
# main lags via the staging-promotion workflow. Updates ride along
|
||||||
|
# automatically on the next consumer workflow run.
|
||||||
|
#
|
||||||
|
# Same regex set as the runtime's bundled pre-commit hook
|
||||||
|
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
|
||||||
|
# Keep the two sides aligned when adding patterns.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
push:
|
push:
|
||||||
branches: [main, staging, master]
|
branches: [main, staging]
|
||||||
merge_group:
|
|
||||||
types: [checks_requested]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secret-scan:
|
scan:
|
||||||
uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
|
name: Scan diff for credential-shaped strings
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2 # need previous commit to diff against on push events
|
||||||
|
|
||||||
|
# For pull_request events the diff base may be many commits behind
|
||||||
|
# HEAD and absent from the shallow clone. Fetch it explicitly.
|
||||||
|
- name: Fetch PR base SHA (pull_request events only)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||||
|
|
||||||
|
# For merge_group events the queue's pre-merge ref is a commit on
|
||||||
|
# `gh-readonly-queue/...` whose parent is the queue's base_sha.
|
||||||
|
# That parent isn't part of the queue branch's shallow clone, so
|
||||||
|
# we fetch it explicitly. Without this the diff falls through to
|
||||||
|
# "no BASE → scan entire tree" mode and false-positives on legit
|
||||||
|
# test fixtures (e.g. canvas/src/lib/validation/__tests__/secret-formats.test.ts).
|
||||||
|
|
||||||
|
- name: Refuse if credential-shaped strings appear in diff additions
|
||||||
|
env:
|
||||||
|
# Plumb event-specific SHAs through env so the script doesn't
|
||||||
|
# need conditional `${{ ... }}` interpolation per event type.
|
||||||
|
# github.event.before/after only exist on push events;
|
||||||
|
# merge_group has its own base_sha/head_sha; pull_request has
|
||||||
|
# pull_request.base.sha / pull_request.head.sha.
|
||||||
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
PUSH_BEFORE: ${{ github.event.before }}
|
||||||
|
PUSH_AFTER: ${{ github.event.after }}
|
||||||
|
run: |
|
||||||
|
# Pattern set covers GitHub family (the actual #2090 vector),
|
||||||
|
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
|
||||||
|
# false-positive rates against agent-generated content. Mirror of
|
||||||
|
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||||
|
# — keep aligned.
|
||||||
|
SECRET_PATTERNS=(
|
||||||
|
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
|
||||||
|
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
|
||||||
|
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
|
||||||
|
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
|
||||||
|
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
|
||||||
|
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
|
||||||
|
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
||||||
|
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
|
||||||
|
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
|
||||||
|
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
|
||||||
|
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
|
||||||
|
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||||
|
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine the diff base. Each event type stores its SHAs in
|
||||||
|
# a different place — see the env block above.
|
||||||
|
case "${{ github.event_name }}" in
|
||||||
|
pull_request)
|
||||||
|
BASE="$PR_BASE_SHA"
|
||||||
|
HEAD="$PR_HEAD_SHA"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
BASE="$PUSH_BEFORE"
|
||||||
|
HEAD="$PUSH_AFTER"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# On push events with shallow clones, BASE may be present in
|
||||||
|
# the event payload but absent from the local object DB
|
||||||
|
# (fetch-depth=2 doesn't always reach the previous commit
|
||||||
|
# across true merges). Try fetching it on demand. If the
|
||||||
|
# fetch fails — e.g. the SHA was force-overwritten — we fall
|
||||||
|
# through to the empty-BASE branch below, which scans the
|
||||||
|
# entire tree as if every file were new. Correct, just slow.
|
||||||
|
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
|
||||||
|
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||||
|
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Files added or modified in this change.
|
||||||
|
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||||
|
# New branch / no previous SHA / BASE unreachable — check the
|
||||||
|
# entire tree as added content. Slower, but correct on first
|
||||||
|
# push.
|
||||||
|
CHANGED=$(git ls-tree -r --name-only HEAD)
|
||||||
|
DIFF_RANGE=""
|
||||||
|
else
|
||||||
|
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
|
||||||
|
DIFF_RANGE="$BASE $HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CHANGED" ]; then
|
||||||
|
echo "No changed files to inspect."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Self-exclude: this workflow file legitimately contains the
|
||||||
|
# pattern strings as regex literals. Without an exclude it would
|
||||||
|
# block its own merge.
|
||||||
|
SELF=".github/workflows/secret-scan.yml"
|
||||||
|
|
||||||
|
OFFENDING=""
|
||||||
|
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
|
||||||
|
# containing whitespace don't word-split silently — a path
|
||||||
|
# with a space would otherwise produce two iterations on
|
||||||
|
# tokens that aren't real filenames, breaking the
|
||||||
|
# self-exclude + diff lookup.
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[ -z "$f" ] && continue
|
||||||
|
[ "$f" = "$SELF" ] && continue
|
||||||
|
if [ -n "$DIFF_RANGE" ]; then
|
||||||
|
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||||
|
else
|
||||||
|
# No diff range (new branch first push) — scan the full file
|
||||||
|
# contents as if every line were new.
|
||||||
|
ADDED=$(cat "$f" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
[ -z "$ADDED" ] && continue
|
||||||
|
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||||
|
if echo "$ADDED" | grep -qE "$pattern"; then
|
||||||
|
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done <<< "$CHANGED"
|
||||||
|
|
||||||
|
if [ -n "$OFFENDING" ]; then
|
||||||
|
echo "::error::Credential-shaped strings detected in diff additions:"
|
||||||
|
# `printf '%b' "$OFFENDING"` interprets backslash escapes
|
||||||
|
# (the literal `\n` we appended above becomes a newline)
|
||||||
|
# WITHOUT treating OFFENDING as a format string. Plain
|
||||||
|
# `printf "$OFFENDING"` is a format-string sink: a filename
|
||||||
|
# containing `%` would be interpreted as a conversion
|
||||||
|
# specifier, corrupting the error message (or printing
|
||||||
|
# `%(missing)` artifacts).
|
||||||
|
printf '%b' "$OFFENDING"
|
||||||
|
echo ""
|
||||||
|
echo "The actual matched values are NOT echoed here, deliberately —"
|
||||||
|
echo "round-tripping a leaked credential into CI logs widens the blast"
|
||||||
|
echo "radius (logs are searchable + retained)."
|
||||||
|
echo ""
|
||||||
|
echo "Recovery:"
|
||||||
|
echo " 1. Remove the secret from the file. Replace with an env var"
|
||||||
|
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
|
||||||
|
echo " process.env.X in code)."
|
||||||
|
echo " 2. If the credential was already pushed (this PR's commit"
|
||||||
|
echo " history reaches a public ref), treat it as compromised —"
|
||||||
|
echo " ROTATE it immediately, do not just remove it. The token"
|
||||||
|
echo " remains valid in git history forever and may be in any"
|
||||||
|
echo " log/cache that consumed this branch."
|
||||||
|
echo " 3. Force-push the cleaned commit (or stack a revert) and"
|
||||||
|
echo " re-run CI."
|
||||||
|
echo ""
|
||||||
|
echo "If the match is a false positive (test fixture, docs example,"
|
||||||
|
echo "or this workflow's own regex literals): use a clearly-fake"
|
||||||
|
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
|
||||||
|
echo "the length suffix, OR add the file path to the SELF exclude"
|
||||||
|
echo "list in this workflow with a short reason."
|
||||||
|
echo ""
|
||||||
|
echo "Mirror of the regex set lives in the runtime's bundled"
|
||||||
|
echo "pre-commit hook (molecule-ai-workspace-runtime:"
|
||||||
|
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ No credential-shaped strings in this change."
|
||||||
|
|||||||
1
.runtime-version
Normal file
1
.runtime-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.129
|
||||||
19
CLAUDE.md
19
CLAUDE.md
@ -71,3 +71,22 @@ Skills persist across restarts. Use them to codify best practices, coding standa
|
|||||||
|
|
||||||
## Language
|
## Language
|
||||||
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
|
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
111
adapter.py
111
adapter.py
@ -15,6 +15,41 @@ logger = logging.getLogger(__name__)
|
|||||||
# the workspace by polling /transcript?limit=999999.
|
# the workspace by polling /transcript?limit=999999.
|
||||||
_TRANSCRIPT_MAX_LIMIT = 1000
|
_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.
|
# Auth-mode constants — provider entries use one of these strings.
|
||||||
# Drives validation behavior in setup() (third-party requires base_url
|
# Drives validation behavior in setup() (third-party requires base_url
|
||||||
# resolution; oauth/anthropic-api leave base_url=None for CLI defaults).
|
# resolution; oauth/anthropic-api leave base_url=None for CLI defaults).
|
||||||
@ -188,6 +223,63 @@ def _strip_provider_prefix(model: str) -> str:
|
|||||||
return model
|
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(
|
def _resolve_provider(
|
||||||
model: str,
|
model: str,
|
||||||
providers: tuple,
|
providers: tuple,
|
||||||
@ -411,6 +503,15 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
)
|
)
|
||||||
auth_env_options = provider["auth_env"]
|
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
|
# Endpoint precedence: operator-set ANTHROPIC_BASE_URL wins (escape
|
||||||
# hatch for custom regional endpoints — e.g. token-plan-sgp.* for
|
# hatch for custom regional endpoints — e.g. token-plan-sgp.* for
|
||||||
# Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the
|
# Xiaomi MiMo, api.minimaxi.com for MiniMax China). Otherwise the
|
||||||
@ -448,6 +549,16 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|||||||
"/".join(auth_env_options),
|
"/".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.
|
# Auth check — any of the provider's accepted env vars satisfies.
|
||||||
# Warning (not raise) so a workspace can still boot for non-LLM
|
# Warning (not raise) so a workspace can still boot for non-LLM
|
||||||
# work (terminal, file editing) while the operator sets the key.
|
# work (terminal, file editing) while the operator sets the key.
|
||||||
|
|||||||
55
config.yaml
55
config.yaml
@ -58,7 +58,10 @@ providers:
|
|||||||
model_prefixes: [minimax-]
|
model_prefixes: [minimax-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.minimax.io/anthropic
|
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.
|
# Z.ai — GLM family. docs.z.ai/scenario-example/develop-tools/claude.
|
||||||
# Model ids are uppercase (GLM-4.6) but the registry lowercases for
|
# Model ids are uppercase (GLM-4.6) but the registry lowercases for
|
||||||
@ -68,7 +71,7 @@ providers:
|
|||||||
model_prefixes: [glm-]
|
model_prefixes: [glm-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.z.ai/api/anthropic
|
base_url: https://api.z.ai/api/anthropic
|
||||||
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
|
||||||
|
|
||||||
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
|
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
|
||||||
- name: moonshot
|
- name: moonshot
|
||||||
@ -76,7 +79,7 @@ providers:
|
|||||||
model_prefixes: [kimi-]
|
model_prefixes: [kimi-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.moonshot.ai/anthropic
|
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
|
# DeepSeek — api-docs.deepseek.com/guides/anthropic_api. Note: their
|
||||||
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
|
# endpoint silently maps unknown model ids to deepseek-v4-flash, so a
|
||||||
@ -87,7 +90,7 @@ providers:
|
|||||||
model_prefixes: [deepseek-]
|
model_prefixes: [deepseek-]
|
||||||
model_aliases: []
|
model_aliases: []
|
||||||
base_url: https://api.deepseek.com/anthropic
|
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: claude-code
|
||||||
runtime_config:
|
runtime_config:
|
||||||
@ -145,50 +148,54 @@ runtime_config:
|
|||||||
required_env: [ANTHROPIC_API_KEY]
|
required_env: [ANTHROPIC_API_KEY]
|
||||||
|
|
||||||
# --- MiniMax (third-party, Anthropic-API-compatible) ---
|
# --- MiniMax (third-party, Anthropic-API-compatible) ---
|
||||||
# Routed via the `minimax` provider entry above. MiniMax docs prefer
|
# Vendor-specific env var so a user with multiple third-party Anthropic-
|
||||||
# ANTHROPIC_AUTH_TOKEN (Bearer-style) — see platform.minimax.io/docs/token-plan/claude-code.
|
# compat keys can run multiple workspaces simultaneously without them
|
||||||
# ANTHROPIC_API_KEY also works (the claude CLI accepts both).
|
# 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
|
- id: MiniMax-M2
|
||||||
name: MiniMax M2 (third-party, Anthropic-API-compatible)
|
name: MiniMax M2 (third-party, Anthropic-API-compatible)
|
||||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
required_env: [MINIMAX_API_KEY]
|
||||||
- id: MiniMax-M2.7
|
- id: MiniMax-M2.7
|
||||||
name: MiniMax M2.7 (third-party, Anthropic-API-compatible)
|
name: MiniMax M2.7 (third-party, Anthropic-API-compatible)
|
||||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
required_env: [MINIMAX_API_KEY]
|
||||||
- id: MiniMax-M2.7-highspeed
|
- id: MiniMax-M2.7-highspeed
|
||||||
name: MiniMax M2.7 High-Speed (third-party, Anthropic-API-compatible)
|
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) ---
|
# --- Z.ai GLM family (third-party, Anthropic-API-compatible) ---
|
||||||
# Routed via the `zai` provider entry. docs.z.ai for the full
|
# GLM_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot. docs.z.ai for
|
||||||
# Anthropic-compat docs. GLM-4.6 is the current-gen flagship; 4.5
|
# the full Anthropic-compat docs. GLM-4.6 is the current-gen flagship;
|
||||||
# remains for users on legacy quotas.
|
# 4.5 remains for users on legacy quotas.
|
||||||
- id: GLM-4.6
|
- id: GLM-4.6
|
||||||
name: Z.ai GLM-4.6 (third-party, Anthropic-API-compatible)
|
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
|
- id: GLM-4.5
|
||||||
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
|
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) ---
|
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
|
||||||
# Routed via the `moonshot` provider entry. platform.kimi.ai for docs.
|
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
|
||||||
# K2.5 is the latest agentic-coding tier; K2 stays as a cheaper option.
|
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
|
||||||
|
# K2 stays as a cheaper option.
|
||||||
- id: kimi-k2.5
|
- id: kimi-k2.5
|
||||||
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
|
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
|
||||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
required_env: [KIMI_API_KEY]
|
||||||
- id: kimi-k2
|
- id: kimi-k2
|
||||||
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
|
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) ---
|
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
|
||||||
# Routed via the `deepseek` provider entry. api-docs.deepseek.com.
|
# DEEPSEEK_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
|
||||||
# Note: unknown deepseek-* ids silently fall back to v4-flash on
|
# api-docs.deepseek.com. Note: unknown deepseek-* ids silently fall
|
||||||
# DeepSeek's side — pick the exact tier you mean.
|
# back to v4-flash on DeepSeek's side — pick the exact tier you mean.
|
||||||
- id: deepseek-v4-pro
|
- id: deepseek-v4-pro
|
||||||
name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible)
|
name: DeepSeek V4 Pro (third-party, Anthropic-API-compatible)
|
||||||
required_env: [ANTHROPIC_AUTH_TOKEN]
|
required_env: [DEEPSEEK_API_KEY]
|
||||||
- id: deepseek-v4-flash
|
- id: deepseek-v4-flash
|
||||||
name: DeepSeek V4 Flash (third-party, Anthropic-API-compatible)
|
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
|
# Default required_env — per-model entries above override this once a
|
||||||
# model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so
|
# model is picked. Keep CLAUDE_CODE_OAUTH_TOKEN as the default so
|
||||||
|
|||||||
@ -9,6 +9,36 @@
|
|||||||
# Pattern matches the legacy monorepo workspace-template/entrypoint.sh:
|
# Pattern matches the legacy monorepo workspace-template/entrypoint.sh:
|
||||||
# fix volume ownership as root, then re-exec via gosu as agent (uid 1000).
|
# 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
|
if [ "$(id -u)" = "0" ]; then
|
||||||
# Configs volume is created by Docker as root; agent needs write access
|
# Configs volume is created by Docker as root; agent needs write access
|
||||||
# for plugin installs, memory writes, .auth_token rotation, etc.
|
# for plugin installs, memory writes, .auth_token rotation, etc.
|
||||||
|
|||||||
@ -24,7 +24,7 @@ common problems.
|
|||||||
## Step 1 — Clone the Repository
|
## Step 1 — Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Molecule-AI/molecule-ai-workspace-template-claude-code.git
|
git clone https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-claude-code.git
|
||||||
cd molecule-ai-workspace-template-claude-code
|
cd molecule-ai-workspace-template-claude-code
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
172
tests/test_adapter_logging.py
Normal file
172
tests/test_adapter_logging.py
Normal 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)."
|
||||||
|
)
|
||||||
244
tests/test_provider_routing.py
Normal file
244
tests/test_provider_routing.py
Normal 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}"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user