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.
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-serverinstalls 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 interface —
BaseAdapter/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 support —
MOLECULE_WORKSPACESenv 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. Version1is 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.sourcewith the plugin identity (details below). - Trigger — the injected turn is the agent's own autonomous self-turn,
stamped with a
source_typeclassification rather than an externalsource(_stamp_trigger_source):params.metadata.source_typemust 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 stampedsource_typemarks 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_typeat the message level is stripped, andparams.metadata.sourceis set to the plugin id for audit provenance. - Turns stamped
self-schedulerare 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_schemas—MOLECULE_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 frommolecule_runtime.mcp_toolsorplatform_tools.registrydirectly.molecule_runtime.mcp_target_resolution—resolve_workspaces(),read_token_file(),print_missing_env_help(),resolve_target_for_adapter(). Adapters parse workspace env vars via this, NOT directly fromos.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:
- Land changes via a reviewed PR with required non-author approvals and required status contexts green on the exact head.
- On merge to
main,auto-release.ymlre-runs the release-blocking gates (unit-tests,responsiveness-e2e, and the fail-closed SDK schema-sync check) inline. Current Gitea supportsworkflow_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. - On green it computes the next patch from the latest
runtime-v*tag and compares it with the reviewed[project].versionfloor. The higher version becomesruntime-vX.Y.Z(so an explicit0.4.0cutover is not flattened to0.3.126). The release bot creates only that tag through the Gitea API; protectedmainis never mutated and no token is written to disk. - The tag trips
publish-runtime.yml→ builds wheel + sdist → publishes to the Gitea package registry → itspropagatejob opens.runtime-versionbump PRs on each of the four maintained workspace templates. Merging a template bump trips that template'spublish-image.yml, which bakes the pinned wheel into a fresh image, pushes:latest+:sha-<7>to the Gitea OCI registry atregistry.moleculesai.app, and auto-promotes the digest into the control-plane stagingruntime_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:
- 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.
- 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.