Metadata-Version: 2.4
Name: molecules-workspace-runtime
Version: 0.4.74
Summary: Molecule AI workspace runtime — shared infrastructure for all agent adapters
License: BSL-1.1
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: a2a-sdk[http-server]<2.0,>=1.0.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: uvicorn>=0.30.0
Requires-Dist: starlette>=0.38.0
Requires-Dist: websockets>=12.0
Requires-Dist: python-multipart>=0.0.27
Requires-Dist: pyyaml>=6.0
Requires-Dist: jsonschema>=4.18
Requires-Dist: langchain-core>=0.3.0
Requires-Dist: opentelemetry-api>=1.24.0
Requires-Dist: opentelemetry-sdk>=1.24.0
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.24.0
Requires-Dist: langfuse<3,>=2.60
Provides-Extra: contracts
Requires-Dist: molecule-ai-contracts>=0.1.0; extra == "contracts"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-asyncio; extra == "test"
Requires-Dist: jsonschema>=4.18; extra == "test"

# molecule-ai-workspace-runtime

Shared Python runtime infrastructure for all [Molecule AI](https://git.moleculesai.app/molecule-ai/molecule-core)
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](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime)).

## 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_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.

```python
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](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:

```python
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):

```json
{"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:

```json
{"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:

```bash
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 from
  `molecule_runtime.mcp_tools` or `platform_tools.registry` directly.
* `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 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:

```json
[
  {
    "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.

```bash
: "${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

```bash
# Run the unit tests
python -m venv .venv && source .venv/bin/activate
pip install -e ".[test]"
pytest -q
```

```bash
# 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.

```dockerfile
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](https://git.moleculesai.app/molecule-ai/molecule-core/issues/87) — original
  workspace executor split (template repos host their own `executor.py`,
  runtime hosts the shared helpers)
- [#2103](https://git.moleculesai.app/molecule-ai/molecule-core/pull/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.
