Compare commits

..

1 Commits

Author SHA1 Message Date
Hongming Wang 227787bbbd test: bash coverage for entrypoint.sh log_boot_context()
CI / validate (push) Failing after 0s
CI / Adapter unit tests (push) Failing after 6s
The Python adapter audit (test_adapter_logging.py) pins the
adapter.py side, but the entrypoint shell function fires earlier and
twice (pre-gosu + post-gosu). When the SDK import wedge keeps the
adapter from running at all, the shell emission is the operator's
only visibility into the boot env.

Eight new tests cover:
- env NAME=set / env NAME=unset shape for every audited var
- value-leak guard: secret strings never appear in output
- WORKSPACE_ID + PLATFORM_URL passthrough by value (not secret)
- <unset> fallback for missing platform identifiers
- uid/gid line shape (used to verify the privilege drop)
- dated boot banner shape (used to count restarts in a crash loop)
- cross-file gate: shell for-loop names == fixture tuple, mirroring
  test_audit_env_list_matches_entrypoint_sh's adapter.py↔shell gate

Strategy: regex-extract the function body from entrypoint.sh and run
it in a fresh /bin/sh with controlled env. We never source the whole
entrypoint because it would chown /workspace and exec molecule-runtime.

Closes the gap from task #251 (follow-up to PR #32 boot-debug logging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:26:35 -07:00
23 changed files with 327 additions and 2459 deletions
-434
View File
@@ -1,434 +0,0 @@
name: CI
# Ported from .github/workflows/ci.yml on 2026-05-11 per internal#326
# (Class-A root: cross-repo `uses:` blocker for Gitea 1.22.6 —
# feedback_gitea_cross_repo_uses_blocked).
#
# Root cause of the main-red CI on this repo:
# The .github/ original used
# uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
# which Gitea 1.22.6 rejects (DEFAULT_ACTIONS_URL=github → 404 against
# the remote repo even though it lives on the same Gitea instance).
# Gitea reads .github/ as a fallback when .gitea/ is absent
# (reference_per_repo_gitea_vs_github_actions_dir), so the .github/
# workflow was firing on Gitea and failing in 1s.
#
# Fix shape: inline the validation logic directly. The canonical
# validator in molecule-ai/molecule-ci already self-clones into the
# runner via a direct HTTPS `git clone` step (validate-workspace-template.yml
# does this verbatim) — so the inline port is just "do that clone +
# invoke the validator script in-place", preserving the
# single-source-of-truth property (each CI run still fetches the
# canonical validator fresh).
#
# Four-surface migration audit (feedback_gitea_actions_migration_audit_pattern):
# 1. YAML — no `workflow_dispatch.inputs`; no `merge_group`; preserved
# `on: [push, pull_request]` from the original. Added workflow-level
# env.GITHUB_SERVER_URL (feedback_act_runner_github_server_url).
# 2. Cache — `actions/setup-python` `cache: pip` preserved; works against
# Gitea's built-in cache server when runner.cache is configured.
# 3. Token — uses auto-injected GITHUB_TOKEN (Gitea-aliased). Validator
# job needs only `contents: read` (no write to issues/PRs).
# 4. Docs — anonymous git-clone of molecule-ci (no token in URL); the
# molecule-ci repo is public on the Gitea instance.
#
# Fork-PR semantics: validate-runtime is intentionally skipped on fork
# PRs because pip-install + docker-build + adapter-import are arbitrary
# code execution. Internal PRs and main pushes get full coverage. The
# `github.event.pull_request.head.repo.fork` field is null for non-PR
# events; the `!= true` comparison defaults to running.
#
# Cross-links:
# - internal#326 — parent tracking issue
# - molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml — pattern source
# - molecule-ai/molecule-core/.gitea/workflows/ci.yml — Gitea port style reference
on: [push, pull_request]
# Defense-in-depth de-dup ONLY (the t4-conformance unique-name fix is the
# actual fail-closed primitive against the shared-host-daemon race; see
# that job). Scope per workflow + ref + EVENT so the push run and the
# pull_request run of the same internal-PR commit get DISTINCT groups —
# they must both complete (each emits its own required-status context;
# feedback_gitea_gate_check_required_list_not_combined_status). Never
# per-SHA-global: that silently cross-cancels legit required checks
# (feedback_concurrency_group_per_sha). cancel-in-progress:false so an
# in-flight live T4 probe is never aborted mid-assertion (a cancelled
# privileged probe would look like a gate failure / flake); a newer push
# to the same ref+event simply queues behind it.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
env:
# Belt-and-suspenders against the runner-default trap
# (feedback_act_runner_github_server_url). Runners are configured
# with this env via /opt/molecule/runners/config.yaml runner.envs,
# but pinning at the workflow level protects against a runner
# regenerated without the config file.
GITHUB_SERVER_URL: https://git.moleculesai.app
# Defense-in-depth on the GITHUB_TOKEN scope. The validate-runtime job
# runs untrusted-by-design code from the calling repo — pip-installs
# requirements.txt (post-install hooks), imports adapter.py, and
# docker-builds the Dockerfile. Each primitive can execute arbitrary
# code with the token in env. Pinning `contents: read` means the worst
# a malicious template PR can do with the token is read public repo
# state — no write to issues, no push to branches, no comment-spam.
permissions:
contents: read
jobs:
validate-static:
name: Template validation (static)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Canonical validator script lives in molecule-ci, fetched fresh on
# every run. Anonymous fetch of the public molecule-ci repo — no
# token needed; no actions/checkout cross-repo idiosyncrasies.
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# Secret scan — the most important check. Always runs, including
# on fork PRs (no third-party code executes here).
- name: Check for secrets
run: |
python3 - << 'PYEOF'
import os, re, sys
from pathlib import Path
PATTERNS = [
re.compile(r'''["']sk-ant-[a-zA-Z0-9]{50,}["']'''),
re.compile(r'''["']ghp_[a-zA-Z0-9]{36,}["']'''),
re.compile(r'''["']AKIA[A-Z0-9]{16}["']'''),
re.compile(r'''["'][a-zA-Z0-9/+=]{40}["']'''),
re.compile(r'''["']sk_test_[a-zA-Z0-9]{24,}["']'''),
re.compile(r'''["']Bearer\s+[a-zA-Z0-9_.-]{20,}["']'''),
re.compile(r'''ghp_[a-zA-Z0-9]{36,}'''),
re.compile(r'''sk-ant-[a-zA-Z0-9]{50,}'''),
]
SKIP_DIRS = {'.molecule-ci', '.molecule-ci-canonical', '.git', 'node_modules', '__pycache__'}
EXTENSIONS = {'.yaml', '.yml', '.md', '.py', '.sh'}
def is_false_positive(line):
ctx = line.lower()
return '...' in ctx or '<example' in ctx or '</example' in ctx
root = Path(os.environ.get('GITHUB_WORKSPACE', '.'))
warnings = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for filename in filenames:
if Path(filename).suffix not in EXTENSIONS:
continue
filepath = Path(dirpath) / filename
try:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
for lineno, line in enumerate(f.readlines(), 1):
for pattern in PATTERNS:
for match in pattern.finditer(line):
if not is_false_positive(line):
warnings.append(f" {filepath}:{lineno}: {match.group(0)[:40]}...")
except Exception:
pass
if warnings:
print("::error::Potential secret found in committed files:")
for w in warnings:
print(w)
sys.exit(1)
else:
print("::notice::No secrets detected")
PYEOF
# Static-only validator — file existence checks, YAML parse,
# AST inspection of adapter.py (no import). Doesn't execute any
# third-party code; safe on fork PRs.
- run: pip install pyyaml -q
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py --static-only
validate-runtime:
name: Template validation (runtime)
runs-on: ubuntu-latest
timeout-minutes: 15
needs: validate-static
# Skip when the PR comes from a fork — those are external,
# untrusted, and would let attackers run pip install / docker build
# / adapter.py import on our runner.
if: github.event.pull_request.head.repo.fork != true
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Fetch molecule-ci canonical scripts
run: git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-ci.git .molecule-ci-canonical
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: requirements.txt
- run: pip install pyyaml -q
# Install the template's runtime dependencies so the validator's
# check_adapter_runtime_load() can import adapter.py the same way
# the workspace container does at boot. Without this, a
# syntactically-valid adapter that ImportErrors on a missing
# transitive dep would build clean and crash on first user prompt.
- if: hashFiles('requirements.txt') != ''
run: pip install -q -r requirements.txt
- if: hashFiles('requirements.txt') == ''
run: pip install -q molecule-ai-workspace-runtime
- run: python3 .molecule-ci-canonical/scripts/validate-workspace-template.py
- name: Docker build smoke test
if: hashFiles('Dockerfile') != ''
run: |
# Graceful skip when the runner's job-container can't reach the
# Docker daemon (e.g. /var/run/docker.sock not mounted into the
# act job container, or the in-container uid not in the docker
# group). Without this guard, CI stays red even when the
# template's Dockerfile is fine — see internal#222 for the
# proper runner-config fix.
if ! docker info >/dev/null 2>&1; then
echo "::warning::docker daemon unreachable from runner job container — skipping Docker build smoke (runner-config gap, not a template issue)."
exit 0
fi
docker build -t template-test . --no-cache 2>&1 | tail -5 && echo "Docker build succeeded"
# --- Layer-3: real T4 tier-4 conformance gate (RFC internal#456 §11) ---
# NOT a string-match. Builds the actual image, runs it under the EXACT
# flags the controlplane provisioner emits for tier-4
# (userdata_containerized.go @ec2384c: --privileged --pid=host --network host
# -v /:/host -v /var/run/docker.sock:/var/run/docker.sock), then drives
# the *uniform T4 privilege contract* defined in
# molecule-ai/molecule-core's workspace-server/internal/provisioner/
# t4_privilege_contract.go and rendered via
# `go run ./workspace-server/cmd/t4-contract-dump`. Each capability
# in the YAML has a stable name, a shell probe that exits 0 on pass,
# and a severity (hard|advisory). Hard misses fail the gate; new
# capabilities propagate WITHOUT a per-template PR (just bump the
# MOLECULE_CORE_REF env, or let it float to main).
#
# PILOT (internal #174): this is the first template to consume the
# uniform contract. template-hermes / template-codex follow on
# sequenced PRs after this lands green.
#
# Anti-tautology (per memory feedback_hermes_listpeers_401_token_…):
# all probes run against a RUNNING container started via the real
# `docker run` flags the provisioner emits — no `chown` + immediate
# `stat` self-fulfilling pairs. The contract's
# `host_root_reach_via_nsenter` probe fails closed if `exec gosu agent`
# ever regresses, exactly as the Hermes equivalent does.
#
# The `list_peers_http_200` probe is OPT-IN (advisory by default in
# this template) because the platform a2a_mcp_server is only spun up
# by the real start.sh boot path with credentials we don't want in
# CI. The probe iterates capabilities; for `list_peers_http_200` we
# skip-with-warning if `/configs/.auth_token` is absent (smoke-mode).
# On a fresh prod provision the probe is exercised end-to-end by the
# post-pin live-verify (task #195).
#
# Concurrency-flake: per-run-unique `--name` + per-run-unique probe
# file paths under /host/tmp/. Push and pull_request runs of the
# same commit share a host Docker daemon (--network host); a static
# name would collide and false-negative. See sibling template-hermes
# ci.yml + task #207 for the canonical rationale.
t4-conformance:
name: T4 tier-4 conformance (live)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: validate-static
if: github.event.pull_request.head.repo.fork != true
env:
# The molecule-core ref the contract YAML is generated from.
# Default `main` floats with the latest contract; pin to a SHA
# for deterministic gate behavior across template branches.
# Adopters MAY override per-PR to test an unmerged contract change.
MOLECULE_CORE_REF: main
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v6.0.0
with:
go-version: "1.25"
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install -q pyyaml
- name: Fetch molecule-core + generate t4_capabilities.yaml from the uniform contract
run: |
set -euo pipefail
git clone --depth 1 --branch "${MOLECULE_CORE_REF}" \
https://git.moleculesai.app/molecule-ai/molecule-core.git .molecule-core
( cd .molecule-core/workspace-server && go run ./cmd/t4-contract-dump ) > t4_capabilities.yaml
# Defense-in-depth: schema-version assertion so a contract
# bump that breaks the parser shape is caught here, not at
# runtime where it would look like a phantom capability miss.
grep -q '^version: 1$' t4_capabilities.yaml || { echo "::error::t4_capabilities.yaml schema version unrecognized"; exit 1; }
echo "=== contract preview ==="
head -40 t4_capabilities.yaml
echo "=== capability names ==="
grep '^ - name:' t4_capabilities.yaml
- name: Build the runtime image
run: |
if ! docker info >/dev/null 2>&1; then
echo "::error::docker daemon unreachable — T4 conformance gate CANNOT verify host-root reach. This is a hard gate; failing closed (do NOT treat as skip). Fix runner-config (internal#222) to unblock."
exit 1
fi
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
docker build -t "$T4_TAG" . --no-cache 2>&1 | tail -5
- name: Run under EXACT tier-4 provisioner flags + iterate contract capabilities
env:
# Per-run-unique probe-id. Used by individual capability
# probes (agent_home_writable, host_fs_write_readback) to
# scope their on-disk markers; without this, concurrent
# same-commit push+pull_request runs would collide on the
# /host/tmp/* path (see template-hermes ci.yml + task #207).
MOLECULE_T4_PROBE_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
# Container name is computed in the script body and exported
# so the inline Python iterator can `docker exec` into it.
T4_PROBE_NAME: "t4probe-${{ github.run_id }}-${{ github.run_attempt }}"
run: |
set -euo pipefail
T4_TAG="t4-conformance-test:${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
T4_PROBE="$T4_PROBE_NAME"
docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true
docker run -d \
--name "$T4_PROBE" \
--network host \
--privileged \
--pid=host \
-v /:/host \
-v /var/run/docker.sock:/var/run/docker.sock \
-e MOLECULE_T4_PROBE_ID="$MOLECULE_T4_PROBE_ID" \
-e MOLECULE_T4_EGRESS_TARGETS="https://api.github.com/zen https://www.google.com/generate_204" \
--entrypoint /bin/sh \
"$T4_TAG" -c 'sleep 600' >/dev/null
trap 'docker rm -f "$T4_PROBE" >/dev/null 2>&1 || true; docker rmi -f "$T4_TAG" >/dev/null 2>&1 || true' EXIT
# ----- Reproduce SaaS-mode token agent-ownership pre-state -----
# The real entrypoint chowns /configs:agent before gosu; in this
# smoke probe /configs is unmounted, so reproduce the contract
# step. The `auth_token_agent_owned` probe THEN asserts the
# post-condition. This is NOT a tautology: the probe asserts
# `stat -c %u` returns 1000, which would fail if the entrypoint
# ever wrote the token as root in the live boot path
# (`host_root_reach_via_nsenter` + the gosu chain is the
# anti-regression guard for that — both probes must pass).
docker exec "$T4_PROBE" sh -c 'mkdir -p /configs && touch /configs/.auth_token && chown -R agent:agent /configs'
# ----- Iterate the contract YAML -----
# Pure-python YAML walker (PyYAML installed earlier). We
# don't exec the probe via shell-only because shell-parsing
# YAML is fragile; we do execute each probe IN the running
# container via `docker exec -u agent` so uid-1000 context is
# enforced.
python3 - <<'PYEOF'
import os, subprocess, sys, yaml
with open("t4_capabilities.yaml") as f:
doc = yaml.safe_load(f)
probe = os.environ["T4_PROBE_NAME"]
fails_hard = []
fails_soft = []
for cap in doc.get("capabilities", []):
name = cap["name"]
sev = cap.get("severity", "advisory")
probe_sh = cap["probe"]
# OPT-OUT semantics for capabilities that need a live
# platform/runtime not stood up in this probe. They are
# exercised end-to-end by the post-pin live-verify burst
# (task #195) instead.
if name == "list_peers_http_200":
# Only run if the in-container runtime has spun up;
# smoke-mode does not. Skip-with-notice keeps the
# gate honest without false negatives.
port = subprocess.run(
["docker","exec","-u","agent",probe,"sh","-c","[ -f /configs/.platform_port ]"],
capture_output=True,
).returncode
if port != 0:
print(f"::notice::skipping {name} — runtime not booted in CI smoke probe; covered by live post-pin verify")
continue
r = subprocess.run(
["docker","exec","-u","agent",probe,"sh","-c",probe_sh],
capture_output=True, text=True,
)
if r.returncode == 0:
print(f" PASS {name} ({sev})")
else:
msg = f"FAIL {name} ({sev}): rc={r.returncode} source={cap.get('source','?')}"
print(f"::error::{msg}")
if r.stderr.strip():
print(f" stderr: {r.stderr.strip()}")
if sev == "hard":
fails_hard.append(name)
else:
fails_soft.append(name)
if fails_hard:
print(f"::error::T4 conformance FAILED — hard capabilities not satisfied: {fails_hard} (RFC internal#456 §11; the gate is fail-closed)")
sys.exit(1)
if fails_soft:
print(f"::warning::T4 conformance: advisory capabilities failed: {fails_soft} (non-blocking, but inspect)")
print(f"::notice::T4 tier-4 conformance PASS — uniform contract satisfied ({len(doc.get('capabilities',[]))} capabilities checked)")
PYEOF
# Aggregator that emits a single `validate` check name — matches the
# historical required-check name on this repo's branch protection.
validate:
name: validate
runs-on: ubuntu-latest
needs: [validate-static, validate-runtime, t4-conformance]
if: always()
timeout-minutes: 1
steps:
- name: Aggregate
run: |
static="${{ needs.validate-static.result }}"
runtime="${{ needs.validate-runtime.result }}"
t4="${{ needs.t4-conformance.result }}"
echo "validate-static: $static"
echo "validate-runtime: $runtime"
echo "t4-conformance: $t4"
if [ "$static" != "success" ]; then
echo "::error::validate-static did not succeed: $static"
exit 1
fi
# Treat `skipped` as a pass for fork-PR semantics (validate-runtime
# is intentionally skipped on forks; static coverage is the gate).
if [ "$runtime" != "success" ] && [ "$runtime" != "skipped" ]; then
echo "::error::validate-runtime did not succeed: $runtime"
exit 1
fi
# T4 conformance is a HARD gate on internal (non-fork) PRs and
# main pushes. `skipped` is only acceptable on fork PRs (where
# the `if:` fork guard short-circuits it) — there the static
# gate is the floor. Any other non-success fails the build:
# "verified" T4 requires this live gate green, never inference.
if [ "$t4" != "success" ] && [ "$t4" != "skipped" ]; then
echo "::error::t4-conformance did not succeed: $t4 — T4 host-root reach / token-ownership not verified on a live container. Failing closed (RFC internal#456 §11)."
exit 1
fi
echo "::notice::Template validation aggregate passed (static=$static, runtime=$runtime, t4=$t4)"
tests:
name: Adapter unit tests
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5
with:
python-version: "3.11"
# pyyaml is the runtime dep that adapter.py's _load_providers reads
# /configs/config.yaml through. In production it arrives transitively
# via molecule-ai-workspace-runtime; in this minimal test env we
# install it explicitly so the YAML-loading code path is actually
# exercised (without it, _load_providers' broad except-Exception
# swallows the ImportError and silently falls back to _BUILTIN_PROVIDERS,
# which is exactly the behavior that bit us 2026-04-30 when CI
# claimed green on a build that couldn't route any third-party model).
- run: pip install -q pytest pytest-asyncio pyyaml
# Tests live under tests/ with their own pytest.ini that anchors
# rootdir there — keeps pytest from importing the package
# __init__.py (which does `from .adapter import ...` for runtime
# discovery and can't be satisfied without molecule_runtime
# installed). See tests/pytest.ini for the full rationale.
- run: python3 -m pytest tests/ -v
-235
View File
@@ -1,235 +0,0 @@
name: publish-image
# Builds the claude-code workspace template Dockerfile and pushes it to ECR as
# `<REGISTRY>/workspace-template-claude-code:latest` + `:sha-<7>`.
#
# Ported/inlined from molecule-ci's publish-template-image.yml reusable
# workflow. Cross-repo `uses:` is BLOCKED on Gitea 1.22.6 because
# DEFAULT_ACTIONS_URL=github causes the runner to attempt the lookup against
# github.com, which always 404s even for same-instance repos.
# (feedback_gitea_cross_repo_uses_blocked)
#
# Registry: production uses ECR (MOLECULE_IMAGE_REGISTRY env var on EC2 /
# Railway) backed by org-level AWS creds. The OSS default in registry.go is
# ghcr.io/molecule-ai but the ECR repo `molecule-ai/workspace-template-claude-code`
# already exists (created by the migration sweep). No GHCR token is in the
# credentials store — Gitea's GITHUB_TOKEN cannot authenticate to ghcr.io.
#
# Gitea 1.22.6 hostile-shape checklist applied:
# - No workflow_dispatch.inputs (silently rejected on 1.22.6)
# - No merge_group: trigger
# - No cross-repo uses:
# - GITHUB_SERVER_URL pinned at workflow level
# (feedback_act_runner_github_server_url)
# - No on.push.paths: (would permanently block path-excluded pushes)
# - timeout-minutes on every job
#
# Cascade signal: molecule-core/publish-runtime.yml fans out by git-pushing
# an updated `.runtime-version` file to this repo's main branch, which trips
# the `on: push: branches: [main]` trigger here. The resolve-version job reads
# that file and forwards the version as a RUNTIME_VERSION docker build-arg so
# pip install resolves the exact fresh version.
on:
push:
branches: [main]
workflow_dispatch:
env:
# Belt-and-suspenders for act_runner runners regenerated without the
# config.yaml envs block. (feedback_act_runner_github_server_url)
GITHUB_SERVER_URL: https://git.moleculesai.app
ECR_REGISTRY: 153263036946.dkr.ecr.us-east-2.amazonaws.com
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/workspace-template-claude-code
AWS_DEFAULT_REGION: us-east-2
permissions:
contents: read
jobs:
resolve-version:
name: Resolve runtime version
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
version: ${{ steps.read.outputs.version }}
sha: ${{ steps.read.outputs.sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- id: read
shell: bash
run: |
if [ -f .runtime-version ]; then
v="$(head -n1 .runtime-version | tr -d '[:space:]')"
echo "version=${v}" >> "$GITHUB_OUTPUT"
echo "resolved runtime version from .runtime-version: ${v}"
else
echo "version=" >> "$GITHUB_OUTPUT"
echo "no .runtime-version file — will use Dockerfile/requirements.txt pin"
fi
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
publish:
name: Build & push workspace-template-claude-code image
# internal#512: pin to the dedicated Linux publish runners (label
# "publish" → molecule-runner-publish-1/2). MUST NOT use `ubuntu-latest`:
# that label is also advertised by the Windows/WSL self-hosted runners
# (hongming-pc-runner-*), so this docker build/push job lands
# non-deterministically on a Windows runner where `aws ecr
# get-login-password | docker login --password-stdin` fails with
# "Failed to initialize: protocol not available" and the image never
# publishes. Placement-dependent, NOT a transient flake. Mirrors the
# molecule-core convention (publish-workspace-server-image.yml /
# publish-runtime.yml / publish-canvas-image.yml: `runs-on: publish`)
# and the codex sibling fix (PR#9).
# AND-of-labels: `publish` is also advertised by some
# hongming-pc-runner-publish-* runners (Windows), whose runner-base
# image (`docker-config-fix`) breaks `docker login --password-stdin`
# with `Error saving credentials: mkdir /home/hongming: permission
# denied` (same EACCES bug class as internal#597/#603 act_runner HOME
# injection). op-host molecule-runner-publish-{1,2} are the only
# runners advertising BOTH `publish` AND `release` (op-host
# /opt/molecule/runners/config.publish.yaml lines 28-29). Requiring
# both labels routes publish to op-host deterministically. Matches
# template-codex tc#22 (merge 0fb25352).
runs-on: [publish, release]
timeout-minutes: 30
needs: resolve-version
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Lint — no bare imports of runtime modules
# Catches `from plugins import ...` style bare imports that work in the
# monorepo layout but explode at startup in the published container
# (ModuleNotFoundError). Runs before Docker login so a bad adapter
# returns red in seconds.
# Fallback module list mirrors scripts/build_runtime_package.py:
# TOP_LEVEL_MODULES as of 2026-04-27.
shell: bash
run: |
set -eu
FALLBACK_MODULES='plugins|adapter_base|config|main|preflight|prompt|coordinator|consolidation|events|heartbeat|transcript_auth|runtime_wedge|watcher|skill_loader|policies|adapters|builtin_tools|executor_helpers|a2a_executor|a2a_client|a2a_tools|a2a_cli|a2a_mcp_server|agent|agents_md|initial_prompt|molecule_ai_status|platform_auth|shared_runtime'
RUNTIME_MODULES=""
mkdir -p /tmp/runtime-wheel
if pip download --quiet molecule-ai-workspace-runtime --no-deps -d /tmp/runtime-wheel 2>/dev/null; then
WHEEL=$(ls /tmp/runtime-wheel/*.whl 2>/dev/null | head -1)
if [ -n "$WHEEL" ]; then
RUNTIME_MODULES=$(unzip -p "$WHEEL" molecule_runtime/_runtime_modules.json 2>/dev/null \
| python3 -c "import sys,json; m=json.load(sys.stdin); print('|'.join(sorted(set(m['top_level_modules']) | set(m['subpackages']))))" 2>/dev/null || echo "")
fi
fi
if [ -n "$RUNTIME_MODULES" ]; then
echo "::notice::lint module list from published wheel"
else
RUNTIME_MODULES="$FALLBACK_MODULES"
echo "::warning::could not read _runtime_modules.json from wheel — using inline fallback"
fi
if HITS=$(grep -nE "^\s*from (${RUNTIME_MODULES}) import" *.py 2>/dev/null); then
echo "::error::Bare imports of runtime modules found — use 'from molecule_runtime.<module> import'"
echo "$HITS" | sed 's/^/ /'
exit 1
fi
echo "::notice::no bare imports of runtime modules in *.py files"
- name: Log in to ECR
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
set -euo pipefail
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
- name: Verify Docker daemon access
run: |
set -euo pipefail
docker info >/dev/null 2>&1 || {
echo "::error::Docker daemon is not accessible — check runner sock mount"
exit 1
}
echo "Docker daemon OK"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Ensure ECR repository exists
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: |
set -euo pipefail
repo_path="${IMAGE_NAME#*/}"
repo_path="${repo_path#*/}" # strip registry host + first slash → molecule-ai/workspace-template-claude-code
if ! aws ecr describe-repositories --repository-names "${repo_path}" --region us-east-2 >/dev/null 2>&1; then
aws ecr create-repository \
--repository-name "${repo_path}" \
--image-scanning-configuration scanOnPush=true \
--region us-east-2 >/dev/null
echo "::notice::created ECR repository ${repo_path}"
else
echo "ECR repository ${repo_path} already exists"
fi
- name: Build image (load for smoke test, do not push yet)
# Build into runner-local docker first. Smoke test runs before push so
# a broken adapter.py never poisons :latest.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
load: true
push: false
tags: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
build-args: |
RUNTIME_VERSION=${{ needs.resolve-version.outputs.version }}
labels: |
org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI workspace template — claude-code runtime
- name: Smoke test — import every /app/*.py
# Boot the locally-loaded image and import each *.py module to verify
# all module-level imports resolve against the pip-installed runtime.
shell: bash
env:
IMAGE: ${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
run: |
set -eu
docker run --rm \
-e WORKSPACE_ID=smoke-test \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-fake-smoke-token \
-e ANTHROPIC_API_KEY=sk-fake-smoke-key \
-e OPENAI_API_KEY=sk-fake-smoke-key \
--entrypoint sh "${IMAGE}" -c '
set -e
cd /app
for f in *.py; do
[ "$f" = "__init__.py" ] && continue
mod="${f%.py}"
python3 -c "import $mod" || { echo "::error::failed to import $mod"; exit 1; }
echo " import $mod OK"
done
'
echo "::notice::${IMAGE}: all /app/*.py modules import cleanly"
- name: Push image to ECR (post-smoke)
# Smoke passed — push both :latest and :sha-<7>. build-push-action
# reuses the cached layers so this is a layer-push, not a rebuild.
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:sha-${{ needs.resolve-version.outputs.sha }}
build-args: |
RUNTIME_VERSION=${{ needs.resolve-version.outputs.version }}
labels: |
org.opencontainers.image.source=https://git.moleculesai.app/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.description=Molecule AI workspace template — claude-code runtime
-196
View File
@@ -1,196 +0,0 @@
name: Secret scan
# Hard CI gate. Refuses any PR / push whose diff additions contain a
# recognisable credential. Defense-in-depth for the #2090-class incident
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
# installation token into tenant-proxy/package.json via `npm init`
# slurping the URL from a token-embedded origin remote. We can't fix
# upstream's clone hygiene, so we gate here.
#
# Same regex set as the runtime's bundled pre-commit hook
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
# Keep the two sides aligned when adding patterns.
#
# Ported from .github/workflows/secret-scan.yml so the gate actually
# fires on Gitea Actions. Differences from the GitHub version:
# - drops `merge_group` event (Gitea has no merge queue)
# - drops `workflow_call` (no cross-repo reusable invocation on Gitea)
# - SELF path updated to .gitea/workflows/secret-scan.yml
# The job name + step name are identical to the GitHub workflow so the
# status-check context (`Secret scan / Scan diff for credential-shaped
# strings (pull_request)`) matches branch protection on this template
# repo's main branch. Before this port, the required-status was satisfied
# only via a compensating signed POST /statuses/{SHA} because the
# .github/ workflow was silently shadowed by the .gitea/ directory taking
# precedence on this repo
# (reference_molecule_core_actions_gitea_only — same applies here).
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
jobs:
scan:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
# For pull_request events the diff base may be many commits behind
# HEAD and absent from the shallow clone. Fetch it explicitly.
- name: Fetch PR base SHA (pull_request events only)
if: github.event_name == 'pull_request'
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- name: Refuse if credential-shaped strings appear in diff additions
env:
# Plumb event-specific SHAs through env so the script doesn't
# need conditional `${{ ... }}` interpolation per event type.
# github.event.before/after only exist on push events;
# pull_request has pull_request.base.sha / pull_request.head.sha.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.event.after }}
run: |
# Pattern set covers GitHub family (the actual #2090 vector),
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
# false-positive rates against agent-generated content. Mirror of
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
# — keep aligned.
SECRET_PATTERNS=(
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
'AKIA[0-9A-Z]{16}' # AWS access key ID
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
)
# Determine the diff base. Each event type stores its SHAs in
# a different place — see the env block above.
case "${{ github.event_name }}" in
pull_request)
BASE="$PR_BASE_SHA"
HEAD="$PR_HEAD_SHA"
;;
*)
BASE="$PUSH_BEFORE"
HEAD="$PUSH_AFTER"
;;
esac
# On push events with shallow clones, BASE may be present in
# the event payload but absent from the local object DB
# (fetch-depth=2 doesn't always reach the previous commit
# across true merges). Try fetching it on demand. If the
# fetch fails — e.g. the SHA was force-overwritten — we fall
# through to the empty-BASE branch below, which scans the
# entire tree as if every file were new. Correct, just slow.
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
fi
# Files added or modified in this change.
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
# New branch / no previous SHA / BASE unreachable — check the
# entire tree as added content. Slower, but correct on first
# push.
CHANGED=$(git ls-tree -r --name-only HEAD)
DIFF_RANGE=""
else
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
DIFF_RANGE="$BASE $HEAD"
fi
if [ -z "$CHANGED" ]; then
echo "No changed files to inspect."
exit 0
fi
# Self-exclude: this workflow file legitimately contains the
# pattern strings as regex literals. Without an exclude it would
# block its own merge. Both the .github/ original and this
# .gitea/ port are excluded so a sync between them stays clean.
SELF_GITHUB=".github/workflows/secret-scan.yml"
SELF_GITEA=".gitea/workflows/secret-scan.yml"
OFFENDING=""
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
# containing whitespace don't word-split silently — a path
# with a space would otherwise produce two iterations on
# tokens that aren't real filenames, breaking the
# self-exclude + diff lookup.
while IFS= read -r f; do
[ -z "$f" ] && continue
[ "$f" = "$SELF_GITHUB" ] && continue
[ "$f" = "$SELF_GITEA" ] && continue
if [ -n "$DIFF_RANGE" ]; then
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
else
# No diff range (new branch first push) — scan the full file
# contents as if every line were new.
ADDED=$(cat "$f" 2>/dev/null || true)
fi
[ -z "$ADDED" ] && continue
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$ADDED" | grep -qE "$pattern"; then
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
break
fi
done
done <<< "$CHANGED"
if [ -n "$OFFENDING" ]; then
echo "::error::Credential-shaped strings detected in diff additions:"
# `printf '%b' "$OFFENDING"` interprets backslash escapes
# (the literal `\n` we appended above becomes a newline)
# WITHOUT treating OFFENDING as a format string. Plain
# `printf "$OFFENDING"` is a format-string sink: a filename
# containing `%` would be interpreted as a conversion
# specifier, corrupting the error message (or printing
# `%(missing)` artifacts).
printf '%b' "$OFFENDING"
echo ""
echo "The actual matched values are NOT echoed here, deliberately —"
echo "round-tripping a leaked credential into CI logs widens the blast"
echo "radius (logs are searchable + retained)."
echo ""
echo "Recovery:"
echo " 1. Remove the secret from the file. Replace with an env var"
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
echo " process.env.X in code)."
echo " 2. If the credential was already pushed (this PR's commit"
echo " history reaches a public ref), treat it as compromised —"
echo " ROTATE it immediately, do not just remove it. The token"
echo " remains valid in git history forever and may be in any"
echo " log/cache that consumed this branch."
echo " 3. Force-push the cleaned commit (or stack a revert) and"
echo " re-run CI."
echo ""
echo "If the match is a false positive (test fixture, docs example,"
echo "or this workflow's own regex literals): use a clearly-fake"
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
echo "the length suffix, OR add the file path to the SELF exclude"
echo "list in this workflow with a short reason."
echo ""
echo "Mirror of the regex set lives in the runtime's bundled"
echo "pre-commit hook (molecule-ai-workspace-runtime:"
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
exit 1
fi
echo "✓ No credential-shaped strings in this change."
+1 -1
View File
@@ -2,7 +2,7 @@ name: CI
on: [push, pull_request]
jobs:
validate:
uses: molecule-ai/molecule-ci/.github/workflows/validate-workspace-template.yml@main
uses: Molecule-AI/molecule-ci/.github/workflows/validate-workspace-template.yml@main
tests:
name: Adapter unit tests
+8 -41
View File
@@ -32,47 +32,14 @@ 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:
needs: resolve-version
uses: molecule-ai/molecule-ci/.github/workflows/publish-template-image.yml@main
uses: Molecule-AI/molecule-ci/.github/workflows/publish-template-image.yml@main
secrets: inherit
with:
# 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 || '' }}
# 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 || '' }}
+12 -191
View File
@@ -1,201 +1,22 @@
name: Secret scan
# Hard CI gate. Refuses any PR / push whose diff additions contain a
# recognisable credential. Defense-in-depth for the #2090-class incident
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
# installation token into tenant-proxy/package.json via `npm init`
# slurping the URL from a token-embedded origin remote. We can't fix
# upstream's clone hygiene, so we gate here.
# 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.
#
# 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.
# 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.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
branches: [main, staging, master]
merge_group:
types: [checks_requested]
jobs:
scan:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
# For pull_request events the diff base may be many commits behind
# HEAD and absent from the shallow clone. Fetch it explicitly.
- name: Fetch PR base SHA (pull_request events only)
if: github.event_name == 'pull_request'
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
# 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."
secret-scan:
uses: Molecule-AI/molecule-core/.github/workflows/secret-scan.yml@staging
-1
View File
@@ -1 +0,0 @@
0.1.129
-21
View File
@@ -72,27 +72,6 @@ Skills persist across restarts. Use them to codify best practices, coding standa
## Language
Always respond in the same language the user uses. If Chinese, respond in Chinese. If English, respond in English. Match exactly.
## Workspace ownership tier — T4 (top-tier)
**T4 (top-tier) workspaces grant full host control.** The Docker socket is mounted into the container; the runtime is started with `--privileged`; the workspace owner can `docker exec` into siblings on the same host and read/write anything the host kernel will let it touch.
What this means in practice:
- The container's home directory `~/.claude/` (and its `sessions/`, `session-env/`, `settings.json`) is persisted across restarts via a host bind mount. Anything you (or the SDK) write there survives container churn — but ownership can drift.
- If you see `EPERM` / "permission denied" on `~/.claude/` writes after a restart — particularly on `settings.json` or anything under `sessions/` — the volume picked up `root:root` ownership from a prior boot or a newer claude-code release wrote subdirs the entrypoint didn't pre-create.
- Recovery (do this yourself; you have the rights): `sudo chown -R agent:agent ~/.claude/`. The entrypoint already runs a recursive chown unconditionally on every boot, so a restart also clears it — but `sudo chown` is faster and doesn't drop the active session.
- The provisioned `~/.claude/settings.json` is informational: it documents that `permission_mode='bypassPermissions'` is the canonical operating mode, which is also set programmatically in `claude_sdk_executor.py` (the file is NOT the source of truth — the SDK kwargs are).
If `cat ~/.claude/settings.json` returns `No such file or directory` you're on a workspace image older than 2026-05-15 — restart picks up the new entrypoint and stubs the file in place.
## Knowing your own model
Use the `get_runtime_identity` MCP tool to know what model you actually are. It reads the live process env (`MODEL`, `MODEL_PROVIDER`, `MOLECULE_MODEL`, `ANTHROPIC_BASE_URL`, `TIER`, `WORKSPACE_ID`, `ADAPTER_MODULE`) and returns the resolved values — no HTTP call, always works, always permitted by RBAC. Do NOT guess from your system prompt or from `requirements.txt`; the operator may have routed you to a different model via persona env between boots.
## Editing your own agent_card
Use the `update_agent_card` MCP tool to update this workspace's `agent_card` on the platform. Pass a JSON object — the platform validates required fields server-side. The change is broadcast as an `agent_card_updated` event so the canvas reflects the new card live. The tool is gated on `memory.write` capability, so read-only agents won't accidentally rewrite the card; T4 owners always have this capability.
## Runtime wedge integration
The `runtime_wedge` module (in `molecule_runtime`) is the universal cross-cutting holder for "this Python process can no longer serve queries — only a workspace restart will recover." It surfaces unrecoverable wedges to two consumers:
+2 -74
View File
@@ -5,23 +5,8 @@ FROM python:3.11-slim
# --add-assignee`, `git clone`, etc. per their idle/cron prompts).
# Without these the team's claim-and-ship loop silently returns
# "(no response generated)" because tools error out.
#
# T4 escalation leg (RFC internal#456 §9 / PR#474):
# sudo + util-linux(nsenter) + docker.io(CLI) are baked here so the
# uid-1000 `agent` (see useradd below — UNCHANGED, agent stays
# uid-1000) has a wired, audited path to host root inside the
# provisioner's `--privileged --pid=host -v /:/host
# -v /var/run/docker.sock:/var/run/docker.sock` container. Without
# sudo, a uid-1000 process in --privileged CANNOT nsenter/chroot
# /host (--privileged grants caps to root, not uid-1000) and cannot
# use the root:docker 0660 docker.sock — T4 would be
# provisioner-shape-only (the documented ABSENT-escalation-leg gap).
# The sudoers drop-in + docker-group add are below, after useradd,
# so `agent` exists. This is ADDITIVE: it does NOT change the agent
# uid and does NOT change /configs token ownership (still uid-1000,
# enforced by entrypoint.sh + the Layer-3 conformance gate).
RUN apt-get update && apt-get install -y --no-install-recommends \
curl gosu nodejs npm ca-certificates git sudo util-linux docker.io \
curl gosu nodejs npm ca-certificates git \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
@@ -32,31 +17,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install claude-code CLI via npm
RUN npm install -g @anthropic-ai/claude-code 2>/dev/null || true
# Create agent user — UNCHANGED. The agent runs as uid-1000; the T4
# escalation leg below is additive and does NOT promote the agent to
# root. claude-code still refuses --dangerously-skip-permissions as
# root, and /configs/.auth_token must stay agent-owned (Hermes
# list_peers 401 class — RFC internal#456 §10).
# Create agent user
RUN useradd -u 1000 -m -s /bin/bash agent
# --- T4 escalation leg (RFC internal#456 §9.3 / PR#474) ---
# Wired path: uid-1000 agent -> host root inside the provisioner's
# --privileged --pid=host -v /:/host -v docker.sock container.
# 1. NOPASSWD sudoers drop-in (mode 0440, visudo-validated at build
# so a malformed sudoers can never ship a broken-sudo image).
# 2. agent in the `docker` group so the bind-mounted root:docker
# 0660 /var/run/docker.sock is usable without sudo.
# Atomic co-sequencing (RFC §10): this ships in the SAME image
# revision as the uid-1000 + agent-owned-token entrypoint contract;
# the Layer-3 conformance gate asserts BOTH on the running container.
RUN set -eux; \
printf 'agent ALL=(ALL) NOPASSWD:ALL\n' > /etc/sudoers.d/agent-t4; \
chmod 0440 /etc/sudoers.d/agent-t4; \
visudo -cf /etc/sudoers.d/agent-t4; \
groupadd -f docker; \
usermod -aG docker agent; \
id agent
WORKDIR /app
# RUNTIME_VERSION is forwarded from the reusable publish workflow as
@@ -81,19 +43,6 @@ RUN pip install --no-cache-dir -r requirements.txt && \
# Copy adapter code
COPY adapter.py .
COPY __init__.py .
# Provider registry. The adapter's _load_providers walks 4 paths:
# 1. /opt/adapter/config.yaml — provisioner-managed canonical
# 2. os.path.dirname(__file__)/config.yaml — alongside adapter.py (this image)
# 3. ${WORKSPACE_CONFIG_PATH}/config.yaml — workspace per-instance overrides
# 4. _BUILTIN_PROVIDERS — oauth + anthropic-api only
# On this image /opt/adapter/ is never populated by the platform
# provisioner, so path 2 (/app/config.yaml) is the load-bearing one.
# Without this COPY the file isn't in the image, all 3 file paths fail,
# and _load_providers falls through to _BUILTIN_PROVIDERS — every
# MiniMax/GLM/Kimi/DeepSeek model silently routes to anthropic-oauth →
# "Not logged in. Please run /login" at first LLM call. Caused the
# canary's 38h chronic red on 2026-05-07/08 (molecule-core#129).
COPY config.yaml .
# Adapter-specific executor — owned by THIS template (universal-runtime
# refactor, molecule-core task #87). Lives alongside adapter.py so
# Python's import system picks the local /app/claude_sdk_executor.py
@@ -119,27 +68,6 @@ COPY scripts/molecule-git-token-helper.sh /app/scripts/molecule-git-token-helper
COPY scripts/molecule-gh-token-refresh.sh /app/scripts/molecule-gh-token-refresh.sh
RUN chmod +x /app/scripts/molecule-git-token-helper.sh /app/scripts/molecule-gh-token-refresh.sh
# Generic GIT_ASKPASS helper — image-side companion to molecule-core PR
# #1525 (workspace-server applyAgentGitIdentity, merge_sha 73a09443a086).
# Reads HTTPS Basic-Auth credentials from env vars (GIT_HTTP_USERNAME /
# GIT_HTTP_PASSWORD, with GITEA_USER / GITEA_TOKEN as fallback) and emits
# them on the git credential-prompt protocol, so container-side `git` can
# authenticate to any private HTTPS remote without on-disk ~/.gitconfig
# or ~/.git-credentials mutation. The platform provisioner sets
# GIT_ASKPASS=/usr/local/bin/molecule-askpass via applyAgentGitIdentity;
# until this binary ships in the runtime image, git invocations error
# with "exec: /usr/local/bin/molecule-askpass: not found" (forward-only
# pin gap — same class as Hermes list_peers and codex template breakage,
# fixed image-side here).
#
# No hardcoded hostnames or vendor names — the script body is identical
# to the one shipped in molecule-core workspace/scripts/molecule-askpass
# and the parallel external workspace template repos, so any deployer
# can fork this template and use it against their own git host without
# editing.
COPY scripts/molecule-askpass /usr/local/bin/molecule-askpass
RUN chmod +x /usr/local/bin/molecule-askpass
# Drop-priv entrypoint — claude-code refuses --dangerously-skip-permissions
# as root, so we run molecule-runtime as the agent user (uid 1000).
# The script handles volume-ownership fix + session-dir symlink before
+49 -403
View File
@@ -144,135 +144,39 @@ def _normalize_provider(entry: dict):
"model_aliases": _coerce_string_list(entry.get("model_aliases"), lowercase=True),
"base_url": entry.get("base_url") or None,
"auth_env": _coerce_string_list(entry.get("auth_env"), lowercase=False),
# Which env var the boot-time vendor-key projection writes the
# vendor key INTO. Defaults to ANTHROPIC_AUTH_TOKEN (Bearer-style
# — correct for MiniMax/GLM/DeepSeek Anthropic-compat shims).
# Kimi For Coding's gateway authenticates with the x-api-key
# header (per kimi.com's official Claude Code doc), which the
# Anthropic SDK / claude CLI emits from ANTHROPIC_API_KEY — so
# that provider's entry sets auth_token_env: ANTHROPIC_API_KEY.
# Env-var names are case-sensitive; preserve case.
"auth_token_env": (
entry.get("auth_token_env")
if isinstance(entry.get("auth_token_env"), str)
and entry.get("auth_token_env").strip()
else "ANTHROPIC_AUTH_TOKEN"
),
}
# Canonical install path the platform provisioner is contracted to clone
# the template repo into. Hardcoded so the adapter's config.yaml lookup
# is invariant across Docker (mounted /app→/opt/adapter) and EC2-host
# (cloned by molecule-controlplane's ec2.go) install paths — robust
# against the site-packages copy that bit us 2026-05-04 11:08Z.
_CANONICAL_ADAPTER_DIR = "/opt/adapter"
# Adjacent-to-adapter.py path. Module-level so tests can monkeypatch it
# to redirect the path-2 lookup at a controlled tmp dir. Production code
# resolves this once at import time and never touches it again — same
# semantics as before.
_TEMPLATE_DIR = os.path.dirname(os.path.abspath(__file__))
def _load_providers(config_path: str) -> tuple:
"""Load the provider registry from the template's bundled config.yaml.
"""Load the provider registry from /configs/config.yaml.
The providers list is a TEMPLATE concern — it describes which
models/auth-modes this runtime image supports — and ships in the
template's own config.yaml alongside adapter.py. The per-workspace
``${WORKSPACE_CONFIG_PATH}/config.yaml`` (default ``/configs/``)
only contains workspace-specific overrides (model, runtime, skills,
prompt files) and does NOT carry a providers section.
The YAML's top-level ``providers:`` list is the canonical source —
canvas Config tab reads the same list to populate its Provider
dropdown so the UI and the adapter never disagree on what's
available. Falls back to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api)
if the file is missing, malformed, or has no providers section, so a
bare-bones workspace still boots with the historical defaults.
Two-step incident history:
• Pre-2026-05-04 09:00Z: only checked ``config_path``, fell back
to ``_BUILTIN_PROVIDERS`` (oauth + anthropic-api). Every
MiniMax / GLM / Kimi / DeepSeek model resolved to
``anthropic-oauth`` and crashed at first LLM call with
"Not logged in. Please run /login". Fixed by adding a
template-bundled lookup using
``os.path.dirname(os.path.abspath(__file__))``.
• 2026-05-04 11:08Z: that ``__file__`` lookup misses on EC2-host
installs because the provisioner copies adapter.py to
``/opt/molecule-venv/lib/python3.12/site-packages/`` —
site-packages wins over PYTHONPATH=/opt/adapter (which the
host install doesn't set), so __file__ resolves to the venv
path WITHOUT an adjacent config.yaml. Same silent fallback
to anthropic-oauth + same "Not logged in" symptom.
• 2026-05-08 (#129): the multi-path lookup that fixed both of
the above was lost in a post-suspension migration cycle (the
Gitea main branch never carried the fix even though the
:latest image had it baked in from a prior build). Canary
chronic red for 38h before this commit restored the lookup.
Resolution order:
1. ``/opt/adapter/config.yaml`` — canonical provisioner-managed
install dir. Hardcoded because the platform contract is
"provisioner clones template repo into /opt/adapter"; this
is invariant across Docker (mounted /app→/opt/adapter) and
EC2-host (cloned by ec2.go) install paths. Robust against
site-packages copy.
2. Adjacent to ``adapter.__file__`` — works in dev/test where
the canonical path doesn't exist. Also covers the Docker
image's /app/config.yaml (bundled by Dockerfile #6).
3. Per-workspace ``${config_path}/config.yaml`` — fallback for
operator-shipped overrides on a private deployment that
wants a custom providers list.
4. ``_BUILTIN_PROVIDERS`` — oauth + anthropic-api defaults so a
bare-bones workspace still boots even with no config.yaml
anywhere.
Per-entry isolation: a single bad provider entry is dropped with
a warning; the rest of the registry survives.
Per-entry isolation: a single bad provider entry is dropped with a
warning; the rest of the registry survives. Used to be a generator
inside tuple(...) that propagated any AttributeError out and reverted
the whole registry to builtins — exactly the silent-fallback failure
mode this file's existence was meant to fix.
"""
canonical_yaml = os.path.join(_CANONICAL_ADAPTER_DIR, "config.yaml")
template_yaml = os.path.join(_TEMPLATE_DIR, "config.yaml")
workspace_yaml = os.path.join(config_path, "config.yaml")
# Deduplicate while preserving order — _CANONICAL_ADAPTER_DIR and
# the __file__ dir collide in dev/test (when imported from
# /opt/adapter directly), and workspace_yaml may also collide if
# config_path == /opt/adapter in tests.
seen = set()
candidates = []
for path in (canonical_yaml, template_yaml, workspace_yaml):
if path not in seen:
seen.add(path)
candidates.append(path)
raw = None
chosen_path = None
yaml_path = os.path.join(config_path, "config.yaml")
try:
import yaml # transitive dep via molecule-ai-workspace-runtime
except ImportError:
logger.warning("providers: yaml import failed; using builtins")
with open(yaml_path, "r") as f:
data = yaml.safe_load(f) or {}
except FileNotFoundError:
logger.info("providers: %s not found, using builtin defaults", yaml_path)
return _BUILTIN_PROVIDERS
except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML
logger.warning("providers: failed to load from %s (%s); using builtins", yaml_path, exc)
return _BUILTIN_PROVIDERS
for yaml_path in candidates:
try:
with open(yaml_path, "r") as f:
data = yaml.safe_load(f) or {}
except FileNotFoundError:
logger.info("providers: %s not found, trying next candidate", yaml_path)
continue
except Exception as exc: # noqa: BLE001 — defensive: never block boot on YAML
logger.warning(
"providers: failed to load from %s (%s); trying next candidate",
yaml_path, exc,
)
continue
candidate_raw = data.get("providers") if isinstance(data, dict) else None
if isinstance(candidate_raw, list) and candidate_raw:
raw = candidate_raw
chosen_path = yaml_path
break
if raw is None:
logger.info(
"providers: no providers section found in %s; using builtin defaults",
" or ".join(candidates),
)
raw = data.get("providers") if isinstance(data, dict) else None
if not isinstance(raw, list) or not raw:
return _BUILTIN_PROVIDERS
parsed = []
@@ -286,139 +190,11 @@ def _load_providers(config_path: str) -> tuple:
parsed.append(normalized)
if not parsed:
logger.warning("providers: no valid entries in %s; using builtins", chosen_path)
logger.warning("providers: no valid entries in %s; using builtins", yaml_path)
return _BUILTIN_PROVIDERS
logger.info("providers: loaded %d entries from %s", len(parsed), chosen_path)
return tuple(parsed)
# Aliases for `MODEL_PROVIDER` env values that should map to a registry
# provider name. The persona env files use shorter / friendlier slugs
# than the registry's canonical names — without this alias map a value
# like ``MODEL_PROVIDER=claude-code`` would fall through to YAML-based
# resolution and (when the YAML doesn't pin a provider) hit the
# model-prefix matcher with the operator-picked MODEL, mis-routing a
# lead workspace through MiniMax even though its CLAUDE_CODE_OAUTH_TOKEN
# was clearly meant to be used.
#
# Maintain this list in sync with the persona env file convention:
# - ``claude-code`` → ``anthropic-oauth`` (Claude Code subscription path)
# - ``anthropic`` → ``anthropic-api`` (direct Anthropic API key)
# Provider names already in the registry alias to themselves implicitly
# (the ``in registry`` check catches them before this map is consulted).
_PROVIDER_SLUG_ALIASES = {
"claude-code": "anthropic-oauth",
"anthropic": "anthropic-api",
}
def _resolve_model_and_provider_from_env(
yaml_model: str,
yaml_provider: str,
providers: tuple,
) -> tuple:
"""Reconcile model + provider from env vars vs YAML, with the persona-env
convention winning over the legacy ``MODEL_PROVIDER``-as-model-id usage.
The persona env files (``~/.molecule-ai/personas/<name>/env`` on the host,
sourced into each workspace container at provision time) declare TWO env
vars with distinct semantics:
* ``MODEL`` — the model id (e.g. ``MiniMax-M2.7-highspeed``, ``opus``).
* ``MODEL_PROVIDER`` — the provider slug (e.g. ``minimax``,
``claude-code``, ``anthropic``).
The legacy ``workspace/config.py`` (in molecule-ai-workspace-runtime)
historically interpreted ``MODEL_PROVIDER`` as the *model id* — a name
chosen before there was a separate ``MODEL`` env var. When both env vars
are set with the persona convention, the legacy code reads
``MODEL_PROVIDER=minimax`` into ``runtime_config.model``, which then
fails to match any registry prefix (``minimax-`` requires a hyphen
suffix) and silently falls through to providers[0] (``anthropic-oauth``).
OAuth-token-less workspaces then wedge at ``query.initialize()`` because
the claude CLI can't authenticate. This is the 2026-05-08 dev-tree
incident — 22/27 non-lead workspaces stuck in ``degraded``.
Resolution order (this function):
1. ``MODEL`` env var → picked_model. Authoritative when set; the
persona env always sets it alongside ``MODEL_PROVIDER`` so the
model id never has to be inferred.
2. ``MODEL_PROVIDER`` env var → explicit_provider, BUT only when the
value matches a known provider name in the registry. This guards
against the legacy case where some callers still set
``MODEL_PROVIDER`` to a model id (e.g. canvas Save+Restart prior to
this fix). If the value isn't a registered provider name and YAML
didn't supply a model, treat it as a model id for back-compat.
3. YAML ``runtime_config.model`` / ``provider`` — used for any field
the env didn't supply. Carries the operator's canvas selection
on workspaces that haven't yet adopted the persona env shape.
Returns ``(picked_model, explicit_provider_name)``. Either may be
empty/None — the caller (``setup``) handles the empty cases via
``_resolve_provider``'s registry fallback.
"""
env_model = (os.environ.get("MODEL") or "").strip()
env_provider = (os.environ.get("MODEL_PROVIDER") or "").strip()
provider_names_lower = {p.get("name", "").lower() for p in providers}
# Detect whether MODEL_PROVIDER carries the persona-convention slug
# (provider name) vs. the legacy convention (model id). Persona-
# convention wins when the value matches a registered provider; we
# fall back to legacy interpretation only when it doesn't.
#
# First, apply the alias map so persona-friendly slugs like
# ``claude-code`` resolve to the canonical registry name
# ``anthropic-oauth``. Without this, a lead workspace's
# ``MODEL_PROVIDER=claude-code`` env would fall through to the model-
# prefix matcher, see ``MODEL=MiniMax-M2.7`` and mis-route to MiniMax
# even though the operator's intent (and the OAuth token they set)
# was the OAuth subscription path.
env_provider_resolved = _PROVIDER_SLUG_ALIASES.get(
env_provider.lower(), env_provider,
) if env_provider else ""
env_provider_is_slug = (
bool(env_provider_resolved)
and env_provider_resolved.lower() in provider_names_lower
)
# Picked model resolution
if env_model:
picked_model = env_model
elif env_provider and not env_provider_is_slug:
# Legacy: MODEL_PROVIDER env carried the model id. Honor it so
# canvas Save+Restart workflows that predate this fix keep working.
picked_model = env_provider
else:
picked_model = yaml_model or ""
# Explicit provider resolution — env wins when it's a registered slug
# (after alias mapping), otherwise fall back to YAML.
#
# YAML aliasing: the molecule-runtime wheel (config.py) auto-derives
# ``runtime_config.provider`` from the YAML/default model slug — the
# default model ``anthropic:claude-opus-4-7`` yields ``anthropic`` as
# the inferred provider. Without applying the alias map here, that
# auto-derived ``anthropic`` slug fails registry lookup and the
# adapter raises ValueError ("provider='anthropic' but it is not in
# the providers registry"), wedging the workspace at boot. The alias
# map already handles this for the env-var path above; mirror the
# same treatment for the YAML path so the runtime-wheel default
# produces a registered provider name in both cases. Caught
# 2026-05-09 on staging-cplead-2 — every workspace booted with
# ``configuration_status=not_configured`` because the YAML provider
# ``anthropic`` was passed through verbatim instead of being aliased
# to ``anthropic-api``.
if env_provider_is_slug:
explicit_provider = env_provider_resolved
elif yaml_provider:
yp_lower = yaml_provider.lower()
explicit_provider = _PROVIDER_SLUG_ALIASES.get(yp_lower, yaml_provider)
else:
explicit_provider = None
return picked_model, explicit_provider
def _strip_provider_prefix(model: str) -> str:
"""Strip LangChain-style "<provider>:<model>" prefix from a model id.
@@ -460,18 +236,12 @@ _VENDOR_KEY_NAMES = frozenset({
def _project_vendor_auth(provider: dict) -> None:
"""Project a per-vendor API key onto the provider's auth-token env at boot.
Third-party Anthropic-compat providers (MiniMax, Z.ai, DeepSeek)
reuse the Anthropic SDK's wire format with a Bearer token, which the
``claude`` CLI / claude-code-sdk reads from ``ANTHROPIC_AUTH_TOKEN``.
Kimi For Coding's gateway instead authenticates with the
``x-api-key`` header (per kimi.com's official Claude Code
integration doc), which the SDK emits from ``ANTHROPIC_API_KEY`` —
so the projection target is per-provider, declared as
``auth_token_env`` in the registry (default ``ANTHROPIC_AUTH_TOKEN``
preserves the existing MiniMax/GLM/DeepSeek behavior unchanged).
"""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
@@ -479,24 +249,21 @@ def _project_vendor_auth(provider: dict) -> None:
/ hermes PR #38.
Behavior:
* Let ``target`` = the provider's ``auth_token_env`` (default
``ANTHROPIC_AUTH_TOKEN``).
* If the matched provider's ``auth_env`` lists any of
``_VENDOR_KEY_NAMES`` and that var is set, copy its value into
``target`` so the SDK finds it.
* **Idempotent**: if ``target`` is already set we do NOT
overwrite — an explicit operator value (workspace secret)
always wins over auto-projection.
* Logs the projection by NAME (e.g. ``KIMI_API_KEY ->
ANTHROPIC_API_KEY``); never logs the secret VALUE. Same
``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 ()
target = provider.get("auth_token_env") or "ANTHROPIC_AUTH_TOKEN"
if os.environ.get(target):
if os.environ.get("ANTHROPIC_AUTH_TOKEN"):
# Operator override wins — never clobber an explicit value.
return
for name in auth_env:
@@ -505,36 +272,21 @@ def _project_vendor_auth(provider: dict) -> None:
value = os.environ.get(name)
if not value:
continue
os.environ[target] = value
os.environ["ANTHROPIC_AUTH_TOKEN"] = value
logger.info(
"auth env projection: %s -> %s (provider=%s)",
name, target, provider.get("name", "<unknown>"),
"auth env projection: %s -> ANTHROPIC_AUTH_TOKEN (provider=%s)",
name, provider.get("name", "<unknown>"),
)
return
def _resolve_provider(
model: str,
providers: tuple,
explicit_provider: str = None,
) -> dict:
def _resolve_provider(model: str, providers: tuple) -> dict:
"""Return the provider entry matching this model id.
If ``explicit_provider`` is given (set via the ``provider:`` field in
workspace config.yaml or runtime_config), look up by name first. If the
named provider is not in the registry, RAISE ``ValueError`` with an
actionable message — silent fallback to ``providers[0]`` is the bug
that motivated #180 (workspace operator picks ``provider: minimax``
in the canvas Config tab, the adapter ignores it, the Claude SDK
silently keeps using ``CLAUDE_CODE_OAUTH_TOKEN`` and the operator has
no way to tell from the canvas that their provider switch did
nothing).
Without an explicit name: match is case-insensitive, prefix wins over
alias when both could apply, and unknown ids fall back to the first
provider in the registry (by convention, the OAuth/safest default —
``anthropic-oauth`` in both _BUILTIN_PROVIDERS and the shipped
config.yaml).
Match is case-insensitive: prefix wins over alias when both could
apply. Unknown ids fall back to the first provider in the registry
(by convention, the OAuth/safest default — anthropic-oauth in both
_BUILTIN_PROVIDERS and the shipped config.yaml).
Pre-condition: ``providers`` is non-empty. _load_providers always
returns at least one entry (built-ins when YAML is missing or every
@@ -546,44 +298,6 @@ def _resolve_provider(
"_load_providers must always return at least one entry "
"(falling back to _BUILTIN_PROVIDERS when needed)"
)
# Explicit provider name takes precedence — fail fast if it's not in
# the registry. Anything else would silently route the operator's
# picked provider through the wrong auth/base_url path. The error
# message tells them exactly which two paths fix it.
if explicit_provider:
ep_lower = explicit_provider.lower()
for provider in providers:
if provider["name"].lower() == ep_lower:
return provider
names = ", ".join(p["name"] for p in providers)
raise ValueError(
f"claude-code adapter: workspace config picks "
f"provider='{explicit_provider}' but it is not in the "
f"providers registry.\n"
f"\n"
f"Known providers: {names}\n"
f"\n"
f"Two ways to fix:\n"
f" (a) Add '{explicit_provider}' to /configs/config.yaml as a "
f"providers: entry. Required keys:\n"
f" providers:\n"
f" - name: {explicit_provider}\n"
f" auth_mode: third_party_anthropic_compat\n"
f" base_url: https://... # provider's Anthropic-compat endpoint\n"
f" auth_env: [{explicit_provider.upper()}_API_KEY]\n"
f" model_prefixes: [...]\n"
f" (b) Switch the workspace runtime template to one that "
f"natively supports {explicit_provider} (CrewAI, LangGraph, or "
f"DeepAgents read provider/model from runtime_config and route "
f"directly without needing an Anthropic-compat shim).\n"
f"\n"
f"Note: claude-code SDK speaks the Anthropic API protocol. "
f"Providers that only expose OpenAI-compatible endpoints "
f"(MiniMax, GLM, Kimi, DeepSeek native APIs) need either an "
f"Anthropic-compat proxy in front, or option (b)."
)
if not model:
return providers[0]
m = model.lower()
@@ -686,52 +400,9 @@ class ClaudeCodeAdapter(BaseAdapter):
# validation + ANTHROPIC_BASE_URL routing from that single decision.
rc = config.runtime_config
if isinstance(rc, dict):
yaml_model = rc.get("model") or ""
yaml_provider_name = rc.get("provider") or ""
picked_model = rc.get("model") or "sonnet"
else:
yaml_model = getattr(rc, "model", None) or ""
yaml_provider_name = getattr(rc, "provider", None) or ""
# Also honor the top-level `provider:` field in /configs/config.yaml.
# The canvas Config-tab Provider dropdown writes there (not into
# runtime_config) on some legacy paths. Either source is canonical;
# whichever is set wins. Root cause of #180: the adapter used to
# ignore both, silently routing every non-Anthropic provider pick
# through anthropic-oauth.
if not yaml_provider_name:
yaml_path = os.path.join(config.config_path, "config.yaml")
try:
import yaml # transitive dep via molecule-ai-workspace-runtime
with open(yaml_path, "r") as f:
data = yaml.safe_load(f) or {}
if isinstance(data, dict):
val = data.get("provider")
if isinstance(val, str) and val.strip():
yaml_provider_name = val.strip()
except FileNotFoundError:
pass
except Exception as exc: # noqa: BLE001 — defensive: never block boot
logger.warning(
"providers: failed to read top-level provider: from %s (%s); "
"falling back to model-based resolution",
yaml_path, exc,
)
# Reconcile env vars (persona convention: MODEL=<id>,
# MODEL_PROVIDER=<slug>) against YAML. Env wins over YAML — the
# persona env files are the canonical per-agent provider mapping
# (Phase 2 mapping 2026-05-08), and the workspace-runtime wheel's
# legacy ``MODEL_PROVIDER``-as-model-id reading would otherwise
# silently route non-leads to providers[0] = anthropic-oauth.
# Documented in detail at _resolve_model_and_provider_from_env.
picked_model, explicit_provider_name = _resolve_model_and_provider_from_env(
yaml_model=yaml_model,
yaml_provider=yaml_provider_name,
providers=providers,
)
if not picked_model:
picked_model = "sonnet"
picked_model = getattr(rc, "model", None) or "sonnet"
# NOTE: do NOT strip the provider prefix here. The pre-fix routing
# behavior — `anthropic:claude-opus-4-7` falls through to
# providers[0] (anthropic-oauth) when no model_prefixes match — is
@@ -740,15 +411,7 @@ class ClaudeCodeAdapter(BaseAdapter):
# `anthropic-api` provider and the CLI then hangs at `initialize`
# because ANTHROPIC_API_KEY isn't set. The strip belongs only at
# the CLI invocation site (create_executor below).
#
# Pass the explicit provider name through so _resolve_provider
# raises ValueError with an actionable message (instead of silently
# routing to providers[0]) when an operator picks a provider that
# isn't in the registry. See #180.
provider = _resolve_provider(
picked_model, providers,
explicit_provider=explicit_provider_name,
)
provider = _resolve_provider(picked_model, providers)
auth_env_options = provider["auth_env"]
# Project the per-vendor API key (MINIMAX_API_KEY, GLM_API_KEY,
@@ -859,26 +522,9 @@ class ClaudeCodeAdapter(BaseAdapter):
# RuntimeConfig dataclass. Read `model` defensively from either shape.
rc = config.runtime_config
if isinstance(rc, dict):
yaml_model = rc.get("model") or ""
yaml_provider = rc.get("provider") or ""
explicit_model = rc.get("model") or ""
else:
yaml_model = getattr(rc, "model", None) or ""
yaml_provider = getattr(rc, "provider", None) or ""
# Reconcile against env vars (persona convention: MODEL=<id>,
# MODEL_PROVIDER=<slug>) using the same helper that ``setup`` uses,
# so the executor and the boot banner agree on the picked model.
# Without this, a workspace whose env says ``MODEL=MiniMax-M2.7``
# but whose runtime wheel pre-dates the persona-env fix would set
# runtime_config.model="minimax" (the slug, mistakenly read by the
# legacy ``MODEL_PROVIDER``-as-model-id path); this helper restores
# the correct model id before it reaches the SDK.
providers = _load_providers(config.config_path)
explicit_model, _ = _resolve_model_and_provider_from_env(
yaml_model=yaml_model,
yaml_provider=yaml_provider,
providers=providers,
)
explicit_model = getattr(rc, "model", None) or ""
explicit_model = _strip_provider_prefix(explicit_model)
# Pre-validation: detect the misconfiguration combo that drove the
@@ -909,7 +555,7 @@ class ClaudeCodeAdapter(BaseAdapter):
"The default fallback ('sonnet') is an Anthropic-native "
"alias; non-Anthropic shims (MiniMax, OpenAI gateways, "
"etc.) won't recognize it and the SDK --print probe will "
"hang for 30s before timing out. Fix: set MODEL "
"hang for 30s before timing out. Fix: set MODEL_PROVIDER "
"as a workspace secret (canvas: Save+Restart with model "
"picked) or set runtime_config.model in /configs/config.yaml."
)
+1 -10
View File
@@ -535,16 +535,7 @@ class ClaudeSDKExecutor(AgentExecutor):
# claude session renders inbound messages as `<channel>` tags
# inline (no inbox poll needed). Drop once channels graduate
# to the default allowlist.
#
# Task #214 — CLI 2.1.143 made the flag variadic (nargs='+').
# The `{flag: value}` shape renders as TWO argv elements (see
# claude_agent_sdk subprocess_cli.py:340) and the channels
# parser then greedily absorbs the SDK's downstream `--print
# <prompt>` argv pair, wedging the SDK at initialize. Fix:
# pack `=value` into the key so the renderer's None-value
# path emits a single argv element which the variadic parser
# cannot reach across.
extra_args={"dangerously-load-development-channels=server:molecule": None},
extra_args={"dangerously-load-development-channels": "server:molecule"},
)
# --- output_config: effort + task_budget (issue #652) ---
+10 -42
View File
@@ -31,16 +31,6 @@ tier: 2
# model_aliases : exact lowercase ids (e.g. ["sonnet", "opus"])
# base_url : ANTHROPIC_BASE_URL to set; null = CLI default (anthropic-native)
# auth_env : env vars accepted; any one being set satisfies auth
# auth_token_env : (optional) the env var the boot-time vendor-key
# projection writes the vendor key INTO. Defaults to
# ANTHROPIC_AUTH_TOKEN (Bearer-style; correct for
# MiniMax/GLM/DeepSeek Anthropic-compat shims). Kimi
# For Coding's gateway authenticates with the
# x-api-key header per kimi.com's official Claude Code
# integration doc, which the Anthropic SDK / claude
# CLI emits from ANTHROPIC_API_KEY (NOT the Bearer
# ANTHROPIC_AUTH_TOKEN) — so its entry sets
# auth_token_env: ANTHROPIC_API_KEY.
providers:
- name: anthropic-oauth
auth_mode: oauth
@@ -83,27 +73,13 @@ providers:
base_url: https://api.z.ai/api/anthropic
auth_env: [GLM_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
# Kimi For Coding — Moonshot's coding-agent tier (K2.6 / "Kimi for
# Coding"). Per kimi.com's OFFICIAL Claude Code integration doc
# (kimi.com/code/docs/en/third-party-tools/other-coding-agents.html,
# "Claude Code" section) the contract is:
# ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ (trailing slash)
# ANTHROPIC_API_KEY=<the Kimi key> (x-api-key header)
# The `sk-kimi-*` key (KIMI_API_KEY in SSOT) authenticates ONLY against
# this gateway — the legacy api.moonshot.ai/anthropic surface 401s it.
# The gateway routes to the served K2.6 model regardless of the Claude
# model name on the wire (proven end-to-end via the OpenClaw template's
# api.kimi.com/coding path, winnerProvider=custom-api-kimi-com).
# auth_token_env pins the projection to ANTHROPIC_API_KEY (x-api-key)
# rather than the default ANTHROPIC_AUTH_TOKEN (Bearer), which this
# gateway rejects.
- name: kimi-coding
# Moonshot AI — Kimi family. platform.kimi.ai/docs/guide/agent-support.
- name: moonshot
auth_mode: third_party_anthropic_compat
model_prefixes: [kimi-]
model_aliases: []
base_url: https://api.kimi.com/coding/
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
auth_token_env: ANTHROPIC_API_KEY
base_url: https://api.moonshot.ai/anthropic
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
@@ -199,23 +175,15 @@ runtime_config:
name: Z.ai GLM-4.5 (third-party, Anthropic-API-compatible)
required_env: [GLM_API_KEY]
# --- Kimi For Coding (third-party, Anthropic-API-compatible) ---
# Routed via the `kimi-coding` provider entry above: the adapter
# auto-sets ANTHROPIC_BASE_URL=https://api.kimi.com/coding/ and
# projects KIMI_API_KEY → ANTHROPIC_API_KEY (x-api-key) per
# kimi.com's official Claude Code integration doc. The gateway
# serves the K2.6 model regardless of the wire model id; the id
# below is the gateway's own served-model name (mirrors the proven
# OpenClaw `kimi-for-coding` route). K2.5 / K2 stay as aliases for
# workspaces pinned to the older labels — they hit the same gateway.
- id: kimi-for-coding
name: Kimi K2.6 (Kimi For Coding, third-party Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
# --- Moonshot AI Kimi family (third-party, Anthropic-API-compatible) ---
# KIMI_API_KEY → ANTHROPIC_AUTH_TOKEN projection at boot.
# platform.kimi.ai for docs. K2.5 is the latest agentic-coding tier;
# K2 stays as a cheaper option.
- id: kimi-k2.5
name: Kimi K2.5 (Kimi For Coding, third-party Anthropic-API-compatible)
name: Moonshot Kimi K2.5 (third-party, Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
- id: kimi-k2
name: Kimi K2 (Kimi For Coding, third-party Anthropic-API-compatible)
name: Moonshot Kimi K2 (third-party, Anthropic-API-compatible)
required_env: [KIMI_API_KEY]
# --- DeepSeek (third-party, Anthropic-API-compatible) ---
+2 -38
View File
@@ -42,15 +42,6 @@ log_boot_context
if [ "$(id -u)" = "0" ]; then
# Configs volume is created by Docker as root; agent needs write access
# for plugin installs, memory writes, .auth_token rotation, etc.
#
# T4 atomic-co-sequencing contract (RFC internal#456 §10): the T4
# escalation leg (sudo NOPASSWD + docker group, baked in the
# Dockerfile) is ADDITIVE. The agent still runs uid-1000 and
# /configs/.auth_token MUST remain agent-owned — escalation must
# NOT regress the Hermes list_peers-401 token-ownership class.
# This chown -R is the agent-ownership half of that contract; the
# Layer-3 conformance gate asserts owner_uid==1000 on the running
# container alongside the host-root-reach assertion.
chown -R agent:agent /configs 2>/dev/null
# /workspace handling — only chown when the contents are root-owned
# (typical on Docker Desktop on Windows where host uid maps to 0).
@@ -79,36 +70,9 @@ if [ "$(id -u)" = "0" ]; then
# finds it when running as agent. The provisioner's mount point is
# hardcoded to /root/.claude/sessions; we don't want to change the
# platform contract just for this template.
#
# NOTE (T4 perms regression): on FIRST boot the host volume mount for
# /home/agent/.claude doesn't exist yet — entrypoint creates it and
# the chown lands inside the `if -d /root/.claude/sessions` guard.
# On SECOND boot with a populated /home/agent/.claude (sessions/,
# session-env/, settings.json — any of which the SDK or agent has
# written between boots) the dir may already be root-owned because
# the SDK's working files inherited root's uid when written under
# the prior root segment of an earlier entrypoint, OR because a
# newer claude-code release writes new subdirs we don't create here.
# That leaves uid-1000 agent EPERMing on every settings/session write
# ("permission restrictions" surfaced to the canvas as a generic
# Bash failure). Fix: create the well-known subdirs idempotently
# and run the chown unconditionally (no-op when ownership is already
# correct, fast on small trees). Stub ~/.claude/settings.json too so
# the agent's introspection (cat ~/.claude/settings.json) succeeds
# and shows operating mode — bypassPermissions is the canonical
# mode set programmatically by claude_sdk_executor.py.
mkdir -p /home/agent/.claude/sessions /home/agent/.claude/session-env
if [ ! -f /home/agent/.claude/settings.json ]; then
cat > /home/agent/.claude/settings.json <<'EOF'
{
"permissions": {"defaultMode": "bypassPermissions"},
"_note": "Mode is also set programmatically by claude_sdk_executor.py (permission_mode='bypassPermissions'); this file is informational and lets `cat ~/.claude/settings.json` succeed."
}
EOF
fi
chown -R agent:agent /home/agent/.claude 2>/dev/null
mkdir -p /home/agent/.claude
if [ -d /root/.claude/sessions ]; then
chown -R agent:agent /root/.claude 2>/dev/null
chown -R agent:agent /root/.claude /home/agent/.claude 2>/dev/null
ln -sfn /root/.claude/sessions /home/agent/.claude/sessions
fi
-10
View File
@@ -1,16 +1,6 @@
# Molecule AI workspace runtime — shared infrastructure
molecule-ai-workspace-runtime>=0.1.22
# P0 band-aid for canvas-chat upload 400 "failed to parse multipart form"
# (task #256; forensic a5bb950f). Starlette `Request.form()` raises
# AssertionError parsing multipart bodies when python-multipart is absent.
# Pinned in molecule-core mc#1578 (SSOT, MERGED 2026-05-19T21:41Z) but the
# PyPI publish of the updated runtime wheel is gated on the Gitea middleman
# rename + PyPI abuse-block recovery. This direct pin in each template is
# REDUNDANT (and harmless) once mc#1578's runtime tag publishes — at that
# point the runtime wheel itself will carry python-multipart as a transitive.
python-multipart>=0.0.27
# Claude Code adapter specific deps
# Claude Agent SDK — programmatic API to Claude Code engine.
# Replaces CLI subprocess approach (no more --print, --resume, json parsing).
+1 -1
View File
@@ -24,7 +24,7 @@ common problems.
## Step 1 — Clone the Repository
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-claude-code.git
git clone https://github.com/Molecule-AI/molecule-ai-workspace-template-claude-code.git
cd molecule-ai-workspace-template-claude-code
```
-35
View File
@@ -1,35 +0,0 @@
#!/bin/sh
# git-askpass helper. Reads HTTPS Basic-Auth credentials from env vars so
# the deployer can wire git authentication for any private remote without
# touching ~/.gitconfig or ~/.git-credentials inside the container.
#
# Wire-up: set GIT_ASKPASS=/usr/local/bin/molecule-askpass in the
# container env, then export GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (or the
# GITEA_USER / GITEA_TOKEN fallback pair). When git encounters an HTTPS
# auth challenge on a host that has no credential.helper configured for
# it, git invokes GIT_ASKPASS twice — once with a "Username for ..."
# prompt and once with a "Password for ..." prompt. We pattern-match on
# that prompt and emit the matching env var.
#
# No hardcoded hostnames or vendor names — the deployer decides which
# host these credentials apply to by virtue of setting GIT_ASKPASS only
# when the target remote is in scope. The helper itself is reusable for
# any HTTPS git remote.
#
# Failure mode: if the env vars are unset, we emit an empty string and
# let git surface "Authentication failed" — this is intentional, so a
# misconfigured deployment fails loudly at first push instead of silently
# falling through to an unrelated credential chain.
case "$1" in
Username*)
printf '%s\n' "${GIT_HTTP_USERNAME:-${GITEA_USER:-}}"
;;
Password*)
printf '%s\n' "${GIT_HTTP_PASSWORD:-${GITEA_TOKEN:-}}"
;;
*)
# Unknown prompt — emit empty and let git decide.
printf '\n'
;;
esac
-89
View File
@@ -1,89 +0,0 @@
"""Shared pytest fixtures + import shims for the adapter test suite.
`adapter.py` imports at module load:
- molecule_runtime.adapters.base (BaseAdapter, AdapterConfig, RuntimeCapabilities)
- molecule_runtime.plugins (lazy in setup(), but stubbed proactively)
- a2a.server.agent_execution (AgentExecutor)
- claude_sdk_executor (lazy in create_executor(), stubbed proactively)
In production those arrive transitively via molecule-ai-workspace-runtime.
The CI runner only installs `pytest pytest-asyncio pyyaml`, so the import
chain would fail with ModuleNotFoundError before any test collects —
exactly the failure that broke CI on the #180 fix branch (PR #4) and
caused the merge wall to block on a green local but red Gitea CI.
Putting the stub installer here (collected before any test module is
imported, per pytest semantics) means every test file can do
`from adapter import ...` at module top without a per-file boilerplate
copy. It also forces a single shape for the stubs so two files can't
silently disagree on whether `BaseAdapter` has
`install_plugins_via_registry` (see test_adapter_prevalidate's
async-setup tests, which need the method to exist on the parent class).
"""
import os
import sys
import types
from dataclasses import dataclass
from unittest.mock import MagicMock
@dataclass
class _StubRuntimeCapabilities:
provides_native_session: bool = False
@dataclass
class _StubAdapterConfig:
runtime_config: object = None
config_path: str = "/tmp/configs"
system_prompt: str = ""
heartbeat: object = None
class _StubBaseAdapter:
async def install_plugins_via_registry(self, *_args, **_kwargs):
pass
def _install_stubs() -> None:
"""Install the smallest set of import shims that adapter.py needs."""
if "molecule_runtime" not in sys.modules:
mr = types.ModuleType("molecule_runtime")
mr.adapters = types.ModuleType("molecule_runtime.adapters")
mr.adapters.base = types.ModuleType("molecule_runtime.adapters.base")
mr.adapters.base.BaseAdapter = _StubBaseAdapter
mr.adapters.base.AdapterConfig = _StubAdapterConfig
mr.adapters.base.RuntimeCapabilities = _StubRuntimeCapabilities
mr.plugins = types.ModuleType("molecule_runtime.plugins")
mr.plugins.load_plugins = lambda **_kwargs: []
sys.modules["molecule_runtime"] = mr
sys.modules["molecule_runtime.adapters"] = mr.adapters
sys.modules["molecule_runtime.adapters.base"] = mr.adapters.base
sys.modules["molecule_runtime.plugins"] = mr.plugins
if "a2a" not in sys.modules:
a2a = types.ModuleType("a2a")
a2a.server = types.ModuleType("a2a.server")
a2a.server.agent_execution = types.ModuleType("a2a.server.agent_execution")
a2a.server.agent_execution.AgentExecutor = type("AgentExecutor", (), {})
sys.modules["a2a"] = a2a
sys.modules["a2a.server"] = a2a.server
sys.modules["a2a.server.agent_execution"] = a2a.server.agent_execution
if "claude_sdk_executor" not in sys.modules:
mod = types.ModuleType("claude_sdk_executor")
mod.ClaudeSDKExecutor = MagicMock(name="ClaudeSDKExecutor")
sys.modules["claude_sdk_executor"] = mod
# Run at conftest import time — pytest collects conftest.py before any
# test module, so the stubs are in sys.modules before `from adapter
# import ...` ever executes.
_install_stubs()
# adapter.py lives in the parent dir of tests/ (template root). pytest's
# `--import-mode=importlib` + tests/pytest.ini anchoring rootdir at
# tests/ means the parent isn't on sys.path automatically. Add it here
# once so every test file can do `from adapter import ...` cleanly.
_PARENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PARENT_DIR not in sys.path:
sys.path.insert(0, _PARENT_DIR)
+14 -50
View File
@@ -129,13 +129,12 @@ _FIXTURE_PROVIDERS_YAML = textwrap.dedent("""
base_url: https://api.z.ai/api/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
- name: kimi-coding
- name: moonshot
auth_mode: third_party_anthropic_compat
model_prefixes: [kimi-]
model_aliases: []
base_url: https://api.kimi.com/coding/
auth_env: [KIMI_API_KEY, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN]
auth_token_env: ANTHROPIC_API_KEY
base_url: https://api.moonshot.ai/anthropic
auth_env: [ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY]
- name: deepseek
auth_mode: third_party_anthropic_compat
@@ -515,15 +514,8 @@ async def test_setup_auth_token_alone_satisfies_third_party_check(
# ---- _load_providers / _resolve_provider unit tests ----
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path, monkeypatch):
"""FileNotFoundError path returns the in-code defaults verbatim.
Monkeypatches the canonical + template paths to a non-existent dir
so only the workspace config_path is in scope. Without this, the
multi-path lookup picks up the repo-root config.yaml that ships
with the template (path 2 finds the bundled providers list and
returns it instead of falling through to builtins).
"""
def test_load_providers_returns_builtin_when_yaml_missing(tmp_path):
"""FileNotFoundError path returns the in-code defaults verbatim."""
_install_stubs()
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
@@ -531,10 +523,6 @@ def test_load_providers_returns_builtin_when_yaml_missing(tmp_path, monkeypatch)
sys.modules.pop("adapter", None)
import adapter as adapter_module
nonexistent = str(tmp_path / "_isolate_canonical")
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
result = adapter_module._load_providers(str(tmp_path))
assert result == adapter_module._BUILTIN_PROVIDERS
@@ -555,7 +543,7 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
names = [p["name"] for p in result]
assert names == [
"anthropic-oauth", "anthropic-api", "xiaomi-mimo", "minimax",
"zai", "kimi-coding", "deepseek",
"zai", "moonshot", "deepseek",
]
# YAML lists must be normalized to tuples for downstream lookup ergonomics.
assert isinstance(result[0]["model_aliases"], tuple)
@@ -565,16 +553,15 @@ def test_load_providers_parses_yaml_and_normalizes(tmp_path):
@pytest.mark.parametrize("model,expected_provider,expected_url", [
("GLM-4.6", "zai", "https://api.z.ai/api/anthropic"),
("glm-4.5", "zai", "https://api.z.ai/api/anthropic"),
("kimi-k2.5", "kimi-coding", "https://api.kimi.com/coding/"),
("kimi-for-coding", "kimi-coding", "https://api.kimi.com/coding/"),
("kimi-k2.5", "moonshot", "https://api.moonshot.ai/anthropic"),
("deepseek-v4-pro", "deepseek", "https://api.deepseek.com/anthropic"),
])
@pytest.mark.asyncio
async def test_setup_routes_extra_providers(
adapter, monkeypatch, configs_dir, model, expected_provider, expected_url
):
"""The Z.ai / Kimi-For-Coding / DeepSeek providers must route
correctly: model id → provider entry → ANTHROPIC_BASE_URL.
"""The Z.ai / Moonshot / DeepSeek providers added in this PR must
route correctly: model id → provider entry → ANTHROPIC_BASE_URL.
Parametrized to keep the matrix coverage tight without 3 near-identical
test bodies. Locks in the per-vendor base_url so a future YAML edit
that mistypes z.ai's `/api/anthropic` suffix gets caught.
@@ -589,12 +576,8 @@ async def test_setup_routes_extra_providers(
assert os.environ.get("ANTHROPIC_BASE_URL") == expected_url
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog, monkeypatch):
"""Malformed YAML → log warning + fallback (don't kill boot).
Isolated from the multi-path lookup by pinning canonical + template
dirs at a non-existent path; only the workspace config_path is read.
"""
def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog):
"""Malformed YAML → log warning + fallback (don't kill boot)."""
_install_stubs()
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if parent_dir not in sys.path:
@@ -602,10 +585,6 @@ def test_load_providers_falls_back_on_malformed_yaml(tmp_path, caplog, monkeypat
sys.modules.pop("adapter", None)
import adapter as adapter_module
nonexistent = str(tmp_path / "_isolate_canonical")
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
(tmp_path / "config.yaml").write_text("providers: [not valid yaml: {{{")
import logging
@@ -643,7 +622,7 @@ def test_resolve_provider_minimax_prefix_matches_minimax_provider():
assert result2["name"] == "minimax"
def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog, monkeypatch):
def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog):
"""Per-entry isolation: one malformed entry shouldn't nuke the registry.
Pre-fix: ``_load_providers`` built the registry via a generator inside
@@ -655,9 +634,6 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog, monkeypatch
Post-fix: per-entry try/except drops the bad entry with a warning,
rest of the registry survives.
Isolated from the multi-path lookup so only the test's tmp config.yaml
is read.
"""
_install_stubs()
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -666,10 +642,6 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog, monkeypatch
sys.modules.pop("adapter", None)
import adapter as adapter_module
nonexistent = str(tmp_path / "_isolate_canonical")
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
yaml_with_typo = textwrap.dedent("""
providers:
- name: good-zai
@@ -718,7 +690,7 @@ def test_load_providers_drops_bad_entry_keeps_rest(tmp_path, caplog, monkeypatch
)
def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, caplog, monkeypatch):
def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, caplog):
"""A YAML field declared as list-of-strings but written as a bare
string (operator forgot brackets) used to silently iterate over
characters → ``('m','i','m','o','-')``. Post-fix: non-list value
@@ -733,10 +705,6 @@ def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, cap
sys.modules.pop("adapter", None)
import adapter as adapter_module
nonexistent = str(tmp_path / "_isolate_canonical")
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
yaml_str_prefix = textwrap.dedent("""
providers:
- name: typo-prefix
@@ -755,7 +723,7 @@ def test_load_providers_string_as_prefix_does_not_split_into_chars(tmp_path, cap
)
def test_load_providers_drops_entry_without_name(tmp_path, caplog, monkeypatch):
def test_load_providers_drops_entry_without_name(tmp_path, caplog):
"""An entry without ``name`` is operator error — no silent fallback
to ``<unnamed>``. Drop the entry with a warning so the boot log
surfaces the typo.
@@ -767,10 +735,6 @@ def test_load_providers_drops_entry_without_name(tmp_path, caplog, monkeypatch):
sys.modules.pop("adapter", None)
import adapter as adapter_module
nonexistent = str(tmp_path / "_isolate_canonical")
monkeypatch.setattr(adapter_module, "_CANONICAL_ADAPTER_DIR", nonexistent)
monkeypatch.setattr(adapter_module, "_TEMPLATE_DIR", nonexistent)
yaml_no_name = textwrap.dedent("""
providers:
- name: good
+20 -74
View File
@@ -110,18 +110,6 @@ def _load_executor():
return claude_sdk_executor
def _channels_entry(extra_args):
"""Return (key, value) for the dev-channels flag, tolerating both shapes.
- separate-value shape: {"dangerously-load-development-channels": "server:X"}
- packed `=` shape (task #214 fix): {"dangerously-load-development-channels=server:X": None}
"""
for k, v in extra_args.items():
if k.split("=", 1)[0] == "dangerously-load-development-channels":
return k, v
return None, None
def test_build_options_forwards_tagged_dev_channels_flag(tmp_path):
"""``_build_options`` must pass the tagged ``server:molecule`` entry to
``--dangerously-load-development-channels``. The Claude Code 2.1.x CLI
@@ -154,28 +142,25 @@ def test_build_options_forwards_tagged_dev_channels_flag(tmp_path):
"extra_args missing — host claude CLI will never see the dev-channels "
"flag and notifications/claude/channel will be filtered at the allowlist"
)
key, value = _channels_entry(kwargs["extra_args"])
# Resolve the tagged payload from whichever shape the executor used.
tagged = value if value is not None else (key.split("=", 1)[1] if "=" in key else None)
assert tagged == "server:molecule", (
f"dev-channels entry must resolve to tagged 'server:molecule' to match "
f"the workspace's MCP-server registration. The CLI rejects bare server "
flag_value = kwargs["extra_args"].get("dangerously-load-development-channels")
assert flag_value == "server:molecule", (
f"dev-channels entry must be tagged 'server:molecule' to match the "
f"workspace's MCP-server registration. The CLI rejects bare server "
f"names with `entries must be tagged` and bare-switch values (None) "
f"with `argument missing`; the latter wedges SDK initialize. "
f"got key={key!r} value={value!r}"
f"got {flag_value!r}"
)
def test_build_options_dev_channels_value_is_not_bare_none(tmp_path):
"""Defense in depth against the original PR #25 bare-switch shape.
A bare ``--dangerously-load-development-channels`` (no value, no
``=value`` packed into the key) renders as an argument-less flag,
which the post-2.1.x CLI rejects with `argument missing`. Pin the
invariant (the rendered payload is non-empty and tag-colon-shaped)
so a regression to the old shape fails immediately at unit-test
time instead of surfacing as a live `Control request timeout:
initialize` wedge in production.
``{flag: None}`` in claude-agent-sdk's extra_args forwarding renders
as a bare ``--flag`` with no value, which the post-2.1.x CLI rejects.
Pin the invariant (non-None, non-empty, contains a tag colon) so a
regression to the old shape fails immediately at unit-test time
instead of surfacing as a live `Control request timeout: initialize`
wedge in production.
"""
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
@@ -189,56 +174,17 @@ def test_build_options_dev_channels_value_is_not_bare_none(tmp_path):
)
executor._build_options()
key, value = _channels_entry(
flag_value = (
sdk.ClaudeAgentOptions.call_args.kwargs["extra_args"]
["dangerously-load-development-channels"]
)
payload = key if value is None else f"{key}={value}"
assert ":" in payload.split("=", 1)[-1], (
f"flag payload must be tagged (server:<name> or plugin:<name>@<marketplace>); "
f"got key={key!r} value={value!r} which the CLI rejects with "
f"`entries must be tagged` or `argument missing`"
assert flag_value is not None, (
"flag value must not be None — bare switch wedges SDK initialize"
)
def test_dev_channels_does_not_swallow_print_prompt_cli_2_1_143(tmp_path):
"""Task #214 regression — claude-code CLI 2.1.143.
CLI 2.1.143 made ``--dangerously-load-development-channels`` variadic
(``nargs='+'``). claude-agent-sdk's renderer (subprocess_cli.py:340)
emits ``{flag: value}`` as TWO argv elements, so the channels parser
greedily absorbs the following ``--print <prompt>`` argv pair as
channel entries and the SDK wedges at initialize. Fix: pack ``=``
into the key so the renderer's ``None``-value path emits ONE argv —
``--dangerously-load-development-channels=server:molecule`` — that
the variadic parser cannot reach across. Both argv orderings
around ``--print <prompt>`` (channels-then-print, print-then-
channels) must keep the prompt argv adjacent to ``--print``.
"""
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
sdk.ClaudeAgentOptions.reset_mock()
executor = mod.ClaudeSDKExecutor(
system_prompt=None, config_path=str(tmp_path), heartbeat=None, model="sonnet",
assert isinstance(flag_value, str) and flag_value, (
f"flag value must be a non-empty string; got {flag_value!r}"
)
executor._build_options()
extra_args = sdk.ClaudeAgentOptions.call_args.kwargs["extra_args"]
# Mirror claude_agent_sdk/_internal/transport/subprocess_cli.py:340.
channels_argv = []
for flag, val in extra_args.items():
channels_argv.append(f"--{flag}") if val is None else channels_argv.extend([f"--{flag}", str(val)])
slots = [a for a in channels_argv if a.startswith("--dangerously-load-development-channels")]
assert len(slots) == 1 and "=" in slots[0] and channels_argv == slots, (
f"channels flag must render as a single argv with `=value` packed in so "
f"CLI 2.1.143's nargs='+' parser cannot swallow --print <prompt>; "
f"got channels_argv={channels_argv!r}"
assert ":" in flag_value, (
f"flag value must be tagged (server:<name> or plugin:<name>@<marketplace>); "
f"got {flag_value!r} which the CLI rejects with `entries must be tagged`"
)
for orientation, full_argv in (
("channels_then_print", channels_argv + ["--print", "hello world"]),
("print_then_channels", ["--print", "hello world"] + channels_argv),
):
idx = full_argv.index("--print")
assert full_argv[idx + 1] == "hello world", (
f"--print prompt argv must stay adjacent ({orientation}); got {full_argv!r}"
)
+206
View File
@@ -0,0 +1,206 @@
r"""Tests for entrypoint.sh's log_boot_context() shell function.
The Python-side audit (test_adapter_logging.py) pins what `_audit_auth_env_presence`
in adapter.py emits. But the shell function fires FIRST — twice, even (once
pre-gosu as root, once post-gosu as agent). When the adapter never runs at
all because the SDK import fails, the entrypoint emission is the operator's
ONLY visibility into the boot env. So this contract needs its own test.
The cross-file gate `test_audit_env_list_matches_entrypoint_sh` proves the
NAME LIST matches; this file proves the SHELL CODE actually emits the
right lines for those names. Without this, a typo in the for-loop body
(e.g. `eval "val=\$$var"` → `val=$var`, which would print the literal
name not its value) silently breaks the audit.
Strategy: extract the `log_boot_context()` function body from entrypoint.sh
and run it in a fresh subprocess with controlled env. Asserts on stdout.
We never source entrypoint.sh wholesale because it would chown /workspace
and exec molecule-runtime — neither is appropriate in a test sandbox.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
TEMPLATE_DIR = Path(__file__).resolve().parent.parent
ENTRYPOINT = TEMPLATE_DIR / "entrypoint.sh"
def _extract_function() -> str:
"""Pull just the log_boot_context() function definition out of entrypoint.sh.
Returns the literal function definition (`log_boot_context() { ... }`) as
a string, suitable for `sh -c "<func>; log_boot_context"`. Bails with a
clear message if the function can't be located — that itself is a
regression worth a loud test failure.
"""
text = ENTRYPOINT.read_text()
# `log_boot_context() {` on its own line, then everything up to the
# matching closing `}` at column 0. The function is small and shape-stable;
# we don't try to be a full shell parser.
match = re.search(r"^log_boot_context\(\)\s*\{.*?^\}\s*$", text, re.DOTALL | re.MULTILINE)
if not match:
pytest.fail("Could not locate log_boot_context() in entrypoint.sh")
return match.group(0)
def _run_function(env: dict[str, str]) -> str:
"""Run log_boot_context() in a fresh /bin/sh with the given env. Returns stdout."""
func = _extract_function()
script = f"{func}\nlog_boot_context\n"
# Empty base env so PATH lookups (`id`, `hostname`, `date`, `ls`) still work
# but no inherited auth vars leak into the test. We restore PATH explicitly.
safe_env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin")}
safe_env.update(env)
result = subprocess.run(
["/bin/sh", "-c", script],
env=safe_env,
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, (
f"log_boot_context exited rc={result.returncode}\n"
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
return result.stdout
# Audit names — kept in lockstep with adapter.py's _AUTH_ENV_AUDIT and the
# entrypoint.sh for-loop. test_audit_env_list_matches_entrypoint_sh and
# test_loop_var_list_matches_audit (below) gate any drift across the three
# locations.
_AUDIT_NAMES = (
"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 test_emits_set_for_present_env():
"""A set var must produce `env NAME=set` — proves the eval-deref works."""
out = _run_function({"MINIMAX_API_KEY": "secret-MUST-NOT-LEAK"})
assert "env MINIMAX_API_KEY=set" in out
def test_emits_unset_for_absent_env():
"""An unset var must produce `env NAME=unset` — proves the empty-string branch."""
out = _run_function({})
for name in _AUDIT_NAMES:
assert f"env {name}=unset" in out, (
f"missing `env {name}=unset` line — for-loop body may be miscoded"
)
def test_never_leaks_value():
"""The audit prints NAMES, not VALUES. Regression here = secret leak.
Same threat model as the Python-side test: an operator-visible boot log
that contains the actual key would defeat the whole point of the audit
(the audit exists so we can answer 'is the key present' WITHOUT exposing
the key). A `eval "val=\\$$var"` typo collapsing to `echo $var` would
trip this test.
"""
secret = "sk-FAKE-MUST-NEVER-APPEAR-IN-BOOT-LOG"
out = _run_function({
"MINIMAX_API_KEY": secret,
"CLAUDE_CODE_OAUTH_TOKEN": secret,
"ANTHROPIC_BASE_URL": "https://api.example.com",
})
assert secret not in out, f"boot-context log leaked the env VALUE:\n{out}"
# ANTHROPIC_BASE_URL is the most-likely-to-be-logged-by-mistake field
# because operators sometimes WANT to see it; pin that it's still
# name-only.
assert "https://api.example.com" not in out
def test_emits_workspace_id_and_platform_url():
"""WORKSPACE_ID and PLATFORM_URL appear by VALUE — these are not secrets.
They're the operator-visible identifiers a support engineer needs to
correlate logs with platform records. Pinning the field shape so a
later refactor doesn't accidentally redact them.
"""
out = _run_function({
"WORKSPACE_ID": "ws-test-1234",
"PLATFORM_URL": "https://test.example.com",
})
assert "workspace_id=ws-test-1234" in out
assert "platform_url=https://test.example.com" in out
def test_emits_unset_marker_when_workspace_id_missing():
"""Missing WORKSPACE_ID falls back to the literal `<unset>` placeholder.
A support engineer reading the boot log must be able to distinguish
'WORKSPACE_ID was empty string' from 'WORKSPACE_ID was never injected
by the platform'. The shell `${VAR:-<unset>}` default handles that.
"""
out = _run_function({})
assert "workspace_id=<unset>" in out
assert "platform_url=<unset>" in out
def test_emits_uid_and_gid():
"""uid/gid line is critical — answers 'did the privilege drop happen?'
The two-emission pattern (pre-gosu as root, post-gosu as agent) only
works as a diagnostic if uid/gid is in every emission. Pin the field
shape; we don't pin the literal value because CI runs vary.
"""
out = _run_function({})
assert re.search(r"uid=\d+\s+gid=\d+", out), (
f"missing or malformed uid/gid line:\n{out}"
)
def test_emits_boot_marker():
"""Each emission starts with the dated `entrypoint boot` banner.
Operators grep for this to count restarts in a crash loop.
"""
out = _run_function({})
# Format: "----- entrypoint boot 2026-05-02T12:34:56Z -----"
assert re.search(
r"-----\s+entrypoint boot \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\s+-----",
out,
), f"missing boot banner:\n{out}"
def test_loop_var_list_matches_audit():
"""The for-loop's literal NAME list must match _AUDIT_NAMES (this file).
Companion to test_audit_env_list_matches_entrypoint_sh in
test_adapter_logging.py: that test cross-checks adapter.py vs
entrypoint.sh; this one cross-checks entrypoint.sh vs the test
fixture above. If a maintainer adds a vendor to entrypoint.sh
without updating the audit name tuple in this file, the existing
`test_emits_unset_for_absent_env` would still pass (because all
audited names also appear in the loop), but the maintainer would
have a false sense of coverage. This test catches that.
"""
text = ENTRYPOINT.read_text()
loop_line = next(
(line for line in text.splitlines()
if "for var in" in line and "CLAUDE_CODE_OAUTH_TOKEN" in line),
None,
)
assert loop_line, "entrypoint.sh missing the auth-env for-loop"
names_in_shell = tuple(
loop_line.split("for var in", 1)[1].split(";", 1)[0].split()
)
assert set(names_in_shell) == set(_AUDIT_NAMES), (
f"_AUDIT_NAMES in this file ({set(_AUDIT_NAMES)}) and the for-loop "
f"in entrypoint.sh ({set(names_in_shell)}) disagree — update the "
"test fixture or the shell loop to bring them back in sync."
)
-287
View File
@@ -1,287 +0,0 @@
"""Tests for ``_resolve_model_and_provider_from_env`` — the env-vs-YAML
reconciliation that fixes the 2026-05-08 dev-tree wedge incident.
Symptom: 22/27 non-lead workspaces (minimax tier) wedged on
``Control request timeout: initialize`` because the runtime wheel's
``workspace/config.py`` interpreted ``MODEL_PROVIDER=minimax`` as the
*model id* instead of the provider slug. ``model="minimax"`` failed to
match the ``minimax-`` registry prefix, fell through to providers[0]
(anthropic-oauth), demanded ``CLAUDE_CODE_OAUTH_TOKEN`` (unset on
non-leads), and the claude CLI hung at SDK init.
The persona env files (``~/.molecule-ai/personas/<name>/env``) declare
the new convention:
* ``MODEL`` — model id (e.g. ``MiniMax-M2.7-highspeed``)
* ``MODEL_PROVIDER`` — provider slug (e.g. ``minimax``)
These tests cover the matrix of (env shape) × (YAML shape) so a future
contributor can't silently regress the wedge fix.
"""
import pytest
from adapter import (
_BUILTIN_PROVIDERS,
_resolve_model_and_provider_from_env,
)
# A registry that contains both anthropic-oauth (providers[0]) and
# minimax/zai (third-party slugs) — matches the shipped config.yaml.
_REGISTRY = _BUILTIN_PROVIDERS + (
{
"name": "minimax",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("minimax-",),
"model_aliases": (),
"base_url": "https://api.minimax.io/anthropic",
"auth_env": ("MINIMAX_API_KEY",),
},
{
"name": "zai",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("glm-",),
"model_aliases": (),
"base_url": "https://api.z.ai/api/anthropic",
"auth_env": ("GLM_API_KEY",),
},
)
def _clear_env(monkeypatch):
monkeypatch.delenv("MODEL", raising=False)
monkeypatch.delenv("MODEL_PROVIDER", raising=False)
# ------------------------------------------------------------------
# Persona env convention: MODEL=<id>, MODEL_PROVIDER=<slug>
# ------------------------------------------------------------------
def test_persona_env_minimax_resolves_correctly(monkeypatch):
"""The 2026-05-08 wedge regression test: persona env shape must
yield model=MiniMax-M2.7-highspeed (not "minimax") and explicit
provider=minimax."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
monkeypatch.setenv("MODEL_PROVIDER", "minimax")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "MiniMax-M2.7-highspeed"
assert provider == "minimax"
def test_persona_env_lead_claude_code_resolves_correctly(monkeypatch):
"""Lead persona env (MODEL=opus, MODEL_PROVIDER=claude-code) —
``claude-code`` is the persona-friendly alias for the canonical
``anthropic-oauth`` registry name. Must resolve via the alias map
so the lead boots through the OAuth subscription path even when
MODEL is a non-Anthropic model id (e.g. an operator who picked
MiniMax in canvas but whose persona env still pins claude-code)."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "opus")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "opus"
# claude-code → anthropic-oauth via the alias map
assert provider == "anthropic-oauth"
def test_persona_env_lead_with_minimax_model_routes_via_oauth(monkeypatch):
"""Lead workspace whose persona pins MODEL_PROVIDER=claude-code but
whose YAML/canvas selection happens to be a MiniMax model still
routes via OAuth — the persona's provider pin wins over the
model-prefix matcher. Without the alias map, the fall-through
mis-routed leads to MiniMax even when their CLAUDE_CODE_OAUTH_TOKEN
was set."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "MiniMax-M2.7")
monkeypatch.setenv("MODEL_PROVIDER", "claude-code")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "MiniMax-M2.7"
assert provider == "anthropic-oauth"
def test_anthropic_alias_resolves_to_anthropic_api(monkeypatch):
"""``MODEL_PROVIDER=anthropic`` alias → ``anthropic-api`` (direct
Anthropic API key path)."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "claude-opus-4-7")
monkeypatch.setenv("MODEL_PROVIDER", "anthropic")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "claude-opus-4-7"
assert provider == "anthropic-api"
def test_persona_env_glm_resolves_correctly(monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "GLM-4.6")
monkeypatch.setenv("MODEL_PROVIDER", "zai")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == "GLM-4.6"
assert provider == "zai"
def test_env_provider_slug_case_insensitive(monkeypatch):
"""Operator typos like ``MiniMax`` (mixed case) still resolve."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "MiniMax-M2.7-highspeed")
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax") # mixed case
_, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert provider == "MiniMax" # caller compares case-insensitively
# ------------------------------------------------------------------
# Legacy convention: MODEL_PROVIDER=<model-id>, MODEL unset
# ------------------------------------------------------------------
def test_legacy_model_provider_as_model_id_still_works(monkeypatch):
"""Pre-2026-05-08 canvas Save+Restart shape: MODEL_PROVIDER carried
the model id directly (e.g. ``MODEL_PROVIDER=MiniMax-M2.7``) and
no MODEL env. Must keep working so existing canvas users don't
break overnight."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL_PROVIDER", "MiniMax-M2.7-highspeed")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
# MiniMax-M2.7-highspeed is not a registered provider name, so
# it's treated as a legacy model-id-in-MODEL_PROVIDER value.
assert model == "MiniMax-M2.7-highspeed"
assert provider is None
# ------------------------------------------------------------------
# Env wins over YAML
# ------------------------------------------------------------------
def test_env_model_wins_over_yaml_model(monkeypatch):
"""When both env MODEL and YAML model are set, env wins."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "GLM-4.6")
model, _ = _resolve_model_and_provider_from_env(
yaml_model="MiniMax-M2.7", yaml_provider="", providers=_REGISTRY,
)
assert model == "GLM-4.6"
def test_env_provider_wins_over_yaml_provider(monkeypatch):
"""Env MODEL_PROVIDER (when a registered slug) wins over YAML provider."""
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "GLM-4.6")
monkeypatch.setenv("MODEL_PROVIDER", "zai")
_, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="minimax", providers=_REGISTRY,
)
assert provider == "zai"
# ------------------------------------------------------------------
# YAML fallback (no env)
# ------------------------------------------------------------------
def test_no_env_falls_back_to_yaml(monkeypatch):
"""Workspace whose env doesn't set MODEL/MODEL_PROVIDER falls back
to the YAML config — preserves existing operator workflows."""
_clear_env(monkeypatch)
model, provider = _resolve_model_and_provider_from_env(
yaml_model="claude-sonnet-4-6",
yaml_provider="anthropic-api",
providers=_REGISTRY,
)
assert model == "claude-sonnet-4-6"
assert provider == "anthropic-api"
def test_no_env_no_yaml_returns_empty(monkeypatch):
"""Pure default path — caller (setup) substitutes ``sonnet``."""
_clear_env(monkeypatch)
model, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="", providers=_REGISTRY,
)
assert model == ""
assert provider is None
def test_yaml_provider_anthropic_is_aliased_to_anthropic_api(monkeypatch):
"""Regression for 2026-05-09 staging-cplead-2 incident: every
workspace booted ``configuration_status=not_configured`` because the
molecule-runtime wheel auto-derives ``runtime_config.provider =
"anthropic"`` from the default model slug ``anthropic:claude-opus-4-7``.
The adapter received ``yaml_provider="anthropic"`` from the wheel and
rejected it with ``ValueError: provider='anthropic' but it is not in
the providers registry`` — but ``anthropic`` is already in
``_PROVIDER_SLUG_ALIASES`` for the env-var path. Mirror the alias map
on the YAML path so the wheel default produces a registered provider
name."""
_clear_env(monkeypatch)
_, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="anthropic", providers=_REGISTRY,
)
assert provider == "anthropic-api", (
f"yaml_provider='anthropic' must resolve through the alias map to "
f"'anthropic-api'; got {provider!r}. Without this aliasing the "
f"wheel-default workspace boot wedges at adapter.setup()."
)
def test_yaml_provider_claude_code_is_aliased_to_anthropic_oauth(monkeypatch):
"""Symmetric coverage: persona-friendly ``claude-code`` slug from the
YAML ``provider:`` field must alias to ``anthropic-oauth``, the same
way the env-var path resolves it. Lead workspaces that pin the OAuth
path in YAML (instead of via env) must not wedge."""
_clear_env(monkeypatch)
_, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="claude-code", providers=_REGISTRY,
)
assert provider == "anthropic-oauth"
def test_yaml_provider_unknown_passes_through_for_actionable_error(monkeypatch):
"""An unaliased, unknown YAML provider (e.g. ``yaml_provider="mystery"``)
must NOT be silently swapped to providers[0] — it must reach
``_resolve_provider`` so the adapter raises the actionable
``Known providers: ...`` error message. The alias map is a
convenience for the two persona-convention slugs only; everything
else must keep its original semantics."""
_clear_env(monkeypatch)
_, provider = _resolve_model_and_provider_from_env(
yaml_model="", yaml_provider="mystery", providers=_REGISTRY,
)
assert provider == "mystery"
# ------------------------------------------------------------------
# Whitespace / empty-value defensive cases
# ------------------------------------------------------------------
def test_whitespace_only_env_treated_as_unset(monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", " ")
monkeypatch.setenv("MODEL_PROVIDER", " ")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="opus", yaml_provider="", providers=_REGISTRY,
)
assert model == "opus"
assert provider is None
def test_empty_env_value_treated_as_unset(monkeypatch):
_clear_env(monkeypatch)
monkeypatch.setenv("MODEL", "")
monkeypatch.setenv("MODEL_PROVIDER", "")
model, provider = _resolve_model_and_provider_from_env(
yaml_model="sonnet", yaml_provider="", providers=_REGISTRY,
)
assert model == "sonnet"
assert provider is None
-146
View File
@@ -1,146 +0,0 @@
"""Tests for the provider-resolution path that was silent-failing on #180.
Regression coverage: when an operator picks a provider in the canvas Config
tab that isn't in the registry, the adapter must raise ValueError with an
actionable message — NOT silently fall through to providers[0]
(anthropic-oauth) and then have the Claude SDK hit the user's OAuth quota
under a different name.
These tests mirror the production failure mode reported by Hongming
2026-05-07 17:35: workspace config.yaml had `provider: minimax` set, the
adapter ignored it entirely, the SDK kept calling the Anthropic API with
CLAUDE_CODE_OAUTH_TOKEN, hit the OAuth quota, and the canvas surfaced
"Agent error (Exception)" with no clue why.
Import-shim setup (sys.path + molecule_runtime / a2a / claude_sdk_executor
stubs) lives in tests/conftest.py — shared with test_adapter_prevalidate
so the two stub installers can't disagree on shape (e.g. BaseAdapter
having install_plugins_via_registry).
"""
import pytest
from adapter import (
_BUILTIN_PROVIDERS,
_resolve_provider,
)
def test_resolve_with_no_explicit_provider_falls_back_to_model_match():
"""No explicit provider → model-based prefix/alias matching, default to providers[0]."""
p = _resolve_provider("claude-opus-4-7", _BUILTIN_PROVIDERS)
assert p["name"] == "anthropic-api" # matches model_prefixes=("claude-",)
def test_resolve_with_no_explicit_provider_falls_back_to_default():
"""Unknown model + no explicit provider → providers[0] (anthropic-oauth)."""
p = _resolve_provider("unknown-model", _BUILTIN_PROVIDERS)
assert p["name"] == "anthropic-oauth"
def test_resolve_with_explicit_provider_in_registry_returns_match():
"""Explicit name lookup wins over model-based resolution."""
# Even though "claude-opus-4-7" would normally resolve to anthropic-api
# via prefix matching, the explicit provider name wins.
p = _resolve_provider(
"claude-opus-4-7", _BUILTIN_PROVIDERS,
explicit_provider="anthropic-oauth",
)
assert p["name"] == "anthropic-oauth"
def test_resolve_with_explicit_provider_case_insensitive():
"""Provider name match is case-insensitive (operators write 'Anthropic-OAuth' etc)."""
p = _resolve_provider(
"sonnet", _BUILTIN_PROVIDERS,
explicit_provider="ANTHROPIC-OAUTH",
)
assert p["name"] == "anthropic-oauth"
def test_resolve_with_explicit_provider_not_in_registry_raises():
"""The #180 regression test: explicit non-registry provider must raise, not fall through."""
with pytest.raises(ValueError) as exc_info:
_resolve_provider(
"MiniMax-M2.7-highspeed", _BUILTIN_PROVIDERS,
explicit_provider="minimax",
)
msg = str(exc_info.value)
# Must name the bad provider so operator knows what they typed
assert "minimax" in msg
# Must list known providers so operator knows what's available
assert "anthropic-oauth" in msg
assert "anthropic-api" in msg
# Must give actionable next steps — NOT just "not found"
assert "providers:" in msg or "Add" in msg
assert "Switch" in msg or "runtime" in msg
def test_resolve_with_explicit_provider_does_not_silent_fallback():
"""Specifically: must not return providers[0] when explicit_provider is bogus.
This is the exact silent-fallback path that caused the user-visible
bug: operator picks 'minimax' → adapter returns anthropic-oauth →
SDK uses CLAUDE_CODE_OAUTH_TOKEN → hits quota.
"""
with pytest.raises(ValueError):
result = _resolve_provider(
"anything", _BUILTIN_PROVIDERS,
explicit_provider="minimax",
)
# If the implementation regresses to silent fallback, this would
# have returned providers[0] (anthropic-oauth) instead of raising.
# Defense-in-depth: guard against accidental "return" inside the
# error path.
assert result["name"] not in {"anthropic-oauth", "anthropic-api"}, (
"REGRESSION: silent fallback to default provider when explicit "
"provider name is not in registry — this is the #180 bug."
)
def test_resolve_with_explicit_provider_in_custom_registry():
"""When operator adds a third-party provider to the registry, explicit lookup finds it."""
custom_registry = _BUILTIN_PROVIDERS + (
{
"name": "minimax",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("minimax-",),
"model_aliases": (),
"base_url": "https://api.minimaxi.com/anthropic-compat",
"auth_env": ("MINIMAX_API_KEY",),
},
)
p = _resolve_provider(
"MiniMax-M2.7-highspeed", custom_registry,
explicit_provider="minimax",
)
assert p["name"] == "minimax"
assert p["base_url"] == "https://api.minimaxi.com/anthropic-compat"
assert "MINIMAX_API_KEY" in p["auth_env"]
def test_resolve_empty_providers_raises():
"""Pre-condition: providers must be non-empty (existing behavior preserved)."""
with pytest.raises(ValueError, match="empty providers tuple"):
_resolve_provider("anything", ())
def test_resolve_explicit_empty_string_treated_as_no_explicit():
"""`provider: ''` (empty string) → fall back to model-based resolution, not raise."""
# This shape can happen when the canvas writes an empty provider field.
# Treating it as "no explicit pick" is more forgiving than raising,
# since the user clearly didn't intend to break their workspace.
p = _resolve_provider(
"claude-opus-4-7", _BUILTIN_PROVIDERS,
explicit_provider="",
)
assert p["name"] == "anthropic-api" # fell through to model-based
def test_resolve_explicit_none_treated_as_no_explicit():
"""`explicit_provider=None` (default) → fall back to model-based resolution."""
p = _resolve_provider(
"claude-opus-4-7", _BUILTIN_PROVIDERS,
explicit_provider=None,
)
assert p["name"] == "anthropic-api"
+1 -80
View File
@@ -219,6 +219,7 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
"""
cases = [
("zai", "GLM_API_KEY"),
("moonshot", "KIMI_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
]
for provider_name, env_name in cases:
@@ -241,83 +242,3 @@ def test_glm_kimi_deepseek_also_project(adapter_module, monkeypatch):
f"{env_name} must project onto ANTHROPIC_AUTH_TOKEN for "
f"provider={provider_name}"
)
def test_kimi_coding_projects_into_anthropic_api_key(adapter_module, monkeypatch):
"""Kimi For Coding's gateway authenticates with the x-api-key header
(kimi.com official Claude Code doc), which the Anthropic SDK / claude
CLI emits from ANTHROPIC_API_KEY — NOT the Bearer ANTHROPIC_AUTH_TOKEN
used by MiniMax/GLM/DeepSeek. The kimi-coding provider sets
auth_token_env: ANTHROPIC_API_KEY so KIMI_API_KEY projects there.
Regression guard for the original mis-route: KIMI_API_KEY landing in
ANTHROPIC_AUTH_TOKEN against api.kimi.com/coding 401s.
"""
import os
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
provider = {
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("kimi-",),
"model_aliases": (),
"base_url": "https://api.kimi.com/coding/",
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"auth_token_env": "ANTHROPIC_API_KEY",
}
adapter_module._project_vendor_auth(provider)
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-kimi-sentinel", (
"KIMI_API_KEY must project onto ANTHROPIC_API_KEY (x-api-key) for "
"the kimi-coding provider per kimi.com's official Claude Code doc"
)
assert os.environ.get("ANTHROPIC_AUTH_TOKEN") is None, (
"KIMI_API_KEY must NOT land in ANTHROPIC_AUTH_TOKEN — the Bearer "
"header 401s against api.kimi.com/coding (the original mis-route)"
)
def test_kimi_coding_operator_anthropic_api_key_wins(adapter_module, monkeypatch):
"""Idempotency holds for the per-provider target too: an explicit
operator ANTHROPIC_API_KEY is never clobbered by the projection."""
import os
_clear_all_auth_env(monkeypatch, adapter_module)
monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-sentinel")
monkeypatch.setenv("ANTHROPIC_API_KEY", "operator-value")
provider = {
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"model_prefixes": ("kimi-",),
"model_aliases": (),
"base_url": "https://api.kimi.com/coding/",
"auth_env": ("KIMI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"auth_token_env": "ANTHROPIC_API_KEY",
}
adapter_module._project_vendor_auth(provider)
assert os.environ.get("ANTHROPIC_API_KEY") == "operator-value", (
"explicit operator ANTHROPIC_API_KEY must win over auto-projection"
)
def test_normalize_provider_parses_auth_token_env(adapter_module):
"""_normalize_provider surfaces auth_token_env; absent → the
ANTHROPIC_AUTH_TOKEN default (preserves MiniMax/GLM/DeepSeek)."""
with_override = adapter_module._normalize_provider({
"name": "kimi-coding",
"auth_mode": "third_party_anthropic_compat",
"base_url": "https://api.kimi.com/coding/",
"auth_env": ["KIMI_API_KEY", "ANTHROPIC_API_KEY"],
"auth_token_env": "ANTHROPIC_API_KEY",
})
assert with_override["auth_token_env"] == "ANTHROPIC_API_KEY"
default = adapter_module._normalize_provider({
"name": "minimax",
"auth_mode": "third_party_anthropic_compat",
"base_url": "https://api.minimax.io/anthropic",
"auth_env": ["MINIMAX_API_KEY"],
})
assert default["auth_token_env"] == "ANTHROPIC_AUTH_TOKEN"