13 Commits

Author SHA1 Message Date
devops-engineer 6963378a89 Merge pull request 'fix(pypi): swap OIDC trusted-publisher for twine + PYPI_TOKEN; port .github -> .gitea' (#6) from fix/pypi-gitea-twine-no-oidc into main
CI / test (3.11) (push) Successful in 3m7s
CI / test (3.12) (push) Successful in 3m10s
Publish to PyPI / publish (push) Blocked by required conditions
Publish to PyPI / build (push) Successful in 3m14s
2026-05-16 00:17:42 +00:00
infra-runtime-be 289c65603e fix(pypi): swap OIDC trusted-publisher for twine + PYPI_TOKEN; port .github -> .gitea
CI / test (3.11) (pull_request) Successful in 2m55s
CI / test (3.12) (pull_request) Successful in 2m59s
Post-2026-05-06 PyPI Trusted-Publisher OIDC is dead for our repos — PyPI
only accepts GitHub/GitLab/Google/ActiveState issuers, not Gitea. This PR:

1. Renames .github/workflows/{ci,publish}.yml -> .gitea/workflows/. (Gitea
   Actions reads .gitea/ exclusively on this repo; the .github/ path was
   silently dead since the migration — saved memory
   reference_molecule_core_actions_gitea_only.)

2. Replaces `pypa/gh-action-pypi-publish` (which requires OIDC id-token
   exchange that PyPI rejects from Gitea) with `python -m twine upload
   --username __token__ --password "$PYPI_TOKEN"`. Mirrors the canonical
   pattern in molecule-core/.gitea/workflows/publish-runtime.yml that has
   been shipping successfully since 2026-05-11.

3. Drops `permissions: id-token: write` (no longer needed without OIDC).

4. Adds `twine check` to the build step (catches metadata regressions
   before upload).

5. Adds concurrency group to serialize tag-driven publishes.

6. Updates README "Releasing" section to describe the twine+SSOT model
   and link to the operator-config rotation runbook.

The PYPI_TOKEN secret is fanned out to this repo from the operator-host
SSOT by /opt/molecule-bootstrap/sync-pypi-token.sh — see operator-config
PR#48. It supersedes PR#4 (which only renamed .github -> .gitea without
fixing the OIDC issue) and unblocks the pushed v0.1.3 tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:59:26 -07:00
devops-engineer d57392df4d fix(deps): raise molecule-ai-workspace-runtime floor to >=0.1.129 (#5)
CI / test (3.11) (push) Successful in 2m57s
CI / test (3.12) (push) Successful in 3m30s
Publish to PyPI / build (push) Successful in 3m43s
Publish to PyPI / publish (push) Failing after 1m1s
Closes molecule-ai/internal#424.
2026-05-15 22:54:24 +00:00
infra-runtime-be 06739ba9e0 fix(deps): raise molecule-ai-workspace-runtime floor to >=0.1.129
CI / test (3.11) (pull_request) Successful in 2m10s
CI / test (3.12) (pull_request) Successful in 2m10s
Pre-0.1.129 the runtime's inline A2A response sniffer in
``send_a2a_message`` checked only for ``result`` / ``error`` keys and
routed the poll-mode success envelope —
``{"status": "queued", "delivery_mode": "poll", "method": "..."}`` —
to the malformed branch:

    [A2A_ERROR] unexpected response shape (no result, no error): {...}

That string then propagated through ``tool_delegate_task`` into the
caller workspace's activity row, surfacing on canvas as the workspace's
task label and triggering a ~3s retry storm.

0.1.129 introduced ``a2a_response.py`` (SSOT typed parser with explicit
``Queued`` variant) + matching ``_A2A_QUEUED_PREFIX`` handling in
``a2a_tools_delegation.tool_delegate_task`` that falls back to the
durable ``/delegate`` + ``/delegations`` polling path — the correct
synchronous facade for poll-mode peers.

The existing ``>=0.1.110`` pin allowed a fresh ``pip install
codex-channel-molecule`` to resolve to a buggy wheel and reproduce the
incident. Raising the floor to 0.1.129 closes that window.

Adds a ``test_runtime_dependency_floor.py`` regression test that
parses ``pyproject.toml`` and asserts the lower bound stays at or
above 0.1.129 — bumps the version to 0.1.3 so a republish carries the
floor downstream.

Closes molecule-ai/internal#424.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:44:25 -07:00
devops-engineer b29ce85137 Merge pull request 'fix(post-suspension): migrate github.com/Molecule-AI refs to git.moleculesai.app (Class G #168)' (#3) from fix/post-suspension-github-urls into main
CI / test (3.11) (push) Successful in 2m26s
CI / test (3.12) (push) Successful in 2m36s
2026-05-07 20:02:45 +00:00
devops-engineer 4a8cd3648f fix(post-suspension): migrate github.com/Molecule-AI refs to git.moleculesai.app (Class G #168)
CI / test (3.11) (pull_request) Successful in 2m43s
CI / test (3.12) (pull_request) Successful in 2m44s
The GitHub org Molecule-AI was suspended on 2026-05-06; canonical SCM
is now Gitea at https://git.moleculesai.app/molecule-ai/. Stale
github.com/Molecule-AI/... URLs return 404 and break tooling that
clones / pip-installs / curls them.

This bundles all non-Go-module URL fixes for this repo into a single PR.
Go module path references (in *.go, go.mod, go.sum) are out of scope
here -- tracked separately under Task #140.

Token-auth clone URLs also flip ${GITHUB_TOKEN} -> ${GITEA_TOKEN} since
the GitHub token does not auth against Gitea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:02:40 -07:00
security-auditor d59d1f15ac ci: re-trigger after runner-config v2 (CONFIG_FILE fix)
CI / test (3.11) (push) Successful in 29s
CI / test (3.12) (push) Successful in 36s
Verify whether failure was setup-python toolcache class (now fixed via
orchestrator's runners-1-8 recreate) or real CODE class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:57:23 -07:00
claude-ceo-assistant a4b3109e49 Merge pull request 'chore(ci): pin artifact actions to @v3 for Gitea act_runner compatibility' (#2) from chore/pin-artifact-actions-v3 into main
CI / test (3.11) (push) Failing after 10s
CI / test (3.12) (push) Failing after 10s
2026-05-07 08:18:14 +00:00
claude-ceo-assistant cdf0892b2e chore(ci): pin artifact actions to @v3 for Gitea act_runner compatibility (internal#46)
CI / test (3.11) (pull_request) Failing after 11s
CI / test (3.12) (pull_request) Failing after 11s
2 uses pinned in .github/workflows/publish.yml (1 upload at line 52, 1
download at line 64). v4 relies on a runtime API shape Gitea's act_runner
v0.6.x doesn't fully support; v3 works end-to-end. YAML parse green.

Sister PRs in molecule-controlplane (#18) and molecule-core (#18). Per
internal#46 Phase 2 audit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:11:34 -07:00
claude-ceo-assistant 2f7f7a36c8 Merge pull request 'docs(install): migrate github.com refs to git.moleculesai.app (#37)' (#1) from fix/install-path-gitea into main
CI / test (3.11) (push) Failing after 11s
CI / test (3.12) (push) Failing after 12s
2026-05-07 06:26:33 +00:00
documentation-specialist 336d1beab1 docs(install): migrate github.com refs to git.moleculesai.app (#37)
CI / test (3.12) (pull_request) Failing after 11s
CI / test (3.11) (pull_request) Failing after 11s
Two refs in README.md:
- Sister-repo cross-link `hermes-channel-molecule` (line 5)
- Anonymous `git clone` install command in Development section (line 73)

Both rewritten to the canonical Gitea path
(https://git.moleculesai.app/molecule-ai/...). Anonymous-clone semantics
preserved — no auth-shape change, repos are public on Gitea.

Other github.com refs in README (openai/codex links) are upstream
references and remain unchanged.

Refs: molecule-ai/internal#37, molecule-ai/internal#38
2026-05-06 23:17:51 -07:00
Hongming Wang 6f961d655c Merge pull request #3 from Molecule-AI/fix/v0.1.2-codex-cli-shape-and-launchd-path
CI / test (3.11) (push) Failing after 13m40s
Publish to PyPI / build (push) Failing after 13m38s
CI / test (3.12) (push) Failing after 13m39s
Publish to PyPI / publish (push) Has been cancelled
v0.1.2: codex CLI subcommand shape + inbox poller + launchd PATH
2026-05-04 21:23:10 -07:00
Hongming Wang 0f194d4507 v0.1.2: codex CLI subcommand shape + inbox poller activation + launchd PATH
Three production bugs caught by the codex agent live-testing the daemon
end-to-end against codex-cli 0.128 + a real LaunchAgent install:

1. Codex CLI 0.6+ moved `--resume` from a flag on `exec` to a
   `resume` subcommand. The daemon was sending
   `codex exec --skip-git-repo-check --resume <sid> <prompt>`, which
   parses on 0.5.x but fails on 0.6.x+. Fixed to:
     fresh:  codex exec --skip-git-repo-check <prompt>
     resume: codex exec resume --skip-git-repo-check <sid> <prompt>
   Verified on codex-cli 0.128 with a live binary; the 0.5.x behavior
   is preserved by the fake-codex test fixture, which now SystemExits
   if it sees the legacy `--resume` flag (regression gate).

2. wait_for_message() never returned anything because nothing in the
   daemon ever called molecule_runtime.inbox.activate() or started
   the poller thread. The wheel ships those primitives but expects
   the embedding runtime to wire them up — the workspace runtime
   does this in start.py, but a standalone daemon embedding the
   tools must do it itself. Added the activation + poller-thread
   start in _RealTools.__init__.

3. The codex binary is a `#!/usr/bin/env node` shim. Under launchd /
   stripped systemd units, the parent process PATH is `/usr/bin:/bin`
   and `env node` 127s out silently. CodexRunner now prepends the
   directory of the resolved codex binary to the subprocess PATH at
   spawn time — Node lives next to codex under nvm / brew /
   pnpm-global, so this restores the discovery without operators
   having to thread PATH through their LaunchAgent / unit file.
   README updated with a note on the launchd/systemd interaction.

Test:
- 31 passed (was 28). Three new regression gates: subcommand-shape,
  PATH-prepend (launchd-default PATH stripped), and PATH-no-double
  (idempotent when codex_bin_dir already present). Verified the two
  behavioural new tests FAIL on the old codex_runner.py by stashing
  the source change only and re-running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:21:44 -07:00
9 changed files with 404 additions and 102 deletions
+95
View File
@@ -0,0 +1,95 @@
name: Publish to PyPI
# Triggered on tag push (vX.Y.Z). Tag-on-push instead of release-creation
# is the cheaper UX — `git tag v0.1.0 && git push origin v0.1.0` ships
# without leaving the terminal.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
# Post-2026-05-06 (Molecule-AI GitHub org suspension): PyPI's Trusted
# Publisher OIDC flow only accepts GitHub/GitLab/Google/ActiveState
# issuers — not Gitea. This workflow uses a long-lived PyPI API token
# stored as the repo-level secret PYPI_TOKEN, fanned out from the
# operator-host SSOT (/etc/molecule-bootstrap/all-credentials.env) by
# /opt/molecule-bootstrap/sync-pypi-token.sh.
permissions:
contents: read
# Serialize tag-driven publishes so two concurrent tag pushes don't both
# try to upload the same version and race PyPI.
concurrency:
group: publish-pypi
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Verify tag matches pyproject version
run: |
tag="${GITHUB_REF#refs/tags/v}"
pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if [ "$tag" != "$pkg" ]; then
echo "::error::tag $tag does not match pyproject version $pkg — aborting publish to keep PyPI in sync with git tags"
exit 1
fi
- name: Build sdist + wheel
run: |
python -m pip install --upgrade pip build twine
python -m build
- name: Smoke-import the built wheel
run: |
python -m venv /tmp/install-test
/tmp/install-test/bin/pip install dist/*.whl
/tmp/install-test/bin/codex-channel-molecule --help
- name: Verify package metadata (twine check)
run: python -m twine check dist/*
- uses: actions/upload-artifact@v3 # pinned to v3 for Gitea act_runner v0.6 compatibility (internal#46)
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3 # pinned to v3 for Gitea act_runner v0.6 compatibility (internal#46)
with:
name: dist
path: dist/
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Publish to PyPI
# PYPI_TOKEN: repo-level Gitea Actions secret, written by
# /opt/molecule-bootstrap/sync-pypi-token.sh from the operator-host
# SSOT. Never set this by hand — rotate via the SSOT instead
# (ops/PYPI_TOKEN_ROTATION.md in operator-config).
env:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
if [ -z "$PYPI_TOKEN" ]; then
echo "::error::PYPI_TOKEN secret is not set. Run sync-pypi-token.sh on the operator host to fan it out from SSOT."
exit 1
fi
python -m pip install --upgrade twine
python -m twine upload \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
dist/*
-69
View File
@@ -1,69 +0,0 @@
name: Publish to PyPI
# Triggered on tag push (vX.Y.Z). Tag-on-push instead of release-creation
# is the cheaper UX — `git tag v0.1.0 && git push origin v0.1.0` ships
# without leaving the terminal.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
permissions:
contents: read
# OIDC token for PyPI trusted-publisher auth — no secret token needed.
# PyPI side: register
# github.com/Molecule-AI/codex-channel-molecule
# workflow=publish.yml environment=pypi
# under "Trusted publisher management" on the codex-channel-molecule
# PyPI project page (see README "Releasing" section).
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Verify tag matches pyproject version
run: |
tag="${GITHUB_REF#refs/tags/v}"
pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if [ "$tag" != "$pkg" ]; then
echo "::error::tag $tag does not match pyproject version $pkg — aborting publish to keep PyPI in sync with git tags"
exit 1
fi
- name: Build sdist + wheel
run: |
python -m pip install --upgrade pip build
python -m build
- name: Smoke-import the built wheel
run: |
python -m venv /tmp/install-test
/tmp/install-test/bin/pip install dist/*.whl
/tmp/install-test/bin/codex-channel-molecule --help
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
+14 -14
View File
@@ -2,7 +2,7 @@
Bridge daemon — gives [codex CLI](https://github.com/openai/codex) push parity with the [Molecule AI](https://moleculesai.com) platform's other external runtimes.
The Molecule platform's [`hermes-channel-molecule`](https://github.com/Molecule-AI/hermes-channel-molecule) plugin gives `hermes-agent` true push delivery — peer agents and canvas-user messages land mid-session as conversation turns. Codex CLI has no plugin API today and its MCP runtime drops inbound notifications, so this daemon is the equivalent push surface — built outside the codex process.
The Molecule platform's [`hermes-channel-molecule`](https://git.moleculesai.app/molecule-ai/hermes-channel-molecule) plugin gives `hermes-agent` true push delivery — peer agents and canvas-user messages land mid-session as conversation turns. Codex CLI has no plugin API today and its MCP runtime drops inbound notifications, so this daemon is the equivalent push surface — built outside the codex process.
## How it works
@@ -55,6 +55,14 @@ codex-channel-molecule
The daemon runs in the foreground; logs go to stderr. For systemd hosts, register a unit; for one-off use, `nohup ... &` plus a log file works.
### Running under launchd / systemd (Node-on-PATH note)
The codex binary is a `#!/usr/bin/env node` shim, so spawning it requires `node` to be discoverable on PATH. Under an interactive shell that's automatic; under `launchd` (macOS) and stripped-down `systemd` units PATH defaults to `/usr/bin:/bin`, and `env node` will 127-out silently.
Since 0.1.2 the daemon resolves `codex` to its absolute path and prepends that directory to the subprocess PATH automatically — Node lives next to `codex` under nvm / brew / pnpm-global, so the shim's `env node` finds it. Operators typically don't need any PATH plumbing in their LaunchAgent / unit file beyond the three required env vars.
If you're on an unusual install layout where `codex` and `node` live in different directories, set the LaunchAgent / unit `PATH` explicitly to include both.
## Deprecation path
When [`openai/codex#17543`](https://github.com/openai/codex/issues/17543) lands upstream — a generic path for handling MCP custom notifications in codex and forwarding them into the active session as user submissions — this daemon becomes redundant. Codex itself will accept inbound molecule messages as `Op::UserInput` directly through the MCP server already wired in `~/.codex/config.toml`. Until then, this is the operator-facing answer.
@@ -62,7 +70,7 @@ When [`openai/codex#17543`](https://github.com/openai/codex/issues/17543) lands
## Development
```sh
git clone https://github.com/Molecule-AI/codex-channel-molecule
git clone https://git.moleculesai.app/molecule-ai/codex-channel-molecule.git
cd codex-channel-molecule
pip install -e ".[test]"
pytest -q
@@ -72,7 +80,7 @@ Tests are entirely real-subprocess (no mocking the spawn boundary) so the boot p
## Releasing
Tag-on-push triggers `publish.yml` which builds + publishes to PyPI via OIDC trusted publishing (no API token needed).
Tag-on-push triggers `.gitea/workflows/publish.yml` which builds + publishes to PyPI via `twine upload` using the `PYPI_TOKEN` repo-level Gitea Actions secret.
```sh
# Bump pyproject.toml `version`, commit, then:
@@ -81,19 +89,11 @@ git tag v0.1.1 && git push origin v0.1.1
The workflow refuses to publish if the tag doesn't match `pyproject.toml`'s `version` — keeps PyPI versions and git tags in lockstep.
**One-time PyPI setup** (before the first release):
### Why twine, not Trusted Publisher OIDC
1. Create the project on PyPI by uploading the first wheel manually, OR
2. Pre-register the project on PyPI under a "Pending publisher" config so the first tagged push creates it.
Post-2026-05-06 (Molecule-AI GitHub-org suspension) the canonical SCM is Gitea. PyPI's Trusted-Publisher OIDC flow only recognises GitHub / GitLab / Google / ActiveState issuers — not Gitea — so this repo (and every other PyPI-publishing repo in `molecule-ai/*`) falls back to a long-lived API token.
Either way, on the project's PyPI page → "Manage" → "Publishing" → "Add a new publisher", configure:
- Owner: `Molecule-AI`
- Repository: `codex-channel-molecule`
- Workflow filename: `publish.yml`
- Environment name: `pypi`
After this, every `git push origin v*.*.*` ships the wheel to PyPI without any further intervention.
The `PYPI_TOKEN` secret is **not set by hand**. It is fanned out from the operator-host SSOT (`/etc/molecule-bootstrap/all-credentials.env`) by `/opt/molecule-bootstrap/sync-pypi-token.sh` (see [operator-config/etc/pypi-publishers.yaml](https://git.moleculesai.app/molecule-ai/operator-config/src/branch/main/etc/pypi-publishers.yaml)). Rotation procedure: [PYPI_TOKEN_ROTATION.md](https://git.moleculesai.app/molecule-ai/operator-config/src/branch/main/ops/PYPI_TOKEN_ROTATION.md).
## License
+15
View File
@@ -64,8 +64,13 @@ class _RealTools:
"""
def __init__(self) -> None:
import molecule_runtime.inbox as inbox
from molecule_runtime.a2a_client import PLATFORM_URL, WORKSPACE_ID
from molecule_runtime import a2a_tools
state = inbox.InboxState(cursor_path=inbox.default_cursor_path())
inbox.activate(state)
inbox.start_poller_thread(state, PLATFORM_URL, WORKSPACE_ID)
self._tools = a2a_tools
async def wait_for_message(self, timeout_secs: float) -> str:
@@ -253,6 +258,16 @@ async def _handle_one(
)
result: CodexResult = await runner.run(message=body, session_id=session_id)
logger.info(
"codex result for %s: exit=%s session=%s stdout=%d stderr_tail=%d",
activity_id,
result.exit_code,
result.session_id or "none",
len(result.text),
len(result.stderr_tail),
)
if result.exit_code != 0:
logger.warning("codex stderr tail for %s: %s", activity_id, result.stderr_tail)
if result.session_id and result.session_id != session_id:
store.set(chat_id, result.session_id)
+36 -4
View File
@@ -69,6 +69,31 @@ class CodexRunner:
)
self._codex_bin = resolved
self._timeout_secs = timeout_secs
# The codex binary is a `#!/usr/bin/env node` shim, so spawning
# it requires `node` to be discoverable via the subprocess PATH.
# Under a shell launch this is fine (operator's interactive PATH
# has Node), but launchd and most systemd unit defaults strip
# PATH down to /usr/bin:/bin — `env node` then 127s out and the
# daemon silently fails on every turn.
#
# Fix: remember the directory containing the resolved codex
# binary. Node lives next to it under nvm / brew / pnpm
# global installs. Subprocesses get that dir prepended to their
# PATH (see _build_env). Operators don't have to thread PATH
# through their LaunchAgent / unit file — just $WORKSPACE_ID,
# $PLATFORM_URL, $MOLECULE_WORKSPACE_TOKEN per the install
# contract.
self._codex_bin_dir = os.path.dirname(resolved)
def _build_env(self) -> dict[str, str]:
env = os.environ.copy()
existing_path = env.get("PATH", "")
if self._codex_bin_dir not in existing_path.split(os.pathsep):
env["PATH"] = (
self._codex_bin_dir
+ (os.pathsep + existing_path if existing_path else "")
)
return env
async def run(
self,
@@ -81,16 +106,23 @@ class CodexRunner:
that session. When None, codex starts a fresh session and assigns
a new id (returned in CodexResult.session_id).
"""
args: list[str] = [self._codex_bin, "exec", "--skip-git-repo-check"]
if session_id:
args.extend(["--resume", session_id])
args.append(message)
args = [
self._codex_bin,
"exec",
"resume",
"--skip-git-repo-check",
session_id,
message,
]
else:
args = [self._codex_bin, "exec", "--skip-git-repo-check", message]
proc = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=os.environ.copy(),
env=self._build_env(),
)
try:
+16 -4
View File
@@ -4,14 +4,26 @@ build-backend = "setuptools.build_meta"
[project]
name = "codex-channel-molecule"
version = "0.1.1"
version = "0.1.3"
description = "Bridge daemon for codex CLI ↔ Molecule platform — long-polls the platform inbox, runs `codex exec --resume <session>` per inbound message, replies via send_message_to_user MCP tool. Counterpart to hermes-channel-molecule."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [{ name = "Molecule AI" }]
dependencies = [
"molecule-ai-workspace-runtime>=0.1.110",
# Floor raised from 0.1.110 → 0.1.129 to pull in the SSOT A2A response
# parser (a2a_response.py, introduced 0.1.129). Pre-0.1.129 the legacy
# inline sniffer in a2a_client.send_a2a_message treated the poll-mode
# ``{"status": "queued", "delivery_mode": "poll", "method": "..."}``
# envelope as malformed and surfaced ``[A2A_ERROR] unexpected response
# shape (no result, no error): ...`` on every reply attempt — that
# then propagated to canvas as the workspace's task label and triggered
# a ~3s retry storm. The 0.1.129+ runtime classifies the envelope as
# ``Queued`` and short-circuits to the durable /delegate-poll path
# (a2a_tools_delegation._delegate_sync_via_polling). See
# ``molecule-ai/internal#424`` and ``molecule-core#2967`` for the
# full incident + fix history.
"molecule-ai-workspace-runtime>=0.1.129",
]
[project.optional-dependencies]
@@ -24,8 +36,8 @@ test = [
codex-channel-molecule = "codex_channel_molecule.daemon:main"
[project.urls]
Homepage = "https://github.com/Molecule-AI/codex-channel-molecule"
Repository = "https://github.com/Molecule-AI/codex-channel-molecule"
Homepage = "https://git.moleculesai.app/molecule-ai/codex-channel-molecule"
Repository = "https://git.moleculesai.app/molecule-ai/codex-channel-molecule"
[tool.setuptools.packages.find]
include = ["codex_channel_molecule*"]
+122 -11
View File
@@ -25,7 +25,8 @@ _FAKE_CODEX_SCRIPT = textwrap.dedent("""\
\"\"\"Fake codex CLI for tests.
Behaviors keyed on argv shape and env:
argv: codex exec [--skip-git-repo-check] [--resume <sid>] <message>
argv: codex exec [--skip-git-repo-check] <message>
argv: codex exec resume [--skip-git-repo-check] <sid> <message>
Echoes a banner to stderr (\"session: <uuid>\") and the input message
to stdout. Honors FAKE_EXIT_CODE for failure-path tests.
@@ -35,17 +36,27 @@ _FAKE_CODEX_SCRIPT = textwrap.dedent("""\
args = sys.argv[1:]
assert args[0] == \"exec\", f\"unexpected first arg: {args[0]!r}\"
if \"--resume\" in args:
raise SystemExit(\"--resume is not supported\")
resume_id = None
i = 1
while i < len(args):
if args[i] == \"--resume\":
resume_id = args[i + 1]
i += 2
elif args[i].startswith(\"--\"):
i += 1 # skip unrecognized flag
else:
break
msg = args[i] if i < len(args) else \"\"
if len(args) > 1 and args[1] == \"resume\":
i = 2
while i < len(args):
if args[i].startswith(\"--\"):
i += 1
else:
break
resume_id = args[i]
msg = args[i + 1] if i + 1 < len(args) else \"\"
else:
i = 1
while i < len(args):
if args[i].startswith(\"--\"):
i += 1 # skip unrecognized flag
else:
break
msg = args[i] if i < len(args) else \"\"
sid = resume_id or os.environ.get(\"FAKE_NEW_SESSION_ID\", \"a1b2c3d4-1111-2222-3333-444455556666\")
sys.stderr.write(f\"session: {sid}\\n\")
@@ -89,6 +100,8 @@ async def test_run_resumes_existing_session(fake_codex):
result = await runner.run(message="follow up", session_id=given)
# Resume → no new session id is captured (input is echoed back as-is).
assert result.session_id == given
assert result.exit_code == 0
assert result.text == "echo: follow up"
@pytest.mark.asyncio
@@ -134,5 +147,103 @@ def test_extract_session_id_matches_alternate_banner_shape():
) == "12345678-aaaa-bbbb-cccc-dddddddddddd"
@pytest.mark.asyncio
async def test_run_uses_exec_resume_subcommand_not_legacy_flag(fake_codex):
"""Codex CLI 0.6+ rejects ``codex exec --resume <sid>``; the correct
invocation is ``codex exec resume [--skip-git-repo-check] <sid> <prompt>``.
The fake-codex fixture raises SystemExit if it sees ``--resume`` in
argv, so the assertion is that the call SUCCEEDS — proving the
runner moved off the legacy flag.
"""
runner = CodexRunner(codex_bin=str(fake_codex), timeout_secs=10.0)
sid = "deadbeef-1111-2222-3333-444455556666"
result = await runner.run(message="follow", session_id=sid)
# Subprocess returned 0 because the fake didn't see --resume. If the
# runner ever regresses to the old shape, the fake exits non-zero
# with "--resume is not supported" → result.exit_code != 0.
assert result.exit_code == 0, (
f"Expected exec resume subcommand shape, got "
f"exit_code={result.exit_code} stderr_tail={result.stderr_tail!r}"
)
assert result.session_id == sid
@pytest.mark.asyncio
async def test_run_prepends_codex_bin_dir_to_subprocess_path(tmp_path):
"""The codex binary is a `#!/usr/bin/env node` shim. Under launchd /
systemd, the parent process's PATH is stripped to /usr/bin:/bin, so
the shebang's `env node` lookup fails and codex 127s out silently.
The runner mitigates this by prepending the codex binary's own
directory (where node lives in nvm/brew/pnpm-global layouts) to the
subprocess PATH. Pin: subprocess sees that dir first in PATH.
"""
bin_dir = tmp_path / "fakebin"
bin_dir.mkdir()
fake = bin_dir / "codex"
# Echo the resolved subprocess PATH so the test can inspect it.
fake.write_text(textwrap.dedent("""\
#!/usr/bin/env python3
import os, sys
sys.stderr.write(f"session: a1b2c3d4-1111-2222-3333-444455556666\\n")
sys.stdout.write(os.environ.get("PATH", ""))
"""))
fake.chmod(0o755)
runner = CodexRunner(codex_bin=str(fake), timeout_secs=10.0)
# Strip PATH down to the launchd-default minimum to prove the
# runner's own injection puts bin_dir back.
import os as _os
saved = _os.environ.get("PATH", "")
_os.environ["PATH"] = "/usr/bin:/bin"
try:
result = await runner.run(message="probe")
finally:
_os.environ["PATH"] = saved
assert result.exit_code == 0
seen_path = result.text
# bin_dir must appear FIRST so `env node` finds it before falling
# back to the (empty for launchd) system PATH.
assert seen_path.startswith(str(bin_dir) + os.pathsep), (
f"expected codex_bin_dir prefix in subprocess PATH, got: {seen_path!r}"
)
assert "/usr/bin" in seen_path, (
f"expected existing PATH preserved after prefix, got: {seen_path!r}"
)
@pytest.mark.asyncio
async def test_run_does_not_duplicate_codex_bin_dir_in_path(tmp_path):
"""If the operator's PATH already includes the codex bin dir (the
shell-launched case), don't double it — keeps logs and downstream
tools sane.
"""
bin_dir = tmp_path / "alreadythere"
bin_dir.mkdir()
fake = bin_dir / "codex"
fake.write_text(textwrap.dedent("""\
#!/usr/bin/env python3
import os, sys
sys.stderr.write(f"session: a1b2c3d4-1111-2222-3333-444455556666\\n")
sys.stdout.write(os.environ.get("PATH", ""))
"""))
fake.chmod(0o755)
runner = CodexRunner(codex_bin=str(fake), timeout_secs=10.0)
import os as _os
saved = _os.environ.get("PATH", "")
_os.environ["PATH"] = f"{bin_dir}:/usr/bin:/bin"
try:
result = await runner.run(message="probe")
finally:
_os.environ["PATH"] = saved
seen_path = result.text
occurrences = seen_path.split(os.pathsep).count(str(bin_dir))
assert occurrences == 1, (
f"expected codex_bin_dir to appear exactly once, got {occurrences}: {seen_path!r}"
)
def test_extract_session_id_returns_none_on_no_match():
assert _extract_session_id("nothing relevant here") is None
+106
View File
@@ -0,0 +1,106 @@
"""Pin the minimum molecule-ai-workspace-runtime version that ships
the SSOT A2A response parser.
Background — see ``molecule-ai/internal#424``:
Pre-runtime-0.1.129 the inline sniffer in ``a2a_client.send_a2a_message``
checked for ``result`` or ``error`` keys and routed everything else to
``[A2A_ERROR] unexpected response shape (no result, no error): ...``.
That branch fired on every reply attempt to a poll-mode peer because the
platform proxy synthesizes the success envelope as
``{"status": "queued", "delivery_mode": "poll", "method": "..."}``
which has NEITHER ``result`` NOR ``error``. The retry loop hammered the
caller's canvas with the error string every ~3s.
Version 0.1.129 introduced ``a2a_response.py`` — a typed parser with an
explicit ``Queued`` variant — and the matching ``[A2A_QUEUED]`` sentinel
in ``a2a_client.py``. ``a2a_tools_delegation.tool_delegate_task`` then
falls back to the durable ``/delegate`` + ``/delegations`` polling path,
which IS the correct synchronous facade for poll-mode peers.
This test asserts the floor is held at ``>=0.1.129`` so a future
dependency-housekeeping pass cannot silently lower it back into the
broken range. If the floor is ever raised further (e.g. to require a
later SSOT parser feature), update the constant below — the lower bound
must never go below 0.1.129.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import pytest
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - Python 3.11+ required by pyproject anyway
import tomli as tomllib
# The earliest runtime version that ships the SSOT A2A response parser
# with the typed ``Queued`` variant. Bumping this floor MUST be paired
# with a comment update in pyproject.toml's dependencies block.
_MINIMUM_RUNTIME_VERSION = (0, 1, 129)
def _parse_version(spec: str) -> tuple[int, int, int]:
"""Extract the lower-bound version from a dependency specifier.
Supports the common shapes used in our pyproject files:
* ``pkg>=1.2.3`` → (1, 2, 3)
* ``pkg>=1.2.3,<2`` → (1, 2, 3)
* ``pkg~=1.2.3`` → (1, 2, 3)
Raises ``ValueError`` on anything else so the test fails loud rather
than silently passing on a malformed specifier.
"""
m = re.search(r"(?:>=|~=)\s*(\d+)\.(\d+)\.(\d+)", spec)
if not m:
raise ValueError(f"no lower-bound version in specifier: {spec!r}")
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
@pytest.fixture(scope="module")
def runtime_specifier() -> str:
"""Return the raw dependency specifier for molecule-ai-workspace-runtime."""
root = Path(__file__).resolve().parent.parent
pyproject = root / "pyproject.toml"
with pyproject.open("rb") as f:
data = tomllib.load(f)
deps = data["project"]["dependencies"]
for dep in deps:
# match the package name allowing a hyphen-or-underscore in case
# the spec ever normalizes — PEP 503 treats them equivalently.
if re.match(r"^molecule[-_]ai[-_]workspace[-_]runtime\b", dep):
return dep
raise AssertionError(
"pyproject.toml is missing a molecule-ai-workspace-runtime dependency"
)
def test_runtime_floor_includes_a2a_response_parser(runtime_specifier: str) -> None:
"""The runtime floor must be at or above the SSOT parser release."""
bound = _parse_version(runtime_specifier)
assert bound >= _MINIMUM_RUNTIME_VERSION, (
f"runtime floor {bound} is below {_MINIMUM_RUNTIME_VERSION}"
f"pre-0.1.129 the A2A response parser misclassifies the poll-mode "
f"queued envelope as malformed and surfaces "
f"'[A2A_ERROR] unexpected response shape' on every poll-mode peer "
f"reply. See molecule-ai/internal#424."
)
def test_runtime_specifier_uses_a_lower_bound(runtime_specifier: str) -> None:
"""A bare ``pkg`` or upper-only spec would silently install ANY version
on a fresh ``pip install`` — including the buggy pre-0.1.129 range.
Require an explicit lower bound (``>=`` or ``~=``).
"""
assert re.search(r">=|~=", runtime_specifier), (
f"runtime dependency {runtime_specifier!r} has no lower bound — "
f"a fresh install could resolve to a pre-0.1.129 wheel with the "
f"broken poll-mode parser"
)