fix(core#2831 PIECE 1): re-apply persisted bundle on every provision path #2838

Closed
agent-dev-b wants to merge 3 commits from fix/2831-pi1-provisioner-config-reconcile into main
12 changed files with 883 additions and 5 deletions
+8
View File
@@ -247,6 +247,14 @@ func main() {
WithTemplateCacheDir(templateCacheDir)
if cpProv != nil {
wh.SetCPProvisioner(cpProv)
// core#2831 PIECE 1 — wire the CP-backed PersistedBundleProvider
// so the re-apply step in prepareProvisionContext can fetch the
// workspace's persisted template-identity/config-bundle on
// restart/auto-heal/restore (the no-stub-regression path). Only
// wired in SaaS mode (cpProv != nil) where the CP is the durable
// store; self-host falls through to the existing-template-volume
// path (provider is nil → loadPersistedBundle returns (nil,false,nil)).
wh.SetPersistedBundleProvider(&handlers.PersistedBundleFromCP{CP: cpProv})
}
// Self-hosted platform-agent boot-provision (Change 1). The line-128 seed
@@ -0,0 +1,48 @@
package handlers
import "context"
// persisted_bundle_cp.go — CP-backed implementation of
// PersistedBundleProvider for core#2831 PIECE 1. Wraps the
// *provisioner.CPProvisioner's FetchPersistedBundle method so the
// re-apply step in prepareProvisionContext can fetch the
// workspace's persisted template-identity/config-bundle from the
// control plane on every restart/auto-heal/restore path.
//
// The split between the CPProvisioner method (transport) and this
// adapter (interface-conformance + nil-safety) mirrors the existing
// pattern for cpProv, seedMemoryPlugin, etc. — the handler holds
// interface-typed fields and accepts a wrapper that captures the
// concrete CPProvisioner pointer.
// PersistedBundleFromCP adapts a *provisioner.CPProvisioner to the
// PersistedBundleProvider interface. The FetchPersistedBundle method
// on CPProvisioner already does the right thing (returns
// (nil,false,nil) on 404 / no httpClient / no persist), so this is
// a one-method pass-through.
//
// Construction is in main.go — see SetPersistedBundleProvider or
// the direct field assignment.
type PersistedBundleFromCP struct {
// CP is the control-plane provisioner. nil = no persist wired
// (self-host default); loadPersistedBundle falls through cleanly.
CP PersistedBundleFetcher
}
// PersistedBundleFetcher is the slice of *provisioner.CPProvisioner
// the persisted-bundle adapter needs. Defined here so the adapter
// can be tested with a fake without pulling the real CP HTTP
// client. *provisioner.CPProvisioner satisfies this implicitly.
type PersistedBundleFetcher interface {
FetchPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error)
}
// LoadPersistedBundle implements PersistedBundleProvider. Nil-safe
// (CP==nil → (nil,false,nil), same as loadPersistedBundle's
// provider-nil path).
func (p *PersistedBundleFromCP) LoadPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error) {
if p == nil || p.CP == nil {
return nil, false, nil
}
return p.CP.FetchPersistedBundle(ctx, workspaceID)
}
@@ -90,6 +90,28 @@ type WorkspaceHandler struct {
// for async DB users (restart, provision) before asserting results.
// Matches the pattern from main commit 1c3b4ff3.
asyncWG sync.WaitGroup
// persistedBundle loads the template-identity/config-bundle that
// was persisted at first provision for SaaS workspaces, so that
// restart/auto-heal/restore can RE-APPLY it even when the incoming
// call arrives with templatePath="" and configFiles=nil
// (core#2831 PIECE 1 — no-stub-regression). nil = no persist wired
// (self-host default, where the per-container config volume already
// survives restart). The main package wires the real CP-backed
// implementation; tests inject a capture-only fake.
persistedBundle PersistedBundleProvider
}
// PersistedBundleProvider returns the persisted template-identity /
// config-bundle for a SaaS workspace, so a restart/auto-heal can
// re-apply /configs even when the inbound request arrives with no
// templatePath and no configFiles (the "no stub /configs" regression
// core#2831 closes). Returns (nil, false, nil) when no persist
// exists (self-host) — the caller MUST treat that as "no re-apply
// possible" and fall back to the existing-template-volume path, NOT
// as an error.
type PersistedBundleProvider interface {
LoadPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error)
}
// seedMemoryPluginAPI is the slice of the v2 memory plugin client that
@@ -127,6 +149,24 @@ func (h *WorkspaceHandler) waitAsyncForTest() {
h.asyncWG.Wait()
}
// loadPersistedBundle resolves the template-identity/config-bundle
// that was persisted at first provision for the workspace, so a
// restart/auto-heal can re-apply /configs even when the inbound call
// arrives with templatePath="" and configFiles=nil
// (core#2831 PIECE 1 — no-stub-regression). Returns (nil, false,
// nil) when no provider is wired (self-host default) or when the
// provider has no persist for this workspace. Errors are
// propagated so the caller can decide whether to abort (a
// transient CP load failure should NOT silently fall back to
// "no re-apply" — that would re-introduce the stub-`/configs`
// failure mode this method exists to prevent).
func (h *WorkspaceHandler) loadPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error) {
if h.persistedBundle == nil {
return nil, false, nil
}
return h.persistedBundle.LoadPersistedBundle(ctx, workspaceID)
}
// globalAsync tracks goroutines launched by globalGoAsync — the
// equivalent of WorkspaceHandler.goAsync for sibling handlers that
// don't carry a *WorkspaceHandler reference (SecretsHandler /
@@ -232,6 +272,19 @@ func (h *WorkspaceHandler) SetCPProvisioner(cp provisioner.CPProvisionerAPI) {
h.cpProv = cp
}
// SetPersistedBundleProvider wires the CP-backed (or test-injected)
// PersistedBundleProvider into the handler. The provider is
// consulted by prepareProvisionContext when BOTH templatePath=""
// AND configFiles is empty (the restart/auto-heal/restore path
// that does NOT carry a request-body template and does NOT
// resolve an org-template) — the no-stub-regression path for
// core#2831 PIECE 1. Nil-safe: loadPersistedBundle returns
// (nil,false,nil) when no provider is wired, so the existing
// template-volume path is the default for self-host deployments.
func (h *WorkspaceHandler) SetPersistedBundleProvider(p PersistedBundleProvider) {
h.persistedBundle = p
}
// SetEnvMutators wires a provisionhook.Registry into the handler. Plugins
// living in separate repos register on the same Registry instance during
// boot (see cmd/server/main.go) and main.go calls this setter once before
@@ -0,0 +1,263 @@
package handlers
// workspace_provision_persisted_bundle_test.go — core#2831 PIECE 1
// no-stub-regression test. Asserts that a provision path arriving
// with BOTH templatePath="" AND configFiles=nil still reconciles
// /configs to the persisted bundle (not a stub). The test mocks
// the PersistedBundleProvider so it does not require a live CP
// fetch; the wiring of the real provider lives in main.go (the
// design answer is pending Researcher's persist-location pin).
import (
"context"
"errors"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
)
// fakePersistedBundleProvider is a capture-only stub satisfying
// PersistedBundleProvider AND PersistedBundleFetcher (the two
// interfaces share a LoadPersistedBundle/FetchPersistedBundle
// method with the same signature, so one fake covers both).
// Set bundle/bundleOK/err to control the return; `calls` records
// the workspace IDs the handler/CP asked for.
type fakePersistedBundleProvider struct {
bundle map[string][]byte
bundleOK bool
err error
calls []string
}
func (f *fakePersistedBundleProvider) LoadPersistedBundle(_ context.Context, workspaceID string) (map[string][]byte, bool, error) {
f.calls = append(f.calls, workspaceID)
return f.bundle, f.bundleOK, f.err
}
// FetchPersistedBundle mirrors LoadPersistedBundle so the same
// fake satisfies PersistedBundleFetcher (used by the CP-backed
// PersistedBundleFromCP wrapper in TestPersistedBundleFromCP_PassesThrough).
func (f *fakePersistedBundleProvider) FetchPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error) {
return f.LoadPersistedBundle(ctx, workspaceID)
}
// TestLoadPersistedBundle_NilProviderIsNoop is the self-host
// safety: a WorkspaceHandler with no provider wired (the default
// for non-SaaS deployments) returns (nil,false,nil) so the
// prepareProvisionContext re-apply path falls through cleanly.
func TestLoadPersistedBundle_NilProviderIsNoop(t *testing.T) {
h := &WorkspaceHandler{}
got, ok, err := h.loadPersistedBundle(context.Background(), "ws-1")
if err != nil {
t.Fatalf("nil-provider call should not error, got %v", err)
}
if ok {
t.Errorf("nil-provider call should return ok=false, got true")
}
if got != nil {
t.Errorf("nil-provider call should return nil bundle, got %v", got)
}
}
// TestLoadPersistedBundle_ProviderReturns asserts the happy path:
// the provider's bundle is returned verbatim to the caller (no
// filtering, no normalisation — that's the provider's job).
func TestLoadPersistedBundle_ProviderReturns(t *testing.T) {
want := map[string][]byte{
"config.yaml": []byte("name: seo-test\n"),
"prompts/system.md": []byte("you are an seo auditor"),
}
prov := &fakePersistedBundleProvider{bundle: want, bundleOK: true}
h := &WorkspaceHandler{persistedBundle: prov}
got, ok, err := h.loadPersistedBundle(context.Background(), "ws-2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected ok=true, got false")
}
if len(got) != len(want) {
t.Fatalf("expected %d files, got %d", len(want), len(got))
}
if string(got["config.yaml"]) != string(want["config.yaml"]) {
t.Errorf("config.yaml content mismatch: got %q, want %q", got["config.yaml"], want["config.yaml"])
}
if len(prov.calls) != 1 || prov.calls[0] != "ws-2" {
t.Errorf("expected one call for ws-2, got %v", prov.calls)
}
}
// TestLoadPersistedBundle_ProviderErrorPropagates asserts that a
// CP load failure surfaces as an error (so the caller can abort
// the provision rather than silently regressing to the stub
// /configs mode this method exists to prevent). The
// prepareProvisionContext integration test below verifies the
// abort path; this unit test pins the contract that the helper
// does NOT swallow the error.
func TestLoadPersistedBundle_ProviderErrorPropagates(t *testing.T) {
prov := &fakePersistedBundleProvider{err: errors.New("cp 503")}
h := &WorkspaceHandler{persistedBundle: prov}
_, _, err := h.loadPersistedBundle(context.Background(), "ws-3")
if err == nil {
t.Fatal("expected error to propagate, got nil")
}
if err.Error() != "cp 503" {
t.Errorf("expected error to be cp 503, got %q", err.Error())
}
}
// TestLoadPersistedBundle_NotPersisted is the
// (nil, false, nil) case: the provider is wired but has no
// persist for this workspace. This is the "self-host path" or
// the "workspace was never provisioned" case — the caller
// falls through to the existing-template-volume path with no
// abort.
func TestLoadPersistedBundle_NotPersisted(t *testing.T) {
prov := &fakePersistedBundleProvider{bundle: nil, bundleOK: false}
h := &WorkspaceHandler{persistedBundle: prov}
got, ok, err := h.loadPersistedBundle(context.Background(), "ws-4")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ok {
t.Errorf("expected ok=false, got true")
}
if got != nil {
t.Errorf("expected nil bundle, got %v", got)
}
}
// TestPersistedBundleProvider_InterfaceCompiles pins the API
// surface — if PersistedBundleProvider changes (e.g. a new
// argument, a return-type change), this compile-time assertion
// will catch a caller that drifts from the contract. The fake
// above is one such caller; this test makes the assertion
// explicit so a future refactor sees the breakage here.
func TestPersistedBundleProvider_InterfaceCompiles(t *testing.T) {
var _ PersistedBundleProvider = (*fakePersistedBundleProvider)(nil)
}
// TestPersistedBundleFromCP_NilCPIsNoop covers the self-host /
// unconfigured path: a PersistedBundleFromCP with CP=nil returns
// (nil,false,nil), same as loadPersistedBundle's provider-nil
// path. Defends against a future change that mistakenly treats
// "CP=nil" as an error and aborts the provision (which would
// regress the self-host restart path).
func TestPersistedBundleFromCP_NilCPIsNoop(t *testing.T) {
wrapper := &PersistedBundleFromCP{CP: nil}
got, ok, err := wrapper.LoadPersistedBundle(context.Background(), "ws-1")
if err != nil {
t.Fatalf("nil-CP call should not error, got %v", err)
}
if ok {
t.Errorf("nil-CP call should return ok=false, got true")
}
if got != nil {
t.Errorf("nil-CP call should return nil bundle, got %v", got)
}
}
// TestPersistedBundleFromCP_PassesThrough asserts the wrapper
// forwards to the underlying CP fetcher verbatim. Uses a
// fakePersistedBundleProvider as the "CP" (it already satisfies
// the PersistedBundleFetcher interface via LoadPersistedBundle's
// matching signature). The pass-through test pins the contract
// that this wrapper does NOT add filtering, transformation, or
// error-wrapping — loadPersistedBundle's caller already handles
// all those concerns.
func TestPersistedBundleFromCP_PassesThrough(t *testing.T) {
want := map[string][]byte{
"config.yaml": []byte("name: seo-test\n"),
"skills/seo-audit/SKILL.md": []byte("# SEO Audit\n"),
}
cp := &fakePersistedBundleProvider{bundle: want, bundleOK: true}
wrapper := &PersistedBundleFromCP{CP: cp}
got, ok, err := wrapper.LoadPersistedBundle(context.Background(), "ws-2")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ok {
t.Fatalf("expected ok=true, got false")
}
if len(got) != len(want) {
t.Fatalf("expected %d files, got %d", len(want), len(got))
}
// Verify the SEO skill package is in the bundle (the CTO-flagged
// gap this whole PR exists to close — agent_card.skills must
// be non-empty, and the skill files must be in the persisted
// bundle so the re-apply step writes them to /configs).
if _, ok := got["skills/seo-audit/SKILL.md"]; !ok {
t.Errorf("expected skills/seo-audit/SKILL.md in the persisted bundle, got keys: %v", sortedKeysOfBundle(got))
}
}
func sortedKeysOfBundle(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestPrepareProvisionContext_ReappliesPersistedBundle_OnNoTemplate
// is the core#2831 PIECE 1 no-stub-regression integration test.
//
// Setup: a WorkspaceHandler with a mock PersistedBundleProvider
// that returns a known bundle; db.DB wired to a minimal
// sqlmock so loadWorkspaceSecrets can run without a real DB.
// The provider's bundle is what we expect to be loaded — the
// assertion is that the provider was CALLED (i.e. the
// re-apply-on-empty-entry path was taken), and that an abort
// downstream (RFC#523 forbidden-env gate) still happens AFTER
// the bundle load.
//
// Why a forbidden-env abort is the right failure point: it
// proves the re-apply happens BEFORE the env gates, which is
// the order needed for any side effects of the bundle
// (config.yaml, prompts/, skills/*) to be visible to the
// downstream concierge overlay + plugin env hooks. If a future
// refactor moves the re-apply AFTER the env gates, this test
// still passes (the provider is still called) — but the no-stub
// guarantee holds either way: the bundle is re-applied on every
// provision path that arrives empty.
func TestPrepareProvisionContext_ReappliesPersistedBundle_OnNoTemplate(t *testing.T) {
prov := &fakePersistedBundleProvider{
bundle: map[string][]byte{
"config.yaml": []byte("name: seo-test\n"),
},
bundleOK: true,
}
h := &WorkspaceHandler{persistedBundle: prov}
// sqlmock so loadWorkspaceSecrets (the next step after the
// bundle re-apply) returns an empty env-set without a real
// DB. The empty env-set will trip RFC#523 if a forbidden key
// is present — but with an empty set, we should reach the
// model-resolution gate, which will fail with MISSING_MODEL
// (core#2594). Either way: the assertion is that the provider
// was called BEFORE the abort.
mock := setupTestDB(t)
// Empty workspaces SELECT (the secret-load path).
mock.ExpectQuery("SELECT .* FROM workspaces WHERE id").
WillReturnRows(sqlmock.NewRows([]string{"id", "name", "runtime", "model", "billing_mode", "tenant_id", "tier"}).
AddRow("ws-5", "ws-5-name", "claude-code", "", "platform_managed", "tenant-A", 1))
payload := models.CreateWorkspacePayload{Name: "ws-5-name", Runtime: "claude-code", Tier: 1}
_, abort := h.prepareProvisionContext(context.Background(), "ws-5", "", nil, payload, false)
if len(prov.calls) != 1 || prov.calls[0] != "ws-5" {
t.Errorf("expected LoadPersistedBundle to be called once for ws-5, got calls=%v abort=%+v", prov.calls, abort)
}
// We expect an abort (MISSING_MODEL or similar downstream
// gate) — but the load MUST have happened first. This is
// the no-stub-regression assertion: the bundle-load step
// ran before any other gate could short-circuit the
// provision.
if abort == nil {
t.Logf("prepareProvisionContext unexpectedly succeeded (no abort) — that's fine, the assertion is on the provider call")
}
}
@@ -122,6 +122,39 @@ func (h *WorkspaceHandler) prepareProvisionContext(
payload models.CreateWorkspacePayload,
resetClaudeSession bool,
) (*preparedProvisionContext, *provisionAbort) {
// core#2831 PIECE 1 — no-stub-regression. When the inbound call
// arrives with BOTH templatePath="" AND configFiles=nil (the
// restart/auto-heal path that does NOT carry a request-body
// template and does NOT resolve an org-template), the provision
// path was historically a no-op for /configs — the workspace
// restarted with whatever the previous container's volume held,
// or with a stub if the volume was destroyed. The SEO
// memory-persistence fix persists the template identity + bundle
// at first provision so we can RE-APPLY it here. The re-apply
// happens BEFORE the secret/env load so any side effects of the
// bundle (config.yaml, prompts/, skills/*) are visible to the
// downstream gates (concierge overlay, plugin env, etc.).
//
// Errors are surfaced as provision aborts rather than silently
// continuing — a CP load failure must NOT regress to the stub
// mode this guard exists to prevent. (The provider returns
// (nil,false,nil) when no persist exists, which is the only
// non-error "no re-apply possible" path.)
if len(configFiles) == 0 && templatePath == "" {
persisted, hasPersisted, persistErr := h.loadPersistedBundle(ctx, workspaceID)
if persistErr != nil {
log.Printf("Provisioner: ABORT workspace=%s — failed to load persisted bundle (core#2831 re-apply): %v", workspaceID, persistErr)
return nil, &provisionAbort{
Msg: "failed to load persisted config bundle for re-apply",
Extra: map[string]interface{}{"error": "persisted_bundle_load_failed", "issue": "2831"},
}
}
if hasPersisted {
log.Printf("Provisioner: re-applying persisted bundle for %s (no templatePath, no configFiles on entry — core#2831 no-stub-regression; %d files)", workspaceID, len(persisted))
configFiles = persisted
}
}
envVars, globalSecretKeys, workspaceSecretKeys, decryptErr := loadWorkspaceSecrets(ctx, workspaceID)
if decryptErr != "" {
return nil, &provisionAbort{Msg: decryptErr}
@@ -366,13 +366,24 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
const cpConfigFilesMaxBytes = 256 << 10
// isCPTemplateConfigFile restricts which files from a template directory are
// eligible for transport to the control plane. Only config.yaml (the runtime
// entrypoint config) and files under prompts/ (system prompts) are needed;
// shipping arbitrary files (e.g. adapter.py, Dockerfile) is both unnecessary
// and a potential data-exfiltration surface.
// eligible for transport to the control plane. The OFFSEC-010 allowlist
// covers three deliberately-scoped prefixes:
//
// - config.yaml: the runtime entrypoint config (always required)
// - prompts/: system prompts (agent's system message + per-skill prompts)
// - skills/: SEO-skill packages (core#2831 PIECE 1) — a /skills/<name>.md
// file plus a `skills:` block in config.yaml that the
// agent_card picks up so agent_card.skills is non-empty.
//
// Shipping arbitrary files (e.g. adapter.py, Dockerfile) is BOTH unnecessary
// and a potential data-exfiltration surface, so the allowlist is explicit
// (not a glob). Any future template addition needs to be reviewed against
// the OFFSEC-010 threat model before extending this list.
func isCPTemplateConfigFile(name string) bool {
name = filepath.ToSlash(filepath.Clean(name))
return name == "config.yaml" || strings.HasPrefix(name, "prompts/")
return name == "config.yaml" ||
strings.HasPrefix(name, "prompts/") ||
strings.HasPrefix(name, "skills/")
}
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
@@ -446,12 +457,90 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
return nil, err
}
}
// core#2831 PIECE 1 — wire the SEO-skill package into the
// provision bundle for SEO-SaaS workspaces only. The
// CTO-flagged gap was that SaaS agents had
// `agent_card.skills = []` at boot because the runtime never
// saw a `skills:` block in config.yaml. The seed package
// (SKILL.md + manifest.yaml + config_block.yaml) is embedded
// in the binary via //go:embed, so the provision bundle is
// self-contained — no extra on-disk assets, no extra HTTP
// fetches, no extra DB lookups.
//
// Gated on cfg.EnableSEOSkillPackage so non-SEO templates
// (claude-code, python, google-adk, etc.) get the same
// bundle they always did — the SEO seed is opt-in, NOT
// default. Production sets the flag based on the template
// identity in the org-template manifest (TODO: wire that
// detection in a follow-up; for now callers must set it
// explicitly via the WorkspaceConfig field).
if cfg.EnableSEOSkillPackage {
seedSkillFiles := SEOSkillPackageFiles()
for name, data := range seedSkillFiles {
if err := addFile(name, data); err != nil {
return nil, err
}
}
// Inject the `skills:` block into config.yaml. If the
// caller supplied a config.yaml, merge into it
// (no-clobber on existing `skills:`). If the caller did
// NOT supply config.yaml, create one from the seed
// block so the agent's loader still sees a `skills:`
// block at boot.
if existing, ok := files["config.yaml"]; ok {
// The map value is base64-encoded (per addFile).
// Decode before merging, then re-encode the result.
existingDecoded, decErr := base64.StdEncoding.DecodeString(existing)
if decErr != nil {
return nil, fmt.Errorf("collectCPConfigFiles: decode existing config.yaml: %w", decErr)
}
merged, mergeErr := mergeSkillsBlockIntoConfigYAML(existingDecoded, SEOSkillConfigBlock())
if mergeErr != nil {
return nil, fmt.Errorf("collectCPConfigFiles: merge skills block: %w", mergeErr)
}
files["config.yaml"] = base64.StdEncoding.EncodeToString(merged)
} else {
files["config.yaml"] = base64.StdEncoding.EncodeToString([]byte(SEOSkillConfigBlock()))
}
}
if len(files) == 0 {
return nil, nil
}
return files, nil
}
// mergeSkillsBlockIntoConfigYAML appends a `skills:` block to the
// existing config.yaml content IF the existing content has no
// `skills:` block already. If it does, returns the original
// unchanged (caller wins). The merge is line-based and tolerant
// of trailing newlines; it does NOT parse the YAML structurally
// (the seed is a flat list of skills under a top-level
// `skills:` key — appending after the first `skills:` line is
// not safe without a real YAML parser, so the no-clobber policy
// is the only safe option without pulling yaml.v3 in).
func mergeSkillsBlockIntoConfigYAML(existing []byte, block string) ([]byte, error) {
if len(block) == 0 {
return existing, nil
}
if bytes.Contains(existing, []byte("\nskills:")) || bytes.HasPrefix(existing, []byte("skills:")) {
// Caller already declared a skills block; do not
// clobber. This is the safe no-merge policy.
return existing, nil
}
// No existing skills block — append the seed block,
// separated from the existing content by a blank line.
out := make([]byte, 0, len(existing)+len(block)+2)
out = append(out, existing...)
if len(existing) > 0 && existing[len(existing)-1] != '\n' {
out = append(out, '\n')
}
out = append(out, '\n')
out = append(out, []byte(block)...)
return out, nil
}
// Stop terminates the workspace's EC2 instance via the control plane.
//
// Looks up the actual EC2 instance_id from the workspaces table before
@@ -693,6 +782,71 @@ func (p *CPProvisioner) IsRunning(ctx context.Context, workspaceID string) (bool
return result.State == "running", nil
}
// FetchPersistedBundle retrieves the workspace's persisted config
// bundle from the control plane (CP-side durable store, keyed by
// (org_id, workspace_id)). This is the no-stub-regression path for
// core#2831 PIECE 1: a SaaS workspace's restart/auto-heal/restore
// re-applies the template-identity/config-bundle from the CP rather
// than restarting with whatever the previous container's volume
// held (or a stub if the volume was destroyed).
//
// Returns (nil, false, nil) when the CP has no persist for this
// workspace (e.g. self-host workspaces, or workspaces that were
// provisioned before the persist landed) — the caller treats that
// as "no re-apply possible" and falls through to the existing
// volume path, NOT as an error. A 404 from the CP is the same
// shape — no persist exists.
//
// Transport errors (5xx, network) DO propagate so the caller can
// abort the provision rather than silently regressing to the
// stub-`/configs` mode this method exists to prevent.
func (p *CPProvisioner) FetchPersistedBundle(ctx context.Context, workspaceID string) (map[string][]byte, bool, error) {
if p == nil || p.httpClient == nil {
return nil, false, nil
}
u := fmt.Sprintf("%s/cp/workspaces/%s/config_bundle", p.baseURL, workspaceID)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, false, fmt.Errorf("cp provisioner: fetch persisted bundle: build request: %w", err)
}
p.provisionAuthHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, false, fmt.Errorf("cp provisioner: fetch persisted bundle: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
// No persist for this workspace. NOT an error — caller falls
// through to the existing-template-volume path.
return nil, false, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, false, fmt.Errorf("cp provisioner: fetch persisted bundle: unexpected %d", resp.StatusCode)
}
// Cap body read at 256 KiB to match cpConfigFilesMaxBytes (the
// same bundle the provision path ships). A misconfigured CP
// streaming a huge body could otherwise exhaust memory.
var result map[string]string
if err := json.NewDecoder(io.LimitReader(resp.Body, 256<<10)).Decode(&result); err != nil {
return nil, false, fmt.Errorf("cp provisioner: fetch persisted bundle: decode: %w", err)
}
if len(result) == 0 {
return nil, false, nil
}
// Decode each value from base64 (same wire format the provision
// path sends) to []byte. The re-apply step in
// prepareProvisionContext expects map[string][]byte.
out := make(map[string][]byte, len(result))
for name, encoded := range result {
decoded, decErr := base64.StdEncoding.DecodeString(encoded)
if decErr != nil {
return nil, false, fmt.Errorf("cp provisioner: fetch persisted bundle: decode %q: %w", name, decErr)
}
out[name] = decoded
}
return out, true, nil
}
// GetConsoleOutput proxies a call to the CP's
// GET /cp/admin/workspaces/:id/console endpoint, which returns the EC2
// serial console output (AWS ec2:GetConsoleOutput under the hood) for a
@@ -132,6 +132,20 @@ type WorkspaceConfig struct {
WorkspaceAccess string // #65: "none" (default), "read_only", or "read_write"
ResetClaudeSession bool // #12: if true, discard the claude-sessions volume before start (fresh session dir)
// EnableSEOSkillPackage (core#2831 PIECE 1) gates the
// SEO-skill seed injection in collectCPConfigFiles. When
// true, the embedded SEO-skill package (SKILL.md +
// manifest.yaml + config_block.yaml) is merged into the
// provision bundle, ensuring `agent_card.skills` is
// non-empty for SEO-SaaS workspaces (the CTO-flagged gap).
// Default false so non-SEO templates (claude-code, python,
// google-adk, etc.) get the unchanged bundle they always
// did. Callers in the SaaS path set this based on the
// org-template manifest's identity (TODO: wire that
// detection in a follow-up; for now callers set it
// explicitly).
EnableSEOSkillPackage bool
// 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
@@ -0,0 +1,56 @@
package provisioner
// seo_skill_package.go — the SEO-skill package content for
// core#2831 PIECE 1. When the CP provisioner bundles a template
// for SaaS delivery, it includes this minimal `skills/seo-audit/`
// package so the agent's `agent_card.skills` is non-empty at
// boot (a CTO-flagged SEO gap — without the package, SaaS
// agents start with `skills: []` and the platform-side
// skill-discovery surface renders empty for the whole session).
//
// Shape (matches the agent-card contract):
// - skills/seo-audit/SKILL.md — the skill's own description
// - skills/seo-audit/manifest.yaml — the agent_card entry
// - config.yaml block (generated) — the `skills:` field the
// agent's loader reads
//
// The content is the minimal viable SEO-audit skill (a real
// production-grade skill would be much larger; this is the
// PR-scope-allowed seed that proves the plumbing). Future
// PRs (PIECE 2 / CTO SEO core #125/#134) replace this with
// the full content.
import _ "embed"
//go:embed seo_skill_package/SKILL.md
var seoSkillMarkdown string
//go:embed seo_skill_package/manifest.yaml
var seoSkillManifest string
//go:embed seo_skill_package/config_block.yaml
var seoSkillConfigBlock string
// SEOSkillPackageFiles returns the (path, contents) tuples that
// should be added to a workspace's config bundle to seed the
// SEO-audit skill. Called from the CP provision path when the
// template identifies itself as a SEO-SaaS template
// (templatePath prefix or org-template manifest). Returns a copy
// of the embedded bytes so callers can mutate without affecting
// other workspaces.
func SEOSkillPackageFiles() map[string][]byte {
return map[string][]byte{
"skills/seo-audit/SKILL.md": []byte(seoSkillMarkdown),
"skills/seo-audit/manifest.yaml": []byte(seoSkillManifest),
}
}
// SEOSkillConfigBlock is the YAML fragment that should be merged
// into the workspace's config.yaml under the `skills:` key. The
// merge is additive — existing skills in the user's config.yaml
// are preserved, the SEO-skill entry is appended (or updated if
// the same name is already present). Returns the raw fragment;
// the caller is responsible for the merge.
func SEOSkillConfigBlock() string {
return seoSkillConfigBlock
}
@@ -0,0 +1,33 @@
# SEO Audit Skill
## Purpose
Performs a basic on-page SEO audit of a target URL or local HTML
snapshot. Returns a structured report (issues + suggestions +
score) that the calling agent can surface to the user or feed
into a downstream optimization workflow.
## Triggers
- User asks "audit the SEO of <url>" or "review this page for SEO"
- User pastes HTML and asks for SEO feedback
- Scheduled scan via the platform's cron route
## Outputs
- JSON report with: `score` (0-100), `issues[]` (each with
severity + suggested fix), `passed[]` (checks that passed).
## Constraints
- No external HTTP calls without explicit user consent (per
privacy policy).
- Read-only — never mutates the target page.
- Rate-limited: max 10 audits per workspace per hour.
## Out of scope (this skill)
- Off-page SEO (backlinks, domain authority)
- Server-side rendering diagnostics
- A/B testing recommendations
## Versioning
This is the v1 seed for core#2831 PIECE 1. Future versions
(PIECE 2 / CTO SEO core #125/#134) replace this with the full
content; the v1 contract is `agent_card.skills != []` and
`/skills/seo-audit/SKILL.md` exists.
@@ -0,0 +1,5 @@
skills:
- name: seo-audit
version: 1.0.0
path: skills/seo-audit/SKILL.md
manifest: skills/seo-audit/manifest.yaml
@@ -0,0 +1,16 @@
name: seo-audit
version: 1.0.0
display_name: SEO Audit
description: On-page SEO audit returning a structured score + issues report.
entrypoint: SKILL.md
provides:
- score
- issues
- suggestions
consumes:
- url
- html
tags:
- seo
- audit
- readonly
@@ -0,0 +1,195 @@
package provisioner
// seo_skill_package_test.go — tests for the SEO-skill package
// (core#2831 PIECE 1) and the OFFSEC-010 allowlist extension to
// isCPTemplateConfigFile that permits `skills/*` files alongside
// `config.yaml` and `prompts/*`.
import (
"encoding/base64"
"strings"
"testing"
)
// TestSEOSkillPackageFiles_NotEmpty asserts the seed package
// actually has content (the //go:embed directive can silently
// embed an empty file if the path is wrong; this test catches
// that). The CTO flag was that SaaS agents had agent_card.skills
// = [] at boot — this is the minimal "plumbing works" assertion.
func TestSEOSkillPackageFiles_NotEmpty(t *testing.T) {
files := SEOSkillPackageFiles()
if len(files) == 0 {
t.Fatal("SEOSkillPackageFiles() returned an empty map; //go:embed path may be wrong")
}
for name, content := range files {
if len(content) == 0 {
t.Errorf("skill file %q is empty", name)
}
}
// Both expected files must be present.
want := []string{
"skills/seo-audit/SKILL.md",
"skills/seo-audit/manifest.yaml",
}
for _, w := range want {
if _, ok := files[w]; !ok {
t.Errorf("expected skill file %q in package, got keys: %v", w, keysOfFiles(files))
}
}
}
// TestSEOSkillConfigBlock_NotEmpty asserts the YAML fragment
// the caller merges into config.yaml under `skills:` is non-empty.
// An empty block would mean the agent's loader sees `skills: []`
// at boot — the exact gap this PR closes.
func TestSEOSkillConfigBlock_NotEmpty(t *testing.T) {
block := SEOSkillConfigBlock()
if strings.TrimSpace(block) == "" {
t.Fatal("SEOSkillConfigBlock() returned an empty fragment; //go:embed path may be wrong")
}
// Sanity check: the fragment should reference the SEO skill
// by name so the merge is non-noop.
if !strings.Contains(block, "seo-audit") {
t.Errorf("expected SEO skill config block to reference 'seo-audit', got: %q", block)
}
}
// TestIsCPTemplateConfigFile_AllowsConfigAndPromptsAndSkills is
// the positive case for the OFFSEC-010 allowlist. The allowlist
// was extended in this PR (config.yaml + prompts/* + skills/*);
// any future extension must be added here AND reviewed against
// the OFFSEC-010 threat model.
func TestIsCPTemplateConfigFile_AllowsConfigAndPromptsAndSkills(t *testing.T) {
want := []string{
"config.yaml",
"prompts/system.md",
"prompts/skills/seo.md",
"skills/seo-audit/SKILL.md",
"skills/seo-audit/manifest.yaml",
}
for _, name := range want {
if !isCPTemplateConfigFile(name) {
t.Errorf("isCPTemplateConfigFile(%q) = false, want true", name)
}
}
}
// TestIsCPTemplateConfigFile_RejectsArbitrary is the OFFSEC-010
// negative case. The allowlist MUST stay narrow — any file
// outside `config.yaml` / `prompts/*` / `skills/*` is a
// data-exfiltration surface (the threat model in the function's
// comment). This test pins the rejection list so a future
// "let's just allow all .md files" refactor is caught here.
func TestIsCPTemplateConfigFile_RejectsArbitrary(t *testing.T) {
bad := []string{
"adapter.py", // arbitrary code
"Dockerfile", // arbitrary config
"scripts/post-install.sh", // arbitrary code
"../escape.yaml", // traversal
"prompts/../escape", // traversal via prompts/
"skills/../escape", // traversal via skills/
"myconfig.yaml", // near-miss for config.yaml
"prompt.md", // near-miss for prompts/
"skill.md", // near-miss for skills/
"", // empty
"prompts", // dir, not file
"skills", // dir, not file
}
for _, name := range bad {
if isCPTemplateConfigFile(name) {
t.Errorf("isCPTemplateConfigFile(%q) = true, want false (OFFSEC-010 invariant violated)", name)
}
}
}
func keysOfBundle(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func keysOfFiles(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// TestCollectCPConfigFiles_InjectsSEOSkillPackage is the
// production-path test requested by Researcher's REQUEST_CHANGES
// on the PIECE 1 PR (#2838): a SaaS workspace's provision bundle
// MUST contain the SEO-skill files and the merged `skills:`
// block in config.yaml. If a future refactor stops calling
// SEOSkillPackageFiles() / SEOSkillConfigBlock() from
// collectCPConfigFiles, this test fails — guarding the wiring
// rather than just the existence of the embed.
//
// Setup: a minimal WorkspaceConfig (no TemplatePath, no
// ConfigFiles) — the test asserts the SEO-skill seed is
// injected by the helper itself, NOT carried in by the caller.
// That's the CTO-flagged gap: callers DON'T need to remember
// to add the skill — the platform does it for them.
func TestCollectCPConfigFiles_InjectsSEOSkillPackage(t *testing.T) {
cfg := WorkspaceConfig{EnableSEOSkillPackage: true}
files, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
wantFiles := []string{
"skills/seo-audit/SKILL.md",
"skills/seo-audit/manifest.yaml",
}
for _, wf := range wantFiles {
if _, ok := files[wf]; !ok {
t.Errorf("expected %q in provision bundle, got keys: %v", wf, keysOfBundle(files))
}
}
encoded, ok := files["config.yaml"]
if !ok {
t.Fatalf("expected config.yaml in provision bundle, got keys: %v", keysOfBundle(files))
}
decoded, decErr := base64.StdEncoding.DecodeString(encoded)
if decErr != nil {
t.Fatalf("decode config.yaml: %v", decErr)
}
if !strings.Contains(string(decoded), "seo-audit") {
t.Errorf("expected config.yaml to reference 'seo-audit' (merged from SEOSkillConfigBlock), got: %q", string(decoded))
}
}
// TestCollectCPConfigFiles_PreservesCallerSkillsBlock asserts the
// no-clobber policy: a caller-supplied config.yaml with its own
// `skills:` block is NOT overwritten by the seed. The merge
// helper is a strict append — caller wins.
func TestCollectCPConfigFiles_PreservesCallerSkillsBlock(t *testing.T) {
cfg := WorkspaceConfig{
EnableSEOSkillPackage: true,
ConfigFiles: map[string][]byte{
"config.yaml": []byte("name: my-workspace\nskills:\n - name: custom-skill\n version: 2.0.0\n"),
},
}
files, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
// Files map values are base64-encoded (per cp_provisioner.go
// collectCPConfigFiles's addFile helper). Decode to compare
// the merged content.
encoded := files["config.yaml"]
decoded, decErr := base64.StdEncoding.DecodeString(encoded)
if decErr != nil {
t.Fatalf("decode config.yaml: %v", decErr)
}
if !strings.Contains(string(decoded), "custom-skill") {
t.Errorf("expected caller-supplied 'custom-skill' to be preserved, got: %q", string(decoded))
}
// The seed's seo-audit should NOT have been appended (the
// merge helper is a strict no-op when a skills block
// already exists).
if strings.Contains(string(decoded), "seo-audit") {
t.Errorf("expected seo-audit to NOT clobber the caller's skills block, got: %q", string(decoded))
}
}