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
23 changed files with 586 additions and 237 deletions
@@ -32,9 +32,11 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
@@ -57,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
-1
View File
@@ -1 +0,0 @@
staging trigger
@@ -31,25 +31,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
if (text) return text;
// Response: result.parts[].text or result.parts[].root.text
// Use the first part that has a direct text field; within that part,
// prefer direct text over root.text. Subsequent parts' root.text fields
// are ignored when a direct text exists in an earlier part.
const result = body.result as Record<string, unknown> | undefined;
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
const firstPartWithText = rParts.find(
(p) => typeof p.text === "string" && (p.text as string) !== ""
);
if (firstPartWithText) {
return firstPartWithText.text as string;
}
// No direct text found; use root.text from the first part (if present).
const firstPart = rParts[0];
if (firstPart) {
const root = firstPart.root as Record<string, unknown> | undefined;
if (typeof root?.text === "string" && root.text !== "") {
return root.text as string;
}
}
const rText = rParts
.map((p) => {
if (p.text) return p.text as string;
const root = p.root as Record<string, unknown> | undefined;
return (root?.text as string) || "";
})
.filter(Boolean)
.join("\n");
if (rText) return rText;
if (typeof body.result === "string") return body.result;
} catch { /* ignore */ }
@@ -3,56 +3,52 @@
* Tests for Spinner component.
*
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
* so we use getAttribute("class") instead of className for assertions.
*/
import React from "react";
import { render, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
afterEach(cleanup);
function getSvgClass(r: ReturnType<typeof render>): string {
const svg = r.container.querySelector("svg");
if (!svg) throw new Error("No SVG found");
return svg.getAttribute("class") ?? "";
}
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const r = render(<Spinner size="sm" />);
expect(getSvgClass(r)).toContain("w-3");
expect(getSvgClass(r)).toContain("h-3");
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const r = render(<Spinner size="md" />);
expect(getSvgClass(r)).toContain("w-4");
expect(getSvgClass(r)).toContain("h-4");
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const r = render(<Spinner size="lg" />);
expect(getSvgClass(r)).toContain("w-5");
expect(getSvgClass(r)).toContain("h-5");
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const r = render(<Spinner />);
expect(getSvgClass(r)).toContain("w-4");
expect(getSvgClass(r)).toContain("h-4");
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
const r = render(<Spinner />);
const svg = r.container.querySelector("svg");
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("includes the motion-safe:animate-spin class for CSS animation", () => {
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
-1
View File
@@ -44,4 +44,3 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
+5 -45
View File
@@ -37,50 +37,6 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
EXPECTED=0
CLONED=0
# clone_one_with_retry — clone a single repo, retrying on transient failure.
#
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
# clone the full manifest (~36 repos) serially on a memory-constrained
# Gitea Actions runner. Under host memory pressure the OOM killer
# occasionally SIGKILLs git-remote-https mid-clone:
#
# error: git-remote-https died of signal 9
# fatal: the remote end hung up unexpectedly
#
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
# job died on the 14th of 36 clones, which wedged staging→main). One
# transient SIGKILL / network blip would otherwise fail the whole tenant
# image rebuild. Retrying after a short backoff lets the pressure subside.
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
# just stops a single flake from being release-blocking.
#
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
clone_one_with_retry() {
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
local attempt=1 max_attempts=3 backoff
while : ; do
# A killed attempt can leave a partial directory behind; git clone
# refuses a non-empty target, so wipe it before each try.
rm -rf "$tdir/$name"
if [ "$ref" = "main" ]; then
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
else
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
fi
if [ "$attempt" -ge "$max_attempts" ]; then
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
return 1
fi
backoff=$((attempt * 3)) # 3s, then 6s
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
sleep "$backoff"
attempt=$((attempt + 1))
done
}
clone_category() {
local category="$1"
local target_dir="$2"
@@ -126,7 +82,11 @@ clone_category() {
fi
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
if [ "$ref" = "main" ]; then
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
else
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
fi
CLONED=$((CLONED + 1))
i=$((i + 1))
done
@@ -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
-10
View File
@@ -77,16 +77,6 @@ async def delegate_task(workspace_id: str, task: str) -> str:
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
-16
View File
@@ -51,22 +51,6 @@ class AdaptorSource:
def _load_module_from_path(module_name: str, path: Path):
"""Import a Python file by absolute path. Returns the module or None on failure."""
# Ensure the plugins_registry package and its submodules are importable in the
# fresh module namespace created by module_from_spec(). Plugin adapters
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
# which requires plugins_registry and its submodules to already be in sys.modules.
# We import and register them before exec_module so the plugin's own
# from ... import statements resolve correctly.
import sys
import plugins_registry
sys.modules.setdefault("plugins_registry", plugins_registry)
for _sub in ("builtins", "protocol", "raw_drop"):
try:
sub = importlib.import_module(f"plugins_registry.{_sub}")
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
except Exception:
# Submodule may not exist in all versions; skip if absent.
pass
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
return None
@@ -1,60 +0,0 @@
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
can be loaded via _load_module_from_path() without ModuleNotFoundError.
"""
import sys
import tempfile
import os
from pathlib import Path
# Ensure the plugins_registry package is importable
import plugins_registry
from plugins_registry import _load_module_from_path
def test_load_adapter_with_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
# Write a temp adapter file that does the exact import from the bug report.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
f.write("assert Adaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
finally:
os.unlink(adapter_path)
def test_load_adapter_with_full_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry import InstallContext, resolve\n")
f.write("from plugins_registry.protocol import PluginAdaptor\n")
f.write("assert InstallContext is not None\n")
f.write("assert resolve is not None\n")
f.write("assert PluginAdaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter_full", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
assert hasattr(module, "resolve"), "module should expose resolve"
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
finally:
os.unlink(adapter_path)
if __name__ == "__main__":
test_load_adapter_with_plugins_registry_import()
test_load_adapter_with_full_plugins_registry_import()
print("ALL TESTS PASS")
+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