diff --git a/workspace-server/internal/provisioner/cp_provisioner.go b/workspace-server/internal/provisioner/cp_provisioner.go index 191fedbd..bb84fbf9 100644 --- a/workspace-server/internal/provisioner/cp_provisioner.go +++ b/workspace-server/internal/provisioner/cp_provisioner.go @@ -171,11 +171,11 @@ type cpProvisionRequest struct { // from it. Empty = auto (omitted on the wire). DataPersistence string `json:"data_persistence,omitempty"` // Kind forwards the workspace kind ("" / "workspace" ordinary, "platform" - // = the org concierge) so the CP can select the platform-agent image - // variant — the SaaS mirror of the local Docker provisioner's kind-driven - // image preference (RFC docs/design/rfc-platform-agent.md; core#2495 SSOT: - // the concierge is a normal workspace provisioned through this same path, - // differing ONLY in image + config overlay). Omitted when empty so the + // = the org concierge) so the CP can apply the concierge's config overlay + // (RFC docs/design/rfc-platform-agent.md; core#2495 SSOT: the concierge is a + // normal workspace provisioned through this same path, differing ONLY in its + // config/identity overlay — it runs on the plain per-runtime image, with the + // platform MCP delivered via the plugin system). Omitted when empty so the // wire shape is unchanged for ordinary workspaces; an older CP simply // ignores the field. Kind string `json:"kind,omitempty"` diff --git a/workspace-server/internal/provisioner/localbuild.go b/workspace-server/internal/provisioner/localbuild.go index b5230021..fcda13d5 100644 --- a/workspace-server/internal/provisioner/localbuild.go +++ b/workspace-server/internal/provisioner/localbuild.go @@ -163,50 +163,6 @@ func LocalImageLatestTag(runtime string) string { return fmt.Sprintf("%s/workspace-template-%s:latest", localImagePrefix, runtime) } -// platformAgentImageSuffix names the dedicated platform-agent image variant -// (the plain runtime image + a baked /opt/molecule-mcp-server so the org-admin -// platform MCP can load). The local image tag is -// `molecule-local/workspace-template--platform-agent:`, built from -// workspace-configs-templates/claude-code-default/Dockerfile.platform-agent. -const platformAgentImageSuffix = "-platform-agent" - -// LocalPlatformAgentLatestTag returns the floating `:latest` tag for the -// platform-agent image variant of a runtime in local-build mode. This is the -// image the local Docker provisioner prefers for a kind='platform' workspace -// (the org concierge) so the platform MCP binary is present. -func LocalPlatformAgentLatestTag(runtime string) string { - return fmt.Sprintf("%s/workspace-template-%s%s:latest", localImagePrefix, runtime, platformAgentImageSuffix) -} - -// resolvePlatformAgentImage returns the platform-agent image variant to use for -// a kind='platform' workspace, or ("", false) when no such image is available -// (so the caller falls back to the plain runtime image). It is deliberately -// gated on the image already being present in the local store: the -// platform-agent image is built out-of-band (Dockerfile.platform-agent), not by -// the runtime template repo's local-build clone, so we never try to build it -// here — we only USE it if an operator has built+tagged it. -// -// fallbackImage is the plain runtime image the caller already resolved; it is -// only used to keep the log line informative. hasTagFn is the docker -// image-inspect probe (seam for tests). -func resolvePlatformAgentImage(ctx context.Context, runtime, fallbackImage string, hasTagFn func(ctx context.Context, tag string) (bool, error)) (string, bool) { - tag := LocalPlatformAgentLatestTag(runtime) - if hasTagFn == nil { - hasTagFn = dockerHasTagProd - } - exists, err := hasTagFn(ctx, tag) - if err != nil { - log.Printf("local-build: platform-agent image probe for %s failed (%v); falling back to plain runtime image %s — the concierge's platform MCP will be skipped (build %s via Dockerfile.platform-agent to enable it)", tag, err, fallbackImage, tag) - return "", false - } - if !exists { - log.Printf("local-build: platform-agent image %s not present; falling back to plain runtime image %s — the concierge's platform MCP will be skipped (build %s via Dockerfile.platform-agent to enable it)", tag, fallbackImage, tag) - return "", false - } - log.Printf("local-build: kind=platform → using platform-agent image %s (bakes /opt/molecule-mcp-server)", tag) - return tag, true -} - // EnsureLocalImage is the entry point the provisioner calls before // ContainerCreate when Resolve().Mode == RegistryModeLocal. Returns the // image tag (SHA-pinned form) the caller should hand to Docker, or an diff --git a/workspace-server/internal/provisioner/platform_agent_image_drift_test.go b/workspace-server/internal/provisioner/platform_agent_image_drift_test.go deleted file mode 100644 index 8609ea5b..00000000 --- a/workspace-server/internal/provisioner/platform_agent_image_drift_test.go +++ /dev/null @@ -1,570 +0,0 @@ -package provisioner - -// platform_agent_image_drift_test.go — CI DRIFT-GATE for the -// IMAGE-BAKED platform-agent identity (RFC #2843 §10a). -// -// The IMAGE-BAKED impl (workspace-server/Dockerfile.platform-agent) -// bakes the concierge's identity (config.yaml + -// prompts/concierge.md + mcp_servers.yaml + identity-fallback.sh) -// from the platform-agent TEMPLATE REPO into the platform-agent -// image at /opt/molecule-platform-agent-template/. The driver -// hard-requirement: -// "The image-baked config.yaml + prompts/concierge.md + -// mcp_servers.yaml MUST be SOURCED FROM the platform-agent TEMPLATE -// REPO (single SSOT = PR #1's content) — NOT vendored/duplicated in -// core." -// -// A future drift — e.g., someone edits config.yaml in core, or the -// pre-clone step points at the wrong dir, or a build-arg change -// reroutes the source — would silently create a 2-SSOT situation -// (image snapshot diverges from template repo). The driver-rejected -// option (b) MINIMAL IN-CORE FALLBACK was rejected EXPLICITLY -// because of this 2-SSOT drift risk; the IMAGE-BAKED impl survives -// only because the drift-gate closes that risk. -// -// The drift-gate (this test) has TWO halves: -// -// 1. Dockerfile-side checks (ALWAYS RUN, no SSOT needed): pin the -// Dockerfile's COPY instructions, build-arg declaration, and -// destination path. Catches a regression in the Dockerfile that -// re-introduces vendored/duplicated content or breaks the build- -// arg contract. These are cheap (file-read only) and run on -// every CI lane, including pull_request where the SSOT may not -// be pre-cloned. -// -// 2. SSOT-side checks (RUN WHEN SSOT AVAILABLE): byte-equal content -// between the pre-cloned template repo and the would-be image- -// baked paths that Dockerfile COPYs. Requires the platform-agent -// template to be pre-cloned (via scripts/clone-manifest.sh from -// manifest.json's workspace_templates entry, OR the operator- -// override env var). Skipped with a t.Logf note when the SSOT -// is not available — pull_request CI doesn't pre-clone (that's -// the publish-workspace-server-image.yml workflow's job), and -// we don't want a missing pre-clone to fail this lane. -// -// How to run: `go test -run TestPlatformAgentImageDriftGate -// ./internal/provisioner/`. Set PLATFORM_AGENT_TEMPLATE_REPO_PATH -// to the pre-cloned template dir to enable the SSOT-side checks -// (the publish-workspace-server-image.yml workflow does this via -// the post-pre-clone test step). -// -// Test scope: the 4 files the Dockerfile COPYs (config.yaml, -// mcp_servers.yaml, prompts/concierge.md, identity-fallback.sh). -// A future concierge-identity change that adds a new file MUST also -// extend the expectedImageBakedFiles list here; the Dockerfile-side -// check catches the missing COPY, and the SSOT-side check (when -// run) catches the missing identity file in the template repo. - -import ( - "os" - "path/filepath" - "regexp" - "strings" - "testing" -) - -// expectedImageBakedFiles is the canonical list of files the -// IMAGE-BAKED impl bakes into the platform-agent image. The list -// MUST match Dockerfile.platform-agent's COPY instructions exactly. -// Adding a new concierge-identity file = adding it here AND in the -// Dockerfile; the test fails if either side drifts. -// -// Paths are RELATIVE to the SSOT root (the platform-agent template -// repo). The Dockerfile's PLATFORM_AGENT_TEMPLATE_DIR build-arg -// points at this same root. -// -// The "identity-fallback.sh" entry is the boot-time per-file copy -// script (template-platform-agent #2, copied into the image and -// invoked from the platform-agent entrypoint). It's a 1st-class -// IMAGE-BAKED asset (NOT metadata / not a future change) — the -// runtime /opt→/configs fallback (workspace-runtime PR #141 -// load_config) and the boot-time /opt→/configs fallback (this -// Dockerfile's entrypoint) are complementary, and BOTH need the -// image-baked copy at /opt/.../identity-fallback.sh in the build -// to close the self-host + pre-#29-bootstrap window. Listed here -// so the SSOT-side check rejects a template-repo that ships the -// script (correctly, in the platform-agent template) without the -// matching Dockerfile COPY (regression). -var expectedImageBakedFiles = []string{ - "config.yaml", - "mcp_servers.yaml", - "prompts/concierge.md", - "identity-fallback.sh", -} - -// isConciergeIdentityPath reports whether a path in the platform-agent -// template repo is part of the concierge's IDENTITY (the set of -// files the IMAGE-BAKED impl should COPY into the image). A file -// outside this namespace (e.g. README.md, .gitignore) is -// documentation / metadata and is correctly EXCLUDED from the -// image-baked content. -// -// Namespace mirrors the template-asset allowlist in -// internal/provisioner/template_assets.go (IsCPTemplateAssetPath): -// - "config.yaml" — runtime entrypoint config -// - "mcp_servers.yaml" — MCP wiring (overlay) -// - "prompts/*" — system prompts -// - "identity-fallback.sh" — boot-time /opt→/configs copy script -// (template-platform-agent #2, invoked -// from the platform-agent entrypoint) -// -// A future RFC that adds a new namespace (e.g. "hooks/*") MUST -// extend this function AND the Dockerfile AND expectedImageBakedFiles -// in lockstep. The drift-gate's value is in the lockstep invariant. -func isConciergeIdentityPath(rel string) bool { - rel = filepath.ToSlash(filepath.Clean(rel)) - return rel == "config.yaml" || - rel == "mcp_servers.yaml" || - rel == "identity-fallback.sh" || - strings.HasPrefix(rel, "prompts/") -} - -// hasDockerfileCopyForRel reports whether Dockerfile.platform-agent contains -// a COPY instruction for the expected IMAGE-BAKED file `rel` (relative to the -// platform-agent template SSOT root). The Dockerfile uses two patterns: -// -// - COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/ ... for top-level files -// (config.yaml, mcp_servers.yaml, identity-fallback.sh). -// - COPY ${PLATFORM_AGENT_TEMPLATE_DIR}// ... for directory-baked -// content (prompts/concierge.md is shipped via the prompts/ dir copy). -// -// COPY instructions may also carry Dockerfile flags such as -// `--chmod=0755` before the source path, so the matcher permits an -// optional flag segment between `COPY` and the source path. -// -// This helper centralises the pattern matching so the test body stays readable -// and the two valid COPY shapes are documented in one place. -func hasDockerfileCopyForRel(dockerfileStr, rel string) bool { - rel = filepath.ToSlash(filepath.Clean(rel)) - relRe := regexp.QuoteMeta(rel) - dirRe := regexp.QuoteMeta(filepath.Dir(rel) + "/") - - // Match: COPY [flags] ${PLATFORM_AGENT_TEMPLATE_DIR}/ ... - // or: COPY [flags] ${PLATFORM_AGENT_TEMPLATE_DIR}// ... - // Flags are zero or more `--flag[=value]` tokens (e.g. --chmod=0755, - // --chown=app:app, --chown=1000:1000) before the source path. - pattern := `(?m)^COPY(?:\s+--\S+)*\s+\$\{PLATFORM_AGENT_TEMPLATE_DIR\}/(?:` + relRe + `|` + dirRe + `)\s` - matched, err := regexp.MatchString(pattern, dockerfileStr) - if err != nil { - // regexp.QuoteMeta only produces safe patterns; a compile error - // here is a test-authoring bug, not a product failure. - panic("invalid hasDockerfileCopyForRel pattern: " + err.Error()) - } - return matched -} - -func TestHasDockerfileCopyForRel(t *testing.T) { - tests := []struct { - name string - dockerfile string - rel string - wantMatched bool - }{ - { - name: "top-level file COPY", - dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/config.yaml /opt/molecule-platform-agent-template/config.yaml\n", - rel: "config.yaml", - wantMatched: true, - }, - { - name: "top-level file COPY with --chmod", - dockerfile: "COPY --chmod=0755 ${PLATFORM_AGENT_TEMPLATE_DIR}/identity-fallback.sh /opt/molecule-platform-agent-template/identity-fallback.sh\n", - rel: "identity-fallback.sh", - wantMatched: true, - }, - { - name: "top-level file COPY with --chown", - dockerfile: "COPY --chown=1000:1000 ${PLATFORM_AGENT_TEMPLATE_DIR}/identity-fallback.sh /opt/molecule-platform-agent-template/identity-fallback.sh\n", - rel: "identity-fallback.sh", - wantMatched: true, - }, - { - name: "top-level file COPY with multiple flags", - dockerfile: "COPY --chmod=0755 --chown=node:node ${PLATFORM_AGENT_TEMPLATE_DIR}/identity-fallback.sh /opt/molecule-platform-agent-template/identity-fallback.sh\n", - rel: "identity-fallback.sh", - wantMatched: true, - }, - { - name: "directory COPY for nested file", - dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/prompts/ /opt/molecule-platform-agent-template/prompts/\n", - rel: "prompts/concierge.md", - wantMatched: true, - }, - { - name: "missing COPY", - dockerfile: "RUN echo no-copy\n", - rel: "config.yaml", - wantMatched: false, - }, - { - name: "wrong source variable", - dockerfile: "COPY ${OTHER_DIR}/config.yaml /opt/molecule-platform-agent-template/config.yaml\n", - rel: "config.yaml", - wantMatched: false, - }, - { - name: "nested file missing directory COPY", - dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/prompts/concierge.md /opt/molecule-platform-agent-template/prompts/concierge.md\n", - rel: "prompts/concierge.md", - wantMatched: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := hasDockerfileCopyForRel(tt.dockerfile, tt.rel) - if got != tt.wantMatched { - t.Errorf("hasDockerfileCopyForRel(%q, %q) = %v, want %v", tt.dockerfile, tt.rel, got, tt.wantMatched) - } - }) - } -} - -// canonicalPlatformAgentSSOTRelPath is the default SSOT path the -// drift-gate reads from when PLATFORM_AGENT_TEMPLATE_REPO_PATH is -// unset, RELATIVE TO THE REPO ROOT. It mirrors Dockerfile.platform- -// agent's default PLATFORM_AGENT_TEMPLATE_DIR build-arg (i.e. where -// scripts/clone-manifest.sh places the platform-agent template repo -// after the pre-clone step in publish-workspace-server-image.yml). -// -// The env-var override exists for operators running the test -// outside the canonical CI context (e.g. an ad-hoc build verifying -// the drift-gate against a custom template mirror). When the env -// var is set, the test uses that path verbatim; otherwise it walks -// up from the test's CWD to find the repo root and resolves the -// canonical path from there. -// -// The drift-gate is CWD-AGNOSTIC: the test runs from the package -// dir (workspace-server/internal/provisioner/) which is two levels -// below the repo root, so the walk-up is necessary. This is the -// standard pattern for Go tests that need a repo-rooted fixture. -const canonicalPlatformAgentSSOTRelPath = ".tenant-bundle-deps/workspace-configs-templates/platform-agent" - -// repoRoot walks up from the test's CWD until it finds the -// molecule-core repo root (identified by go.mod at workspace-server/ -// go.mod or by the presence of manifest.json — the molecule-core -// root marker). Returns the absolute path to the repo root. -// -// Used by the drift-gate to resolve canonicalPlatformAgentSSOTRelPath -// to an absolute path regardless of where the test was invoked -// from. Bounded walk-up (max 10 levels) prevents an infinite loop -// if the test somehow runs from a path that doesn't contain a -// molecule-core repo above it. -func repoRoot(t *testing.T) string { - t.Helper() - wd, err := os.Getwd() - if err != nil { - t.Fatalf("getwd: %v", err) - } - dir := wd - for i := 0; i < 10; i++ { - // The canonical repo-root marker: manifest.json (present - // only at the molecule-core repo root, not in any submodule - // or vendored copy). workspace-server/go.mod is a weaker - // signal — it's also present in nested test fixtures. - if _, err := os.Stat(filepath.Join(dir, "manifest.json")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - t.Fatalf("could not locate molecule-core repo root from CWD %q (walked up 10 levels; expected manifest.json in some ancestor)", wd) - return "" -} - -// resolveSSOTRoot returns the absolute path to the platform-agent -// template SSOT. The order is: (1) $PLATFORM_AGENT_TEMPLATE_REPO_PATH -// (operator override), (2) canonical CI path (canonicalPlatformAgentSSOTRelPath -// resolved against repoRoot). Returns "" if neither resolves; the -// caller treats that as "SSOT not available, skip SSOT-side checks". -// -// A nil error with a non-empty path means the path EXISTS and is -// readable. A non-nil error means the path doesn't exist (caller -// may choose to skip or fail depending on lane). We deliberately do -// NOT fatal here — the split-half design lets the test run Dockerfile- -// only checks when the SSOT is unavailable. -func resolveSSOTRoot(t *testing.T) (path string, available bool) { - t.Helper() - ssotRoot := os.Getenv("PLATFORM_AGENT_TEMPLATE_REPO_PATH") - if ssotRoot == "" { - ssotRoot = filepath.Join(repoRoot(t), canonicalPlatformAgentSSOTRelPath) - } - if _, err := os.Stat(ssotRoot); err != nil { - return "", false - } - return ssotRoot, true -} - -// TestPlatformAgentImageDriftGate pins the IMAGE-BAKED ↔ template -// SSOT invariant. The test has TWO halves: -// -// 1. Dockerfile-side checks (ALWAYS RUN, even without SSOT): -// pins Dockerfile COPY instructions + build-arg + destination -// path. Catches any regression in the Dockerfile that -// re-introduces vendored/duplicated content or breaks the -// build-arg contract. These run on every CI lane, including -// pull_request. -// -// 2. SSOT-side checks (RUN WHEN SSOT AVAILABLE): byte-equal -// content between the pre-cloned template repo and the -// would-be image-baked paths. Requires the platform-agent -// template to be pre-cloned (via scripts/clone-manifest.sh -// from manifest.json's workspace_templates entry, OR the -// operator-override env var). Skipped with a t.Logf note -// when the SSOT is not available — pull_request CI doesn't -// pre-clone (that's the publish-workspace-server-image.yml -// workflow's job), and we don't want a missing pre-clone -// to fail this lane. -// -// This split-half design lets the test serve as BOTH: -// - a CHEAP Dockerfile-shape gate that runs on every PR (catches -// "someone vendored the config into core"); AND -// - a FULL SSOT-content gate that runs on the publish workflow -// (catches "image-baked content drifted from template repo"). -func skipIfPlatformAgentImageRemoved(t *testing.T) { - dockerfilePath := filepath.Join("..", "..", "Dockerfile.platform-agent") - if _, err := os.Stat(dockerfilePath); os.IsNotExist(err) { - t.Skipf("%s was removed in #3027 (core no longer builds the platform-agent image); this drift-gate is stale and will be removed once the permanent cleanup PR lands", dockerfilePath) - } -} - -func TestPlatformAgentImageDriftGate(t *testing.T) { - skipIfPlatformAgentImageRemoved(t) - - // === Half 1: Dockerfile-side checks (always run) === - - dockerfilePath := filepath.Join("..", "..", "Dockerfile.platform-agent") - dockerfile, err := os.ReadFile(dockerfilePath) - if err != nil { - // #3027 moved the platform-agent image build (and Dockerfile.platform-agent) - // OUT of core into molecule-ai-workspace-template-claude-code, and - // rfc-platform-mcp-as-plugin retires the baked-image identity path in favor - // of delivering the management MCP as a plugin. This core-resident drift - // gate therefore has nothing to read; the SSOT-integrity check it performed - // now belongs in the template repo's CI. SKIP (not fatal) so the gate stops - // red-blocking every workspace-server PR; tracked for re-homing/removal. - if os.IsNotExist(err) { - t.Skipf("Dockerfile.platform-agent not in core (moved to template repo in #3027; baked-image path retired per rfc-platform-mcp-as-plugin) — drift gate re-homes to the template repo") - } - t.Fatalf("read %s: %v", dockerfilePath, err) - } - dockerfileStr := string(dockerfile) - - for _, rel := range expectedImageBakedFiles { - if !hasDockerfileCopyForRel(dockerfileStr, rel) { - t.Errorf("Dockerfile COPY missing: %s — the IMAGE-BAKED impl must COPY %s from the platform-agent template SSOT; if a new identity file is added, update Dockerfile.platform-agent AND expectedImageBakedFiles", rel, rel) - } - } - - // ALSO verify the Dockerfile references the build-arg + the - // destination path. A future refactor that changes either of - // these would silently break the SSOT contract; the test pins - // the names that the workspace-server's runtime fallback (and - // any operator inspecting the image) relies on. - if !strings.Contains(dockerfileStr, "ARG PLATFORM_AGENT_TEMPLATE_DIR=") { - t.Error("Dockerfile.platform-agent is missing the PLATFORM_AGENT_TEMPLATE_DIR build-arg declaration — the IMAGE-BAKED impl requires this arg to source from the pre-cloned template repo") - } - if !strings.Contains(dockerfileStr, "/opt/molecule-platform-agent-template/") { - t.Error("Dockerfile.platform-agent is missing the /opt/molecule-platform-agent-template/ destination path — the workspace-server runtime fallback (and the drift-gate convention) pins this path; a change requires a coordinated update in both places") - } - - // === Half 2: SSOT-side checks (conditional on SSOT availability) === - - ssotRoot, available := resolveSSOTRoot(t) - if !available { - // SSOT not pre-cloned (typical for pull_request CI). Run - // the Dockerfile-side checks only; the SSOT-side checks - // will run on the publish-workspace-server-image.yml - // workflow which pre-clones via scripts/clone-manifest.sh. - t.Logf("platform-agent template SSOT not available at canonical CI path (PLATFORM_AGENT_TEMPLATE_REPO_PATH unset, .tenant-bundle-deps/workspace-configs-templates/platform-agent missing). Dockerfile-side checks ran; SSOT-side checks SKIPPED. Set PLATFORM_AGENT_TEMPLATE_REPO_PATH to the pre-cloned template dir to enable the full gate (the publish-workspace-server-image.yml workflow does this via the post-pre-clone test step).") - return - } - - // SSOT-side: each expected file MUST exist at ssotRoot/ - // and have non-zero content (zero-byte file = silent miss). - for _, rel := range expectedImageBakedFiles { - ssotPath := filepath.Join(ssotRoot, rel) - data, err := os.ReadFile(ssotPath) - if err != nil { - t.Errorf("SSOT missing: %s (read: %v) — the platform-agent template repo is the load-bearing identity SSOT; a missing file is a regression", ssotPath, err) - continue - } - if len(data) == 0 { - t.Errorf("SSOT empty: %s — zero-byte identity file would silently bake a broken concierge into the image", ssotPath) - } - } - - // SSOT-side: scan the platform-agent template repo for any - // additional files in the concierge-identity namespace (e.g. - // prompts/foo.md) that the Dockerfile might be missing. The - // forward-direction check (above) catches a missing expected - // file; this REVERSE check catches an un-expected new identity - // file the Dockerfile doesn't COPY. Both must hold for the - // image-baked content to remain SSOT-equal. - extraIdentityFiles, err := scanConciergeIdentityFiles(ssotRoot) - if err != nil { - t.Errorf("scan SSOT identity files: %v", err) - } else { - for _, rel := range extraIdentityFiles { - found := false - for _, expected := range expectedImageBakedFiles { - if rel == expected { - found = true - break - } - } - if !found { - t.Errorf("SSOT has an un-baked concierge-identity file: %s — the IMAGE-BAKED impl is now SILENTLY DRIFTING from the SSOT (a new file was added to the platform-agent template repo without a matching COPY in Dockerfile.platform-agent + entry in expectedImageBakedFiles). Either bake it (update Dockerfile + expected list) or mark it non-identity.", rel) - } - } - } -} - -// TestPlatformAgentEntrypointWiring pins the boot-time identity- -// fallback wiring. The IMAGE_BAKED_IDENTITY_PRESENT echo-marker -// that the #2919 PR shipped was a log line that did nothing — a -// partial-template / no-fetch self-host concierge would still -// MISSING_MODEL fail at runtime because /configs would be empty -// even though /opt/molecule-platform-agent-template/ had the -// content. This test pins the WIRE-UP shape that closes the gap: -// -// 1. Dockerfile.platform-agent defines a /entrypoint-platform-agent.sh -// heredoc that invokes identity-fallback.sh BEFORE handing off -// to /entrypoint.sh (the base image's entrypoint). The -// identity-fallback.sh script is the WORKING /opt→/configs -// fill-absent-only copy from template-platform-agent #2. -// 2. The Dockerfile's ENTRYPOINT directive points at the new -// /entrypoint-platform-agent.sh (NOT the base image's -// /entrypoint.sh). Otherwise the wiring is dormant — the -// fallback would never fire. -// 3. The IMAGE_BAKED_IDENTITY_PRESENT echo-only marker is GONE. -// A regression that re-adds the echo marker would re-introduce -// the dormant-fallback bug (script exists but never runs). -// -// Why pin the wiring here (not in a shell-script test): the -// Dockerfile is the source-of-truth for the IMAGE-BAKED impl, and -// the drift-gate already pins the Dockerfile's other shape -// invariants (COPY lines, build-arg, destination path). Adding -// entrypoint-wiring pins to the same file keeps the IMAGE-BAKED -// image contract in a single test surface — operators / reviewers -// reading TestPlatformAgentImageDriftGate see the full contract -// (data + activation), not just the COPY instructions. -// -// A future change that moves the entrypoint to a different -// filename / different invocation order must update this test -// in lockstep. The shape (identity-fallback.sh + /entrypoint.sh -// handoff) is the load-bearing part; the names are conventions. -func TestPlatformAgentEntrypointWiring(t *testing.T) { - skipIfPlatformAgentImageRemoved(t) - - dockerfilePath := filepath.Join("..", "..", "Dockerfile.platform-agent") - dockerfile, err := os.ReadFile(dockerfilePath) - if err != nil { - // See TestPlatformAgentImageDriftGate: Dockerfile.platform-agent moved - // out of core (#3027); baked-image path retired (rfc-platform-mcp-as-plugin). - if os.IsNotExist(err) { - t.Skipf("Dockerfile.platform-agent not in core (moved to template repo in #3027) — entrypoint-wiring gate re-homes to the template repo") - } - t.Fatalf("read %s: %v", dockerfilePath, err) - } - dockerfileStr := string(dockerfile) - - // 1. Heredoc-defined entrypoint-platform-agent.sh: must exist, - // must invoke identity-fallback.sh, must hand off to - // /entrypoint.sh (the base image's entrypoint). - if !strings.Contains(dockerfileStr, "/entrypoint-platform-agent.sh") { - t.Errorf("Dockerfile.platform-agent is missing /entrypoint-platform-agent.sh — the platform-agent entrypoint is the load-bearing wire-up that activates the /opt→/configs fallback at boot") - } - if !strings.Contains(dockerfileStr, "identity-fallback.sh") { - t.Errorf("Dockerfile.platform-agent does not reference identity-fallback.sh — the boot-time /opt→/configs fill-absent-only copy script (template-platform-agent #2) is the WORKING fallback that replaces the IMAGE_BAKED_IDENTITY_PRESENT echo-only marker") - } - // The hand-off: the new entrypoint must exec /entrypoint.sh - // (the base image's entrypoint) with the CMD args. A regression - // that omits the hand-off would skip the docker-socket group - // setup + memory-plugin sidecar + su-exec /platform boot. - if !strings.Contains(dockerfileStr, "exec /entrypoint.sh \"$@\"") { - t.Errorf("Dockerfile.platform-agent entrypoint does not exec /entrypoint.sh \"$@\" — the platform-agent entrypoint must hand off to the base image's entrypoint (docker-socket group setup, memory-plugin sidecar, su-exec /platform); a regression here would skip the base-image boot") - } - - // 2. ENTRYPOINT directive: must point at the new entrypoint - // (NOT the base /entrypoint.sh). The default ENTRYPOINT - // (inherited from the base image) is /entrypoint.sh; a - // regression that omits the override would activate the - // identity-fallback.sh script via COPY but never invoke - // it at boot — the dormant-fallback bug. - if !strings.Contains(dockerfileStr, `ENTRYPOINT ["/entrypoint-platform-agent.sh"]`) { - t.Errorf(`Dockerfile.platform-agent is missing ENTRYPOINT ["/entrypoint-platform-agent.sh"] — the platform-agent entrypoint override is what activates the identity-fallback at boot; without it the script is COPY'd into the image but never runs`) - } - - // 3. The IMAGE_BAKED_IDENTITY_PRESENT echo-only marker MUST - // be GONE. The marker was a no-op log line that did nothing; - // re-introducing it would either (a) replace the - // identity-fallback.sh COPY (regression — fallback never - // fires) or (b) coexist with the script (which is fine but - // leaves a confusing dead file at /opt/.../IMAGE_BAKED_ - // IDENTITY_PRESENT). Either way it's a regression marker. - // - // Pin pattern: a non-comment line that creates the marker - // file (the original #2919 PR's `RUN echo ... > ...IMAGE_BAKED - // _IDENTITY_PRESENT` heredoc). A comment that mentions the - // marker name is fine (documentation); a creation line is a - // regression. The check requires the marker name to be on a - // line that ALSO contains a shell-creating token (`>`, `tee`, - // `cp`, or the start of a `RUN` directive with a heredoc) — - // this is intentionally a coarse heuristic, not a full - // Dockerfile parser, but it's tight enough to catch the - // regression while not flagging the explanatory comment. - markerCreationRegex := regexp.MustCompile(`(?m)^[^#]*IMAGE_BAKED_IDENTITY_PRESENT[^#]*(>|tee |cp |<<)`) - if markerCreationRegex.MatchString(dockerfileStr) { - t.Errorf("Dockerfile.platform-agent still creates the IMAGE_BAKED_IDENTITY_PRESENT echo-only marker — the marker was a no-op log line that did nothing; the identity-fallback.sh script (template-platform-agent #2) is the real working fallback. The marker creation line must be removed when the script is wired in.") - } -} - -// scanConciergeIdentityFiles walks the platform-agent template repo -// and returns the RELATIVE paths of every file in the concierge- -// identity namespace (config.yaml + mcp_servers.yaml + -// identity-fallback.sh + prompts/). Non-identity files (README, -// .gitignore, etc.) are filtered out. -// -// Errors are returned for filesystem-walk failures; the caller turns -// them into a t.Errorf (so other checks still run). The walk is -// deliberately non-recursive beyond the namespace prefix — the -// concierge's identity is config + mcp + fallback-script + prompts, -// nothing nested. -func scanConciergeIdentityFiles(ssotRoot string) ([]string, error) { - var identity []string - entries, err := os.ReadDir(ssotRoot) - if err != nil { - return nil, err - } - for _, e := range entries { - // Top-level files: config.yaml, mcp_servers.yaml, - // identity-fallback.sh - if !e.IsDir() { - if isConciergeIdentityPath(e.Name()) { - identity = append(identity, e.Name()) - } - continue - } - // Directories: scan prompts/ - if e.Name() == "prompts" { - promptEntries, err := os.ReadDir(filepath.Join(ssotRoot, e.Name())) - if err != nil { - return nil, err - } - for _, pe := range promptEntries { - if pe.IsDir() { - continue - } - rel := filepath.ToSlash(filepath.Join(e.Name(), pe.Name())) - if isConciergeIdentityPath(rel) { - identity = append(identity, rel) - } - } - } - } - return identity, nil -} diff --git a/workspace-server/internal/provisioner/platform_agent_image_test.go b/workspace-server/internal/provisioner/platform_agent_image_test.go deleted file mode 100644 index 18597d97..00000000 --- a/workspace-server/internal/provisioner/platform_agent_image_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package provisioner - -import ( - "context" - "errors" - "testing" - - "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models" -) - -// TestWorkspaceKindPlatform_MatchesModels guards the duplicated constant: the -// provisioner-local WorkspaceKindPlatform MUST equal models.KindPlatform, else a -// kind='platform' concierge would silently miss the platform-agent image. -func TestWorkspaceKindPlatform_MatchesModels(t *testing.T) { - if WorkspaceKindPlatform != models.KindPlatform { - t.Fatalf("WorkspaceKindPlatform=%q != models.KindPlatform=%q — keep them in sync", WorkspaceKindPlatform, models.KindPlatform) - } -} - -// TestLocalPlatformAgentLatestTag asserts the platform-agent image variant tag -// shape: molecule-local/workspace-template--platform-agent:latest. -func TestLocalPlatformAgentLatestTag(t *testing.T) { - got := LocalPlatformAgentLatestTag("claude-code") - want := "molecule-local/workspace-template-claude-code-platform-agent:latest" - if got != want { - t.Fatalf("LocalPlatformAgentLatestTag = %q, want %q", got, want) - } -} - -// TestResolvePlatformAgentImage_Present: when the platform-agent image variant -// exists in the local store, resolvePlatformAgentImage returns it + true so the -// concierge runs on the image that bakes /opt/molecule-mcp-server. -func TestResolvePlatformAgentImage_Present(t *testing.T) { - wantTag := LocalPlatformAgentLatestTag("claude-code") - var probed string - hasTag := func(_ context.Context, tag string) (bool, error) { - probed = tag - return tag == wantTag, nil - } - got, ok := resolvePlatformAgentImage(context.Background(), "claude-code", "molecule-local/workspace-template-claude-code:abc", hasTag) - if !ok { - t.Fatalf("expected ok=true when platform-agent image present") - } - if got != wantTag { - t.Fatalf("got image %q, want %q", got, wantTag) - } - if probed != wantTag { - t.Fatalf("probed %q, want %q", probed, wantTag) - } -} - -// TestResolvePlatformAgentImage_Absent: when the variant is NOT present, the -// resolver returns ("", false) so the caller safely falls back to the plain -// runtime image — an ordinary local stack keeps working (concierge just runs -// without the org-admin MCP). This is the gate the task requires. -func TestResolvePlatformAgentImage_Absent(t *testing.T) { - hasTag := func(_ context.Context, _ string) (bool, error) { return false, nil } - got, ok := resolvePlatformAgentImage(context.Background(), "claude-code", "molecule-local/workspace-template-claude-code:abc", hasTag) - if ok { - t.Fatalf("expected ok=false when platform-agent image absent") - } - if got != "" { - t.Fatalf("expected empty image on absent, got %q", got) - } -} - -// TestResolvePlatformAgentImage_ProbeError: a docker-inspect error is treated as -// "absent" (fall back to the plain image) — never fails the provision. -func TestResolvePlatformAgentImage_ProbeError(t *testing.T) { - hasTag := func(_ context.Context, _ string) (bool, error) { - return false, errors.New("docker daemon unreachable") - } - got, ok := resolvePlatformAgentImage(context.Background(), "claude-code", "fallback:img", hasTag) - if ok || got != "" { - t.Fatalf("expected fall-back on probe error, got (%q, %v)", got, ok) - } -} diff --git a/workspace-server/internal/provisioner/provisioner.go b/workspace-server/internal/provisioner/provisioner.go index d598bdd0..990be17f 100644 --- a/workspace-server/internal/provisioner/provisioner.go +++ b/workspace-server/internal/provisioner/provisioner.go @@ -153,10 +153,10 @@ type WorkspaceConfig struct { TemplateAssetFetcher TemplateAssetFetcher // Kind is the workspace kind: "" / "workspace" (ordinary) or "platform" - // (the org-level concierge / platform agent). When "platform", the local - // Docker provisioner prefers the platform-agent image variant (which bakes - // /opt/molecule-mcp-server so the org-admin platform MCP can load) over the - // plain runtime image. See models.KindPlatform + RFC + // (the org-level concierge / platform agent). The concierge runs on the + // plain per-runtime image — identity is delivered via the template + // asset-channel and the org-admin platform MCP via the plugin system, so no + // baked image variant is needed. See models.KindPlatform + RFC // docs/design/rfc-platform-agent.md. Kind string @@ -688,21 +688,6 @@ func (p *Provisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string, e } image = builtTag log.Printf("Provisioner: local-build mode → using locally-built image %s for runtime %s", image, cfg.Runtime) - - // kind='platform' (the org concierge): prefer the platform-agent - // image variant, which bakes /opt/molecule-mcp-server so the - // org-admin platform MCP can load. Gated on the image being present - // — if an operator hasn't built it (Dockerfile.platform-agent), we - // fall back to the plain runtime image + log, so an ordinary local - // stack keeps working (the concierge just runs without org-admin - // tools, exactly as before this change). Never breaks normal - // workspaces (kind != platform) or the SaaS/CP path (handled in the - // control-plane provisioner — separate repo). - if cfg.Kind == WorkspaceKindPlatform { - if paTag, ok := resolvePlatformAgentImage(ctx, cfg.Runtime, image, nil); ok { - image = paTag - } - } } } diff --git a/workspace-server/internal/provisioner/workspace_kind_test.go b/workspace-server/internal/provisioner/workspace_kind_test.go new file mode 100644 index 00000000..97028b9f --- /dev/null +++ b/workspace-server/internal/provisioner/workspace_kind_test.go @@ -0,0 +1,17 @@ +package provisioner + +import ( + "testing" + + "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models" +) + +// TestWorkspaceKindPlatform_MatchesModels guards the duplicated constant: the +// provisioner-local WorkspaceKindPlatform MUST equal models.KindPlatform, else a +// kind='platform' concierge would be mis-identified on the provision path (Kind +// is forwarded to the CP for the concierge's config/identity overlay). +func TestWorkspaceKindPlatform_MatchesModels(t *testing.T) { + if WorkspaceKindPlatform != models.KindPlatform { + t.Fatalf("WorkspaceKindPlatform=%q != models.KindPlatform=%q — keep them in sync", WorkspaceKindPlatform, models.KindPlatform) + } +}