Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9279f9292b | |||
| a832bd805c | |||
| 6958cd7966 | |||
| d4d3306150 | |||
| a3c9f0b717 | |||
| de9f46ea30 | |||
| 7ff5622a42 | |||
| bea89ce4e9 | |||
| 14f05b5a64 | |||
| 7caee806df | |||
| a914f675a4 |
@@ -32,11 +32,9 @@ on:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
staging trigger
|
||||
@@ -31,17 +31,25 @@ 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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -3,52 +3,56 @@
|
||||
* 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, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, 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 { 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");
|
||||
const r = render(<Spinner size="sm" />);
|
||||
expect(getSvgClass(r)).toContain("w-3");
|
||||
expect(getSvgClass(r)).toContain("h-3");
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const r = render(<Spinner size="md" />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-5");
|
||||
expect(svg?.className).toContain("h-5");
|
||||
const r = render(<Spinner size="lg" />);
|
||||
expect(getSvgClass(r)).toContain("w-5");
|
||||
expect(getSvgClass(r)).toContain("h-5");
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("w-4");
|
||||
expect(svg?.className).toContain("h-4");
|
||||
const r = render(<Spinner />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
const r = render(<Spinner />);
|
||||
const svg = r.container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.className).toContain("motion-safe:animate-spin");
|
||||
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -44,3 +44,4 @@
|
||||
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||
]
|
||||
}
|
||||
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||
|
||||
@@ -37,6 +37,50 @@ 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"
|
||||
@@ -82,11 +126,7 @@ clone_category() {
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$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
|
||||
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@@ -71,17 +71,10 @@ 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 and lets
|
||||
// ECR's credential-helper-driven flow take over without a stale GHCR
|
||||
// payload masking it).
|
||||
// GHCR_USER/GHCR_TOKEN env is set (lets public images pull through).
|
||||
//
|
||||
// The Docker SDK doesn't read ~/.docker/config.json — every authenticated
|
||||
// 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).
|
||||
// pull needs an explicit RegistryAuth string.
|
||||
func ghcrAuthHeader() string {
|
||||
user := strings.TrimSpace(os.Getenv("GHCR_USER"))
|
||||
token := strings.TrimSpace(os.Getenv("GHCR_TOKEN"))
|
||||
@@ -91,7 +84,7 @@ func ghcrAuthHeader() string {
|
||||
payload := map[string]string{
|
||||
"username": user,
|
||||
"password": token,
|
||||
"serveraddress": provisioner.RegistryHost(),
|
||||
"serveraddress": "ghcr.io",
|
||||
}
|
||||
js, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,7 +9,6 @@ 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)
|
||||
}
|
||||
@@ -30,10 +29,6 @@ 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()
|
||||
@@ -59,41 +54,7 @@ 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.
|
||||
|
||||
@@ -29,7 +29,6 @@ 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
|
||||
@@ -128,32 +127,20 @@ func (w *Watcher) tick(ctx context.Context, fetch digestFetcher) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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://%s/v2/%s/manifests/latest", provisioner.RegistryHost(), repo)
|
||||
manifestURL := fmt.Sprintf("https://ghcr.io/v2/%s/manifests/latest", repo)
|
||||
req, err := http.NewRequestWithContext(ctx, "HEAD", manifestURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -184,22 +171,14 @@ func (w *Watcher) remoteDigest(ctx context.Context, runtime string) (string, err
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (w *Watcher) fetchPullToken(ctx context.Context, repo string) (string, error) {
|
||||
host := provisioner.RegistryHost()
|
||||
q := url.Values{}
|
||||
q.Set("service", host)
|
||||
q.Set("service", "ghcr.io")
|
||||
q.Set("scope", "repository:"+repo+":pull")
|
||||
tokURL := "https://" + host + "/token?" + q.Encode()
|
||||
tokURL := "https://ghcr.io/token?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", tokURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -3,9 +3,6 @@ package imagewatch
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -163,100 +160,6 @@ 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,7 +3,6 @@ package provisioner
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultRegistryPrefix is the upstream OSS face for all workspace template
|
||||
@@ -63,32 +62,6 @@ 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,50 +127,6 @@ 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
|
||||
|
||||
@@ -77,6 +77,16 @@ 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", "")
|
||||
|
||||
@@ -51,6 +51,22 @@ 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
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user