Compare commits

...

14 Commits

Author SHA1 Message Date
core-be 018a9acf1c fix(security#321): path traversal guard in loadWorkspaceEnv (CWE-22)
sop-tier-check / tier-check (pull_request) Failing after 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 2s
audit-force-merge / audit (pull_request) Has been skipped
CWE-22: Path traversal in loadWorkspaceEnv (org_helpers.go). A malicious
org YAML setting filesDir to "../../../etc" caused filepath.Join to
escape the org root and read an arbitrary .env file.

Guard: resolveInsideRoot(orgBaseDir, filesDir) — already used at
org_import.go:327 for the same ws.FilesDir input. On traversal rejection,
log and return only the org-root env vars (silent — callers expect
empty map on read failure). No caller changes needed.

Tests: 6 new cases in org_path_test.go covering:
- Normal load (org root + workspace override)
- Traversal attempt rejected (../escape-target)
- Deep traversal rejected (10× "../")
- Empty filesDir loads org root only
- Non-existent filesDir is silent no-op
- Empty orgBaseDir returns empty map

Fixes: #321

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 13:51:12 +00:00
core-be 768578b03a fix(QA-audit #307 #308): asyncio lifecycle fix + push-mode queue test coverage
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Failing after 1s
Issue #307 (regression, medium):
- test_a2a_tools_inbox_wrappers.py: replace _run() helper that called
  asyncio.get_event_loop().run_until_complete() — bypasses
  pytest-asyncio lifecycle, causing 14 tests to pass in isolation but
  exit-1 in full suite. Convert all test methods to
  @pytest.mark.asyncio async def / await.

Issue #308 (test gap, low):
- test_a2a_response.py: push-mode queue handling (PR #278, a2a_proxy.go
  push-at-capacity path) had no dedicated tests despite ~17 uncovered
  lines. Add 3 fixtures (push_queued_full/notify/no_method), 4 test
  cases covering classification, method field, method sentinel, and
  queue_id log output. Also add adversarial inputs for
  queued="yes" (string) and queued=False to confirm is True check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 13:37:51 +00:00
hongming-pc2 f34cc2783a Merge pull request 'ci: add Docker daemon health-check step before build' (#285) from ci/docker-daemon-health-guard into main
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
publish-workspace-server-image / build-and-push (push) Failing after 1s
2026-05-10 12:54:16 +00:00
infra-sre 6d94fd3077 fix(ci): scope trigger to main only — revert accidental staging push addition
audit-force-merge / audit (pull_request) Failing after 1s
The Docker daemon health-check fix should not change which branches trigger
the build. Revert accidental addition of 'staging' to branch filters.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 12:08:34 +00:00
infra-sre 8b6a11ccc7 fix(ci): restore SHA-pins that were accidentally reverted to mutable tags
Reverts two accidental mutable-tag changes introduced in this branch:
- pypa/gh-action-pypi-publish: release/v1 -> cef22109... (matches #276 intent)
- actions/checkout: @v6 -> de0fac2e... (matches #276 intent)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 12:08:07 +00:00
core-devops 8af1eb6774 ci: add Docker daemon health-check to canvas image workflow
Cover the canvas image publish workflow with the same `docker info`
guard added to publish-workspace-server-image.yml (commit 5216e781).
publish-canvas-image.yml was the only docker-build workflow still
missing the step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 12:00:47 +00:00
core-be 14287ab1e9 Merge pull request 'fix(workspace-server): emit Gitea/PyPI URLs for external user instructions (RFC #229 P2-5)' (#295) from fix/external-connection-user-facing-urls into main
publish-workspace-server-image / build-and-push (push) Waiting to run
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-10 11:43:10 +00:00
claude-ceo-assistant 65f9df24b8 Merge branch 'main' into fix/external-connection-user-facing-urls
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 32s
sop-tier-check / tier-check (pull_request) Successful in 33s
audit-force-merge / audit (pull_request) Failing after 2s
2026-05-10 11:37:44 +00:00
claude-ceo-assistant a8bdeb033f merge: RFC #229 P2-batch
Secret scan / Scan diff for credential-shaped strings (push) Successful in 38s
publish-workspace-server-image / build-and-push (push) Successful in 9m22s
Auto-merge per Hongming policy.
2026-05-10 11:34:06 +00:00
claude-ceo-assistant b34ec9f1e2 Merge branch 'main' into fix/external-connection-user-facing-urls
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 30s
sop-tier-check / tier-check (pull_request) Successful in 30s
2026-05-10 11:32:26 +00:00
claude-ceo-assistant d278c22a82 Merge branch 'main' into fix/workspace-server-registry-config-helper
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 33s
sop-tier-check / tier-check (pull_request) Successful in 36s
audit-force-merge / audit (pull_request) Successful in 35s
2026-05-10 11:31:49 +00:00
core-be a355b6f0ad fix(workspace-server): emit Gitea/PyPI URLs for external user instructions (RFC #229 P2-5)
audit-force-merge / audit (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
sop-tier-check / tier-check (pull_request) Successful in 23s
The Molecule-AI GitHub org was suspended 2026-05-06; canonical SCM is
now git.moleculesai.app. external_connection.go was still emitting
github.com URLs in operator-facing copy-paste blocks, breaking
external-agent onboarding silently.

Per-site decisions (8 emit sites in 1 file):

- L124 (channel template doc comment): swap source-of-truth comment to
  Gitea host.
- L137 /plugin marketplace add Molecule-AI/...: swap to explicit Gitea
  HTTPS URL form. End-to-end-verified path per internal#37 § 1.A.
- L138 /plugin install molecule@molecule-mcp-claude-channel: marketplace
  name is molecule-channel (per remote .claude-plugin/marketplace.json),
  not the repo name. Fix to molecule@molecule-channel.
- L157 --channels plugin:molecule@molecule-mcp-claude-channel: same
  marketplace-name fix.
- L179 user-facing GitHub URL: swap to Gitea.
- L261 pip install git+https://github.com/Molecule-AI/molecule-sdk-python:
  not on PyPI; swap to git+https://git.moleculesai.app/molecule-ai/...
- L310 hermes-channel doc comment: swap source-of-truth comment.
- L339 pip install git+https://github.com/Molecule-AI/hermes-channel-molecule:
  not on PyPI; swap to Gitea.
- L369 issue-tracker URL: swap to Gitea.

Verification:
- molecule-ai-workspace-runtime, codex-channel-molecule are on PyPI (200);
  no swap needed for those pip lines (they were already package-name form).
- molecule-mcp-claude-channel, molecule-sdk-python, hermes-channel-molecule
  are NOT on PyPI; swapped to git+https://git.moleculesai.app/molecule-ai/
  form. All three repos are public on Gitea (default branch main) and
  serve git-upload-pack unauthenticated (verified curl 200 against
  /info/refs?service=git-upload-pack).
- Third-party github URLs (gin import, openai/codex, NousResearch/
  hermes-agent upstream issue trackers, npm @openai/codex) intentionally
  preserved.

Adds TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs regression guard
to prevent the same broken URLs from re-emerging on future template
edits.

go vet / go build / existing TestExternal* — all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:23:46 -07:00
core-be 0846ebc1f6 fix(workspace-server): respect MOLECULE_IMAGE_REGISTRY in imagewatch + admin_workspace_images (RFC #229 P2-4)
audit-force-merge / audit (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
sop-tier-check / tier-check (pull_request) Successful in 26s
Two surfaces in workspace-server hardcoded `ghcr.io` and silently bypassed
the `MOLECULE_IMAGE_REGISTRY` env override that flips every other image
operation to the configured private mirror (e.g. AWS ECR in production):

  1. internal/imagewatch/watch.go — image-auto-refresh polled
     `https://ghcr.io/v2/...` and `https://ghcr.io/token` directly. Post-
     suspension, with the platform pointed at ECR, the watcher silently
     stopped seeing digest changes (every poll either 404'd or hung on a
     registry it has no business talking to).

  2. internal/handlers/admin_workspace_images.go — Docker Engine auth
     payload pinned `serveraddress: "ghcr.io"`, so when the operator sets
     `MOLECULE_IMAGE_REGISTRY=…ecr…/molecule-ai` the engine matched the
     wrong credential entry on every authenticated pull.

Fix: extract `provisioner.RegistryHost()` returning the host portion of
`RegistryPrefix()` (e.g. `ghcr.io` ← `ghcr.io/molecule-ai`, or
`004947743811.dkr.ecr.us-east-2.amazonaws.com` ← the ECR mirror prefix),
and route both surfaces through it. Default behavior is unchanged for
OSS users on GHCR.

Tests
- New `TestRegistryHost_SplitsHostFromOrgPath` and
  `TestRegistryHost_NeverEmpty` pin the helper across GHCR / ECR /
  self-hosted Gitea / bare-host edge cases.
- New `TestGHCRAuthHeader_RespectsRegistryEnv` asserts the Docker auth
  payload's `serveraddress` follows MOLECULE_IMAGE_REGISTRY (and never
  leaks the org-path suffix).
- New `TestRemoteDigest_RegistryHostFollowsEnv` stands up an httptest
  server, points MOLECULE_IMAGE_REGISTRY at it, and confirms both the
  token endpoint and the manifest HEAD land there — i.e. the full image-
  watch loop respects the env override end-to-end.

Both new tests were verified to FAIL on the pre-fix code path before the
helper was wired in, so a future revert can't silently re-introduce the
bug.

Out of scope (followup needed)
ECR uses `aws ecr get-authorization-token` (SigV4 + basic-auth) instead
of GHCR's `/token?service=…&scope=…` flow. This PR makes the URL host-
configurable; the bearer-token negotiation in `fetchPullToken` still
speaks the GHCR flavor. On ECR with `IMAGE_AUTO_REFRESH=true`, the
watcher will now fail loudly at the token fetch (logged per tick) rather
than silently hitting ghcr.io. Operators on ECR should keep
IMAGE_AUTO_REFRESH=false until ECR auth is wired — tracked as a separate
task. Net effect of this PR alone is strictly better than pre-fix:
fail-loud > silent-broken.

Refs: RFC #229 P2-4
tier:low

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 04:21:27 -07:00
infra-sre 5216e781cd ci: add Docker daemon health-check step before build
sop-tier-check / tier-check (pull_request) Failing after 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
Run `docker info` as the first CI step to catch runner Docker socket
permission issues (docker.sock unreadable, daemon restarted, group
membership drift) before the expensive `docker build` step.  The error
now surfaces immediately with a clear `::error::` message rather than
silently continuing into `docker build` where the same failure would
appear 60-90s later as a cryptic ECR auth error.

Gitea Actions run 4350 (2026-05-10 05:58 UTC) is the trigger: the runner's
docker.sock became inaccessible for ~6 minutes, `docker build` failed
at step 2 with `permission denied...docker.sock`, and `go build` (step 3)
was never reached — masking the compile errors that were already on
main.  The downstream code errors only surfaced once run 4407 succeeded
at `docker build` and finally reached `go build`.

Now: `docker info` → fail in ~1s with actionable error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 10:01:01 +00:00
15 changed files with 543 additions and 56 deletions
@@ -59,6 +59,25 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible (e.g. permission change, daemon restart, or group-membership
# drift) rather than silently continuing to step 2 where `docker build`
# fails deep in the process with a cryptic ECR auth error that doesn't
# surface the root cause. Also reports the daemon version so operator
# can correlate with runner host logs.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
#
# Why: workspace-template-* repos on Gitea are private. The pre-fix
@@ -54,6 +54,22 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
- name: Compute tags
id: tags
shell: bash
@@ -107,6 +107,22 @@ jobs:
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build (Task #173 fix).
#
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on
@@ -71,10 +71,17 @@ func TemplateImageRef(runtime string) string {
// ghcrAuthHeader returns the base64-encoded JSON auth payload Docker's
// ImagePull expects in PullOptions.RegistryAuth, or empty string when no
// GHCR_USER/GHCR_TOKEN env is set (lets public images pull through).
// GHCR_USER/GHCR_TOKEN env is set (lets public images pull through and lets
// ECR's credential-helper-driven flow take over without a stale GHCR
// payload masking it).
//
// The Docker SDK doesn't read ~/.docker/config.json — every authenticated
// pull needs an explicit RegistryAuth string.
// pull needs an explicit RegistryAuth string. The serveraddress field is
// resolved from provisioner.RegistryHost() so it tracks MOLECULE_IMAGE_REGISTRY
// when the operator points the platform at a private mirror (e.g. ECR).
// Leaving it hardcoded to "ghcr.io" caused the engine to match the wrong
// auth entry post-suspension when MOLECULE_IMAGE_REGISTRY was flipped to
// the AWS ECR mirror (RFC #229).
func ghcrAuthHeader() string {
user := strings.TrimSpace(os.Getenv("GHCR_USER"))
token := strings.TrimSpace(os.Getenv("GHCR_TOKEN"))
@@ -84,7 +91,7 @@ func ghcrAuthHeader() string {
payload := map[string]string{
"username": user,
"password": token,
"serveraddress": "ghcr.io",
"serveraddress": provisioner.RegistryHost(),
}
js, err := json.Marshal(payload)
if err != nil {
@@ -9,6 +9,7 @@ import (
func TestGHCRAuthHeader_NoEnvReturnsEmpty(t *testing.T) {
t.Setenv("GHCR_USER", "")
t.Setenv("GHCR_TOKEN", "")
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
if got := ghcrAuthHeader(); got != "" {
t.Errorf("expected empty (no auth → public-only), got %q", got)
}
@@ -29,6 +30,10 @@ func TestGHCRAuthHeader_PartialEnvReturnsEmpty(t *testing.T) {
}
func TestGHCRAuthHeader_EncodesDockerEnginePayload(t *testing.T) {
// Default registry env (unset → ghcr.io/molecule-ai) means the
// serveraddress field should resolve to ghcr.io. Pin both env vars so the
// test is hermetic regardless of the host's MOLECULE_IMAGE_REGISTRY.
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
t.Setenv("GHCR_USER", "alice")
t.Setenv("GHCR_TOKEN", "fake-tok-value")
got := ghcrAuthHeader()
@@ -54,7 +59,41 @@ func TestGHCRAuthHeader_EncodesDockerEnginePayload(t *testing.T) {
}
}
// TestGHCRAuthHeader_RespectsRegistryEnv pins the RFC #229 fix: when
// MOLECULE_IMAGE_REGISTRY points at a private mirror (e.g. AWS ECR), the
// Docker engine auth payload's serveraddress must reflect that mirror's
// host so credential matching lands on the right entry. Pre-fix this was
// hardcoded to "ghcr.io" and silently dropped the override.
func TestGHCRAuthHeader_RespectsRegistryEnv(t *testing.T) {
t.Setenv("GHCR_USER", "alice")
t.Setenv("GHCR_TOKEN", "fake-tok-value")
t.Setenv("MOLECULE_IMAGE_REGISTRY", "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai")
got := ghcrAuthHeader()
if got == "" {
t.Fatal("expected non-empty auth header")
}
raw, err := base64.URLEncoding.DecodeString(got)
if err != nil {
t.Fatalf("auth header is not valid base64-url: %v", err)
}
var payload map[string]string
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decoded auth is not valid JSON: %v (raw=%s)", err, raw)
}
want := "004947743811.dkr.ecr.us-east-2.amazonaws.com"
if payload["serveraddress"] != want {
t.Errorf("serveraddress: got %q, want %q (must follow MOLECULE_IMAGE_REGISTRY host)",
payload["serveraddress"], want)
}
// Sanity: the org-path portion must NOT leak into serveraddress.
if payload["serveraddress"] == "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" {
t.Error("serveraddress must be host-only, not host+org-path")
}
}
func TestGHCRAuthHeader_TrimsWhitespace(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
// .env lines often have trailing newlines or accidental spaces. Without
// trimming, a stray space would produce an auth payload the engine
// rejects with a confusing 401.
@@ -121,7 +121,7 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
// operators whose external agent IS a Claude Code session (laptop or
// remote dev VM); routes the workspace's A2A traffic into the running
// Claude Code session as conversation turns via MCP. The plugin source
// lives at github.com/Molecule-AI/molecule-mcp-claude-channel — polling
// lives at git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel — polling
// based, no tunnel required (uses /workspaces/:id/activity?since_secs=,
// platform-side support shipped in #2300).
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
@@ -134,8 +134,8 @@ const externalChannelTemplate = `# Claude Code channel — bridges this workspac
# The plugin is NOT on Anthropic's default allowlist, so a one-time
# marketplace-add is needed before install:
#
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
# /plugin install molecule@molecule-mcp-claude-channel
# /plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git
# /plugin install molecule@molecule-channel
#
# Then either run /reload-plugins or restart Claude Code so the
# plugin is registered.
@@ -154,7 +154,7 @@ chmod 600 ~/.claude/channels/molecule/.env
# flag to opt in — without it, you'll see "not on the approved channels
# allowlist" on startup.
claude --dangerously-load-development-channels \
--channels plugin:molecule@molecule-mcp-claude-channel
--channels plugin:molecule@molecule-channel
# You should see on stderr:
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
@@ -176,7 +176,7 @@ claude --dangerously-load-development-channels \
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
#
# Multi-workspace: comma-separate IDs and tokens (same order). See
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
# https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel for
# pairing flow, push-mode upgrade, and v0.2 roadmap.
# Need help?
@@ -258,7 +258,7 @@ claude mcp add molecule -s user -- env \
// externalPythonTemplate uses molecule-sdk-python's RemoteAgentClient +
// A2AServer (PR #13 in that repo). Until the SDK cuts a v0.y release
// to PyPI the snippet pins git+main.
const externalPythonTemplate = `# pip install 'git+https://github.com/Molecule-AI/molecule-sdk-python.git@main'
const externalPythonTemplate = `# pip install 'git+https://git.moleculesai.app/molecule-ai/molecule-sdk-python.git@main'
import asyncio
from molecule_agent import RemoteAgentClient, A2AServer
@@ -307,7 +307,7 @@ if __name__ == "__main__":
// A2A traffic into the running hermes gateway as platform messages
// via the molecule-channel plugin.
//
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
// The plugin (molecule-ai/hermes-channel-molecule on Gitea) is a hermes
// platform adapter that:
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
// stdio MCP subprocess (separate from any hermes-side MCP
@@ -336,7 +336,7 @@ const externalHermesChannelTemplate = `# Hermes channel — bridges this workspa
#
# 1. Install the runtime + plugin:
pip install molecule-ai-workspace-runtime
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
pip install 'git+https://git.moleculesai.app/molecule-ai/hermes-channel-molecule.git'
# 2. Export the workspace credentials:
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
@@ -366,7 +366,7 @@ hermes gateway --replace
# by the plugin's molecule_runtime MCP subprocess).
#
# Source + issue tracker:
# https://github.com/Molecule-AI/hermes-channel-molecule
# https://git.moleculesai.app/molecule-ai/hermes-channel-molecule
# Need help?
# Documentation: https://doc.moleculesai.app/docs/guides/external-agent-registration
@@ -75,3 +75,46 @@ func TestExternalMcpTemplates_UseMoleculeMcpWrapper(t *testing.T) {
}
}
}
// TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs pins the invariant
// that operator-facing snippets never embed github.com URLs pointing at
// Molecule-AI repos.
//
// Why: the Molecule-AI GitHub org was suspended 2026-05-06 and the
// canonical SCM is now git.moleculesai.app. Any `pip install
// git+https://github.com/Molecule-AI/...` or marketplace-add Molecule-AI/
// URL emitted to an external operator hits a 404 / org-suspended page,
// breaking onboarding silently. RFC #229 P2-5.
//
// Third-party github URLs (gin, openai/codex, NousResearch/hermes-agent
// upstream issue trackers, npm @openai/codex) remain valid — only
// Molecule-AI/ paths are broken.
func TestExternalTemplates_NoBrokenMoleculeAIGitHubURLs(t *testing.T) {
templates := map[string]string{
"externalCurlTemplate": externalCurlTemplate,
"externalChannelTemplate": externalChannelTemplate,
"externalUniversalMcpTemplate": externalUniversalMcpTemplate,
"externalPythonTemplate": externalPythonTemplate,
"externalHermesChannelTemplate": externalHermesChannelTemplate,
"externalCodexTemplate": externalCodexTemplate,
"externalOpenClawTemplate": externalOpenClawTemplate,
}
// Substrings that imply the snippet is pointing an operator at the
// suspended Molecule-AI GitHub org.
bannedSubstrings := []string{
"github.com/Molecule-AI/",
"github.com/molecule-ai/",
// Bare `Molecule-AI/<repo>` form used by `/plugin marketplace add`
// resolves through GitHub by default — explicit Gitea URL is
// required post-suspension.
"marketplace add Molecule-AI/",
"marketplace add molecule-ai/",
}
for name, body := range templates {
for _, banned := range bannedSubstrings {
if strings.Contains(body, banned) {
t.Errorf("%s contains %q — Molecule-AI GitHub org is suspended; use git.moleculesai.app/molecule-ai/<repo> instead (RFC #229 P2-5)", name, banned)
}
}
}
}
@@ -91,6 +91,10 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// SECURITY: filesDir is sourced from untrusted org YAML input (ws.FilesDir).
// resolveInsideRoot guard prevents path traversal (CWE-22) where a malicious
// filesDir like "../../../etc" could escape the org root.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@@ -98,7 +102,14 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
// Reject traversal attempt silently — callers expect an empty map
// on any read failure.
log.Printf("loadWorkspaceEnv: rejecting filesDir %q: %v", filesDir, err)
return envVars
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
return envVars
}
@@ -98,3 +98,96 @@ func TestResolveInsideRoot_DeepSubpath(t *testing.T) {
t.Errorf("result %q is not inside %q", got, rootAbs)
}
}
// ─── loadWorkspaceEnv ───────────────────────────────────────────────────────
// writeEnv is a test helper that creates a file at path with KEY=VALUE content.
func writeEnv(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func TestLoadWorkspaceEnv_LoadsOrgRootAndWorkspaceEnv(t *testing.T) {
tmp := t.TempDir()
writeEnv(t, filepath.Join(tmp, ".env"), "ORG_VAR=org_value\n")
writeEnv(t, filepath.Join(tmp, "ws-files", ".env"), "WS_VAR=ws_value\n")
got := loadWorkspaceEnv(tmp, "ws-files")
if got["ORG_VAR"] != "org_value" {
t.Errorf("ORG_VAR: got %q, want %q", got["ORG_VAR"], "org_value")
}
if got["WS_VAR"] != "ws_value" {
t.Errorf("WS_VAR: got %q, want %q", got["WS_VAR"], "ws_value")
}
}
func TestLoadWorkspaceEnv_WorkspaceOverridesOrg(t *testing.T) {
tmp := t.TempDir()
writeEnv(t, filepath.Join(tmp, ".env"), "SHARED=org\n")
writeEnv(t, filepath.Join(tmp, "ws", ".env"), "SHARED=ws\n")
got := loadWorkspaceEnv(tmp, "ws")
if got["SHARED"] != "ws" {
t.Errorf("SHARED: got %q, want %q (workspace should override)", got["SHARED"], "ws")
}
}
func TestLoadWorkspaceEnv_RejectsTraversal(t *testing.T) {
tmp := t.TempDir()
// Write a .env outside the org root to prove it is NOT loaded.
parentDir := filepath.Dir(tmp)
escapeTarget := filepath.Join(parentDir, "escape-target")
writeEnv(t, filepath.Join(escapeTarget, ".env"), "ESCAPED=should_not_be_loaded\n")
got := loadWorkspaceEnv(tmp, "../escape-target")
if _, ok := got["ESCAPED"]; ok {
t.Error("ESCAPED key leaked — path traversal not blocked")
}
}
func TestLoadWorkspaceEnv_RejectsDeepTraversal(t *testing.T) {
tmp := t.TempDir()
// Deep traversal: ".." repeated enough to escape tmp's parent.
parentDir := filepath.Dir(tmp)
deepTraversal := strings.Repeat("../", 10)
escapeTarget := filepath.Join(parentDir, "escape-deep")
writeEnv(t, filepath.Join(escapeTarget, ".env"), "DEEP=should_not_be_loaded\n")
got := loadWorkspaceEnv(tmp, deepTraversal+"escape-deep")
if _, ok := got["DEEP"]; ok {
t.Error("DEEP key leaked from deep traversal")
}
}
func TestLoadWorkspaceEnv_EmptyFilesDirLoadsOrgRootOnly(t *testing.T) {
tmp := t.TempDir()
writeEnv(t, filepath.Join(tmp, ".env"), "ONLY_ROOT=rootonly\n")
got := loadWorkspaceEnv(tmp, "")
if got["ONLY_ROOT"] != "rootonly" {
t.Errorf("ONLY_ROOT: got %q, want %q", got["ONLY_ROOT"], "rootonly")
}
}
func TestLoadWorkspaceEnv_NonExistentFilesDirIsSilent(t *testing.T) {
tmp := t.TempDir()
writeEnv(t, filepath.Join(tmp, ".env"), "ROOT=ok\n")
// Must not error — missing filesDir is a silent no-op.
got := loadWorkspaceEnv(tmp, "this-dir-does-not-exist")
if got["ROOT"] != "ok" {
t.Errorf("ROOT: got %q, want %q", got["ROOT"], "ok")
}
}
func TestLoadWorkspaceEnv_EmptyOrgBaseDirReturnsEmpty(t *testing.T) {
got := loadWorkspaceEnv("", "any-dir")
if len(got) != 0 {
t.Errorf("empty orgBaseDir should return empty map, got %d entries", len(got))
}
}
+30 -9
View File
@@ -29,6 +29,7 @@ import (
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
)
// DefaultInterval is the polling cadence. Runtime publishes happen at most
@@ -127,20 +128,32 @@ func (w *Watcher) tick(ctx context.Context, fetch digestFetcher) {
}
}
// remoteDigest queries GHCR for the current manifest digest of the
// workspace-template-<runtime>:latest image. Uses the Docker Registry V2
// HTTP API: get a bearer token, then HEAD the manifest.
// remoteDigest queries the configured registry for the current manifest
// digest of the workspace-template-<runtime>:latest image. Uses the Docker
// Registry V2 HTTP API: get a bearer token, then HEAD the manifest.
//
// Registry host is resolved from provisioner.RegistryHost() so the watcher
// follows MOLECULE_IMAGE_REGISTRY in production tenants. Pre-RFC #229 this
// was hardcoded to ghcr.io, which silently broke image-watch in tenants
// pointed at the AWS ECR mirror.
//
// Auth: if GHCR_USER+GHCR_TOKEN are set, basic-auth the token request
// (works for both public and private images). If unset, anonymous token
// (works for public images only — every workspace template is public).
//
// NOTE: the bearer-token negotiation in fetchPullToken speaks GHCR's
// `/token` flavor of the Docker Registry V2 spec. ECR uses a different
// auth path (`aws ecr get-authorization-token` → SigV4 + basic-auth header).
// Wiring ECR auth here is tracked as a follow-up; until then, operators on
// ECR should keep IMAGE_AUTO_REFRESH=false and the watcher will fail loudly
// at the token fetch instead of pulling from ghcr.io behind their back.
func (w *Watcher) remoteDigest(ctx context.Context, runtime string) (string, error) {
repo := "molecule-ai/workspace-template-" + runtime
tok, err := w.fetchPullToken(ctx, repo)
if err != nil {
return "", fmt.Errorf("pull token: %w", err)
}
manifestURL := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/latest", repo)
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/latest", provisioner.RegistryHost(), repo)
req, err := http.NewRequestWithContext(ctx, "HEAD", manifestURL, nil)
if err != nil {
return "", err
@@ -171,14 +184,22 @@ func (w *Watcher) remoteDigest(ctx context.Context, runtime string) (string, err
return digest, nil
}
// fetchPullToken negotiates a short-lived bearer token from GHCR's token
// endpoint scoped to repo:pull. GHCR requires a token even for anonymous
// pulls of public images.
// fetchPullToken negotiates a short-lived bearer token from the registry's
// `/token` endpoint scoped to repo:pull. GHCR requires a token even for
// anonymous pulls of public images.
//
// Registry host follows provisioner.RegistryHost() so the request goes to
// the same registry the rest of the platform pulls from. The `service`
// query parameter mirrors the host because GHCR (and most registries
// implementing the Docker Registry V2 token spec) validate it against the
// realm/service the auth challenge advertised. ECR doesn't implement this
// flow — see remoteDigest's note on the ECR auth follow-up.
func (w *Watcher) fetchPullToken(ctx context.Context, repo string) (string, error) {
host := provisioner.RegistryHost()
q := url.Values{}
q.Set("service", "ghcr.io")
q.Set("service", host)
q.Set("scope", "repository:"+repo+":pull")
tokURL := "https://ghcr.io/token?" + q.Encode()
tokURL := "https://" + host + "/token?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", tokURL, nil)
if err != nil {
return "", err
@@ -3,6 +3,9 @@ package imagewatch
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
@@ -160,6 +163,100 @@ func TestTick_DigestFetchErrorSkipsRuntime(t *testing.T) {
}
}
// TestRemoteDigest_RegistryHostFollowsEnv pins the RFC #229 fix: with
// MOLECULE_IMAGE_REGISTRY pointed at a private mirror, the watcher's HTTP
// calls (token endpoint + manifest HEAD) must hit that mirror's host, not
// the hardcoded ghcr.io of the pre-fix code path. We stand up an httptest
// server, point MOLECULE_IMAGE_REGISTRY at its host, and assert both
// endpoints get hit on it.
//
// Without this test, a future refactor could revert the helper indirection
// and the watcher would silently go back to talking to ghcr.io even when
// the platform is configured for ECR — exactly the bug RFC #229 is closing.
func TestRemoteDigest_RegistryHostFollowsEnv(t *testing.T) {
var (
mu sync.Mutex
tokenHits int
manifestHits int
lastTokenURL string
lastManifestURL string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
switch {
case strings.HasPrefix(r.URL.Path, "/token"):
tokenHits++
lastTokenURL = r.URL.String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"token":"fake-bearer"}`))
case strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/manifests/latest"):
manifestHits++
lastManifestURL = r.URL.Path
w.Header().Set("Docker-Content-Digest", "sha256:cafef00d")
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// httptest.Server.URL is "http://127.0.0.1:NNNN". RegistryHost() works
// over the host:port portion (provisioner.RegistryPrefix takes the env
// verbatim), so we strip the scheme and append "/molecule-ai" to mimic
// the prefix shape MOLECULE_IMAGE_REGISTRY actually uses in production.
host := strings.TrimPrefix(srv.URL, "http://")
t.Setenv("MOLECULE_IMAGE_REGISTRY", host+"/molecule-ai")
w := newTestWatcher(&fakeRefresher{}, "claude-code")
// Use the test-server URL scheme by overriding the http client only —
// remoteDigest constructs https://<host>/... internally. We need the
// watcher to hit our http server, so swap the URL scheme by injecting
// a transport that rewrites https→http for this test.
w.http = &http.Client{Transport: rewriteToHTTP{}}
digest, err := w.remoteDigest(context.Background(), "claude-code")
if err != nil {
t.Fatalf("remoteDigest failed: %v", err)
}
if digest != "sha256:cafef00d" {
t.Errorf("digest: got %q, want sha256:cafef00d", digest)
}
mu.Lock()
defer mu.Unlock()
if tokenHits != 1 {
t.Errorf("token endpoint hits: got %d, want 1 (watcher must hit configured registry, not ghcr.io)", tokenHits)
}
if manifestHits != 1 {
t.Errorf("manifest HEAD hits: got %d, want 1 (watcher must hit configured registry, not ghcr.io)", manifestHits)
}
// service= query param must reflect the configured host so registries
// that validate the param (GHCR-style spec) accept the request.
if !strings.Contains(lastTokenURL, "service="+host) && !strings.Contains(lastTokenURL, "service=127.0.0.1") {
t.Errorf("token URL service param not host-derived: got %q", lastTokenURL)
}
wantManifestPath := "/v2/molecule-ai/workspace-template-claude-code/manifests/latest"
if lastManifestURL != wantManifestPath {
t.Errorf("manifest path: got %q, want %q", lastManifestURL, wantManifestPath)
}
}
// rewriteToHTTP is a tiny RoundTripper that flips https→http so the watcher
// (which builds https URLs from the configured registry host) can target an
// httptest.Server that only speaks http. Production code paths still go
// over https; this is a unit-test seam only.
type rewriteToHTTP struct{}
func (rewriteToHTTP) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme == "https" {
clone := req.Clone(req.Context())
clone.URL.Scheme = "http"
req = clone
}
return http.DefaultTransport.RoundTrip(req)
}
func TestShortDigest(t *testing.T) {
cases := map[string]string{
"sha256:abcdef0123456789": "sha256:abcdef012345",
@@ -3,6 +3,7 @@ package provisioner
import (
"fmt"
"os"
"strings"
)
// defaultRegistryPrefix is the upstream OSS face for all workspace template
@@ -62,6 +63,32 @@ func RegistryPrefix() string {
return defaultRegistryPrefix
}
// RegistryHost returns just the registry host portion of RegistryPrefix() —
// i.e. everything before the first "/" separator. This is the value that
// belongs in:
//
// - Docker Engine PullOptions.RegistryAuth payloads (`serveraddress` field)
// — the engine matches credentials against host, not host+org-path.
// - Docker Registry V2 HTTP API base URLs (e.g. `https://<host>/v2/...`)
// — the V2 API is host-rooted; the org-path lives in the manifest path.
//
// Examples:
//
// "ghcr.io/molecule-ai" → "ghcr.io"
// "123456789012.dkr.ecr.us-east-2.amazonaws.com/molecule-ai" → "123456789012.dkr.ecr.us-east-2.amazonaws.com"
// "git.moleculesai.app/molecule-ai" → "git.moleculesai.app"
//
// If RegistryPrefix() ever returns a bare host (no `/`), we return it as-is
// rather than letting strings.SplitN produce an empty string — defensive
// against a misconfiguration where the operator sets just the host.
func RegistryHost() string {
prefix := RegistryPrefix()
if i := strings.IndexByte(prefix, '/'); i > 0 {
return prefix[:i]
}
return prefix
}
// RuntimeImage returns the canonical image reference for the given runtime,
// using the current RegistryPrefix() and the moving `:latest` tag.
//
@@ -127,6 +127,50 @@ func TestComputeRuntimeImages_ReflectsCurrentEnv(t *testing.T) {
}
}
// TestRegistryHost_SplitsHostFromOrgPath pins the contract that callers
// (Docker auth payloads, registry V2 HTTP base URLs) need: the host portion
// must be free of the "/molecule-ai" org suffix that appears in the
// pull-prefix form. Pre-RFC #229, ghcr.io was hardcoded in two places
// (imagewatch + admin_workspace_images auth payload); this helper is the
// single source they should resolve from.
func TestRegistryHost_SplitsHostFromOrgPath(t *testing.T) {
cases := []struct {
name string
env string
want string
}{
{"default GHCR", "", "ghcr.io"},
{"AWS ECR mirror", "004947743811.dkr.ecr.us-east-2.amazonaws.com/molecule-ai", "004947743811.dkr.ecr.us-east-2.amazonaws.com"},
{"self-hosted Gitea", "git.moleculesai.app/molecule-ai", "git.moleculesai.app"},
// Bare host (no /org) — defensive: return as-is rather than empty.
{"bare host no org-path", "registry.example.com", "registry.example.com"},
// Multi-level org path — split at the first "/" only.
{"nested org path", "registry.example.com/org/sub", "registry.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", tc.env)
got := RegistryHost()
if got != tc.want {
t.Errorf("RegistryHost() with env=%q: got %q, want %q", tc.env, got, tc.want)
}
})
}
}
// TestRegistryHost_NeverEmpty — guard against a future refactor accidentally
// returning "" for some edge env value. An empty serveraddress in the
// Docker engine auth payload, or an empty host in `https:///v2/...`, would
// silently break image operations.
func TestRegistryHost_NeverEmpty(t *testing.T) {
for _, env := range []string{"", "ghcr.io/molecule-ai", "/leading-slash", "host-only", "host/with/path"} {
t.Setenv("MOLECULE_IMAGE_REGISTRY", env)
if got := RegistryHost(); got == "" {
t.Errorf("RegistryHost() with env=%q returned empty (would break Docker auth + V2 HTTP)", env)
}
}
}
// TestKnownRuntimes_AlphabeticalOrder — pin the order so test snapshots
// (and human readers diffing the file) see deterministic output. Adding a
// new runtime out of alphabetical order will fail this test, which is the
+46 -1
View File
@@ -105,6 +105,23 @@ _FIXTURES = {
"status": "queued",
"delivery_mode": "poll",
},
# Push-mode queue envelope (PR #278): returned when a push-mode workspace
# is at capacity. The platform queues the request and returns
# {queued: true, message: "...", queue_id: "..."}. Checked via
# data.get("queued") is True before the poll-mode envelope so the two
# shapes are mutually exclusive even if a buggy server sends both.
"push_queued_full": {
"queued": True,
"method": "message/send",
"queue_id": "q-abc-123",
},
"push_queued_notify": {
"queued": True,
"method": "notify",
},
"push_queued_no_method": {
"queued": True,
},
"malformed_empty_dict": {},
"malformed_unexpected_keys": {"foo": "bar", "baz": 42},
"malformed_status_queued_no_delivery_mode": {
@@ -159,6 +176,29 @@ class TestQueuedVariant:
a2a_response.parse(_FIXTURES["poll_queued_full"])
assert any("queued for poll-mode peer" in r.message for r in caplog.records)
# Push-mode queue tests (PR #278 — a2a_proxy.go push-at-capacity path)
def test_push_queued_full_returns_queued(self):
v = a2a_response.parse(_FIXTURES["push_queued_full"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_push_queued_notify(self):
v = a2a_response.parse(_FIXTURES["push_queued_notify"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "notify"
def test_push_queued_missing_method_uses_message_send_sentinel(self):
# Unlike poll-mode (where absent method → "unknown"), push-mode
# defaults to "message/send" per the a2a_proxy.go contract.
v = a2a_response.parse(_FIXTURES["push_queued_no_method"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_push_queued_logs_queue_id(self, caplog):
with caplog.at_level(logging.INFO, logger="a2a_response"):
a2a_response.parse(_FIXTURES["push_queued_full"])
assert any("q-abc-123" in r.message for r in caplog.records)
class TestResultVariant:
"""``parse()`` extracts the JSON-RPC ``result`` envelope into
@@ -361,7 +401,9 @@ _ADVERSARIAL_INPUTS: list[Any] = [
{"error": {"message": None, "code": None}},
{"error": {"message": ["nested", "list"]}},
{"status": None, "delivery_mode": None, "method": None},
{"status": "queued", "delivery_mode": "push", "method": "x"}, # wrong delivery_mode
{"status": "queued", "delivery_mode": "push", "method": "x"}, # wrong delivery_mode → Malformed
{"queued": "yes"}, # string "yes" is not True → Malformed
{"queued": False}, # False is not True → Malformed
{"status": "running", "delivery_mode": "poll"}, # wrong status
{"status": 42, "delivery_mode": "poll"}, # non-string status
# Deeply-nested junk
@@ -436,6 +478,9 @@ class TestRegressionGate:
"poll_queued_full": a2a_response.Queued,
"poll_queued_notify": a2a_response.Queued,
"poll_queued_no_method": a2a_response.Queued,
"push_queued_full": a2a_response.Queued,
"push_queued_notify": a2a_response.Queued,
"push_queued_no_method": a2a_response.Queued,
"malformed_empty_dict": a2a_response.Malformed,
"malformed_unexpected_keys": a2a_response.Malformed,
"malformed_status_queued_no_delivery_mode": a2a_response.Malformed,
@@ -15,7 +15,6 @@ The wrappers are ~40 LOC of glue. The full delivery behavior
"""
from __future__ import annotations
import asyncio
import json
from unittest.mock import MagicMock, patch
@@ -29,24 +28,22 @@ def _require_workspace_id(monkeypatch):
yield
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# tool_inbox_peek
# ---------------------------------------------------------------------------
class TestToolInboxPeek:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_inbox_peek())
out = await a2a_tools.tool_inbox_peek()
assert "not enabled" in out
def test_returns_json_array_of_messages(self):
@pytest.mark.asyncio
async def test_returns_json_array_of_messages(self):
import a2a_tools
msg1 = MagicMock()
@@ -58,20 +55,21 @@ class TestToolInboxPeek:
fake_state.peek.return_value = [msg1, msg2]
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_peek(limit=5))
out = await a2a_tools.tool_inbox_peek(limit=5)
# peek limit is forwarded
fake_state.peek.assert_called_once_with(limit=5)
parsed = json.loads(out)
assert len(parsed) == 2
assert parsed[0]["activity_id"] == "a1"
def test_non_int_limit_falls_back_to_10(self):
@pytest.mark.asyncio
async def test_non_int_limit_falls_back_to_10(self):
import a2a_tools
fake_state = MagicMock()
fake_state.peek.return_value = []
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_inbox_peek(limit="garbage")) # type: ignore[arg-type]
await a2a_tools.tool_inbox_peek(limit="garbage") # type: ignore[arg-type]
fake_state.peek.assert_called_once_with(limit=10)
@@ -81,49 +79,54 @@ class TestToolInboxPeek:
class TestToolInboxPop:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_inbox_pop("act-1"))
out = await a2a_tools.tool_inbox_pop("act-1")
assert "not enabled" in out
def test_rejects_empty_activity_id(self):
@pytest.mark.asyncio
async def test_rejects_empty_activity_id(self):
import a2a_tools
fake_state = MagicMock()
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop(""))
out = await a2a_tools.tool_inbox_pop("")
assert "activity_id is required" in out
fake_state.pop.assert_not_called()
def test_rejects_non_str_activity_id(self):
@pytest.mark.asyncio
async def test_rejects_non_str_activity_id(self):
import a2a_tools
fake_state = MagicMock()
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop(123)) # type: ignore[arg-type]
out = await a2a_tools.tool_inbox_pop(123) # type: ignore[arg-type]
assert "activity_id is required" in out
fake_state.pop.assert_not_called()
def test_returns_removed_true_when_popped(self):
@pytest.mark.asyncio
async def test_returns_removed_true_when_popped(self):
import a2a_tools
fake_state = MagicMock()
fake_state.pop.return_value = MagicMock() # truthy = something was removed
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop("act-7"))
out = await a2a_tools.tool_inbox_pop("act-7")
parsed = json.loads(out)
assert parsed == {"removed": True, "activity_id": "act-7"}
fake_state.pop.assert_called_once_with("act-7")
def test_returns_removed_false_when_unknown(self):
@pytest.mark.asyncio
async def test_returns_removed_false_when_unknown(self):
import a2a_tools
fake_state = MagicMock()
fake_state.pop.return_value = None
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop("act-missing"))
out = await a2a_tools.tool_inbox_pop("act-missing")
parsed = json.loads(out)
assert parsed == {"removed": False, "activity_id": "act-missing"}
@@ -134,25 +137,28 @@ class TestToolInboxPop:
class TestToolWaitForMessage:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=1.0))
out = await a2a_tools.tool_wait_for_message(timeout_secs=1.0)
assert "not enabled" in out
def test_timeout_payload_when_no_message(self):
@pytest.mark.asyncio
async def test_timeout_payload_when_no_message(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=0.1))
out = await a2a_tools.tool_wait_for_message(timeout_secs=0.1)
parsed = json.loads(out)
assert parsed["timeout"] is True
assert parsed["timeout_secs"] == 0.1
def test_returns_message_when_delivered(self):
@pytest.mark.asyncio
async def test_returns_message_when_delivered(self):
import a2a_tools
msg = MagicMock()
@@ -160,37 +166,40 @@ class TestToolWaitForMessage:
fake_state = MagicMock()
fake_state.wait.return_value = msg
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=2.0))
out = await a2a_tools.tool_wait_for_message(timeout_secs=2.0)
parsed = json.loads(out)
assert parsed["activity_id"] == "a-9"
def test_timeout_clamped_to_300(self):
@pytest.mark.asyncio
async def test_timeout_clamped_to_300(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs=99999))
await a2a_tools.tool_wait_for_message(timeout_secs=99999)
# Whatever wait was called with, it must not exceed 300
passed = fake_state.wait.call_args.args[0]
assert passed == 300.0
def test_timeout_clamped_to_zero_floor(self):
@pytest.mark.asyncio
async def test_timeout_clamped_to_zero_floor(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs=-5))
await a2a_tools.tool_wait_for_message(timeout_secs=-5)
passed = fake_state.wait.call_args.args[0]
assert passed == 0.0
def test_non_numeric_timeout_falls_back_to_60(self):
@pytest.mark.asyncio
async def test_non_numeric_timeout_falls_back_to_60(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs="garbage")) # type: ignore[arg-type]
await a2a_tools.tool_wait_for_message(timeout_secs="garbage") # type: ignore[arg-type]
passed = fake_state.wait.call_args.args[0]
assert passed == 60.0