fix(core#2831 PIECE 1): re-apply persisted bundle on every provision path #2838
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user