From 10bd830f6d351022fa46cbbe9673a9f798279af6 Mon Sep 17 00:00:00 2001 From: Simon Graband Date: Wed, 29 Jul 2026 15:06:35 +0200 Subject: [PATCH] feat: point gitlab-cli at a self-hosted host via GITLAB_HOST Reaching a self-hosted GitLab needed two manual steps: declare GITLAB_HOST so it reached the container, then edit the mixin spec to put the host in serviceAuth.hosts. Both are now unnecessary. serviceAuth entries gain hostsFromCredential, naming a credential whose resolved value is a host. When it resolves, its value replaces that service's declared hosts. Replacement rather than addition is deliberate: an instance-specific token must not be released to gitlab.com as well. The override is resolved during auth setup, before the effective policy is resolved and cached, so the host flows into the allow set through the existing ReleaseHosts union and stays reachable. gitlab-cli declares GITLAB_HOST as a non-apiKey credential and wires all three tokens to it. Closes #4 --- docs/extensions/README.md | 10 ++ extensions/features/gitlab-cli/README.md | 25 ++++ extensions/features/gitlab-cli/spec.yaml | 9 +- internal/config/secrets.go | 7 +- internal/config/spec.go | 5 + internal/config/spec_map.go | 17 ++- internal/config/spec_map_test.go | 40 +++++++ .../testdata/golden/feature-gitlab-cli.json | 15 ++- internal/model/types.go | 4 + internal/runtime/active_secrets.go | 76 ++++++++++++ internal/runtime/auth_manager.go | 14 ++- internal/runtime/auth_manager_test.go | 7 +- internal/runtime/release_hosts_test.go | 109 ++++++++++++++++++ internal/runtime/runtime.go | 36 +++++- 14 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 internal/runtime/release_hosts_test.go diff --git a/docs/extensions/README.md b/docs/extensions/README.md index 104e8eb..e93ebe9 100644 --- a/docs/extensions/README.md +++ b/docs/extensions/README.md @@ -408,6 +408,16 @@ Secrets are split across two `spec.yaml` sections: describe how the gateway injects that credential as an HTTP header when it proxies requests to those hosts. The service-id in `serviceAuth` and `serviceDomains` is the same key used under `credentials.sources`. +- `network.serviceAuth..hostsFromCredential` names another + `credentials.sources` id whose resolved value is a host, for services that + can point at a self-hosted instance. When that credential resolves, its value + **replaces** the declared `hosts` for this service — it is not additive, + since an instance-specific token must not be released to the public default + host as well. The replacement host joins the allow set like a declared one, + so one env var is enough to make the tool reach the instance with token + injection working. An unset credential keeps the declared hosts; a value that + is not a usable host warns and is ignored. Values may be a bare host, + `host:port`, or a full URL — the scheme, port and path are stripped. The placeholder convention is Go `fmt`-style `%s`, not `{secret}`. An empty `valueFormat` means "inject the raw secret value" with no wrapping (see diff --git a/extensions/features/gitlab-cli/README.md b/extensions/features/gitlab-cli/README.md index 0bbc4c5..7bc1f14 100644 --- a/extensions/features/gitlab-cli/README.md +++ b/extensions/features/gitlab-cli/README.md @@ -8,3 +8,28 @@ requests from the command line. Opt-in (disabled by default). ## Installation Downloads the latest `.deb` release from GitLab's release API. Requires root. + +## Auth + +`GITLAB_TOKEN`/`GITLAB_ACCESS_TOKEN`, `OAUTH_TOKEN` and +`JOB_TOKEN`/`CI_JOB_TOKEN` are resolved from the host environment, the layered +secrets files, or the persisted env store, and injected as env vars. `spec.yaml` +maps each to the header the gateway injects. + +## Self-hosted instances + +Set `GITLAB_HOST` to point `glab` at a self-hosted instance: + +```bash +GITLAB_HOST=gitlab.example.com enclave --features +gitlab-cli +``` + +That single env var selects the instance for `glab`, retargets token injection, +and joins the network allowlist — no spec edit or `allow_domains` entry. See +`serviceAuth.hostsFromCredential` in `docs/extensions/README.md` for the +accepted value forms and why the host replaces the `gitlab.com` defaults instead +of adding to them. + +To set it persistently, put it in a secrets file rather than the shell — global +in `~/.local/state/enclave/secrets/global.env`, or per project under +`~/.local/state/enclave/projects//`. diff --git a/extensions/features/gitlab-cli/spec.yaml b/extensions/features/gitlab-cli/spec.yaml index a0ae719..8a7077a 100644 --- a/extensions/features/gitlab-cli/spec.yaml +++ b/extensions/features/gitlab-cli/spec.yaml @@ -18,8 +18,11 @@ credentials: gitlab-token: { env: [GITLAB_TOKEN, GITLAB_ACCESS_TOKEN] } gitlab-oauth-token: { env: [OAUTH_TOKEN] } gitlab-job-token: { env: [JOB_TOKEN, CI_JOB_TOKEN] } + # Not a credential: glab reads GITLAB_HOST to pick the instance, and + # hostsFromCredential below retargets token injection at it. + gitlab-host: { env: [GITLAB_HOST], apiKey: false } network: serviceAuth: - gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"] } - gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"] } - gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"] } + gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } + gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } + gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } diff --git a/internal/config/secrets.go b/internal/config/secrets.go index 3e833c6..31a33e1 100644 --- a/internal/config/secrets.go +++ b/internal/config/secrets.go @@ -241,9 +241,10 @@ func normalizeSecretRelease(secretID string, release *model.SecretReleaseConfig) return &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: hosts, - Header: header, - Format: format, + Hosts: hosts, + Header: header, + Format: format, + HostsFromSecret: strings.TrimSpace(release.HTTP.HostsFromSecret), }, }, nil } diff --git a/internal/config/spec.go b/internal/config/spec.go index f045702..7390a0d 100644 --- a/internal/config/spec.go +++ b/internal/config/spec.go @@ -129,6 +129,11 @@ type specServiceAuth struct { // these hosts are unioned with any serviceDomains entries pointing at this // service id. Hosts []string `json:"hosts,omitempty"` + // HostsFromCredential names a credentials.sources id whose resolved value + // is a host. When that credential resolves at runtime, its value replaces + // Hosts for this service, so a self-hosted instance can be selected by + // setting one env var instead of editing the spec. + HostsFromCredential string `json:"hostsFromCredential,omitempty"` } type specEnvironment struct { diff --git a/internal/config/spec_map.go b/internal/config/spec_map.go index d66b63d..908bd15 100644 --- a/internal/config/spec_map.go +++ b/internal/config/spec_map.go @@ -31,10 +31,18 @@ func validateServiceAuthMappings(doc specDocument, specPath string) error { sources[id] = struct{}{} } } - for id := range doc.Network.ServiceAuth { + for id, auth := range doc.Network.ServiceAuth { if _, ok := sources[id]; !ok { return fmt.Errorf("%s: network.serviceAuth[%q] has no matching credentials.sources entry", specPath, id) } + if auth.HostsFromCredential == "" { + continue + } + // Same typo class as the service ids above: an unmatched reference + // would silently leave the service pinned to its static hosts. + if _, ok := sources[auth.HostsFromCredential]; !ok { + return fmt.Errorf("%s: network.serviceAuth[%q].hostsFromCredential references credential %q with no matching credentials.sources entry", specPath, id, auth.HostsFromCredential) + } } for host, id := range doc.Network.ServiceDomains { if _, ok := sources[id]; !ok { @@ -139,9 +147,10 @@ func buildSecrets(doc specDocument) map[string]model.SecretConfig { if auth, ok := doc.Network.ServiceAuth[id]; ok { sc.Release = &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: hostsByService[id], - Header: auth.HeaderName, - Format: auth.ValueFormat, + Hosts: hostsByService[id], + Header: auth.HeaderName, + Format: auth.ValueFormat, + HostsFromSecret: auth.HostsFromCredential, }, } } diff --git a/internal/config/spec_map_test.go b/internal/config/spec_map_test.go index 0e1a102..9f27727 100644 --- a/internal/config/spec_map_test.go +++ b/internal/config/spec_map_test.go @@ -57,6 +57,46 @@ network: } }) + t.Run("unmatched hostsFromCredential fails", func(t *testing.T) { + doc := mustDoc(t, ` +schemaVersion: "1" +kind: mixin +name: gitlab-cli +credentials: + sources: + gitlab-token: { env: [GITLAB_TOKEN] } + gitlab-host: { env: [GITLAB_HOST], apiKey: false } +network: + serviceAuth: + gitlab-token: { headerName: private-token, hostsFromCredential: gitlab-hsot } +`) + if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err == nil { + t.Fatal("expected error for hostsFromCredential with no matching credentials.sources entry") + } + }) + + t.Run("matching hostsFromCredential passes", func(t *testing.T) { + doc := mustDoc(t, ` +schemaVersion: "1" +kind: mixin +name: gitlab-cli +credentials: + sources: + gitlab-token: { env: [GITLAB_TOKEN] } + gitlab-host: { env: [GITLAB_HOST], apiKey: false } +network: + serviceAuth: + gitlab-token: { headerName: private-token, hosts: [gitlab.com], hostsFromCredential: gitlab-host } +`) + if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err != nil { + t.Fatalf("validateServiceAuthMappings() error = %v, want nil", err) + } + secrets := buildSecrets(doc) + if got := secrets["gitlab-token"].Release.HTTP.HostsFromSecret; got != "gitlab-host" { + t.Fatalf("HostsFromSecret = %q, want %q", got, "gitlab-host") + } + }) + t.Run("matching ids pass", func(t *testing.T) { doc := mustDoc(t, ` schemaVersion: "1" diff --git a/internal/config/testdata/golden/feature-gitlab-cli.json b/internal/config/testdata/golden/feature-gitlab-cli.json index ef77bfb..c94ada3 100644 --- a/internal/config/testdata/golden/feature-gitlab-cli.json +++ b/internal/config/testdata/golden/feature-gitlab-cli.json @@ -5,6 +5,12 @@ "needs_root": true, "priority": 50, "secrets": { + "gitlab-host": { + "env_vars": [ + "GITLAB_HOST" + ], + "api_key": false + }, "gitlab-job-token": { "env_vars": [ "JOB_TOKEN", @@ -16,7 +22,8 @@ "*.gitlab.com", "gitlab.com" ], - "header": "job-token" + "header": "job-token", + "hosts_from_secret": "gitlab-host" } } }, @@ -31,7 +38,8 @@ "gitlab.com" ], "header": "authorization", - "format": "Bearer %s" + "format": "Bearer %s", + "hosts_from_secret": "gitlab-host" } } }, @@ -46,7 +54,8 @@ "*.gitlab.com", "gitlab.com" ], - "header": "private-token" + "header": "private-token", + "hosts_from_secret": "gitlab-host" } } } diff --git a/internal/model/types.go b/internal/model/types.go index b382899..9bb1726 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -260,6 +260,10 @@ type HTTPSecretReleaseConfig struct { Hosts []string `json:"hosts"` Header string `json:"header"` Format string `json:"format,omitempty"` + // HostsFromSecret names another secret id whose resolved value is a host. + // When that secret resolves, its value replaces Hosts for this release + // rule; see spec.yaml network.serviceAuth.hostsFromCredential. + HostsFromSecret string `json:"hosts_from_secret,omitempty"` } // ReleaseHosts returns the sorted, deduplicated HTTP release hosts declared diff --git a/internal/runtime/active_secrets.go b/internal/runtime/active_secrets.go index 9379a30..102420d 100644 --- a/internal/runtime/active_secrets.go +++ b/internal/runtime/active_secrets.go @@ -11,7 +11,10 @@ import ( "fmt" "os" "sort" + "strings" + "enclave/internal/domainpattern" + "enclave/internal/logx" "enclave/internal/model" "enclave/internal/secretfile" ) @@ -163,6 +166,79 @@ func resolveEnvAliasValue(secret activeSecret, layeredSecrets map[string]string, return chosen.value, chosen.source, true, nil } +// resolveReleaseHostOverrides resolves the credentials named by each release +// rule's HostsFromSecret and returns secret-id -> replacement hosts. A service +// whose host credential is unset or unusable keeps its declared hosts, so the +// default (e.g. gitlab.com) still applies. +// +// The replacement is deliberately not additive: the referenced credential names +// the one instance the token belongs to, and injecting an instance-specific +// token into the default hosts as well would expose it to a service that cannot +// accept it. +func resolveReleaseHostOverrides(secrets []activeSecret, hostHome string, layeredSecrets map[string]string, persistedEnv map[string]string) map[string][]string { + // Several services commonly share one host credential (gitlab's three + // tokens), so group by the reference and resolve each credential once. + byID := make(map[string]activeSecret, len(secrets)) + referencedBy := map[string][]string{} + for _, secret := range secrets { + byID[secret.ID] = secret + if secret.ReleaseHTTP != nil && secret.ReleaseHTTP.HostsFromSecret != "" { + ref := secret.ReleaseHTTP.HostsFromSecret + referencedBy[ref] = append(referencedBy[ref], secret.ID) + } + } + + overrides := map[string][]string{} + for ref, secretIDs := range referencedBy { + source, ok := byID[ref] + if !ok { + continue + } + value, _, found, err := resolveActiveSecretValue(source, hostHome, layeredSecrets, persistedEnv) + if err != nil { + logx.Warnf("Cannot resolve host credential %s (%v); keeping declared hosts.", ref, err) + continue + } + if !found { + continue + } + host := normalizeReleaseHost(ref, value) + if host == "" { + continue + } + for _, id := range secretIDs { + overrides[id] = []string{host} + } + } + return overrides +} + +// normalizeReleaseHost turns a credential value into an allowlist-comparable +// host, tolerating the URL forms CLIs accept (glab reads GITLAB_HOST as either +// a bare host or a full URL). An unusable value warns and is dropped rather +// than failing the session, since the declared hosts remain valid. +func normalizeReleaseHost(secretID string, value string) string { + trimmed := value + if index := strings.Index(trimmed, "://"); index >= 0 { + trimmed = trimmed[index+len("://"):] + } + if index := strings.IndexAny(trimmed, "/?#"); index >= 0 { + trimmed = trimmed[:index] + } + // NormalizeHost strips the port and IPv6 brackets but does not validate the + // labels, so the pattern validator runs after it to reject junk that would + // otherwise become a bogus allowlist entry. + host, err := domainpattern.NormalizeHost(trimmed) + if err == nil { + host, err = domainpattern.Normalize(host) + } + if err != nil { + logx.Warnf("Secret %s: value %q is not a usable host (%v); keeping declared hosts.", secretID, value, err) + return "" + } + return host +} + // resolveFileSecretValue reads the secret's file source, if any. A missing file // (or an empty resolved value) reports found=false so the caller can fall back // to the env aliases; a malformed parser or file content fails loudly. diff --git a/internal/runtime/auth_manager.go b/internal/runtime/auth_manager.go index 2670254..8aaca07 100644 --- a/internal/runtime/auth_manager.go +++ b/internal/runtime/auth_manager.go @@ -168,6 +168,9 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex for _, secret := range suppressedActiveSecrets { logx.Debugf("Suppressed declared API key secret %s due to %s", secret.ID, suppressionReason) } + // Must precede shouldUseSecretReleases: that call resolves and caches the + // effective policy, which unions the release hosts into the allow set. + m.releaseHostOverrides = resolveReleaseHostOverrides(eligibleSecrets, m.host.Home, layeredSecrets, persistedEnv) secretReleaseEnabled := m.shouldUseSecretReleases(eligibleSecrets) for _, secret := range eligibleSecrets { secretValue, secretSource, found, err := resolveActiveSecretValue(secret, m.host.Home, layeredSecrets, persistedEnv) @@ -190,7 +193,7 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex SecretID: secret.ID, Placeholder: placeholder, Value: secretValue, - Hosts: append([]string{}, secret.ReleaseHTTP.Hosts...), + Hosts: m.releaseHostsFor(secret), Header: secret.ReleaseHTTP.Header, Format: secret.ReleaseHTTP.Format, }) @@ -414,6 +417,15 @@ func (m authManager) apiKeySecretSuppressionReason() string { return "" } +// releaseHostsFor returns the hosts a secret's release rule applies to, after +// any serviceAuth.hostsFromCredential replacement. +func (m authManager) releaseHostsFor(secret activeSecret) []string { + if hosts, ok := m.releaseHostOverrides[secret.ID]; ok { + return append([]string{}, hosts...) + } + return append([]string{}, secret.ReleaseHTTP.Hosts...) +} + func (m authManager) shouldUseSecretReleases(activeSecrets []activeSecret) bool { hasHTTPRelease := false for _, secret := range activeSecrets { diff --git a/internal/runtime/auth_manager_test.go b/internal/runtime/auth_manager_test.go index 44179ab..4d950ee 100644 --- a/internal/runtime/auth_manager_test.go +++ b/internal/runtime/auth_manager_test.go @@ -824,9 +824,10 @@ func secretConfig(envVars []string, release *model.HTTPSecretReleaseConfig) mode if release != nil { cfg.Release = &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: append([]string{}, release.Hosts...), - Header: release.Header, - Format: release.Format, + Hosts: append([]string{}, release.Hosts...), + Header: release.Header, + Format: release.Format, + HostsFromSecret: release.HostsFromSecret, }, } } diff --git a/internal/runtime/release_hosts_test.go b/internal/runtime/release_hosts_test.go new file mode 100644 index 0000000..c9fbfad --- /dev/null +++ b/internal/runtime/release_hosts_test.go @@ -0,0 +1,109 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package runtime + +import ( + "strings" + "testing" + + "enclave/internal/model" +) + +// gitlabLikeProfile mirrors the gitlab-cli shape: several services sharing one +// host credential via serviceAuth.hostsFromCredential. +func gitlabLikeProfile() model.Profile { + return model.Profile{ + Name: "tool", + Secrets: map[string]model.SecretConfig{ + "gitlab-host": secretConfig([]string{"GITLAB_HOST"}, nil), + "gitlab-token": secretConfig([]string{"GITLAB_TOKEN"}, &model.HTTPSecretReleaseConfig{ + Hosts: []string{"gitlab.com", "*.gitlab.com"}, + Header: "private-token", + HostsFromSecret: "gitlab-host", + }), + "gitlab-job-token": secretConfig([]string{"JOB_TOKEN"}, &model.HTTPSecretReleaseConfig{ + Hosts: []string{"gitlab.com", "*.gitlab.com"}, + Header: "job-token", + HostsFromSecret: "gitlab-host", + }), + }, + } +} + +func TestResolveReleaseHostOverridesReplacesDeclaredHosts(t *testing.T) { + t.Setenv("GITLAB_HOST", "gitlab.example.com") + + r := runtimeWithProfile(t, gitlabLikeProfile()) + overrides := resolveReleaseHostOverrides(mustActiveSecrets(t, r), r.host.Home, map[string]string{}, map[string]string{}) + + for _, id := range []string{"gitlab-token", "gitlab-job-token"} { + hosts, ok := overrides[id] + if !ok { + t.Fatalf("overrides[%s] missing, want the resolved host", id) + } + // Replacement, not addition: the instance token must not be released + // to gitlab.com as well. + if len(hosts) != 1 || hosts[0] != "gitlab.example.com" { + t.Fatalf("overrides[%s] = %v, want [gitlab.example.com]", id, hosts) + } + } + if _, ok := overrides["gitlab-host"]; ok { + t.Fatalf("overrides contains the host credential itself, want only referencing services") + } +} + +func TestNormalizeReleaseHost(t *testing.T) { + cases := map[string]string{ + "https://gitlab.example.com/group": "gitlab.example.com", + "gitlab.example.com:8443": "gitlab.example.com", + " GitLab.Example.com ": "gitlab.example.com", + "not a host": "", + } + for value, want := range cases { + t.Run(value, func(t *testing.T) { + if got := normalizeReleaseHost("gitlab-host", value); got != want { + t.Fatalf("normalizeReleaseHost(%q) = %q, want %q", value, got, want) + } + }) + } +} + +func TestResolveReleaseHostOverridesKeepsDeclaredHostsWhenUnset(t *testing.T) { + t.Setenv("GITLAB_HOST", "") + + r := runtimeWithProfile(t, gitlabLikeProfile()) + overrides := resolveReleaseHostOverrides(mustActiveSecrets(t, r), r.host.Home, map[string]string{}, map[string]string{}) + + if len(overrides) != 0 { + t.Fatalf("overrides = %v, want none when the host credential is unset", overrides) + } +} + +// The replacement host must reach the allow set, or the release rule would name +// a host the sandbox cannot resolve, and it must not leak into the loaded spec, +// which is shared across the session. +func TestSpecNetworkDomainsIncludesOverriddenReleaseHost(t *testing.T) { + r := runtimeWithProfile(t, gitlabLikeProfile()) + r.releaseHostOverrides = map[string][]string{ + "gitlab-token": {"gitlab.example.com"}, + "gitlab-job-token": {"gitlab.example.com"}, + } + + allowed, _ := r.specNetworkDomains() + joined := strings.Join(allowed, ",") + + if !strings.Contains(joined, "gitlab.example.com") { + t.Fatalf("allowed = %v, want the overridden host included", allowed) + } + if strings.Contains(joined, "gitlab.com,") || strings.Contains(joined, ",gitlab.com") { + t.Fatalf("allowed = %v, want the replaced default host dropped", allowed) + } + if hosts := r.profile.Secrets["gitlab-token"].Release.HTTP.Hosts; strings.Join(hosts, ",") != "gitlab.com,*.gitlab.com" { + t.Fatalf("profile hosts = %v, want the declared hosts unchanged", hosts) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index d385efb..a537c8d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -10,6 +10,7 @@ package runtime import ( "context" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -59,7 +60,12 @@ type Runtime struct { policyResolved bool policyResult policy.ResolveResult policyErr error - backend backend.Backend + // releaseHostOverrides maps a secret id to the hosts that replace its + // declared release hosts, resolved from serviceAuth.hostsFromCredential. + // Populated during auth setup, before the effective policy is resolved and + // cached, so the replacement host reaches the allow set too. + releaseHostOverrides map[string][]string + backend backend.Backend } type ExecutionContext struct { @@ -982,15 +988,39 @@ func (r *Runtime) specProxyManaged() []string { func (r *Runtime) specNetworkDomains() (allowed []string, denied []string) { allowed = append([]string(nil), r.profile.AllowedDomains...) denied = append([]string(nil), r.profile.DeniedDomains...) - allowed = append(allowed, model.ReleaseHosts(r.profile.Secrets)...) + allowed = append(allowed, r.releaseHosts(r.profile.Secrets)...) for _, feature := range r.features { allowed = append(allowed, feature.AllowedDomains...) denied = append(denied, feature.DeniedDomains...) - allowed = append(allowed, model.ReleaseHosts(feature.Secrets)...) + allowed = append(allowed, r.releaseHosts(feature.Secrets)...) } return allowed, denied } +// releaseHosts is model.ReleaseHosts with any resolved +// serviceAuth.hostsFromCredential replacement applied, so a host selected at +// runtime becomes resolvable exactly like a declared one. The loaded spec is +// left untouched. +func (r *Runtime) releaseHosts(secrets map[string]model.SecretConfig) []string { + if len(r.releaseHostOverrides) == 0 { + return model.ReleaseHosts(secrets) + } + effective := maps.Clone(secrets) + for id, hosts := range r.releaseHostOverrides { + sc, ok := effective[id] + if !ok || sc.Release == nil || sc.Release.HTTP == nil { + continue + } + release := *sc.Release + http := *release.HTTP + http.Hosts = hosts + release.HTTP = &http + sc.Release = &release + effective[id] = sc + } + return model.ReleaseHosts(effective) +} + // addWorktreeMetadataMounts mounts the linked-worktree gitdir/commondir // according to worktree_metadata: follow ties them to the project mount mode, // readonly forces them read-only, none skips them entirely.