From 5e8d975bb6572e4d5b40897b7a6a285cd4a73405 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:05:22 -0400 Subject: [PATCH 1/2] fix(run): --output-env carries a structured document, naming the run environment its reader derives the key from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A configuration document — a workspace group that is one YAML or JSON document rather than key/value pairs — is carried under a key that hashes its origin, its name AND the environment (resources.ConfigurationDocumentKey), which is how the SDK finds it again. The two --output-env writers built their EnvironmentVariableManager without SetEnvironment, so core's carrier refused: "configuration document requires origin, name and environment". The agent's own Init request has always carried the environment, which is why no run without --output-env ever met this. What surfaced it: the composition root's merged fixture-composition.yaml (`codefly generate fixture-composition`, cli#1197) is a root-owned document and so is handed to EVERY service of the run; platform-core's composition gate passes --output-env for saas/auth-gateway and refused at the first Init on 0.1.219 (platform-core#117, b514028c). Reproduced on 0.1.219 with a one-module workspace carrying a root fixture-composition.yaml; the same run passes with this change and the file carries the document under CODEFLY__CONFIGURATION_DOCUMENT_V1__… with origin, name and environment "local", read back by resources.DecodeConfigurationDocument. The writers now take the run's environment from the world (runner.world.Env, as the Init request does) and refuse by name when they are handed none, rather than failing on the first document with the contract's generic error. Core's contract is right: a document without its environment is unreadable by the SDK, so inventing an origin in the composition would not have worked either. Co-Authored-By: Claude Fable 5.1 --- pkg/orchestration/excluded_root_output_env.go | 5 ++ pkg/orchestration/runner.go | 30 ++++++++- pkg/orchestration/runner_output_env_test.go | 62 +++++++++++++++++-- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/pkg/orchestration/excluded_root_output_env.go b/pkg/orchestration/excluded_root_output_env.go index 175d6b493..d6777ed07 100644 --- a/pkg/orchestration/excluded_root_output_env.go +++ b/pkg/orchestration/excluded_root_output_env.go @@ -69,9 +69,14 @@ func (flow *Flow) exportExcludedOriginEnvironment(ctx context.Context) error { return fmt.Errorf("resolve excluded root dependency endpoints: %w", err) } + environment, err := flow.world.Env.Proto() + if err != nil { + return fmt.Errorf("name the run environment for the excluded root's output environment: %w", err) + } if err := AppendServiceProcessConfigurationsToFile( ctx, flow.outputEnvPath, + environment, runtimeContext, serviceConfiguration, workspaceConfigurations, diff --git a/pkg/orchestration/runner.go b/pkg/orchestration/runner.go index fe9c4be60..bcc78b078 100644 --- a/pkg/orchestration/runner.go +++ b/pkg/orchestration/runner.go @@ -372,9 +372,14 @@ func (runner *Runner) Init(ctx context.Context) (*OutputProperty, error) { // every dependency secret in the owner-only file (and record stale // pre-barrier values). Own, workspace, and this service's own runtime // configurations are final at Init, so they are written now. + environment, envErr := runner.world.Env.Proto() + if envErr != nil { + return nil, w.Wrapf(envErr, "cannot name the run environment for the output environment") + } err = AppendServiceProcessConfigurationsToFile( ctx, runner.outputEnv, + environment, runtimeContext, conf, workspaceConfigurations, @@ -980,9 +985,14 @@ func (runner *Runner) Start(ctx context.Context) (*OutputProperty, error) { if dependencyErr != nil { return nil, w.Wrapf(dependencyErr, "cannot load dependency configurations for output environment") } + environment, envErr := runner.world.Env.Proto() + if envErr != nil { + return nil, w.Wrapf(envErr, "cannot name the run environment for the output environment") + } err = AppendServiceProcessConfigurationsToFile( ctx, runner.outputEnv, + environment, runtimeContext, nil, nil, @@ -1829,11 +1839,12 @@ func (runner *Runner) WithRemote(environment *environments.Environment) { runner.remoteEnvironment = environment } -func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, confs []*basev0.Configuration) error { +func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, environment *basev0.Environment, confs []*basev0.Configuration) error { w := wool.Get(ctx).In("resources.AppendToFile", wool.Field("filePath", filePath)) // filter out for native filtered := resources.FilterConfigurations(confs, resources.NewRuntimeContextNative()) m := resources.NewEnvironmentVariableManager() + m.SetEnvironment(environment) err := m.AddConfigurations(ctx, filtered...) if err != nil { return w.Wrapf(err, "cannot add configurations") @@ -1852,9 +1863,22 @@ func AppendEnvironmentVariablesToFile(ctx context.Context, filePath string, conf // runtime configurations are filtered to the selected backend. The output is // owner-only because these inputs intentionally include provider credentials // and dependency connection secrets. +// +// The run's environment is part of what is written, not decoration: a +// structured configuration document (a workspace group that is one YAML or +// JSON document rather than key/value pairs, such as the composition root's +// fixture-composition) is carried under a key that hashes its origin, its name +// AND the environment, which is how the SDK finds it again +// (resources.ConfigurationDocumentKey). The agent's own Init request carries +// the environment and so could always deliver such a document; this writer +// built its manager without one, so the first root-owned document a composition +// handed every service made `--output-env` refuse the whole run at Init with +// core's "configuration document requires origin, name and environment" — the +// shape platform-core's composition gate met on 0.1.219. func AppendServiceProcessConfigurationsToFile( ctx context.Context, filePath string, + environment *basev0.Environment, runtimeContext *basev0.RuntimeContext, serviceConfiguration *basev0.Configuration, workspaceConfigurations []*basev0.Configuration, @@ -1862,7 +1886,11 @@ func AppendServiceProcessConfigurationsToFile( runtimeConfigurations []*basev0.Configuration, ) error { w := wool.Get(ctx).In("resources.AppendServiceProcessConfigurationsToFile", wool.Field("filePath", filePath)) + if environment == nil || environment.GetName() == "" { + return w.NewError("the service process environment names no run environment, so a configuration document could not be carried under the key its reader derives") + } manager := resources.NewEnvironmentVariableManager() + manager.SetEnvironment(environment) if err := manager.AddConfigurations(ctx, serviceConfiguration); err != nil { return w.Wrapf(err, "cannot add service configuration") } diff --git a/pkg/orchestration/runner_output_env_test.go b/pkg/orchestration/runner_output_env_test.go index 1c9cd0c9d..01ef85d28 100644 --- a/pkg/orchestration/runner_output_env_test.go +++ b/pkg/orchestration/runner_output_env_test.go @@ -18,7 +18,7 @@ func TestAppendEnvironmentVariablesToFileIsOwnerOnly(t *testing.T) { t.Fatal(err) } - if err := AppendEnvironmentVariablesToFile(context.Background(), path, nil); err != nil { + if err := AppendEnvironmentVariablesToFile(context.Background(), path, localRunEnvironment(), nil); err != nil { t.Fatal(err) } info, err := os.Stat(path) @@ -40,7 +40,7 @@ func TestAppendEnvironmentVariablesToFileRejectsSymlink(t *testing.T) { t.Fatal(err) } - if err := AppendEnvironmentVariablesToFile(context.Background(), link, nil); err == nil { + if err := AppendEnvironmentVariablesToFile(context.Background(), link, localRunEnvironment(), nil); err == nil { t.Fatal("expected symlink rejection") } } @@ -130,9 +130,17 @@ func TestAppendServiceProcessConfigurationsToFileIncludesSecretsAndFiltersDepend ) runtime := configuration("mind/mind", "runtime", "session_id", "session-123", false, native) + // The composition root's own structured document, handed to every service + // of the run as platform-core's merged fixture-composition is: it is + // carried under a key that hashes origin, name AND environment, so a + // writer that names no environment cannot write it at all. + document := &basev0.Configuration{Origin: resources.ConfigurationWorkspace, RuntimeContext: native, Infos: []*basev0.ConfigurationInformation{{ + Name: "fixture-composition", + Data: &basev0.ConfigurationData{Kind: "yaml", Content: []byte("schema: codefly.dev/authority/fixture-composition/v1\nroles: []\n")}, + }}} if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, service, - []*basev0.Configuration{workspace}, + context.Background(), path, localRunEnvironment(), native, service, + []*basev0.Configuration{workspace, document}, []*basev0.Configuration{postgres, containerOnly}, []*basev0.Configuration{runtime}, ); err != nil { @@ -148,11 +156,21 @@ func TestAppendServiceProcessConfigurationsToFileIncludesSecretsAndFiltersDepend "CODEFLY__WORKSPACE_CONFIGURATION__DOGFOOD__CAMPAIGN=headless\n", "CODEFLY__SERVICE_SECRET_CONFIGURATION__INFRA__POSTGRES__POSTGRES__READ_WRITE_CONNECTION=postgres://runtime\n", "CODEFLY__SERVICE_CONFIGURATION__MIND__MIND__RUNTIME__SESSION_ID=session-123\n", + // The document, under the key its reader derives for this environment. + resources.ConfigurationDocumentKey(resources.ConfigurationWorkspace, "fixture-composition", "local", false) + "=", } { if !strings.Contains(text, expected) { t.Fatalf("service process environment is missing %q; keys=%v", expected, environmentKeys(text)) } } + carrier := carrierValue(t, text, resources.ConfigurationDocumentKey(resources.ConfigurationWorkspace, "fixture-composition", "local", false)) + content, err := resources.DecodeConfigurationDocument(carrier, resources.ConfigurationWorkspace, "fixture-composition", "local", false) + if err != nil { + t.Fatalf("the written document does not read back for environment local: %v", err) + } + if !strings.Contains(string(content), "codefly.dev/authority/fixture-composition/v1") { + t.Fatalf("the document lost its content: %s", content) + } if strings.Contains(text, "REDIS") || strings.Contains(text, "redis://container") { t.Fatalf("container-only dependency leaked into native environment; keys=%v", environmentKeys(text)) } @@ -192,13 +210,13 @@ func TestServiceProcessOutputEnvWritesDependencyConfigsOncePerRun(t *testing.T) // Init phase: own + runtime only, NO dependency configurations. if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, own, nil, nil, []*basev0.Configuration{runtime}, + context.Background(), path, localRunEnvironment(), native, own, nil, nil, []*basev0.Configuration{runtime}, ); err != nil { t.Fatal(err) } // Start phase: refreshed dependency configurations only. if err := AppendServiceProcessConfigurationsToFile( - context.Background(), path, native, nil, nil, []*basev0.Configuration{dependency}, nil, + context.Background(), path, localRunEnvironment(), native, nil, nil, []*basev0.Configuration{dependency}, nil, ); err != nil { t.Fatal(err) } @@ -400,3 +418,35 @@ func TestAppendRuntimeEnvironmentToFileOmitsForbiddenDependencyEndpoint(t *testi t.Fatalf("private endpoint reached the output environment:\n%s", text) } } + +// localRunEnvironment is the run environment the writers name their carriers +// for, as the runner reads it off the world. +func localRunEnvironment() *basev0.Environment { return &basev0.Environment{Name: "local"} } + +// carrierValue is the value written for key in a KEY=value file. +func carrierValue(t *testing.T, text, key string) string { + t.Helper() + for _, line := range strings.Split(text, "\n") { + if value, ok := strings.CutPrefix(line, key+"="); ok { + return value + } + } + t.Fatalf("%s is not in the file; keys=%v", key, environmentKeys(text)) + return "" +} + +// A writer that names no run environment refuses before it writes anything, +// by name, rather than failing on the first document with core's contract +// error — which is what platform-core's composition gate saw on 0.1.219: the +// composition root's merged fixture-composition reached every service's +// `--output-env` and the whole run refused at the first Init. +func TestServiceProcessOutputEnvRequiresTheRunEnvironment(t *testing.T) { + path := filepath.Join(t.TempDir(), "runtime.env") + err := AppendServiceProcessConfigurationsToFile(context.Background(), path, nil, resources.NewRuntimeContextNative(), nil, nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "names no run environment") { + t.Fatalf("got %v, want the writer to refuse without an environment", err) + } + if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { + t.Fatalf("a refused write left a file: %v", statErr) + } +} From 0397d343d044213c6ce4eb8464aba5870543835e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:40:17 -0400 Subject: [PATCH 2/2] docs(run): what --output-env writes, and the run environment its documents are keyed on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-PR docs for the writer fix: the file's contents, its owner-only mode and the secrets in it, the Start-only dependency write, and that a structured configuration document is carried under a key hashing origin, name and the selected run environment — the key the SDK derives — for the normal and the excluded-root file alike. Co-Authored-By: Claude Fable 5.1 --- docs/commands.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/commands.md b/docs/commands.md index 42dd9b0c4..7599802cd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -135,6 +135,20 @@ writer follows the gateway's current address. The `--output-env` file is a map of the run's current environment, rewritten whole: a key written again replaces its line, so a reader never meets two values for one key. +**What `--output-env` writes, and for which environment.** The file is the +service's process environment as the agent receives it, owner-only (`0600`): +its own configuration and runtime configurations, the workspace groups it is +handed, and — written once at Start, after the dependency barrier — its +dependencies' configurations; secret values are in it, which is why it is +owner-only and never committed. A workspace group that is one YAML or JSON +**structured document** rather than key/value pairs (the composition root's +merged `fixture-composition`, for example) is carried under a key that hashes +the group's origin, its name and the **selected run environment** — the same +key the SDK derives to read it back — so every write names the environment the +run selected (`--env`), the excluded root's file included. A writer handed no +environment refuses by name rather than writing a document no reader could +find. + **The delivery host's federation surface.** In a host environment the native run hands the service `host.delivery` names, when it declares the `federation` workspace configuration, the three keys that turn its delivery surface on,