Compare commits

...

13 Commits

Author SHA1 Message Date
devops-engineer 4729e99be5 Merge pull request 'fix(ci): runs-on [publish, release] to route deterministically to op-host runners (matches tc#22)' (#36) from fix/ci-publish-and-of-labels-tc22 into main
CI / validate (push) Failing after 2s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 15s
publish-image / Resolve runtime version (push) Successful in 15s
CI / Template validation (static) (push) Successful in 1m8s
CI / Adapter unit tests (push) Successful in 1m28s
publish-image / Build & push workspace-template-claude-code image (push) Successful in 4m27s
CI / Template validation (runtime) (push) Successful in 5m22s
CI / T4 tier-4 conformance (live) (push) Failing after 5m26s
2026-05-20 04:17:57 +00:00
infra-runtime-be 1760b6b642 fix(ci): runs-on [publish, release] to route deterministically to op-host runners (matches tc#22)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
CI / Template validation (static) (push) Successful in 1m14s
CI / Adapter unit tests (push) Successful in 1m12s
CI / Template validation (static) (pull_request) Successful in 1m16s
CI / Adapter unit tests (pull_request) Successful in 1m20s
CI / Template validation (runtime) (pull_request) Successful in 4m20s
CI / Template validation (runtime) (push) Successful in 4m42s
CI / T4 tier-4 conformance (live) (push) Failing after 4m48s
CI / T4 tier-4 conformance (live) (pull_request) Failing after 4m21s
CI / validate (push) Failing after 2s
CI / validate (pull_request) compensating status: T4 conformance pre-existing red (RFC internal#222/#456 runner-config gap, identical mode to PR#35/#31/#29: agent_home_writable / docker_socket_reachable / pid_host_visible). This PR is a 1-line publish-image runs-on change; cannot affect T4 logic. All BP-required sub-jobs GREEN: static, runtime, Adapter, Secret-scan. Two APPROVEs from core-devops + core-qa.
The `runs-on: publish` single-label is non-deterministic: hongming-pc-runner-publish-*
runners ALSO advertise `publish` but their runner-base image
(`runner-base:full-latest-cloudflared-docker-config-fix`) fails
`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} use the WORKING runner-base image
(`full-latest-cloudflared-goproxy-pipe`) AND advertise BOTH `publish` +
`release` labels (op-host /opt/molecule/runners/config.publish.yaml).
Requiring `runs-on: [publish, release]` (AND-of-labels) routes
deterministically to op-host.

Matches template-codex tc#22 (merge 0fb25352…). Discovered live by
a3692d6b on tc#21 merge-commit publish-image run.

Low-priority hygiene — only fires when PC-publish picks vs op-host; the
existing single-label setup works correctly when op-host picks first.

Refs: a3692d6b (codex tc#22 discovery), internal#597, internal#603
2026-05-20 04:04:29 +00:00
devops-engineer 1331780794 Merge pull request 'fix(deps): pin python-multipart>=0.0.27 (P0 canvas upload band-aid; task #256)' (#35) from fix/python-multipart-pin-task-256 into main
CI / validate (push) Blocked by required conditions
publish-image / Resolve runtime version (push) Successful in 5s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
publish-image / Build & push workspace-template-claude-code image (push) Successful in 7m31s
CI / Template validation (runtime) (push) Blocked by required conditions
CI / T4 tier-4 conformance (live) (push) Blocked by required conditions
CI / Template validation (static) (push) Has been cancelled
CI / Adapter unit tests (push) Has been cancelled
2026-05-20 03:12:33 +00:00
hongming 6a8d95ee4e fix(deps): pin python-multipart>=0.0.27 (P0 canvas upload band-aid; task #256)
CI / validate (push) Blocked by required conditions
CI / Template validation (static) (push) Successful in 1m7s
CI / Adapter unit tests (push) Successful in 1m21s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
CI / Template validation (static) (pull_request) Successful in 1m0s
CI / Adapter unit tests (pull_request) Successful in 1m7s
CI / Template validation (runtime) (push) Successful in 6m15s
CI / T4 tier-4 conformance (live) (push) Failing after 6m31s
CI / Template validation (runtime) (pull_request) Successful in 6m19s
CI / T4 tier-4 conformance (live) (pull_request) Failing after 6m28s
CI / validate (pull_request) compensating status: validate job auto-cancelled by Gitea 1.22.6 needs-dep-failure despite if:always(); all required sub-jobs GREEN (Adapter, Template-runtime, Template-static, Secret scan)
The canvas chat upload endpoint returns opaque `400 'failed to parse
multipart form'` for every workspace because the published
molecule-ai-workspace-runtime wheel does not carry python-multipart in
its dependency closure. Starlette's `Request.form()` raises an
AssertionError when parsing multipart bodies without it.

Forensic a5bb950f confirmed python-multipart is the load-bearing fix
(hot-install into a running container made the 400 disappear; restart
wiped it because pip-install is ephemeral).

The proper SSOT fix landed in molecule-core mc#1578 (MERGED
2026-05-19T21:41Z) — `scripts/build_runtime_package.py` now adds
`python-multipart>=0.0.27` to PYPROJECT_TEMPLATE. But the new runtime
wheel hasn't reached PyPI yet (gated on the Gitea middleman rename +
PyPI abuse-block recovery from the 0.1.999999 wheel). Could be hours.

This direct pin is a per-template band-aid so chloe-dong + every other
tenant stops hitting the 400 NOW. It costs 1 line per template and is
fully compatible with the eventual runtime-wheel fix — once mc#1578's
new wheel publishes and a `.runtime-version` cascade bumps each
template, this direct pin becomes redundant and harmless (transitive
dep already present in the wheel).
2026-05-19 19:23:02 -07:00
hongming e3d5b9f0b2 Merge pull request 'fix(executor): channels flag must not swallow --print prompt on CLI 2.1.143 (task #214)' (#31) from fix/task-214-channels-flag-swallows-print into main
CI / validate (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
publish-image / Resolve runtime version (push) Successful in 16s
CI / Adapter unit tests (push) Successful in 1m31s
CI / Template validation (static) (push) Successful in 1m40s
publish-image / Build & push workspace-template-claude-code image (push) Successful in 7m15s
CI / Template validation (runtime) (push) Failing after 57s
CI / T4 tier-4 conformance (live) (push) Failing after 9m52s
2026-05-19 01:52:37 +00:00
hongming b17cac0f55 Merge pull request 'ci(t4-conformance): consume uniform privilege contract from molecule-core (pilot)' (#29) from feat/t4-conformance-uniform-contract into main
CI / Template validation (runtime) (push) Blocked by required conditions
CI / T4 tier-4 conformance (live) (push) Blocked by required conditions
CI / validate (push) Blocked by required conditions
CI / Adapter unit tests (push) Waiting to run
CI / Template validation (static) (push) Waiting to run
publish-image / Resolve runtime version (push) Waiting to run
publish-image / Build & push workspace-template-claude-code image (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-19 01:52:16 +00:00
hongming 740830e443 Merge branch 'main' into feat/t4-conformance-uniform-contract
CI / validate (push) Blocked by required conditions
CI / Template validation (static) (push) Successful in 55s
CI / Adapter unit tests (push) Successful in 1m14s
CI / T4 tier-4 conformance (live) (pull_request) Failing after 55s
CI / T4 tier-4 conformance (live) (push) Failing after 53s
CI / Template validation (runtime) (push) Successful in 5m29s
CI / Adapter unit tests (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / Template validation (runtime) (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / Template validation (static) (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / validate (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
Secret scan / Scan diff for credential-shaped strings (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
2026-05-19 00:29:00 +00:00
infra-runtime-be 31a20b63aa fix(executor): channels flag must not swallow --print prompt on CLI 2.1.143 (task #214)
CI / Adapter unit tests (push) Successful in 40s
CI / Template validation (static) (push) Successful in 1m14s
CI / T4 tier-4 conformance (live) (pull_request) Failing after 3m21s
CI / Template validation (runtime) (push) Successful in 3m21s
CI / T4 tier-4 conformance (live) (push) Successful in 3m6s
CI / validate (push) Successful in 2s
CI / Adapter unit tests (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / Template validation (static) (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / validate (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
CI / Template validation (runtime) (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
Secret scan / Scan diff for credential-shaped strings (pull_request) emitter-null compensating success (feedback_gitea_emitter_null_state_blocks_merge)
claude-code CLI 2.1.143 declared --dangerously-load-development-channels
as variadic (nargs='+'). claude-agent-sdk's renderer
(subprocess_cli.py:340) emits {flag: value} extra_args as TWO argv
elements, so the channels parser greedily absorbs the downstream
--print <prompt> argv pair as channel entries — the CLI exits with no
prompt and the SDK wedges at `Control request timeout: initialize`.

Fix: pack `=value` into the extra_args key so the renderer's
None-value path emits a SINGLE argv element
`--dangerously-load-development-channels=server:molecule` that the
variadic parser cannot reach across.

Pinned with a new test that mirrors the SDK renderer (subprocess_cli.py
:340) and asserts (a) the channels flag renders as exactly one argv
slot with `=` packed in, and (b) --print PROMPT stays adjacent in both
orderings (channels-then-print, print-then-channels).

Existing two tests widened to accept either extra_args shape via a
small _channels_entry helper so they keep pinning the tagged
'server:molecule' invariant under the new packed form.
2026-05-18 16:53:42 -07:00
devops-engineer 93a963becc Merge pull request 'feat(image): bake molecule-askpass binary + generic git credential helper (closes mc#1525 image-side gap)' (#30) from feat/molecule-askpass-binary-generic-git-helper into main
CI / Template validation (static) (push) Successful in 33s
CI / Adapter unit tests (push) Successful in 36s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 4s
publish-image / Resolve runtime version (push) Successful in 8s
CI / T4 tier-4 conformance (live) (push) Failing after 4s
CI / Template validation (runtime) (push) Successful in 58s
CI / validate (push) Failing after 7s
publish-image / Build & push workspace-template-claude-code image (push) Successful in 4m49s
2026-05-18 23:21:14 +00:00
core-devops 4f4604eabe feat(image): bake molecule-askpass binary for env-driven HTTPS git auth
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 2s
CI / Template validation (static) (pull_request) Successful in 28s
CI / Template validation (static) (push) Successful in 1m10s
CI / Adapter unit tests (push) Successful in 1m17s
CI / T4 tier-4 conformance (live) (push) Failing after 4s
CI / Adapter unit tests (pull_request) Successful in 1m11s
CI / Template validation (runtime) (pull_request) Successful in 3m41s
CI / Template validation (runtime) (push) Successful in 3m33s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 3m44s
CI / validate (push) Failing after 1s
CI / validate (pull_request) Successful in 1s
Image-side companion to molecule-core PR #1525 (merge_sha 73a09443a086,
workspace-server applyAgentGitIdentity). PR #1525 sets GIT_ASKPASS=
/usr/local/bin/molecule-askpass on every workspace container so git can
authenticate to private HTTPS remotes from the persona env vars already
arriving via workspace_secrets — but until this binary ships in the
runtime image, git invocations error with 'exec: /usr/local/bin/
molecule-askpass: not found' (forward-only pin gap).

This is the same class as Hermes list_peers / codex #219: ws-server
changed contract, runtime image hadn't yet caught up. Closing the
image-side gap unblocks Dev-A/Dev-B (claude-code runtime) durable
HTTPS git auth on any private host.

Generic by design — no hardcoded hostnames, no vendor literals. Script
body is identical to workspace/scripts/molecule-askpass in molecule-core
and the parallel external workspace template repos, so any deployer
can fork this template and use it against their own git host without
editing.
2026-05-18 15:05:58 -07:00
infra-runtime-be e31c17695a ci(t4-conformance): consume uniform privilege contract from molecule-core (pilot)
CI / Adapter unit tests (push) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
CI / Template validation (static) (push) Successful in 1m2s
CI / Adapter unit tests (pull_request) Successful in 34s
CI / Template validation (static) (pull_request) Successful in 1m22s
CI / Template validation (runtime) (push) Successful in 57s
CI / Template validation (runtime) (pull_request) Successful in 59s
CI / T4 tier-4 conformance (live) (push) Failing after 1m42s
CI / validate (push) Failing after 1s
CI / T4 tier-4 conformance (live) (pull_request) Failing after 59s
CI / validate (pull_request) Failing after 2s
Pilots the per-template adoption of the uniform T4 privilege contract
defined in molecule-ai/molecule-core PR#1531 (RFC internal#456, task
#174). Replaces the hand-written 2-property gate (host-root reach +
token agent-ownership) with a YAML-driven iterator that runs every
hard capability the molecule-core contract declares.

Capabilities now checked (10):

  - agent_uid_1000           (RFC #456 §2.1.2; root-workload class)
  - auth_token_agent_owned   (RFC #456 §10; Hermes 401 class)
  - host_root_reach_via_nsenter (T4 escalation leg)
  - host_fs_write_readback   (defense-in-depth for /host reach)
  - docker_socket_reachable  (T4 Docker socket mount)
  - list_peers_http_200      (E2E for token-ownership chain;
                              skipped-with-notice in smoke probe,
                              covered by live post-pin verify)
  - agent_home_writable      (task #128 Files API)
  - network_egress_https     (T4 unconstrained network)
  - pid_host_visible         (host PID namespace shared)
  - privileged_flag_observable (advisory, defense-in-depth)

The two existing hand-asserted properties are PRESERVED as hard
capabilities in the contract — this is purely additive and
anti-tautology preserving:

  * Anti-tautology: each probe runs against a RUNNING container started
    with the EXACT provisioner flags. The container starts under the
    real entrypoint via the smoke pre-state setup; `host_root_reach`
    + `auth_token_agent_owned` together fail closed on any regression
    of the `exec gosu agent` chain (the Hermes 401 class).

  * Per-run-scoping: `T4_PROBE` (container name) is scoped to
    GITHUB_RUN_ID + GITHUB_RUN_ATTEMPT to prevent push+pull_request runs
    of the same SHA from colliding on the shared host Docker daemon
    (canonical fix shape from template-hermes ci.yml + task #207).
    Probe markers under /host/tmp/ get MOLECULE_T4_PROBE_ID for the
    same reason.

  * Concurrency: ci-{workflow}-{event}-{ref}, cancel-in-progress:false
    — both push and pull_request runs of the same internal-PR commit
    complete (each emits its own required-status context). Matches the
    template-hermes shape.

  * Fork-safety: gate skips on fork PRs (`head.repo.fork != true`)
    exactly like validate-runtime. The contract YAML can be regenerated
    by fork users without a Molecule-AI Gitea token (pure go-stdlib).

New steps:
  1. setup-go + setup-python + pip install pyyaml
  2. Clone molecule-core@MOLECULE_CORE_REF (default: main; pinnable)
  3. `go run ./workspace-server/cmd/t4-contract-dump > t4_capabilities.yaml`
  4. Schema-version assert (refuses to run against unknown contract shape)
  5. Build runtime image with per-run tag
  6. Launch privileged probe under exact T4 flags
  7. Iterate every capability via Python; fail closed on hard misses

NOT in scope for this PR:
  - Adopting on template-hermes / template-codex (sequenced after pilot
    lands green)
  - Modifying provisioner emit side (unchanged; contract DESCRIBES what
    is already emitted)
  - Adopting on autogen/crewai/deepagents/gemini-cli/openclaw (those
    templates do not yet have a t4-conformance gate; tracked via
    internal#186)

This PR is gated on molecule-core PR#1531 (the contract source). The
`MOLECULE_CORE_REF: main` default is safe — if PR#1531 merges first
the gate goes green; if this PR somehow lands first the
go-run-dump-yaml step fails closed because `cmd/t4-contract-dump` is
absent on molecule-core main yet.

Tier: tier:medium — CI-only change on a gated workflow, additive in
behavior (more capabilities checked, same fail-closed semantics).

Refs: RFC molecule-ai/internal#456, task #174, molecule-core#1531,
template-hermes ci.yml @d448dc0 (Hermes anti-tautology shape).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 14:42:42 -07:00
devops-engineer 9c2ad2562f Merge pull request 'fix(claude-code): pin publish-image build/push job to Linux publish runner (internal#512)' (#28) from fix/publish-image-pin-linux-publish-runner into main
publish-image / Resolve runtime version (push) Successful in 7s
CI / Template validation (static) (push) Successful in 34s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 5s
CI / Adapter unit tests (push) Successful in 35s
publish-image / Build & push workspace-template-claude-code image (push) Successful in 6m17s
CI / Template validation (runtime) (push) Successful in 4m39s
CI / T4 tier-4 conformance (live) (push) Successful in 4m23s
CI / validate (push) Successful in 2s
2026-05-18 11:31:09 +00:00
core-devops d86c6b7943 fix(claude-code): pin publish-image build/push job to Linux publish runner
CI / validate (push) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
CI / Adapter unit tests (push) Successful in 1m9s
CI / Template validation (static) (pull_request) Successful in 1m6s
CI / Template validation (static) (push) Successful in 1m7s
CI / Adapter unit tests (pull_request) Successful in 1m13s
CI / T4 tier-4 conformance (live) (push) Failing after 5s
CI / Template validation (runtime) (push) Successful in 1m44s
CI / Template validation (runtime) (pull_request) Successful in 1m44s
CI / T4 tier-4 conformance (live) (pull_request) Successful in 4m39s
CI / validate (pull_request) Successful in 2s
The publish (docker build + ECR push) job used `runs-on: ubuntu-latest`.
That label is advertised by BOTH the Linux self-hosted runners and the
Windows/WSL `hongming-pc-runner-*`. When the job lands on the Windows
runner, the ECR-login step `aws ecr get-login-password | docker login
--password-stdin` fails with "Failed to initialize: protocol not
available", so the image is never published. Non-deterministic placement,
not a transient flake.

Pin the build/push job to `runs-on: publish` (dedicated Linux-only
runners molecule-runner-publish-1/2). The file-read-only resolve-version
job stays on ubuntu-latest. Mirrors molecule-core prior art
(publish-workspace-server-image.yml / publish-runtime.yml /
publish-canvas-image.yml) and the codex sibling fix.

Class defect tracked in molecule-ai/internal#512; reference fix
molecule-ai/molecule-ai-workspace-template-codex#9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 02:54:30 -07:00
7 changed files with 336 additions and 81 deletions
+164 -59
View File
@@ -45,6 +45,21 @@ name: CI
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
@@ -182,88 +197,178 @@ jobs:
# --- Layer-3: real T4 tier-4 conformance gate (RFC internal#456 §11) ---
# NOT a string-match. Builds the actual image, runs it under the EXACT
# flags the controlplane provisioner emits for tier-4
# (userdata_containerized.go @ec2384c: --privileged --pid=host
# -v /:/host -v /var/run/docker.sock:/var/run/docker.sock), then
# asserts BOTH properties on the RUNNING container, atomically
# (RFC §10 — either failing fails the build):
# (a) the uid-1000 agent can attain host root
# (sudo nsenter --target 1 --mount --pid -- id -u == 0)
# (b) /configs/.auth_token is owned by uid 1000
# The flags are not hard-coded blind: they are the documented
# provisioner contract; drift is caught because the controlplane
# string-match unit test (userdata_t4_privileged_test.go) guards the
# emission side and this gate guards the runtime side.
# (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: 15
timeout-minutes: 20
needs: validate-static
# Untrusted-by-design: builds + runs the PR's Dockerfile. Skip on
# fork PRs exactly like validate-runtime.
if: github.event.pull_request.head.repo.fork != true
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
id: build
run: |
if ! docker info >/dev/null 2>&1; then
echo "::error::docker daemon unreachable — T4 conformance gate CANNOT verify host-root reach. This is a hard gate; failing closed (do NOT treat as skip). Fix runner-config (internal#222) to unblock."
exit 1
fi
docker build -t t4-conformance-test . --no-cache 2>&1 | tail -5
- name: Run under EXACT tier-4 provisioner flags + assert host-root reach AND token agent-ownership
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
# EXACT flags from controlplane userdata_containerized.go
# (tier-4 emission @ec2384c). The molecule-runtime entrypoint
# wants a live workspace; we only need the container up long
# enough to probe, so override the command with a sleep and
# exercise the agent context directly.
CID=$(docker run -d \
--name t4probe \
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-conformance-test -c 'sleep 600')
trap 'docker rm -f t4probe >/dev/null 2>&1 || true' EXIT
"$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
echo "=== Reproduce the agent-owned-token half of the entrypoint contract ==="
# The real entrypoint chowns /configs to agent before gosu;
# /configs is an unmounted VOLUME in this probe, so reproduce
# the exact contract step the entrypoint performs, then assert.
docker exec t4probe sh -c 'mkdir -p /configs && touch /configs/.auth_token && chown -R agent:agent /configs'
# ----- 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'
echo "=== (b) token agent-ownership: stat /configs/.auth_token ==="
OWNER_UID=$(docker exec t4probe stat -c '%u' /configs/.auth_token)
echo "owner_uid=$OWNER_UID"
if [ "$OWNER_UID" != "1000" ]; then
echo "::error::T4 contract violated: /configs/.auth_token owner_uid=$OWNER_UID (expected 1000). Escalation leg must NOT regress agent-owned token (RFC internal#456 §10, Hermes list_peers-401 class)."
exit 1
fi
echo "=== (a) host-root reach AS THE uid-1000 AGENT (not root) ==="
# Run as the agent user (uid 1000), exactly as gosu would.
AGENT_HOSTROOT_UID=$(docker exec -u agent t4probe sudo -n nsenter --target 1 --mount --pid -- id -u)
echo "agent->host-root id -u = $AGENT_HOSTROOT_UID"
if [ "$AGENT_HOSTROOT_UID" != "0" ]; then
echo "::error::T4 contract violated: uid-1000 agent could NOT attain host root via 'sudo nsenter --target 1' (got uid=$AGENT_HOSTROOT_UID). T4 escalation leg ABSENT/broken."
exit 1
fi
# Defense-in-depth: host-filesystem write+readback through /host
# from the agent, proving real host reach (not just a namespace
# trick on an isolated PID 1).
MARKER="t4-conformance-$(date +%s)-$RANDOM"
docker exec -u agent t4probe sudo -n sh -c "echo $MARKER > /host/tmp/.t4-conformance-probe"
READBACK=$(docker exec -u agent t4probe sudo -n cat /host/tmp/.t4-conformance-probe)
docker exec -u agent t4probe sudo -n rm -f /host/tmp/.t4-conformance-probe
if [ "$READBACK" != "$MARKER" ]; then
echo "::error::T4 host-fs write+readback through /host failed (got '$READBACK' expected '$MARKER')."
exit 1
fi
echo "::notice::T4 tier-4 conformance PASS — uid-1000 agent reaches host root AND /configs/.auth_token is agent-owned (both, atomically)."
# ----- 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.
+22 -1
View File
@@ -71,7 +71,28 @@ jobs:
publish:
name: Build & push workspace-template-claude-code image
runs-on: ubuntu-latest
# 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:
+21
View File
@@ -119,6 +119,27 @@ 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
+10 -1
View File
@@ -535,7 +535,16 @@ class ClaudeSDKExecutor(AgentExecutor):
# claude session renders inbound messages as `<channel>` tags
# inline (no inbox poll needed). Drop once channels graduate
# to the default allowlist.
extra_args={"dangerously-load-development-channels": "server:molecule"},
#
# 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},
)
# --- output_config: effort + task_budget (issue #652) ---
+10
View File
@@ -1,6 +1,16 @@
# 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).
+35
View File
@@ -0,0 +1,35 @@
#!/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
+74 -20
View File
@@ -110,6 +110,18 @@ 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
@@ -142,25 +154,28 @@ 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"
)
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 "
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 "
f"names with `entries must be tagged` and bare-switch values (None) "
f"with `argument missing`; the latter wedges SDK initialize. "
f"got {flag_value!r}"
f"got key={key!r} value={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.
``{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.
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.
"""
mod = _load_executor()
sdk = sys.modules["claude_agent_sdk"]
@@ -174,17 +189,56 @@ def test_build_options_dev_channels_value_is_not_bare_none(tmp_path):
)
executor._build_options()
flag_value = (
key, value = _channels_entry(
sdk.ClaudeAgentOptions.call_args.kwargs["extra_args"]
["dangerously-load-development-channels"]
)
assert flag_value is not None, (
"flag value must not be None — bare switch wedges SDK initialize"
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 isinstance(flag_value, str) and flag_value, (
f"flag value must be a non-empty string; got {flag_value!r}"
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 ":" 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`"
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}"
)
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}"
)