diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 982f3c51..ef433d99 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -19,6 +19,7 @@ import ( "github.com/codefly-dev/cli/pkg/orchestration" runfixture "github.com/codefly-dev/cli/pkg/run/fixture" "github.com/codefly-dev/cli/pkg/run/identity" + runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext" "github.com/codefly-dev/cli/pkg/solutionrun" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" @@ -58,6 +59,7 @@ import ( type launcher struct { vouch *identity.Launcher + authority *runworkcontext.Authority workspace *resources.Workspace env *environments.Environment flow launcherFlow @@ -130,7 +132,7 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re return err } cluster := &launcher{ - vouch: launcherIdentity, workspace: workspace, env: env, flow: flow, fixture: fixture, + vouch: launcherIdentity, authority: flow.WorkContextAuthority(), workspace: workspace, env: env, flow: flow, fixture: fixture, stateDir: stateDir, identities: identities, signer: signer, @@ -178,6 +180,11 @@ func (cluster *launcher) close() { default: close(cluster.stop) } + // The key set is served for the life of the run and no longer: a listener + // outliving the run would answer for an issuer nothing is still minting. + if cluster.authority != nil { + _ = cluster.authority.Close() + } } // declare records identity workloads the run vouches for, with their delivery diff --git a/pkg/environments/work_context.go b/pkg/environments/work_context.go new file mode 100644 index 00000000..6cfdef6d --- /dev/null +++ b/pkg/environments/work_context.go @@ -0,0 +1,23 @@ +package environments + +// WorkContextConfiguration is the workspace configuration group a service +// verifies inbound Work Contexts against: where the key set is served and the +// issuer it must be signed under. +// +// Like FederationConfiguration, the group stays AUTHORED and is never +// render-owned: a composition with a host declares it, pointing at the host's +// key set. A native run derives it too, for the composition that has no host +// to point at, and it is handed last so it settles over what an authored group +// leaves empty rather than displacing one. +// +// Without that, a service whose identity comes from the host cannot boot on +// its own, because nothing local serves the key set its verifier loads. +const WorkContextConfiguration = "work-context" + +// The two keys a verifier is configured from. A key set without a pinned +// issuer would admit a capability any holder of a key in that set signed under +// any issuer name, so neither is optional. +const ( + WorkContextKeySetURLKey = "authority-jwks-url" + WorkContextIssuerKey = "issuer" +) diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index ca03a3c7..69436ba6 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -2,6 +2,8 @@ package orchestration import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "maps" @@ -20,6 +22,7 @@ import ( "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/cli/pkg/internal/selectionguard" "github.com/codefly-dev/cli/pkg/remotenetwork" + runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext" "github.com/codefly-dev/core/architecture" "github.com/codefly-dev/core/configurations" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -35,6 +38,11 @@ import ( ) type Flow struct { + // workContext serves the key set a service verifies inbound Work Contexts + // against, for the composition that authors no work-context group. Created + // here because the loader that layers its group is bound here. + workContext *runworkcontext.Authority + // configurationReferences orders a consumer after the producers its // workspace configurations reference (configurationReferenceOption); every // rebuild of the graph carries it. @@ -423,6 +431,35 @@ func (f FlowFailure) Error() string { return fmt.Sprintf("%s: %s", f.Service, f.Message) } +// declaredWorkspaceConfigurationGroups names every workspace-configuration +// group a service in this workspace depends on. A declared group nothing +// supplies is refused by the configuration manager before any service reads a +// value from it, so a local run routes them in empty rather than leaving a +// module that composes no host unable to start. +func declaredWorkspaceConfigurationGroups(ctx context.Context, workspace *resources.Workspace) []string { + refs, err := workspace.LoadServiceWithModules(ctx) + if err != nil { + return nil + } + var groups []string + for _, ref := range refs { + service, loadErr := workspace.LoadService(ctx, ref) + if loadErr != nil { + continue + } + for _, group := range service.WorkspaceConfigurationDependencies { + if !slices.Contains(groups, group) { + groups = append(groups, group) + } + } + } + return groups +} + +// WorkContextAuthority is the key set this run serves, for the composition +// that authors no work-context group. The launcher closes it with the run. +func (flow *Flow) WorkContextAuthority() *runworkcontext.Authority { return flow.workContext } + func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *environments.Environment, mode Mode, opts ...FlowOption) (*Flow, error) { w := wool.Get(ctx).In("NewFlow") @@ -501,7 +538,19 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour if err != nil { return nil, w.Wrap(err) } - configurationManager.WithLoader(localReader) + // The run serves the key set a service verifies inbound Work Contexts + // against, layered over the loader so it is part of the set the manager + // resolves DECLARED dependencies from. A composition that authors the group + // keeps its own; this answers only for the one that does not. + runID := make([]byte, 8) + if _, readErr := rand.Read(runID); readErr != nil { + return nil, w.Wrap(readErr) + } + workContext, startErr := runworkcontext.Start(hex.EncodeToString(runID)) + if startErr != nil { + return nil, w.Wrap(startErr) + } + configurationManager.WithLoader(runworkcontext.WithKeySet(localReader, workContext, declaredWorkspaceConfigurationGroups(ctx, workspace))) // Bound here, with the loader: the resolution plans producer discovery // against the root's group names, so a manager that loaded a root group and // a world that cannot name it would resolve that group's references for @@ -553,6 +602,7 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour } flow := &Flow{ + workContext: workContext, workspace: workspace, graphWorkspace: graphWorkspace, moduleSeeds: options.moduleClosureSeeds, diff --git a/pkg/run/workcontext/authority.go b/pkg/run/workcontext/authority.go new file mode 100644 index 00000000..e5081699 --- /dev/null +++ b/pkg/run/workcontext/authority.go @@ -0,0 +1,114 @@ +// Package workcontext serves the verification key set a native run stands in +// for the host with. +// +// A service that verifies an inbound Work Context loads its key set from an +// endpoint, and the one transport rule for that endpoint already admits +// "plaintext http to a loopback host, on a local run only". Nothing served it, +// so a module whose identity comes from the host could not boot on its own. +// This serves it, over that admitted hop, for the life of the run. +// +// The key set is the run's own and so is the issuer stamped beside it. A +// verifier pins the issuer it accepts, so a capability signed here is +// structurally unacceptable to a deployed service, which pins the host's. +package workcontext + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "time" +) + +// KeySetPath is where the run serves its key set. It is the whole route: the +// consumer reads the URL verbatim and appends nothing. +const KeySetPath = "/.well-known/work-context-jwks.json" + +// keyID names the run's verification key inside the set. +const keyID = "local-run" + +// Authority is the key set a native run serves, and the issuer it stamps. +type Authority struct { + issuer string + public ed25519.PublicKey + private ed25519.PrivateKey + listener net.Listener + server *http.Server +} + +// Start mints the run's signing key and serves its public half on loopback. +// +// runID distinguishes one run's issuer from another's, so two runs on one +// machine never accept each other's capabilities. +func Start(runID string) (*Authority, error) { + if runID == "" { + return nil, fmt.Errorf("a run's work-context authority is issued under the run's own identity, and this run has none") + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("cannot mint this run's work-context key: %w", err) + } + // Loopback only: the admitted hop is a loopback one, and a key set reachable + // from off the machine is not what was admitted. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("cannot serve this run's work-context key set: %w", err) + } + authority := &Authority{ + issuer: fmt.Sprintf("codefly://local-run/%s", runID), + public: public, + private: private, + listener: listener, + } + mux := http.NewServeMux() + mux.HandleFunc("GET "+KeySetPath, authority.serveKeySet) + authority.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { _ = authority.server.Serve(listener) }() + return authority, nil +} + +// Issuer is the issuer this run stamps and a verifier must pin. +func (a *Authority) Issuer() string { return a.issuer } + +// KeySetURL is the full route the key set is served at. +func (a *Authority) KeySetURL() string { + return fmt.Sprintf("http://%s%s", a.listener.Addr().String(), KeySetPath) +} + +// Signer is the run's private key, for minting capabilities under this issuer. +func (a *Authority) Signer() (string, ed25519.PrivateKey) { return keyID, a.private } + +// Close stops serving the key set. +func (a *Authority) Close() error { + if a == nil || a.server == nil { + return nil + } + return a.server.Close() +} + +// keySet is the document a verifier parses: a JWKS carrying Ed25519 signing +// keys, which is the only shape the Work Context verifier admits. +func (a *Authority) keySet() any { + return map[string]any{"keys": []map[string]string{{ + "kty": "OKP", + "crv": "Ed25519", + "use": "sig", + "alg": "EdDSA", + "kid": keyID, + "x": base64.RawURLEncoding.EncodeToString(a.public), + }}} +} + +func (a *Authority) serveKeySet(w http.ResponseWriter, _ *http.Request) { + document, err := json.Marshal(a.keySet()) + if err != nil { + http.Error(w, "cannot encode the key set", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/jwk-set+json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(document) +} diff --git a/pkg/run/workcontext/authority_test.go b/pkg/run/workcontext/authority_test.go new file mode 100644 index 00000000..eeca02ed --- /dev/null +++ b/pkg/run/workcontext/authority_test.go @@ -0,0 +1,128 @@ +package workcontext + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// fetch reads the key set the authority serves over its admitted loopback hop. +func fetch(t *testing.T, authority *Authority) map[string]any { + t.Helper() + response, err := http.Get(authority.KeySetURL()) + if err != nil { + t.Fatalf("the key set is not reachable at the URL the run hands out: %v", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + t.Fatalf("the key set answered %d", response.StatusCode) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(body, &document); err != nil { + t.Fatalf("the key set is not JSON: %v", err) + } + return document +} + +// The verifier admits exactly one shape: an OKP/Ed25519 signing key whose `x` +// is raw-URL base64 of a 32-byte public key. Emitting a padded encoding, a +// different curve or a JWK alg the parser rejects would leave the run serving a +// document no service can load, which is the failure this guards. +func TestServedKeySetIsTheShapeAVerifierAdmits(t *testing.T) { + authority, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = authority.Close() }() + + keys, ok := fetch(t, authority)["keys"].([]any) + if !ok || len(keys) != 1 { + t.Fatalf("the key set must carry exactly one key, got %v", keys) + } + key, ok := keys[0].(map[string]any) + if !ok { + t.Fatalf("the key is not an object") + } + for field, want := range map[string]string{"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig"} { + if key[field] != want { + t.Errorf("%s is %v, and a verifier admits only %q", field, key[field], want) + } + } + if kid, _ := key["kid"].(string); kid == "" { + t.Error("the key carries no key id, so a token cannot name which key signed it") + } + encoded, _ := key["x"].(string) + if strings.ContainsAny(encoded, "=+/") { + t.Errorf("x must be raw-URL base64, got %q", encoded) + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("x does not decode as raw-URL base64: %v", err) + } + if len(decoded) != ed25519.PublicKeySize { + t.Fatalf("x is %d bytes, and an Ed25519 public key is %d", len(decoded), ed25519.PublicKeySize) + } +} + +// The published half must be the signing half's: a run that serves a key set +// unrelated to what it signs with refuses every capability it mints, which is +// indistinguishable at the far end from a key set that was never served. +func TestThePublishedKeyVerifiesWhatTheRunSigns(t *testing.T) { + authority, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = authority.Close() }() + + key, _ := fetch(t, authority)["keys"].([]any)[0].(map[string]any) + encoded, _ := key["x"].(string) + published, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + keyID, private := authority.Signer() + if keyID != key["kid"] { + t.Errorf("the signer names key %q, the key set publishes %v", keyID, key["kid"]) + } + message := []byte("a capability this run minted") + if !ed25519.Verify(ed25519.PublicKey(published), message, ed25519.Sign(private, message)) { + t.Fatal("the published key does not verify what the run signs") + } +} + +// Two runs must not accept each other's capabilities. A verifier pins the +// issuer, so the issuer is what separates them. +func TestEachRunIssuesUnderItsOwnIssuer(t *testing.T) { + first, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = first.Close() }() + second, err := Start("run-2") + if err != nil { + t.Fatal(err) + } + defer func() { _ = second.Close() }() + + if first.Issuer() == second.Issuer() { + t.Fatalf("both runs issue under %q, so each would accept the other's capabilities", first.Issuer()) + } + if !strings.Contains(first.Issuer(), "run-1") { + t.Errorf("the issuer does not name the run: %q", first.Issuer()) + } +} + +// A run with no identity cannot issue under one. +func TestARunWithoutAnIdentityServesNothing(t *testing.T) { + if _, err := Start(""); err == nil { + t.Fatal("a run with no identity was allowed to issue capabilities") + } +} diff --git a/pkg/run/workcontext/loader.go b/pkg/run/workcontext/loader.go new file mode 100644 index 00000000..7092f95e --- /dev/null +++ b/pkg/run/workcontext/loader.go @@ -0,0 +1,146 @@ +package workcontext + +import ( + "context" + + "github.com/codefly-dev/core/configurations" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/pkg/environments" +) + +// WithKeySet layers this run's work-context group over a loader, for the +// composition that authors none. +// +// It belongs on the loader rather than beside the per-service configurations +// a run hands out: the group is a declared workspace-configuration dependency, +// and those are resolved from the manager's own loaded set +// (configurations.Manager.GetWorkspaceDependenciesConfigurations). A group +// handed alongside that set is never found by it, and the service fails to +// boot with "no configuration found for work-context". +// +// An authored group always wins. A composition with a host declares where its +// key set is served; this answers only for the one that does not. +func WithKeySet(inner configurations.Loader, authority *Authority, declared []string) configurations.Loader { + return &keySetLoader{Loader: inner, authority: authority, declared: declared} +} + +type keySetLoader struct { + configurations.Loader + authority *Authority + // declared names every workspace-configuration group some service in the + // composition depends on. A declared group that nothing supplies is a boot + // failure at the manager — GetWorkspaceDependenciesConfigurations refuses a + // dependency it cannot find — whatever the service would do with empty + // values. Locally there is no composition to supply the host's groups, so + // they are routed in empty and each service decides what absence means, + // which is the rule those services already document. + declared []string + // configurations is built once: the Manager resolves secret references in + // the returned objects IN PLACE and reads them back, so handing out fresh + // copies would discard what it resolved. + configurations []*basev0.Configuration +} + +func (l *keySetLoader) Load(ctx context.Context, env *resources.Environment) error { + if err := l.Loader.Load(ctx, env); err != nil { + return err + } + loaded := l.Loader.Configurations() + layered := append([]*basev0.Configuration{}, loaded...) + + // Routing a declared group in empty is a LOCAL answer only. A deployed + // composition that declares a group and supplies nothing has a + // misconfiguration, and the manager refusing to start is how that is found; + // covering it here would turn a loud boot failure into a service running + // silently without the capability it said it needed. + if local(env) { + layered = append(layered, l.empties(loaded)...) + } + + // The key set is added on its own terms: a composition that authors the + // group keeps its own, and a run with no authority adds nothing. This is + // deliberately NOT folded into the check above — the two were conjoined, + // so a composition that authored work-context and nothing else had its + // other declared groups silently skipped. + if l.authority != nil && !named(loaded, environments.WorkContextConfiguration) { + layered = append(layered, &basev0.Configuration{ + Origin: resources.ConfigurationWorkspace, + Infos: []*basev0.ConfigurationInformation{{ + Name: environments.WorkContextConfiguration, + ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: environments.WorkContextKeySetURLKey, Value: l.authority.KeySetURL()}, + {Key: environments.WorkContextIssuerKey, Value: l.authority.Issuer()}, + }, + }}, + }) + } + l.configurations = layered + return nil +} + +// local reports whether this run is the local environment, which is the only +// one a missing declared group is answered for rather than refused. +func local(env *resources.Environment) bool { + return env != nil && resources.Match(env.Name, resources.LocalEnvironment().Name) +} + +// empties routes in every declared group the composition did not supply, +// carrying no values. It never covers a group that was supplied, and never the +// work-context group, which carries this run's own key set. +func (l *keySetLoader) empties(loaded []*basev0.Configuration) []*basev0.Configuration { + var out []*basev0.Configuration + for _, group := range l.declared { + if group == environments.WorkContextConfiguration || named(loaded, group) || named(out, group) { + continue + } + out = append(out, &basev0.Configuration{ + Origin: resources.ConfigurationWorkspace, + Infos: []*basev0.ConfigurationInformation{{Name: group}}, + }) + } + return out +} + +func (l *keySetLoader) Configurations() []*basev0.Configuration { + if l.configurations == nil { + return l.Loader.Configurations() + } + return l.configurations +} + +// CompositionRootWorkspaceConfigurationNames forwards the optional capability +// the wrapped loader may carry. Swallowing it would leave the Manager unable to +// tell which groups come from the composition root, which it injects into every +// service in the run. +func (l *keySetLoader) CompositionRootWorkspaceConfigurationNames() []string { + if inner, ok := l.Loader.(interface { + CompositionRootWorkspaceConfigurationNames() []string + }); ok { + return inner.CompositionRootWorkspaceConfigurationNames() + } + return nil +} + +// AmbiguousWorkspaceConfigurations forwards the loader's other optional +// capability, for the same reason. +func (l *keySetLoader) AmbiguousWorkspaceConfigurations() map[string]error { + if inner, ok := l.Loader.(interface { + AmbiguousWorkspaceConfigurations() map[string]error + }); ok { + return inner.AmbiguousWorkspaceConfigurations() + } + return nil +} + +func named(configs []*basev0.Configuration, group string) bool { + for _, configuration := range configs { + for _, info := range configuration.GetInfos() { + if info.GetName() == group { + return true + } + } + } + return false +} diff --git a/pkg/run/workcontext/loader_test.go b/pkg/run/workcontext/loader_test.go new file mode 100644 index 00000000..9cc07c9e --- /dev/null +++ b/pkg/run/workcontext/loader_test.go @@ -0,0 +1,124 @@ +package workcontext + +import ( + "context" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" +) + +type stubLoader struct { + configurations []*basev0.Configuration + rootNames []string +} + +func (s *stubLoader) Identity() string { return "stub" } +func (s *stubLoader) Load(context.Context, *resources.Environment) error { return nil } +func (s *stubLoader) Configurations() []*basev0.Configuration { return s.configurations } +func (s *stubLoader) DNS() []*basev0.DNS { return nil } +func (s *stubLoader) CompositionRootWorkspaceConfigurationNames() []string { return s.rootNames } + +func group(name string) *basev0.Configuration { + return &basev0.Configuration{Infos: []*basev0.ConfigurationInformation{{Name: name}}} +} + +func groupsIn(configs []*basev0.Configuration) map[string]bool { + out := map[string]bool{} + for _, configuration := range configs { + for _, info := range configuration.GetInfos() { + out[info.GetName()] = true + } + } + return out +} + +func load(t *testing.T, inner *stubLoader, authority *Authority, declared []string, env *resources.Environment) map[string]bool { + t.Helper() + loader := WithKeySet(inner, authority, declared) + if err := loader.Load(t.Context(), env); err != nil { + t.Fatal(err) + } + return groupsIn(loader.Configurations()) +} + +func authority(t *testing.T) *Authority { + t.Helper() + made, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = made.Close() }) + return made +} + +// A deployed composition that declares a group and supplies nothing has a +// misconfiguration, and the manager refusing to start is how it is found. +// Covering it here would turn a loud boot failure into a service running +// without the capability it said it needed. +func TestADeployedRunIsNotGivenGroupsItsCompositionDidNotSupply(t *testing.T) { + groups := load(t, &stubLoader{}, authority(t), []string{"platform", "internal-auth"}, + &resources.Environment{Name: "production"}) + for _, group := range []string{"platform", "internal-auth"} { + if groups[group] { + t.Errorf("%q was routed in empty outside a local environment, hiding a composition that supplies it nowhere", group) + } + } +} + +func TestALocalRunIsGivenTheDeclaredGroupsNothingSupplied(t *testing.T) { + groups := load(t, &stubLoader{}, authority(t), []string{"platform", "internal-auth"}, + resources.LocalEnvironment()) + for _, group := range []string{"platform", "internal-auth"} { + if !groups[group] { + t.Errorf("%q was not routed in, so the service fails at the manager before it can decide what absence means", group) + } + } +} + +// These two were conjoined: an early return skipped the declared groups +// whenever work-context happened to be authored, so a composition that +// authored one group lost the rest. +func TestAnAuthoredKeySetDoesNotSuppressTheOtherDeclaredGroups(t *testing.T) { + inner := &stubLoader{configurations: []*basev0.Configuration{group("work-context")}} + groups := load(t, inner, authority(t), []string{"work-context", "platform"}, resources.LocalEnvironment()) + if !groups["platform"] { + t.Error("platform was skipped because work-context was authored; the two are unrelated") + } +} + +// A composition that says where its key set is served keeps saying it. +func TestAnAuthoredKeySetIsNotReplacedByTheRuns(t *testing.T) { + inner := &stubLoader{configurations: []*basev0.Configuration{group("work-context")}} + loader := WithKeySet(inner, authority(t), nil) + if err := loader.Load(t.Context(), resources.LocalEnvironment()); err != nil { + t.Fatal(err) + } + count := 0 + for _, configuration := range loader.Configurations() { + for _, info := range configuration.GetInfos() { + if info.GetName() == "work-context" { + count++ + } + } + } + if count != 1 { + t.Fatalf("work-context appears %d times; the run added one beside the composition's own", count) + } +} + +// Swallowing this would leave the Manager unable to tell which groups come +// from the composition root, which it injects into every service in the run. +func TestTheCompositionRootCapabilityIsForwarded(t *testing.T) { + inner := &stubLoader{rootNames: []string{"platform"}} + loader := WithKeySet(inner, authority(t), nil) + forwarder, ok := loader.(interface { + CompositionRootWorkspaceConfigurationNames() []string + }) + if !ok { + t.Fatal("the decorator does not carry the composition-root capability at all") + } + if names := forwarder.CompositionRootWorkspaceConfigurationNames(); len(names) != 1 || names[0] != "platform" { + t.Errorf("the capability is not forwarded: %v", names) + } +}