hongming 6c4b0b3b87
ci / obs-smoke (push) Has been skipped
lint-infisical-extractor / lint-infisical-extractor (push) Successful in 6s
auto-release / Release decision (push) Successful in 12s
meta-ci-advisory / meta (advisory) (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
Schema Sync / Vendored schemas in sync with molecule-ai-sdk (push) Successful in 17s
ci / lint (push) Successful in 22s
auto-release / Gate — SDK schema sync (push) Successful in 13s
ci / build (push) Successful in 29s
ci / smoke-install (push) Successful in 31s
consumer-drift / runtime-ssot-consumers (push) Successful in 57s
auto-release / Gate — unit-tests (push) Successful in 1m15s
auto-release / Gate — responsiveness-e2e (push) Successful in 1m22s
ci / unit-tests (push) Successful in 1m38s
ci / responsiveness-e2e (push) Successful in 1m43s
auto-release / Cut runtime-v<target> tag at gated commit (push) Successful in 17s
publish-runtime / publish (push) Successful in 1m1s
publish-runtime / propagate (push) Successful in 51s
fix(npm_auth): only a DESIGNATED package token may become the npm _authToken (#413)
MOLECULE_TEMPLATE_REPO_TOKEN and GITEA_TOKEN are general forge credentials, not package tokens. Measured on prod: repo API 200, packages API 401, whoami 403 — while the same request with no Authorization header returns 200.

Writing either as the npm _authToken turns a working anonymous fetch into a hard 401, fail-closing every MCP plugin not pre-baked into the image (molecule-ai-plugin-image-gen#2). The management MCP escaped only because its prebake means it never fetches.

Narrows the precedence to MOLECULE_NPM_TOKEN. Auth is not disabled: a private package remains reachable by setting that var to a read:package token. The git-transport resolver is untouched.
2026-08-16 10:11:44 +00:00

molecule-ai-workspace-runtime

Shared Python runtime infrastructure for all Molecule AI agent adapters and workspace template images.

This repo is the canonical source of truth as of 2026-05-20. Direct PRs are the editable path. The monorepo molecule-core/workspace-server installs the published wheel under its canonical distribution name, molecules-workspace-runtime.

Previously the monorepo workspace/ directory was the source and this repo was a publish-time mirror. That arrangement is reversed by the standalone-as-SSOT migration (CTO-GO 2026-05-20).

What lives here

This package provides the core machinery every Molecule AI workspace container needs:

  • A2A server — registers with the platform, heartbeats, serves A2A JSON-RPC
  • Adapter interfaceBaseAdapter / AdapterConfig / SetupResult
  • Built-in tools — delegation, memory, approvals, sandbox, audit, telemetry
  • Skill loader — loads and hot-reloads skill modules from /configs/skills/
  • Plugin system — per-workspace + shared plugin discovery and install
  • Config / preflight — YAML config loading with validation
  • External-runtime MCP (molecule-mcp) — universal MCP stdio server for external agents (Claude Code, hermes, codex, etc.) running outside the platform's container fleet
  • Multi-workspace supportMOLECULE_WORKSPACES env var lets one MCP process serve N workspaces concurrently (introduced in the multi-WS PR series, finalised in 0.2.0)

Calling the platform: platform_auth.platform_headers() is mandatory

Every request this runtime sends to PLATFORM_URL must build its headers with molecule_runtime.platform_auth.platform_headers(workspace_id) — including plugins and daemons that talk to the platform on their own.

from molecule_runtime.platform_auth import platform_headers

resp = await client.post(
    f"{PLATFORM_URL}/workspaces/{peer_id}/a2a",
    headers=platform_headers(my_workspace_id, source=True),
    json=payload,
)

The platform's TenantGuard middleware is registered on the root engine, so it runs before authentication. A request carrying neither X-Molecule-Org-Id nor Fly-Replay-Src is rejected outright:

400 {"code":"TENANT_ORG_HEADER_REQUIRED",
     "error":"missing tenant routing header",
     "required_header":"X-Molecule-Org-Id"}

A valid workspace token does not help — measured on a live tenant, a bogus bearer and a real bearer return byte-identical 400s. /workspaces/:id/a2a is in no exemption list, so workspace-to-workspace A2A is 100% dead on any tenant whose platform process has MOLECULE_ORG_ID set if the header is missing. The guard is a passthrough when the platform's own MOLECULE_ORG_ID is empty, which is why a self-host deployment never sees this and why the defect stayed latent.

platform_headers() reads MOLECULE_ORG_ID from the container environment and omits the header when it cannot resolve one — it never defaults or derives a value, because an org id is a routing claim the runtime has no standing to invent, and the platform's own 400 is a better failure than a fabricated header. For the same reason, a workspace registered with its own platform_url (the multi-tenant MOLECULE_WORKSPACES bridge, below) gets no org header at all: this process's env describes at most one of those tenants.

tests/test_platform_headers_ssot.py enforces this structurally — it walks the AST of the package and fails if any platform-bound request builds headers by another route. If it fails on your change, the fix is to use the builder, not to extend the allowlist (every entry on that allowlist is separately asserted to emit the tenant header). Background: runtime#373, incident runtime#360.

Plugin local A2A transport (channel + trigger lanes)

A plugin whose plugin.yaml declares kind: channel or kind: trigger can declare a workspace-owned daemon with contributes.daemons. At boot the runtime discovers and supervises every declared daemon, but binds a private local A2A socket only for daemons owned by those two lane kinds (_LANE_KINDS in molecule_runtime/channel_events.py); other supervised daemons never inherit or receive the reserved lane environment. Both lanes share the same socket + ephemeral-capability mechanism and the same request-body ceiling as the public A2A proxy; they differ only in how provenance is stamped and which environment variables carry the capability.

A kind: channel daemon receives:

  • MOLECULE_CHANNEL_API_VERSION — the SDK/host contract version. Version 1 is required by the current client.

  • MOLECULE_CHANNEL_A2A_SOCKET — a private Unix socket serving the workspace's existing A2A HTTP/JSON-RPC application.

  • MOLECULE_CHANNEL_A2A_TOKEN — a distinct, ephemeral bearer capability for that plugin's socket. The helper sends it in the local-only capability header.

  • MOLECULE_CHANNEL_PLUGIN_ID — the installed plugin identity the runtime stamps as channel provenance.

A kind: trigger daemon receives its own, distinct set — MOLECULE_TRIGGER_API_VERSION, MOLECULE_TRIGGER_A2A_SOCKET, MOLECULE_TRIGGER_A2A_TOKEN, MOLECULE_TRIGGER_PLUGIN_ID — plus MOLECULE_TRIGGER_STATE_DIR, the durable schedule-state directory it shares with the runtime schedule API. The names are distinct per lane so a channel daemon and a trigger daemon in the same workspace never see each other's socket or token.

The two lanes stamp provenance differently:

  • Channel — the injected turn represents an external party. The runtime overwrites params.metadata.source with the plugin identity (details below).
  • Trigger — the injected turn is the agent's own autonomous self-turn, stamped with a source_type classification rather than an external source (_stamp_trigger_source):
    • params.metadata.source_type must be in the runtime allow-list (TRIGGER_ALLOWED_SOURCE_TYPES, today {"self-scheduler"}); a value outside it is rejected, and an absent value defaults to the granted type. This allow-list is the security boundary: a trigger daemon cannot make its turn pass as a user-directed turn (the stamped source_type marks it a routine self-ping), cannot claim a channel source, and cannot use any self-turn class it was not granted.
    • Any daemon-supplied source / source_type at the message level is stripped, and params.metadata.source is set to the plugin id for audit provenance.
    • Turns stamped self-scheduler are routine self-pings: they drop rather than queue behind an in-flight turn, and their output is governed by the autonomous-loop replay guard (molecule_runtime/autonomous_loop_guard.py).

See docs/trigger-daemons.md for the full kind: trigger daemon reference — supervision + hot-start, the schedule grid and state directory, the /internal/schedules API, poke semantics, and the health-file contract. The rest of this section documents the channel lane's client contract; the trigger client (send_trigger_message) shares the same transport and delivery semantics.

The socket does not define a second event envelope. Provider plugins import the provider-neutral client from molecule-ai-sdk; they do not import the runtime's private host implementation:

from molecule_plugin.channel import (
    channel_message_response_text,
    send_channel_message,
)

response = await send_channel_message(
    text,
    metadata={
        "chat_id": chat_id,
        "user_id": sender_id,
        "username": sender_name,
        "message_id": external_message_id,
    },
)
reply_text = channel_message_response_text(response)

The helper sends the existing platform request shape (IDs shown explicitly):

{"jsonrpc":"2.0","id":"req-1","method":"message/send","params":{"message":{"kind":"message","role":"user","messageId":"msg-1","parts":[{"kind":"text","text":"hello"}]},"metadata":{"chat_id":"C123","user_id":"U456","username":"Ada","message_id":"171.1"}}}

With a2a-sdk 1.x, a completed turn returns the existing JSON-RPC Task shape; IDs and timestamp are generated per turn:

{"jsonrpc":"2.0","id":"req-1","result":{"kind":"task","id":"task-1","contextId":"ctx-1","artifacts":[{"artifactId":"artifact-1","parts":[{"kind":"text","text":"pong"}]}],"status":{"state":"completed","timestamp":"2026-07-13T03:44:52Z","message":{"kind":"message","messageId":"reply-1","role":"agent","taskId":"task-1","contextId":"ctx-1","parts":[{"kind":"text","text":"pong"}]}}}}

message/send returns that synchronous result when the turn completes. Clients that need an explicit start acknowledgement plus completion can post the same envelope with method: "message/stream" and consume the existing A2A working-status and terminal-message SSE events. The reusable helper intentionally targets message/send; streaming clients use an UDS-aware HTTP client directly.

Channel provenance uses the existing platform fields under params.metadata: source, chat_id, user_id, username, and message_id. The daemon supplies channel event fields, but the runtime always overwrites only the canonical params.metadata.source with MOLECULE_CHANNEL_PLUGIN_ID. A client claim at params.message.metadata.source is rejected before dispatch rather than mirrored into a second provenance surface. Before stamping, the listener requires the plugin-specific MOLECULE_CHANNEL_A2A_TOKEN; another same-UID daemon finding the socket path does not receive that token through its own injected environment and cannot select a different source merely by changing request JSON. Plugins still run under the workspace UID and are trusted code, not mutually sandboxed principals. The socket directory is mode 0700 and each socket is mode 0600 before the daemon starts. Paths and tokens are ephemeral per-boot capabilities and must not be persisted.

If the socket bind fails, the runtime removes the reserved capability variables for both lanes and still starts the daemon. send_channel_message (and the trigger lane's send_trigger_message) raise ChannelCapabilityUnavailable when the version, socket, or token is absent or unsupported, which means this host cannot run the plugin. Once a local send is attempted, a connection, timeout, or HTTP failure raises ChannelDeliveryUnknown; the same external event must not be replayed because the agent may already have accepted the turn.

The runtime intentionally does not depend on molecule-ai-sdk. Instead, molecule_runtime/channel_sdk.py is a byte-for-byte copy of the SDK-owned molecule_plugin/channel.py; molecule_runtime.channel_events hosts the socket and retains ChannelEvent* aliases only for runtime compatibility. Check a local SDK checkout before updating the vendor:

scripts/check-channel-sdk-vendor.sh ../molecule-ai-sdk

CI runs the same exact-copy gate against molecule-ai-sdk main, in addition to the client/host conformance tests.

MCP SSOT public surface (issue #38)

Adapters (a2a_mcp_server, langchain integrations, future SDKs) consume the universal Molecule tool + target-resolution contract via the SSOT modules in molecule_runtime. Adapters are shims; base MCP/runtime is the source of truth. The drift is one of the failure modes the SSOT was created to prevent (a previous refactor split the universal Molecule contract across multiple modules, which made it easy for a future adapter to silently fork it).

  • molecule_runtime.mcp_schemasMOLECULE_MCP_TOOLS, openai_function_tools(), PERMISSION_MAP, get_tool_schema(name), validate_adapter_schemas(adapter_tools). Adapters import tool lists and per-tool schemas from here, NOT from molecule_runtime.mcp_tools or platform_tools.registry directly.
  • molecule_runtime.mcp_target_resolutionresolve_workspaces(), read_token_file(), print_missing_env_help(), resolve_target_for_adapter(). Adapters parse workspace env vars via this, NOT directly from os.environ.

tests/test_mcp_ssot.py pins the SSOT public surface (drift tests): the in-tree a2a_mcp_server adapter's TOOLS list is asserted to be the same object as the SSOT, and the env-driven workspace resolution contract is tested across the legacy single-workspace, single-workspace-token-file, and multi-workspace-JSON shapes.

Multiple External Workspaces

molecule-mcp can serve more than one external workspace from the same local process. Set MOLECULE_WORKSPACES to a JSON array of workspace credentials:

[
  {
    "id": "workspace-id-local-to-hongming-org",
    "token": "...",
    "platform_url": "https://hongming.moleculesai.app"
  },
  {
    "id": "different-workspace-id-local-to-agents-team-org",
    "token": "...",
    "platform_url": "https://agents-team.moleculesai.app"
  }
]

Each entry is independently registered and heartbeated against its own platform_url; inbox polling and outbound A2A calls also route by the workspace ID that initiated the call.

org_id is intentionally not part of this local MCP bridge config. The tenant is selected by platform_url, and the workspace token is scoped by the tenant that issued it. Workspace IDs do not need to match across orgs; use the ID and token returned by each tenant.

Installation

The runtime distribution is served by Molecule's Gitea registry. Public PyPI is used only for its public dependencies; do not install the retired molecule-ai-workspace-runtime distribution name. Set RUNTIME_VERSION to the version suffix of a reviewed, published runtime-v* tag; every install below is exact-pinned so the public dependency index cannot select a higher impostor.

: "${RUNTIME_VERSION:?set RUNTIME_VERSION to a reviewed published version}"

pip install \
  --index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  "molecules-workspace-runtime==${RUNTIME_VERSION}"

# Recommended when installing the external MCP server as an isolated CLI:
pipx install \
  --pip-args='--index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ --extra-index-url https://pypi.org/simple/' \
  "molecules-workspace-runtime==${RUNTIME_VERSION}"

Contributing

This repo is the editable source. Open PRs directly here.

Branch protection contract

  • Required non-author approvals must be satisfied on the exact PR head.
  • All required status contexts must pass on that same head; do not rely on stale approvals or results from an earlier revision.
  • No admin-bypass; no force-push to main
  • Use a designated per-agent persona token, not the founder PAT, for CI and repository automation.

Local development

# Run the unit tests
python -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
pytest -q
# Build a local wheel + smoke-install
pip install build
python -m build
pip install dist/*.whl
molecule-mcp --help

Release process

Releases are automatic on a green merge to main (CTO standing directive, 2026-06-10) — no manual tag or approval gate:

  1. Land changes via a reviewed PR with required non-author approvals and required status contexts green on the exact head.
  2. On merge to main, auto-release.yml re-runs the release-blocking gates (unit-tests, responsiveness-e2e, and the fail-closed SDK schema-sync check) inline. Current Gitea supports workflow_run; this repository keeps the gates inline so the release decision and exact merge commit are checked together instead of depending on a separate subscriber workflow.
  3. On green it computes the next patch from the latest runtime-v* tag and compares it with the reviewed [project].version floor. The higher version becomes runtime-vX.Y.Z (so an explicit 0.4.0 cutover is not flattened to 0.3.126). The release bot creates only that tag through the Gitea API; protected main is never mutated and no token is written to disk.
  4. The tag trips publish-runtime.yml → builds wheel + sdist → publishes to the Gitea package registry → its propagate job opens .runtime-version bump PRs on each of the four maintained workspace templates. Merging a template bump trips that template's publish-image.yml, which bakes the pinned wheel into a fresh image, pushes :latest + :sha-<7> to the Gitea OCI registry at registry.moleculesai.app, and auto-promotes the digest into the control-plane staging runtime_image_pins. Agents boot from the promoted pinned image (runtime baked at build, not pip-installed at boot). Production pin promotion remains separate and explicit; it requires its own reviewed control-plane change.

Loop safety: the release bot creates a tag only; there is no bump commit or bot-actor guard. The tag push does not match on: push: branches:[main], so cutting the tag never re-enters auto-release.yml.

Manual bump (escape hatch): edit version = in pyproject.toml in a PR and tag runtime-vX.Y.Z on main post-merge; publish-runtime.yml still fires on any runtime-v* tag.

Consumer pinning

The four maintained workspace templates pin this package by exact version. Molecule Core installs the published wheel separately and does not carry a .runtime-version template pin.

ARG RUNTIME_VERSION
RUN pip install --no-cache-dir \
    --index-url https://git.moleculesai.app/api/packages/molecule-ai/pypi/simple/ \
    --extra-index-url https://pypi.org/simple/ \
    "molecules-workspace-runtime==${RUNTIME_VERSION}"

The published runtime tag is the gating event: after successful registry publication, the cascade opens exact-pin PRs for consumers. A template can also adopt the published version by editing its pin directly.

Architecture: why a separate repo

The runtime needs to ship as a wheel and sdist (so the four maintained workspace template images can pip install it AND so operators can run molecule-mcp outside our container fleet) while still evolving fast.

A standalone editable repo with independent CI cadence avoids two problems the previous mirror arrangement had:

  1. CI saturation — runtime-only changes had to go through the monorepo's full PR-CI lane (Go build, Docker layers, integration tests). Now Python unit tests + lint + wheel build + smoke install run independently in ~2-3 minutes.
  2. Bidirectional drift — when standalone was a publish artifact but also accepted ad-hoc PRs (mirror-guard CI gave inconsistent enforcement), security fixes landed in standalone never reached the monorepo and monorepo features (multi-WS code) never reached standalone. The standalone-as-SSOT migration audited and reconciled this drift.

Back-history

  • #87 — original workspace executor split (template repos host their own executor.py, runtime hosts the shared helpers)
  • #2103 — first attempt at "standalone is the source" (predated mirror-guard CI); reverted because direct edits caused drift
  • Standalone-as-SSOT migration (CTO-GO 2026-05-20) — this is the canonical flip, with the audit + drift reconciliation baked into the initial 0.2.0 release.
S
Description
No description provided
Readme 3.5 MiB
2026-06-09 23:17:15 +00:00
Languages
Python 99%
Shell 1%