feat(provisioner#24): generic template-asset channel (RFC #2843 #24) #2845

Merged
devops-engineer merged 4 commits from fix/rfc-2843-24-asset-channel into main 2026-06-14 14:51:11 +00:00
6 changed files with 862 additions and 14 deletions
@@ -185,7 +185,26 @@ type cpProvisionRequest struct {
// EC2 instance's /configs directory. OFFSEC-010: collected by
// collectCPConfigFiles which rejects symlinks and non-regular files
// before including them. Serialised as base64 to avoid JSON escaping.
//
// ConfigFiles is the SM-bound bundle (the CP stages it through AWS
// Secrets Manager as molecule/workspace/<id>/config). It's the right
// transport for SMALL non-secret config text only — it has a 256 KiB
// cap and the SM transport is sized + scoped for *secrets*. See the
// core-devops 10:13 SM-inventory RCA.
ConfigFiles map[string]string `json:"config_files,omitempty"`
// TemplateAssets (RFC #2843 #24) are non-secret template assets
// (config.yaml + prompts/ + agent-skills/) fetched from a
// non-secret asset channel (template repo / Gitea shallow clone per
// RFC §4.2 transport option (a)). They travel on a SEPARATE wire
// field from ConfigFiles (the SM-bound bundle) so a future CP can
// route them through a non-secret transport without going through
// the SM cap. Serialised as base64 to avoid JSON escaping.
//
// Keys are restricted to the template-asset allowlist enforced by
// IsCPTemplateAssetPath (config.yaml / prompts/* / agent-skills/*);
// see collectCPConfigFiles for the enforcement.
TemplateAssets map[string][]byte `json:"template_assets,omitempty"`
}
type cpProvisionResponse struct {
@@ -257,7 +276,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
// - Caps total size at cpConfigFilesMaxBytes (a transport-DoS guard,
// not the retired 12 KiB user-data ceiling — config now ships off
// user-data via the CP's Secrets-Manager seeding path)
configFiles, err := collectCPConfigFiles(cfg)
configFiles, templateAssets, err := collectCPConfigFiles(cfg)
if err != nil {
return "", fmt.Errorf("cp provisioner: collect config files: %w", err)
}
@@ -285,6 +304,7 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
PlatformURL: cfg.PlatformURL,
Env: env,
ConfigFiles: configFiles,
TemplateAssets: templateAssets,
}
body, err := json.Marshal(req)
@@ -365,6 +385,18 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
// growth (more schedules, longer prompts, more skills) never re-hits a wall.
const cpConfigFilesMaxBytes = 256 << 10
// cpTemplateAssetsMaxBytes bounds the aggregate TEMPLATE-ASSET payload
// (config.yaml + prompts/* + agent-skills/*) delivered via the generic
// non-secret asset channel (RFC #2843 #24). This is a SEPARATE, much larger
// bound than cpConfigFilesMaxBytes: the config bundle is capped at 256 KiB
// because it rides the Secrets-Manager/user-data transport, but template
// ASSETS ride a non-secret channel with no SM size limit — so reusing the
// 256 KiB cap here would re-create the original #2831 skill-drop failure (the
// seo-all skill package alone is ~716 KiB). 16 MiB comfortably fits real skill
// trees + prompts + config while still bounding a runaway/malicious fetcher
// (pure transport-DoS guard, not a secrets-transport limit).
const cpTemplateAssetsMaxBytes = 16 << 20
// 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;
@@ -375,9 +407,11 @@ func isCPTemplateConfigFile(name string) bool {
return name == "config.yaml" || strings.HasPrefix(name, "prompts/")
}
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, map[string][]byte, error) {
files := make(map[string]string)
assets := make(map[string][]byte)
total := 0
totalAssets := 0
addFile := func(name string, data []byte) error {
name = filepath.ToSlash(filepath.Clean(name))
if name == "." || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") || strings.Contains(name, "/../") {
@@ -390,6 +424,30 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
files[name] = base64.StdEncoding.EncodeToString(data)
return nil
}
addAsset := func(name string, data []byte) error {
name = filepath.ToSlash(filepath.Clean(name))
if name == "." || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") || strings.Contains(name, "/../") {
return fmt.Errorf("invalid template asset path %q", name)
}
// Blast-radius guard (RC #11690): a fetcher that returns
// paths outside the template-asset namespace is either
// a programming error or an attack. Either way, fail closed.
// Specifically excluded: MEMORY.md, USER.md, CLAUDE.md
// (curated durable memory — agent-owned state, reconciled by
// the boot entrypoint, NOT by this collect path), .claude/sessions/
// (Claude Code session dir, agent-owned), and any other
// agent-state path. The PM-flagged in #24_clarify invariant
// is enforced here in code, not in comments.
if !IsCPTemplateAssetPath(name) {
return fmt.Errorf("template asset path %q rejected: not in template-asset allowlist (config.yaml / prompts/* / agent-skills/*) — see IsCPTemplateAssetPath", name)
}
totalAssets += len(data)
if totalAssets > cpTemplateAssetsMaxBytes {
return fmt.Errorf("template assets exceed %d bytes", cpTemplateAssetsMaxBytes)
}
assets[name] = append([]byte(nil), data...)
return nil
}
if cfg.TemplatePath != "" {
// Reject symlinks on the root itself — WalkDir follows symlinks,
@@ -397,10 +455,10 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
// would bypass the subsequent path-relativization checks below.
rootInfo, err := os.Lstat(cfg.TemplatePath)
if err != nil {
return nil, fmt.Errorf("collectCPConfigFiles: lstat template path: %w", err)
return nil, nil, fmt.Errorf("collectCPConfigFiles: lstat template path: %w", err)
}
if rootInfo.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("collectCPConfigFiles: template path must not be a symlink")
return nil, nil, fmt.Errorf("collectCPConfigFiles: template path must not be a symlink")
}
err = filepath.WalkDir(cfg.TemplatePath, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -438,18 +496,56 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
return addFile(rel, data)
})
if err != nil {
return nil, err
return nil, nil, err
}
}
for name, data := range cfg.ConfigFiles {
if err := addFile(name, data); err != nil {
return nil, err
return nil, nil, err
}
}
if len(files) == 0 {
return nil, nil
// RFC #2843 #24 — generic template-asset channel. When
// cfg.TemplateAssetFetcher is wired (SaaS) and
// cfg.TemplateIdentity is set, fetch the template's
// config.yaml + prompts/ + agent_skills/ via the
// non-secret asset channel (template repo, Gitea shallow
// clone per §4.2 transport option (a)) and land them in
// the SEPARATE TemplateAssets field of the provision
// request — NOT in ConfigFiles (the SM-bound bundle).
// The split is load-bearing: ConfigFiles is the bundle
// the CP stages through AWS Secrets Manager, which is
// sized + scoped for *secrets* and caps at 256 KiB. The
// 716 KiB SEO skill package can't ride SM and shouldn't
// try to (core-devops 10:13 SM-inventory RCA).
//
// The fetch is OPT-IN: nil fetcher = no-op (self-host
// default; falls through to the local TemplatePath path
// above). Every key in the fetcher's output is gated by
// IsCPTemplateAssetPath at the addAsset boundary above —
// paths outside the template-asset namespace abort the
// provision rather than silently sneaking MEMORY.md /
// CLAUDE.md / .claude/sessions/ into the bundle.
//
// The fetch is fail-closed: a transport error aborts
// the provision rather than regressing to stub-mode
// /configs (the same contract as the persisted-bundle
// provider in #2831 PIECE 1).
if cfg.TemplateAssetFetcher != nil && cfg.TemplateIdentity != "" {
fetchedAssets, fetchErr := cfg.TemplateAssetFetcher.Load(context.Background(), cfg.TemplateIdentity)
if fetchErr != nil {
return nil, nil, fmt.Errorf("collectCPConfigFiles: fetch template assets (RFC #2843 #24): %w", fetchErr)
}
for name, data := range fetchedAssets {
if err := addAsset(name, data); err != nil {
return nil, nil, err
}
}
}
return files, nil
if len(files) == 0 && len(assets) == 0 {
return nil, nil, nil
}
return files, assets, nil
}
// Stop terminates the workspace's EC2 instance via the control plane.
@@ -103,7 +103,7 @@ func TestCollectCPConfigFiles_DoSGuardStillBounds(t *testing.T) {
for i := range huge {
huge[i] = 'a'
}
_, err := collectCPConfigFiles(WorkspaceConfig{
_, _, err := collectCPConfigFiles(WorkspaceConfig{
ConfigFiles: map[string][]byte{"config.yaml": huge},
})
if err == nil {
@@ -128,7 +128,7 @@ func TestCollectCPConfigFiles_AcceptsSEOSizedBundle(t *testing.T) {
for i := range promptBlob {
promptBlob[i] = 'p'
}
files, err := collectCPConfigFiles(WorkspaceConfig{
files, _, err := collectCPConfigFiles(WorkspaceConfig{
ConfigFiles: map[string][]byte{
"config.yaml": cfgBlob,
"prompts/system.md": promptBlob,
@@ -145,7 +145,7 @@ func TestCollectCPConfigFiles_AcceptsSEOSizedBundle(t *testing.T) {
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), cfgBlob, 0o600); err != nil {
t.Fatal(err)
}
if _, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl}); err != nil {
if _, _, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl}); err != nil {
t.Fatalf("collectCPConfigFiles rejected an SEO-sized template config.yaml: %v", err)
}
}
@@ -437,6 +437,107 @@ func TestStart_CollectsConfigFiles(t *testing.T) {
}
}
// TestStart_SendsTemplateAssetsOnSeparateField — the load-bearing
// wire-shape test (RFC #2843 #24, Reviewer-CR2 addendum). When
// cfg.TemplateAssetFetcher is wired, fetched assets travel on the
// SEPARATE TemplateAssets field, NOT merged into ConfigFiles.
// This split lets a future CP route non-secret assets through a
// non-SM transport without going through the 256 KiB SM cap
// (the core-devops 10:13 SM-inventory RCA). The test exercises
// the Start → cpProvisionRequest path end-to-end so a future
// refactor that re-merges the two transports would be caught.
func TestStart_SendsTemplateAssetsOnSeparateField(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"config.yaml": []byte("# from template repo"),
"prompts/system.md": []byte("# template system prompt"),
"agent-skills/seo-audit/SKILL.md": []byte("# 716 KiB SEO skill goes here"),
},
}
var gotBody cpProvisionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "claude-code",
Tier: 2,
PlatformURL: "http://tenant",
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
// No cfg.ConfigFiles — pure fetcher path. If the wire
// shape is right, TemplateAssets is non-empty and
// ConfigFiles is empty.
})
if err != nil {
t.Fatalf("Start: %v", err)
}
// 1. TemplateAssets must contain the fetched files.
if got, want := len(gotBody.TemplateAssets), 3; got != want {
t.Errorf("TemplateAssets length = %d, want %d (fetcher output must reach the wire)", got, want)
}
if got, want := string(gotBody.TemplateAssets["config.yaml"]), "# from template repo"; got != want {
t.Errorf("TemplateAssets[config.yaml] = %q, want %q", got, want)
}
if got, want := string(gotBody.TemplateAssets["prompts/system.md"]), "# template system prompt"; got != want {
t.Errorf("TemplateAssets[prompts/system.md] = %q, want %q", got, want)
}
if got, want := string(gotBody.TemplateAssets["agent-skills/seo-audit/SKILL.md"]), "# 716 KiB SEO skill goes here"; got != want {
t.Errorf("TemplateAssets[agent-skills/seo-audit/SKILL.md] = %q, want %q", got, want)
}
// 2. ConfigFiles must be empty — the transport split.
if got, want := len(gotBody.ConfigFiles), 0; got != want {
t.Errorf("ConfigFiles length = %d, want %d (transport split — fetched assets must NOT leak into the SM-bound ConfigFiles)", got, want)
}
}
// TestStart_AbortsOnFetcherAssetOutsideAllowlist — the
// load-bearing blast-radius test (RC #11690). A fetcher
// returning a path outside the template-asset allowlist
// MUST abort the provision at the Start level (the
// transport-DoS-and-blast-radius contract).
func TestStart_AbortsOnFetcherAssetOutsideAllowlist(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"MEMORY.md": []byte("# hostile — agent-owned curated memory must not be transported by the provision path"),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("CP should not be called when fetcher returns a path outside the allowlist; got request: %+v", r)
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "claude-code",
Tier: 2,
PlatformURL: "http://tenant",
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
})
if err == nil {
t.Fatal("expected Start to abort on fetcher-returned MEMORY.md path, got nil")
}
if !strings.Contains(err.Error(), "MEMORY.md") {
t.Errorf("expected error to mention the rejected path MEMORY.md, got: %v", err)
}
if !strings.Contains(err.Error(), "allowlist") {
t.Errorf("expected error to mention the allowlist, got: %v", err)
}
}
// TestStart_SymlinkTemplatePathError — a symlink TemplatePath should cause
// collectCPConfigFiles to return an error, which Start must propagate.
// Without this wiring, OFFSEC-010's root-symlink guard is dead code.
@@ -1176,7 +1277,7 @@ func TestCollectCPConfigFiles_SkipsSymlinks(t *testing.T) {
t.Fatal(err)
}
files, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl})
files, _, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl})
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
@@ -1210,7 +1311,7 @@ func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
t.Fatal(err)
}
_, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: link})
_, _, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: link})
if err == nil {
t.Error("collectCPConfigFiles with symlink TemplatePath should return error")
}
@@ -110,6 +110,7 @@ const (
type WorkspaceConfig struct {
WorkspaceID string
TemplatePath string // Host path to template dir to copy from (e.g. claude-code-default/)
TemplateIdentity string // RFC #2843 #24: opaque token the TemplateAssetFetcher resolves to the template repo+ref (e.g. "claudius-v1.2.3" or a sha). Used by SaaS; ignored by the local-dir TemplatePath path.
ConfigFiles map[string][]byte // Generated config files to write into /configs volume
PluginsPath string // Host path to plugins directory (mounted at /plugins)
WorkspacePath string // Host path to bind-mount as /workspace (if empty, uses Docker named volume)
@@ -132,6 +133,21 @@ 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)
// TemplateAssetFetcher (RFC #2843 #24) is the generic
// non-secret asset channel for template assets
// (config.yaml + prompts/ + agent-skills/). The fetcher
// resolves cfg.TemplateIdentity to a shallow clone of the
// template repo (Gitea per RFC §4.2 transport option (a))
// and returns the asset file map. nil = no provider wired
// (self-host default; falls through to the local TemplatePath
// path for the config bundle). For SaaS workspaces, main.go
// wires a real impl (Gitea shallow clone). When the fetcher
// is set, it MERGES with cfg.ConfigFiles (caller wins on
// conflict, like the prior PersistedBundleProvider pattern
// in #2831 PIECE 1) so the existing TemplatePath+ConfigFiles
// path keeps working for callers that don't opt in.
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
@@ -0,0 +1,105 @@
package provisioner
// template_assets.go — generic template-asset channel (RFC #2843 #24).
//
// This is the "generic, non-secret asset channel" the RFC proposes:
// a workspace's template assets (config.yaml + prompts/ + agent-skills/)
// are materialized from a template identity (resolved by the caller —
// the template repo path, a cached ref, etc.) rather than forced
// through the AWS Secrets Manager config bundle path that caps at
// cpConfigFilesMaxBytes (256 KiB) and silently drops any skill over
// the cap.
//
// The fetcher is interface-typed so tests inject fakes and the real
// implementation (in main.go) wires the Gitea shallow-clone per
// RFC §4.2 transport option (a). The interface is NARROW (Load only)
// to keep the abstraction minimal — the template identity resolution
// and fetch are both the caller's job.
//
// Blast-radius / isolation (Reviewer-CR2 RC #11690 on the prior
// head): the fetcher ONLY materializes TEMPLATE ASSETS. Every
// path the fetcher returns is gated by IsCPTemplateAssetPath
// (this file) BEFORE it lands in the wire payload. Paths outside
// the allowlist — MEMORY.md, USER.md, CLAUDE.md, .claude/sessions/,
// /etc/passwd, traversal sequences — are REJECTED (the provision
// aborts with a structured error) rather than silently admitted.
// This is the load-bearing guard: a fetcher that returns a path
// outside the template-asset namespace is a programming error or
// an attack, and either way the safe response is to fail closed.
//
// Transport split (Reviewer-CR2 addendum on the prior head):
// fetched assets go to a SEPARATE wire field (TemplateAssets on
// cpProvisionRequest) rather than being merged into ConfigFiles.
// ConfigFiles is the bundle the CP stages through AWS Secrets
// Manager — the wrong layer for non-secret assets per the
// core-devops 10:13 SM-inventory RCA. The split lets a future CP
// route TemplateAssets through a non-secret channel (Gitea asset
// pin, S3 non-secret bucket, etc.) without a wire-shape change.
//
// Concurrency: the fetcher's Load is called from prepareProvisionContext,
// which serializes per-workspace (the existing per-workspace
// restart/provision gate at workspace_dispatchers.go:439 holds the
// mutex). No additional locking needed.
import (
"context"
"path/filepath"
"strings"
)
// TemplateAssetFetcher materializes a template's
// config.yaml + prompts/ + agent-skills/ from a non-secret
// asset channel (template repo, Gitea shallow clone per
// RFC #2843 §4.2). Returned paths are RELATIVE to the
// template asset root (e.g. "config.yaml", "prompts/system.md",
// "agent-skills/seo-audit/SKILL.md") and the bytes are raw
// file contents (not base64-encoded — the generic channel
// does not require encoding; the wire format encodes per
// its own transport).
//
// Returned errors: a transport / resolution failure is
// returned as a non-nil error so the caller can abort the
// provision rather than silently regressing to stub-mode
// /configs (the same fail-closed contract as the persisted-
// bundle provider in #2831 PIECE 1).
//
// CONTRACT: every key in the returned map MUST match
// IsCPTemplateAssetPath. Keys that don't match are rejected
// by the caller (the provision aborts). Implementations that
// can't constrain their output to the allowlist must filter
// before returning.
type TemplateAssetFetcher interface {
Load(ctx context.Context, templateIdentity string) (map[string][]byte, error)
}
// IsCPTemplateAssetPath reports whether a path returned by a
// TemplateAssetFetcher is eligible for transport to the workspace.
//
// Allowlist (load-bearing blast-radius guard — see RC #11690):
//
// - "config.yaml" — the runtime entrypoint config
// - "prompts/*" — system prompts
// - "agent-skills/*" — the agent's skill packages
//
// Everything else is REJECTED. Specifically excluded:
// MEMORY.md / USER.md (curated durable memory — agent-owned
// state, reconciled by the boot entrypoint, not by this
// collect path), CLAUDE.md (runtime memory file, agent-owned),
// .claude/sessions/* (Claude Code session dir, agent-owned),
// anything outside the template-asset namespace.
//
// Path normalization: the function applies filepath.ToSlash
// + filepath.Clean before matching, so Windows-style
// separators, redundant "./" segments, and trailing slashes
// are normalized. Traversal sequences (".." or paths
// containing "/../") are NOT explicitly stripped here —
// callers must check for traversal before calling this
// function (the existing addFile in cp_provisioner.go does).
// This function only validates the namespace; the traversal
// check is a separate invariant.
func IsCPTemplateAssetPath(name string) bool {
name = filepath.ToSlash(filepath.Clean(name))
return name == "config.yaml" ||
strings.HasPrefix(name, "prompts/") ||
strings.HasPrefix(name, "agent-skills/")
}
@@ -0,0 +1,530 @@
package provisioner
// template_assets_test.go — generic template-asset channel tests
// (RFC #2843 #24). Verifies:
// 1. The fetcher wires into collectCPConfigFiles when both
// cfg.TemplateAssetFetcher and cfg.TemplateIdentity are set.
// 2. Fetched assets land in the SEPARATE TemplateAssets field
// (NOT merged into ConfigFiles) — the transport split that
// Reviewer-CR2 addendum called out as the load-bearing fix
// (a fetcher must not push 716 KiB skills through the 256 KiB
// SM-bound ConfigFiles).
// 3. Every key in the fetcher's output is gated by
// IsCPTemplateAssetPath. Paths outside the template-asset
// allowlist (config.yaml / prompts/* / agent-skills/*) ABORT
// the provision — Reviewer-CR2 RC #11690's load-bearing
// blast-radius guard.
// 4. A transport error on the fetcher ABORTS the provision
// (fail-closed; never regresses to stub /configs).
// 5. Nil fetcher = no-op (self-host default; the existing
// TemplatePath local-dir path still works).
// 6. Empty TemplateIdentity with a non-nil fetcher = no-op
// (the fetcher is only called when there's an identity
// to resolve).
import (
"context"
"encoding/base64"
"errors"
"runtime"
"strings"
"testing"
)
// fakeTemplateAssetFetcher is a capture-only stub satisfying
// TemplateAssetFetcher. Returns the configured bundle+err;
// records the template identities the handler asked for.
type fakeTemplateAssetFetcher struct {
bundle map[string][]byte
err error
calls []string
}
func (f *fakeTemplateAssetFetcher) Load(_ context.Context, templateIdentity string) (map[string][]byte, error) {
f.calls = append(f.calls, templateIdentity)
return f.bundle, f.err
}
// TestCollectCPConfigFiles_MergesFetcherAssets is the
// happy path: fetcher returns assets, the assets land in
// TemplateAssets (the SEPARATE wire field — the transport
// split per Reviewer-CR2 addendum). Fails if a future
// refactor stops calling cfg.TemplateAssetFetcher.Load
// from collectCPConfigFiles when the fetcher + identity
// are set, or re-merges assets into ConfigFiles.
func TestCollectCPConfigFiles_MergesFetcherAssets(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"config.yaml": []byte("# from template repo"),
"prompts/system.md": []byte("# template system prompt"),
"agent-skills/seo-audit/SKILL.md": []byte("# seo skill"),
},
}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
}
files, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
// All 3 fetched assets land in TemplateAssets (the
// non-secret transport), NOT in ConfigFiles (the SM-bound
// bundle). The transport split is the load-bearing fix.
wantKeys := []string{
"config.yaml",
"prompts/system.md",
"agent-skills/seo-audit/SKILL.md",
}
for _, wk := range wantKeys {
if _, ok := assets[wk]; !ok {
t.Errorf("expected %q in TemplateAssets, got keys: %v", wk, keysOfAssetMap(assets))
}
if _, ok := files[wk]; ok {
t.Errorf("did NOT expect %q in ConfigFiles (transport split — TemplateAssets is the non-secret channel)", wk)
}
}
// Fetcher was called once with the right identity.
if len(prov.calls) != 1 || prov.calls[0] != "seo-agent-v1.2.3" {
t.Errorf("expected one Load call with identity=seo-agent-v1.2.3, got calls=%v", prov.calls)
}
}
// TestCollectCPConfigFiles_CallerWinsOnConfigFiles asserts
// the caller's cfg.ConfigFiles entry lands in the ConfigFiles
// field (the SM-bound bundle) even when a fetcher is wired.
// The two fields are independent transports — caller-provided
// files are still the SM-bound bundle (small non-secret config
// text only), fetcher-provided assets ride the non-secret
// TemplateAssets field.
func TestCollectCPConfigFiles_CallerWinsOnConfigFiles(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"config.yaml": []byte("# from template repo"),
},
}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
ConfigFiles: map[string][]byte{
"config.yaml": []byte("# caller override"),
},
}
files, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
// Caller's ConfigFiles["config.yaml"] is base64-encoded into
// the SM-bound ConfigFiles field. The fetcher's same-key
// result lands in TemplateAssets (separate transport) — no
// conflict because they're on different fields.
decoded, decErr := base64.StdEncoding.DecodeString(files["config.yaml"])
if decErr != nil {
t.Fatalf("decode config.yaml from ConfigFiles: %v", decErr)
}
if string(decoded) != "# caller override" {
t.Errorf("expected caller override in ConfigFiles, got %q", string(decoded))
}
if got := string(assets["config.yaml"]); got != "# from template repo" {
t.Errorf("expected fetcher asset in TemplateAssets, got %q", got)
}
}
// TestCollectCPConfigFiles_FetcherErrorAborts is the
// fail-closed assertion: a transport error from the fetcher
// must abort the provision rather than regressing to stub
// /configs (the same fail-closed contract as the
// persisted-bundle provider in #2831 PIECE 1). If a future
// refactor swallows the fetch error, the bundle would
// silently miss the agent-skills/ files and the workspace
// would boot with a stub config — the exact regression
// this test guards against.
func TestCollectCPConfigFiles_FetcherErrorAborts(t *testing.T) {
prov := &fakeTemplateAssetFetcher{err: errors.New("gitea 503")}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
}
_, _, err := collectCPConfigFiles(cfg)
if err == nil {
t.Fatal("expected collectCPConfigFiles to abort on fetcher error, got nil")
}
if !stringsContains(err.Error(), "gitea 503") {
t.Errorf("expected error to surface the underlying gitea 503, got: %v", err)
}
}
// TestCollectCPConfigFiles_NilFetcherIsNoop asserts the
// self-host default: a WorkspaceConfig without a fetcher
// does NOT call anything (the existing TemplatePath +
// ConfigFiles path is unchanged). The RFC's opt-in
// contract: nil fetcher = no asset channel, no behavior
// change for self-host callers.
func TestCollectCPConfigFiles_NilFetcherIsNoop(t *testing.T) {
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3", // set but no fetcher wired
}
_, _, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles with nil fetcher: %v", err)
}
}
// TestCollectCPConfigFiles_EmptyIdentityNoop asserts that
// even a wired fetcher is NOT called when TemplateIdentity is
// empty (the Gitea fetcher needs an identity to resolve; an
// empty identity would be a programming error and should
// be a no-op rather than a fetch-with-empty-identity call).
func TestCollectCPConfigFiles_EmptyIdentityNoop(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{"config.yaml": []byte("# unexpected")},
}
cfg := WorkspaceConfig{
TemplateIdentity: "", // empty
TemplateAssetFetcher: prov, // wired but no identity
}
_, _, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles with empty identity: %v", err)
}
if len(prov.calls) != 0 {
t.Errorf("expected fetcher NOT to be called with empty identity, got calls=%v", prov.calls)
}
}
// --- Blast-radius allowlist tests (Reviewer-CR2 RC #11690) ---
// TestIsCPTemplateAssetPath_AllowsConfigYaml pins the happy
// path: a fetcher returning "config.yaml" passes the
// allowlist.
func TestIsCPTemplateAssetPath_AllowsConfigYaml(t *testing.T) {
if !IsCPTemplateAssetPath("config.yaml") {
t.Error("expected config.yaml to be allowed")
}
}
// TestIsCPTemplateAssetPath_AllowsPromptsPrefix pins the
// prompts/* namespace. Note: "prompts/" (with trailing slash)
// is normalized to "prompts" by filepath.Clean, and the
// allowlist requires the "prompts/" prefix on a non-empty
// path — the literal "prompts" string is rejected (it's a
// directory name, not a file). The test pins file-shaped
// paths under prompts/.
func TestIsCPTemplateAssetPath_AllowsPromptsPrefix(t *testing.T) {
for _, ok := range []string{"prompts/system.md", "prompts/sub/foo.md"} {
if !IsCPTemplateAssetPath(ok) {
t.Errorf("expected %q to be allowed", ok)
}
}
}
// TestIsCPTemplateAssetPath_AllowsAgentSkillsPrefix pins
// the agent-skills/* namespace (the load-bearing addition
// for the 716 KiB SEO skill package per core-devops 10:13).
func TestIsCPTemplateAssetPath_AllowsAgentSkillsPrefix(t *testing.T) {
for _, ok := range []string{
"agent-skills/seo-audit/SKILL.md",
"agent-skills/seo-audit/manifest.yaml",
"agent-skills/index.json",
} {
if !IsCPTemplateAssetPath(ok) {
t.Errorf("expected %q to be allowed", ok)
}
}
}
// TestIsCPTemplateAssetPath_RejectsMemoryMd pins the
// curated-memory exclusion. MEMORY.md is agent-owned
// durable state — reconciled by the boot entrypoint, NOT
// by the provision path.
func TestIsCPTemplateAssetPath_RejectsMemoryMd(t *testing.T) {
if IsCPTemplateAssetPath("MEMORY.md") {
t.Error("MEMORY.md must NOT be allowed (agent-owned curated memory, reconciled by boot entrypoint)")
}
}
// TestIsCPTemplateAssetPath_RejectsUserMd pins the
// curated-memory exclusion for USER.md.
func TestIsCPTemplateAssetPath_RejectsUserMd(t *testing.T) {
if IsCPTemplateAssetPath("USER.md") {
t.Error("USER.md must NOT be allowed (agent-owned curated memory, reconciled by boot entrypoint)")
}
}
// TestIsCPTemplateAssetPath_RejectsClaudeMd pins the
// runtime-memory exclusion. CLAUDE.md is the runtime's
// memory file (Claude Code reads it at session start).
func TestIsCPTemplateAssetPath_RejectsClaudeMd(t *testing.T) {
if IsCPTemplateAssetPath("CLAUDE.md") {
t.Error("CLAUDE.md must NOT be allowed (runtime memory file, agent-owned state)")
}
}
// TestIsCPTemplateAssetPath_RejectsClaudeSessionsPath
// pins the Claude Code session-dir exclusion. Sessions
// live on their own volume; pushing them into the
// template-asset channel would clobber or duplicate them.
func TestIsCPTemplateAssetPath_RejectsClaudeSessionsPath(t *testing.T) {
for _, bad := range []string{
".claude/sessions/abc.json",
".claude/sessions",
".claude/settings.json",
} {
if IsCPTemplateAssetPath(bad) {
t.Errorf("%q must NOT be allowed (Claude Code agent-owned state)", bad)
}
}
}
// TestIsCPTemplateAssetPath_RejectsAbsoluteAndTraversal
// pins the path-shape guards. The function applies
// filepath.Clean + filepath.ToSlash before matching, so
// ".." and absolute paths normalize to non-matching
// shapes. The addFile/addAsset callsite separately
// rejects traversal sequences; this is a belt-and-braces
// assertion that the allowlist itself doesn't admit
// weird shapes.
func TestIsCPTemplateAssetPath_RejectsAbsoluteAndTraversal(t *testing.T) {
for _, bad := range []string{
"../etc/passwd",
"prompts/../secrets",
"/etc/passwd",
"..",
".",
"",
} {
if IsCPTemplateAssetPath(bad) {
t.Errorf("%q must NOT be allowed", bad)
}
}
}
// TestIsCPTemplateAssetPath_NormalizesSlashes pins the
// Windows-separator normalization for the file path
// pre-processing. On Windows, filepath.ToSlash converts
// backslashes to forward slashes BEFORE the match, so a
// fetcher returning "prompts\\system.md" is treated as
// "prompts/system.md" (allowed). On Linux/macOS, the
// backslash is a valid filename character (not a
// separator), so the same input would be rejected as a
// literal filename. This is intentional — the
// normalization matches the OS's notion of a path
// separator, so a malicious fetcher can't smuggle a
// backslash-as-separator past the allowlist on Windows
// by relying on case-insensitive or platform-specific
// matching.
//
// Test executes the normalization contract on whatever
// host runs the test: a path that already uses forward
// slashes (the Linux/macOS case) must pass.
func TestIsCPTemplateAssetPath_NormalizesSlashes(t *testing.T) {
if !IsCPTemplateAssetPath("prompts/system.md") {
t.Error("expected forward-slash path to pass the allowlist (the canonical case)")
}
// Backslashes: only normalize to slashes on Windows.
// On Linux, a backslash is part of the literal name and
// is rejected (which is the correct behavior — a
// fetcher returning Windows-style paths on a Linux host
// is either a misconfigured fetcher or an attack, and
// either way failing closed is the safe response).
if runtime.GOOS == "windows" {
if !IsCPTemplateAssetPath(`prompts\system.md`) {
t.Error("expected backslash-separated path to normalize and pass the allowlist on Windows")
}
} else {
if IsCPTemplateAssetPath(`prompts\system.md`) {
t.Error("backslash is a literal character on non-Windows hosts and must NOT be treated as a path separator by the allowlist")
}
}
}
// TestCollectCPConfigFiles_RejectsFetcherAssetOutsideAllowlist
// is the load-bearing test for RC #11690. A fetcher that
// returns a path outside the template-asset allowlist
// (MEMORY.md / USER.md / CLAUDE.md / .claude/sessions/*)
// MUST abort the provision. If a future refactor weakens
// the addAsset gate, this test catches the regression.
func TestCollectCPConfigFiles_RejectsFetcherAssetOutsideAllowlist(t *testing.T) {
cases := []struct {
name string
badKey string
expectedSub string
}{
{"MEMORY.md", "MEMORY.md", "MEMORY.md"},
{"USER.md", "USER.md", "USER.md"},
{"CLAUDE.md", "CLAUDE.md", "CLAUDE.md"},
{"claude-sessions", ".claude/sessions/abc.json", ".claude/sessions/abc.json"},
{"absolute-path", "/etc/passwd", "/etc/passwd"},
{"adapter.py", "adapter.py", "adapter.py"},
{"Dockerfile", "Dockerfile", "Dockerfile"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{c.badKey: []byte("hostile content")},
}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
}
_, _, err := collectCPConfigFiles(cfg)
if err == nil {
t.Fatalf("expected provision to abort on fetcher-returned path %q outside template-asset allowlist, got nil error", c.badKey)
}
if !stringsContains(err.Error(), c.expectedSub) {
t.Errorf("expected error to mention %q, got: %v", c.expectedSub, err)
}
})
}
}
// TestCollectCPConfigFiles_FetcherAssetsBase64EncodedOnWire
// pins the wire-format invariant for the new TemplateAssets
// field: assets travel base64-encoded over JSON (same as
// ConfigFiles), to avoid JSON escaping issues with binary
// content (manifests, SKILL.md files, etc.). The collect
// function returns the raw bytes; the marshal step in
// Start does the encoding. This test documents the
// invariant at the collect boundary.
func TestCollectCPConfigFiles_FetcherAssetsRawBytes(t *testing.T) {
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"config.yaml": []byte("# raw bytes, will be base64 by marshaler"),
"agent-skills/seo-audit/SKILL.md": []byte("raw-skill"),
},
}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
}
_, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
if got := string(assets["config.yaml"]); got != "# raw bytes, will be base64 by marshaler" {
t.Errorf("expected raw bytes in TemplateAssets, got %q (encoding happens at marshal time, not at collect time)", got)
}
if got := string(assets["agent-skills/seo-audit/SKILL.md"]); got != "raw-skill" {
t.Errorf("expected raw skill bytes in TemplateAssets, got %q", got)
}
}
// TestCollectCPConfigFiles_NoAssetsWhenNoFetcher pins the
// non-fetcher case: when no fetcher is wired (self-host
// default), the TemplateAssets field is nil/empty. A
// future refactor that always populates TemplateAssets
// (even with empty data) would inflate the wire payload
// for every self-host workspace — this test catches that.
func TestCollectCPConfigFiles_NoAssetsWhenNoFetcher(t *testing.T) {
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
// TemplateAssetFetcher is nil
}
_, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
if len(assets) != 0 {
t.Errorf("expected nil/empty TemplateAssets when no fetcher wired, got %d entries: %v", len(assets), keysOfAssetMap(assets))
}
}
// TestCollectCPConfigFiles_PreservesCallerConfigFiles pins
// the existing TemplatePath + ConfigFiles path: when a
// fetcher is NOT wired, the SM-bound ConfigFiles field
// behaves exactly as before (TemplatePath walk +
// cfg.ConfigFiles entries). The transport split is
// additive — it doesn't disturb the existing self-host
// path.
func TestCollectCPConfigFiles_PreservesCallerConfigFiles(t *testing.T) {
cfg := WorkspaceConfig{
ConfigFiles: map[string][]byte{
"config.yaml": []byte("# caller"),
"generated.secret": []byte("not really a secret"),
},
}
files, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("collectCPConfigFiles: %v", err)
}
if _, ok := files["config.yaml"]; !ok {
t.Error("expected config.yaml in ConfigFiles (caller-provided)")
}
if _, ok := files["generated.secret"]; !ok {
t.Error("expected generated.secret in ConfigFiles (caller-provided)")
}
if len(assets) != 0 {
t.Errorf("expected empty TemplateAssets when no fetcher wired, got %d entries", len(assets))
}
}
// keysOfAssetMap returns the sorted keys of a map for stable
// test output. Local helper so test output doesn't depend on
// Go's randomized map iteration order.
func keysOfAssetMap(m map[string][]byte) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// stringsContains is a tiny shim so the test doesn't need
// the strings import elsewhere (it does already, but this
// keeps the dependency local). Existence is asserted via
// string comparison.
func stringsContains(s, substr string) bool {
return strings.Contains(s, substr)
}
// TestCollectCPConfigFiles_AssetsAllowLargeSkillPackage is the regression for
// Reviewer-CR2's size-cap RC on head 7bcc3b5f: template ASSETS ride a
// NON-secret channel and must NOT be bound by the 256 KiB SM/config-bundle cap
// (cpConfigFilesMaxBytes). Reusing that cap re-creates the original #2831
// skill-drop — the motivating 716 KiB seo-all package would abort client-side.
// A >256 KiB asset payload must SUCCEED on TemplateAssets (bounded only by the
// far larger cpTemplateAssetsMaxBytes DoS guard) while ConfigFiles stays capped.
func TestCollectCPConfigFiles_AssetsAllowLargeSkillPackage(t *testing.T) {
// 716 KiB skill blob — over the old 256 KiB cap, well under the asset bound.
big := make([]byte, 716<<10)
for i := range big {
big[i] = 'x'
}
prov := &fakeTemplateAssetFetcher{
bundle: map[string][]byte{
"config.yaml": []byte("# from template repo"),
"agent-skills/seo-audit/big-skill.md": big,
},
}
cfg := WorkspaceConfig{
TemplateIdentity: "seo-agent-v1.2.3",
TemplateAssetFetcher: prov,
}
_, assets, err := collectCPConfigFiles(cfg)
if err != nil {
t.Fatalf("a %d-byte skill package must succeed on the non-secret asset channel (no SM cap), got error: %v", len(big), err)
}
if got := len(assets["agent-skills/seo-audit/big-skill.md"]); got != len(big) {
t.Errorf("expected the full %d-byte skill in TemplateAssets, got %d", len(big), got)
}
}
// TestCollectCPConfigFiles_ConfigFilesStillCappedAt256K pins that lifting the
// asset cap did NOT relax the SM/user-data transport limit: the SM-bound
// ConfigFiles bundle keeps its 256 KiB cap (cpConfigFilesMaxBytes).
func TestCollectCPConfigFiles_ConfigFilesStillCappedAt256K(t *testing.T) {
big := make([]byte, (256<<10)+1)
for i := range big {
big[i] = 'y'
}
cfg := WorkspaceConfig{
ConfigFiles: map[string][]byte{"system-prompt.md": big},
}
if _, _, err := collectCPConfigFiles(cfg); err == nil {
t.Fatal("ConfigFiles over 256 KiB must still be rejected (SM/user-data transport cap unchanged)")
}
}