From 39ed6a81728fc8b5c5e50b8f891ca587376701f3 Mon Sep 17 00:00:00 2001 From: Timo Derstappen Date: Sat, 10 Oct 2026 23:49:54 +0200 Subject: [PATCH] feat: a skills GitHub App source for agent-manager's skill resolution --- docs/agents.md | 2 + docs/cli.md | 1 + internal/config/aikey_test.go | 25 +++++ internal/config/config.go | 8 ++ internal/config/githubapp.go | 62 ++++++++++ internal/lab/agentstest.go | 2 +- internal/lab/backstagetest_edit.go | 2 +- internal/lab/discover.go | 25 +++-- internal/lab/githubapp.go | 54 +++++++++ internal/lab/githubapp_test.go | 106 ++++++++++++++++++ internal/lab/platform.go | 5 + internal/lab/render.go | 6 + .../templates/agent-platform-values.yaml.tmpl | 12 +- main.go | 13 +++ 14 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 internal/config/githubapp.go create mode 100644 internal/lab/githubapp.go create mode 100644 internal/lab/githubapp_test.go diff --git a/docs/agents.md b/docs/agents.md index 7e45ab7c..dd2d0c41 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -154,6 +154,8 @@ environment -> Secret and never enters `agentlab.yaml`, `state/` (the rendered values carry the Secret's *name*), a log line or a process's argv. Re-running with a new token rotates the Secret. +**A skills GitHub App** is the way a lab reaches GitHub without any token: agent-manager's chart takes a read-only App (`skills.github.app.secretName`) and resolves skills with its installation tokens, at the App's rate limit. `agentlab configure --github-app-id --github-app-installation-id --github-app-private-key-source op:////` records it as `githubApp` in `agentlab.yaml` (the ids are public; the key is a reference). Every `up` and `platform` apply the ids to the Secret `agentlab-github-app` in `agent-platform` and hand the key to `beekeeper secret copy --to-secret` (key `private-key`, never read by agentlab; a failed placement fails the run), and the values name the Secret for agent-manager. The chart refuses the App together with `tokenSecret`, so `githubApp` and `githubToken.source` are not recorded together; a `$GITHUB_TOKEN` still reaches the portal and the migrate Job, which take a token only. + Without either the lab is as before: the values name no Secret, the consumers call GitHub unauthenticated, and a Secret an earlier run created stays — unreferenced — until `agentlab down` (a run that merely lacks the diff --git a/docs/cli.md b/docs/cli.md index 1d86767b..23d26caa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -170,6 +170,7 @@ The flags pin a value regardless of the discovery, with or without | `--workspaces-github-app-id`, `--workspaces-github-client-id` | The public ids of the `github` instance's App (`platform.workspaces.github`); its private key and client secret never pass through agentlab. | | `--ai-key-source ` | Where the Anthropic key of the agents' default ModelConfig lives: a reference `beekeeper secret copy` resolves (`op:////`, or `#` of a SOPS file), never a value. Every `up` and `platform` place it into the Secret `kagent/kagent-anthropic` through beekeeper (on PATH, or `configure` refuses), so a recreated lab carries the key without a manual step; `--ai-key-source ""` clears it (the key then comes from `$ANTHROPIC_API_KEY`, else a placeholder). See [The model](agents.md#the-model). | | `--github-token-source ` | Where the GitHub token of the portal's skill discovery and agent-manager's skill resolution lives: a reference `beekeeper secret copy` resolves, as for `--ai-key-source`, never a value. Every `up` and `platform` place it into the Secret `agentlab-github-token` in `agent-platform` and `kagent` through beekeeper, so a lab started without `$GITHUB_TOKEN` (an agent's) calls GitHub authenticated; `--github-token-source ""` clears it (the token then comes from `$GITHUB_TOKEN`, else GitHub is called unauthenticated). See [Agents](agents.md#the-github-token). | +| `--github-app-id `, `--github-app-installation-id `, `--github-app-private-key-source ` | The skills GitHub App agent-manager resolves skills with (`githubApp`): its public ids and a reference to its private key that `beekeeper secret copy` resolves, never a value. Every `up` and `platform` write the Secret `agentlab-github-app` in `agent-platform` (the key placed by beekeeper) and wire `agent-manager.skills.github.app`. Not together with `--github-token-source`; `""` on all three clears it. | ## Environment variables diff --git a/internal/config/aikey_test.go b/internal/config/aikey_test.go index f4f7c6cf..3b5294a7 100644 --- a/internal/config/aikey_test.go +++ b/internal/config/aikey_test.go @@ -42,3 +42,28 @@ func TestGitHubTokenSourceIsAReference(t *testing.T) { t.Errorf("a pasted value must be refused naming githubToken.source, got %v", err) } } + +// TestGitHubAppValidate: a complete App with numeric ids and a key reference +// is taken; a partial one, a non-numeric id, a pasted key and an App next to +// githubToken.source are refused naming githubApp. +func TestGitHubAppValidate(t *testing.T) { + good := GitHubApp{AppID: "123456", InstallationID: "7890", PrivateKeySource: "op://lab/skills-app/private-key"} + cfg := Default() + cfg.GitHubApp = good + if err := cfg.Validate(); err != nil { + t.Errorf("a complete App refused: %v", err) + } + for name, mutate := range map[string]func(*Config){ + "partial": func(c *Config) { c.GitHubApp.InstallationID = "" }, + "non-numeric": func(c *Config) { c.GitHubApp.AppID = "skills-app" }, + "pasted key": func(c *Config) { c.GitHubApp.PrivateKeySource = "pasted value" }, + "with token": func(c *Config) { c.GitHubToken.Source = "op://lab/github-token/credential" }, + } { + cfg := Default() + cfg.GitHubApp = good + mutate(cfg) + if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "githubApp") { + t.Errorf("%s: want a refusal naming githubApp, got %v", name, err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ac25bf68..32af39ef 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1040,6 +1040,8 @@ type Config struct { AIKey AIKey `yaml:"aiKey,omitempty"` // GitHubToken is where the token of the lab's GitHub calls comes from. GitHubToken GitHubToken `yaml:"githubToken,omitempty"` + // GitHubApp is the skills GitHub App agent-manager resolves skills with. + GitHubApp GitHubApp `yaml:"githubApp,omitempty"` Users []User `yaml:"users"` Platform Platform `yaml:"platform"` @@ -1451,6 +1453,12 @@ func (c *Config) Validate() error { if err := c.GitHubToken.Validate(); err != nil { return fmt.Errorf("githubToken.source %q: %w", c.GitHubToken.Source, err) } + if err := c.GitHubApp.Validate(); err != nil { + return fmt.Errorf("githubApp: %w", err) + } + if c.GitHubApp.Configured() && c.GitHubToken.Source != "" { + return errors.New("githubApp and githubToken.source both set: agent-manager takes one GitHub credential, its chart refuses both — clear one (`agentlab configure --github-token-source \"\"`)") + } if c.SubstrateNodes < 0 || c.SubstrateNodes > MaxSubstrateNodes { return fmt.Errorf("substrateNodes: %d, want 0 (the single-node lab) to %d", c.SubstrateNodes, MaxSubstrateNodes) } diff --git a/internal/config/githubapp.go b/internal/config/githubapp.go new file mode 100644 index 00000000..dd2f88f8 --- /dev/null +++ b/internal/config/githubapp.go @@ -0,0 +1,62 @@ +package config + +import ( + "errors" + "fmt" +) + +// GitHubApp is the read-only skills GitHub App agent-manager resolves skills +// with: installation tokens at the App's rate limit instead of a static +// token or GitHub's anonymous window. The App id and installation id are +// public; the private key is never this file: PrivateKeySource names where +// the operator's secret tooling finds it, and `agentlab up` and `platform` +// hand the reference to that tooling, which writes the key straight into the +// lab's Secret agentlab-github-app (internal/lab/githubapp.go). +type GitHubApp struct { + // AppID is the App's numeric id. + AppID string `yaml:"appId,omitempty"` + // InstallationID is the numeric id of the App's installation whose + // repositories the skills come from. + InstallationID string `yaml:"installationId,omitempty"` + // PrivateKeySource is a reference `beekeeper secret copy` resolves to the + // App's private key (PEM), in the form AIKey.Source takes. + PrivateKeySource string `yaml:"privateKeySource,omitempty"` +} + +// Configured reports whether any part of the App is recorded; Validate +// refuses a partial one. +func (a GitHubApp) Configured() bool { + return a.AppID != "" || a.InstallationID != "" || a.PrivateKeySource != "" +} + +// Validate refuses a partial App, an id that is not a number and a private +// key that is not a reference. +func (a GitHubApp) Validate() error { + if !a.Configured() { + return nil + } + if a.AppID == "" || a.InstallationID == "" || a.PrivateKeySource == "" { + return errors.New("appId, installationId and privateKeySource are set together or not at all") + } + for name, id := range map[string]string{"appId": a.AppID, "installationId": a.InstallationID} { + if !isDigits(id) { + return fmt.Errorf("%s %q is not a numeric GitHub id", name, id) + } + } + if err := ValidateSecretSource(a.PrivateKeySource); err != nil { + return fmt.Errorf("privateKeySource %q: %w", a.PrivateKeySource, err) + } + return nil +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/internal/lab/agentstest.go b/internal/lab/agentstest.go index 8d62c6c0..7b4914c8 100644 --- a/internal/lab/agentstest.go +++ b/internal/lab/agentstest.go @@ -877,7 +877,7 @@ func proveAgentsTestRefreshSkills(session *musterSession, fixture SkillsFixture, if !authenticated { step("%supdate_agent refreshSkills: skipped, agent-manager calls GitHub without a token", agentManagerToolPrefix) note("deployment %s/%s carries no GitHub credential (%s): refreshSkills would resolve %s's head on the anonymous window this machine shares", platformNamespace, agentManagerMCPServer, strings.Join(agentManagerGitHubEnv, ", "), fixture.Repo) - return fmt.Sprintf("SKIP: update_agent{refreshSkills} — agent-manager calls GitHub unauthenticated, on GitHub's anonymous rate limit (60 requests an hour, shared by this machine); record githubToken.source (`agentlab configure --github-token-source `) or export $%s, then `agentlab platform`, to prove it", GitHubTokenEnv), nil + return fmt.Sprintf("SKIP: update_agent{refreshSkills} — agent-manager calls GitHub unauthenticated, on GitHub's anonymous rate limit (60 requests an hour, shared by this machine); record the skills GitHub App (`agentlab configure --github-app-id --github-app-installation-id --github-app-private-key-source `), githubToken.source or $%s, then `agentlab platform`, to prove it", GitHubTokenEnv), nil } step("%supdate_agent refreshSkills — the skill re-pins to %s's head %.12s", agentManagerToolPrefix, fixture.Repo, head) refreshed, err := agentManagerUpdate(session, map[string]any{nameKey: agentsTestAgent, refreshSkillsKey: true}) diff --git a/internal/lab/backstagetest_edit.go b/internal/lab/backstagetest_edit.go index d76fd357..f568a75e 100644 --- a/internal/lab/backstagetest_edit.go +++ b/internal/lab/backstagetest_edit.go @@ -260,7 +260,7 @@ func proveRefreshSkills(primary *portalSession, spec agentSpec) (string, error) if !authenticated { step("E4/E5 Update skills: skipped, agent-manager calls GitHub without a token") note("deployment %s/%s carries no GitHub credential (%s): refreshSkills would resolve %s's head on the anonymous window this machine shares", platformNamespace, agentManagerMCPServer, strings.Join(agentManagerGitHubEnv, ", "), skillsTestRepo) - return fmt.Sprintf("SKIP: E4/E5 validate_agent{update, refreshSkills} and update_agent{refreshSkills} — agent-manager calls GitHub unauthenticated, on GitHub's anonymous rate limit (60 requests an hour, shared by this machine); record githubToken.source (`agentlab configure --github-token-source `) or export $%s, then `agentlab platform`, to prove them", GitHubTokenEnv), nil + return fmt.Sprintf("SKIP: E4/E5 validate_agent{update, refreshSkills} and update_agent{refreshSkills} — agent-manager calls GitHub unauthenticated, on GitHub's anonymous rate limit (60 requests an hour, shared by this machine); record the skills GitHub App (`agentlab configure --github-app-id --github-app-installation-id --github-app-private-key-source `), githubToken.source or $%s, then `agentlab platform`, to prove them", GitHubTokenEnv), nil } skill := spec.Skills[0] diff --git a/internal/lab/discover.go b/internal/lab/discover.go index 4d54fd64..5b43fa43 100644 --- a/internal/lab/discover.go +++ b/internal/lab/discover.go @@ -40,11 +40,13 @@ type Discovery struct { // is set on the host. GitHubTokenSource string GitHubToken bool - ClusterExists bool - ClusterPorts map[int]bool - KindGateway string - Servers []HostServer - FLM *FLMServer + // GitHubApp is the skills GitHub App recorded (githubApp), zero when none. + GitHubApp config.GitHubApp + ClusterExists bool + ClusterPorts map[int]bool + KindGateway string + Servers []HostServer + FLM *FLMServer // KVMMissing names the KVM devices this machine lacks (vmmanager.go): // empty when the platform's VM provisioner can run as a pod of the node. KVMMissing []string @@ -110,9 +112,9 @@ const flmOwner = "FastFlowLM" // Discover probes this machine. Nothing here needs the cluster; every probe // is loopback or a local CLI and degrades to "not found". func Discover(cfg *config.Config) *Discovery { - d := &Discovery{AnthropicKey: os.Getenv(AnthropicKeyEnv) != "", KeySource: cfg.AIKey.Source, GitHubTokenSource: cfg.GitHubToken.Source, GitHubToken: os.Getenv(GitHubTokenEnv) != ""} + d := &Discovery{AnthropicKey: os.Getenv(AnthropicKeyEnv) != "", KeySource: cfg.AIKey.Source, GitHubTokenSource: cfg.GitHubToken.Source, GitHubToken: os.Getenv(GitHubTokenEnv) != "", GitHubApp: cfg.GitHubApp} d.Tools = toolVersions(dockerVersion()) - if d.KeySource != "" || d.GitHubTokenSource != "" { + if d.KeySource != "" || d.GitHubTokenSource != "" || d.GitHubApp.Configured() { d.SecretTool = secretToolVersion() } d.ClusterExists, d.ClusterPorts = kindNodePublishedPorts(cfg.ControlPlaneNode()) @@ -309,6 +311,10 @@ func (d *Discovery) Preflight() error { problems = append(problems, fmt.Sprintf("%s is not on PATH (or does not answer) — githubToken.source %s is placed into the Secret %s/%s by `%s secret copy --to-secret` at every up and platform\n install: %s (or clear the source: agentlab configure --github-token-source \"\")", secretTool, d.GitHubTokenSource, platformNamespace, gitHubTokenSecret, secretTool, secretToolInstall)) } + if d.GitHubApp.Configured() && d.SecretTool == "" { + problems = append(problems, fmt.Sprintf("%s is not on PATH (or does not answer) — githubApp.privateKeySource %s is placed into the Secret %s/%s by `%s secret copy --to-secret` at every up and platform\n install: %s (or clear the App: agentlab configure --github-app-id \"\" --github-app-installation-id \"\" --github-app-private-key-source \"\")", + secretTool, d.GitHubApp.PrivateKeySource, platformNamespace, gitHubAppSecret, secretTool, secretToolInstall)) + } if len(problems) == 0 { return nil } @@ -417,11 +423,16 @@ func (d *Discovery) Report(cfg *config.Config) string { line("Anthropic key", "no aiKey.source and $%s is not set — the default ModelConfig gets a placeholder (it resolves, agent turns fail at Anthropic) until the key is placed: `%s secret copy --to-secret kind-%s/%s/%s/%s`, or the source recorded (`agentlab configure --ai-key-source `), or the variable exported and `agentlab platform` re-run", AnthropicKeyEnv, secretTool, cfg.ClusterName, kagentNamespace, anthropicSecret, anthropicSecretKey) } + if d.GitHubApp.Configured() { + line("GitHub App", "githubApp %s (installation %s), private key from %s — %s places it into the Secret %s/%s at every up and platform: agent-manager resolves skills as the App, at its rate limit; agentlab never reads the key", d.GitHubApp.AppID, d.GitHubApp.InstallationID, d.GitHubApp.PrivateKeySource, secretTool, platformNamespace, gitHubAppSecret) + } switch { case d.GitHubTokenSource != "": line("GitHub token", "githubToken.source %s — %s places it into the Secret %s/%s at every up and platform: the portal's skill discovery and agent-manager's skill resolution call GitHub authenticated (5000 requests an hour); agentlab never reads the value", d.GitHubTokenSource, secretTool, platformNamespace, gitHubTokenSecret) case d.GitHubToken: line("GitHub token", "$%s is set — the portal's skill discovery and agent-manager's skill resolution call GitHub authenticated (5000 requests an hour) from deploy time", GitHubTokenEnv) + case d.GitHubApp.Configured(): + line("GitHub token", "no githubToken.source and $%s is not set — the portal's skill discovery shares this machine's unauthenticated GitHub window (60 requests an hour); agent-manager uses the App", GitHubTokenEnv) default: line("GitHub token", "no githubToken.source and $%s is not set — skill discovery and resolution share this machine's unauthenticated GitHub window (60 requests an hour) until the source is recorded (`agentlab configure --github-token-source `) or the variable exported, and `agentlab platform` re-runs", GitHubTokenEnv) } diff --git a/internal/lab/githubapp.go b/internal/lab/githubapp.go new file mode 100644 index 00000000..9e5e298a --- /dev/null +++ b/internal/lab/githubapp.go @@ -0,0 +1,54 @@ +package lab + +import ( + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/giantswarm/agentlab/internal/config" +) + +// gitHubAppSecret is the Secret of the skills GitHub App in agent-platform/, +// in the shape agent-manager's chart reads (skills.github.app.secretName): +// the public ids, which agentlab applies, and the private key, which the +// secret tooling places from githubApp.privateKeySource so agentlab never +// reads it. Server-side apply owns only the id keys, so the key the tooling +// patched survives every later apply. +const ( + gitHubAppSecret = "agentlab-github-app" // #nosec G101 -- Secret NAME, not a credential + gitHubAppSecretAppID = "app-id" + gitHubAppSecretInstallationID = "installation-id" + gitHubAppSecretPrivateKey = "private-key" +) + +// gitHubAppWired is the render's answer: the App is recorded and the chart +// line takes the key (the 4.x agent-manager chart's skills.github.app). +func gitHubAppWired(cfg *config.Config) bool { + return cfg.GitHubApp.Configured() && !cfg.LegacyChart() +} + +// ensureGitHubAppSecret is the deploy-time half, run before the install: the +// values name the Secret whenever the App is recorded, so a placement that +// fails fails the run and a lab never deploys agent-manager without it. +// Without an App nothing is written. +func ensureGitHubAppSecret(_ context.Context, cfg *config.Config) error { + if !gitHubAppWired(cfg) { + return nil + } + app := cfg.GitHubApp + if err := ensureSecret(platformNamespace, gitHubAppSecret, corev1.SecretTypeOpaque, map[string][]byte{ + gitHubAppSecretAppID: []byte(app.AppID), + gitHubAppSecretInstallationID: []byte(app.InstallationID), + }); err != nil { + return err + } + answer, err := runSecretTool(secretCopyArgs(app.PrivateKeySource, secretTarget(cfg, platformNamespace, gitHubAppSecret, gitHubAppSecretPrivateKey))...) + if err != nil { + return fmt.Errorf("secret %s/%s key %s from githubApp.privateKeySource %s: %w\n agent-manager references it; when %s answers, re-run `agentlab platform`", + platformNamespace, gitHubAppSecret, gitHubAppSecretPrivateKey, app.PrivateKeySource, err, secretTool) + } + note("secret %s/%s: App %s, installation %s, private key placed from %s by %s (%s); agentlab never read it — agent-manager resolves skills as the App", platformNamespace, gitHubAppSecret, app.AppID, app.InstallationID, app.PrivateKeySource, secretTool, strings.TrimSpace(answer)) + return nil +} diff --git a/internal/lab/githubapp_test.go b/internal/lab/githubapp_test.go new file mode 100644 index 00000000..289ca746 --- /dev/null +++ b/internal/lab/githubapp_test.go @@ -0,0 +1,106 @@ +package lab + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/giantswarm/agentlab/internal/config" +) + +const gitHubAppTestKeySource = "op://lab/skills-app/private-key" + +func gitHubAppTestConfig() *config.Config { + cfg := gitHubTokenTestConfig() + cfg.GitHubApp = config.GitHubApp{AppID: "123456", InstallationID: "7890", PrivateKeySource: gitHubAppTestKeySource} + return cfg +} + +// TestGitHubAppWiresAgentManager: a recorded App names the Secret in +// agent-manager's skills.github.app and drops its tokenSecret (the chart +// refuses both), while the portal keeps the token $GITHUB_TOKEN gives it; +// the reference itself appears in nothing the render writes. +func TestGitHubAppWiresAgentManager(t *testing.T) { + t.Setenv(GitHubTokenEnv, gitHubTestToken) + s := renderGitHubTokenSurfaces(t, gitHubAppTestConfig()) + if got := dig(s.values, agentManagerValuesKey, "skills", "github", "app", "secretName"); got != gitHubAppSecret { + t.Errorf("agent-manager.skills.github.app.secretName = %v, want %s", got, gitHubAppSecret) + } + if got := dig(s.values, agentManagerValuesKey, "skills", "github", "tokenSecret"); got != nil { + t.Errorf("agent-manager.skills.github.tokenSecret = %v next to the App, want nothing", got) + } + if got := dig(s.values, componentBackstage, componentBackstage, "extraEnvVarsSecrets"); !reflect.DeepEqual(got, []any{gitHubTokenSecret}) { + t.Errorf("backstage.backstage.extraEnvVarsSecrets = %v, want the portal's token [%s]", got, gitHubTokenSecret) + } + for name, text := range s.raw() { + if strings.Contains(text, gitHubAppTestKeySource) { + t.Errorf("%s carries the private key reference:\n%s", name, text) + } + } +} + +// TestGitHubAppUnsetLeavesTheLabAsItWas: without an App nothing names its +// Secret. +func TestGitHubAppUnsetLeavesTheLabAsItWas(t *testing.T) { + t.Setenv(GitHubTokenEnv, "") + for name, text := range renderGitHubTokenSurfaces(t, gitHubTokenTestConfig()).raw() { + if strings.Contains(text, gitHubAppSecret) { + t.Errorf("%s names %s without githubApp:\n%s", name, gitHubAppSecret, text) + } + } +} + +// TestGitHubAppLegacyChartTakesNoKeys: the 3.x line has no skills App key. +func TestGitHubAppLegacyChartTakesNoKeys(t *testing.T) { + t.Setenv(GitHubTokenEnv, "") + cfg := gitHubAppTestConfig() + cfg.Platform.ChartVersion = legacyChartVersion + for name, text := range renderGitHubTokenSurfaces(t, cfg).raw() { + if strings.Contains(text, gitHubAppSecret) { + t.Errorf("%s wires the App on the 3.x line:\n%s", name, text) + } + } +} + +// TestEnsureGitHubAppSecret: nothing without an App; with one agentlab +// applies the public ids and hands the private key to the secret tooling, +// never writing it itself; a failed placement fails the run. +func TestEnsureGitHubAppSecret(t *testing.T) { + newFakeLab(t) + ctx := context.Background() + + calls := fakeSecretTool(t, func() error { return nil }) + if err := ensureGitHubAppSecret(ctx, gitHubTokenTestConfig()); err != nil { + t.Fatal(err) + } + if len(*calls) != 0 { + t.Errorf("secret tooling called without an App: %q", *calls) + } + if exists, _ := objectExists(ctx, gvrSecrets, platformNamespace, gitHubAppSecret); exists { + t.Errorf("%s/%s written without an App", platformNamespace, gitHubAppSecret) + } + + cfg := gitHubAppTestConfig() + if err := ensureGitHubAppSecret(ctx, cfg); err != nil { + t.Fatal(err) + } + want := [][]string{secretCopyArgs(gitHubAppTestKeySource, secretTarget(cfg, platformNamespace, gitHubAppSecret, gitHubAppSecretPrivateKey))} + if !reflect.DeepEqual(*calls, want) { + t.Errorf("secret tooling calls = %q, want %q", *calls, want) + } + for key, value := range map[string]string{gitHubAppSecretAppID: "123456", gitHubAppSecretInstallationID: "7890"} { + got, err := secretDataKey(ctx, platformNamespace, gitHubAppSecret, key) + if err != nil || string(got) != value { + t.Errorf("%s/%s key %s = %q (err %v), want %q", platformNamespace, gitHubAppSecret, key, got, err, value) + } + } + if secretHasKey(platformNamespace, gitHubAppSecret, gitHubAppSecretPrivateKey) { + t.Errorf("agentlab wrote %s itself; the key is the tooling's to place", gitHubAppSecretPrivateKey) + } + + fakeSecretTool(t, nil) + if err := ensureGitHubAppSecret(ctx, cfg); err == nil || !strings.Contains(err.Error(), "githubApp.privateKeySource") { + t.Errorf("err = %v, want the failed placement naming githubApp.privateKeySource", err) + } +} diff --git a/internal/lab/platform.go b/internal/lab/platform.go index 0a086545..8db1e239 100644 --- a/internal/lab/platform.go +++ b/internal/lab/platform.go @@ -349,6 +349,11 @@ func platformUp(cfg *config.Config, header string, offers Offers) error { if err := ensureGitHubTokenSecrets(ctx, cfg); err != nil { return err } + // The skills GitHub App (githubapp.go) — before the install too: + // agent-manager's env reads its Secret without `optional`. + if err := ensureGitHubAppSecret(ctx, cfg); err != nil { + return err + } // The klaus-gateway component's Secrets (klausgateway.go) — before the // install too: the chart mounts obo.existingSecret and reads // slack.secretName without `optional`. diff --git a/internal/lab/render.go b/internal/lab/render.go index b3950594..ff5df366 100644 --- a/internal/lab/render.go +++ b/internal/lab/render.go @@ -173,6 +173,11 @@ type tmplData struct { // agent-manager's skills.github.tokenSecret, the migrate Job's // githubToken. Only ever the Secret's name, never the token. GitHubToken bool + // GitHubApp mirrors gitHubAppWired(cfg): githubApp is recorded + // (githubapp.go), so agent-manager's skills.github.app names the Secret + // agentlab-github-app instead of the token, which its chart refuses + // together with the App. Only ever the Secret's name. + GitHubApp bool // BackstageAppConfigChecksum is the data checksum of the lab's app-config // overlay (appconfig.go): stamped on the overlay ConfigMap, where // `agentlab status` reads a hand edit against it, and carried in the @@ -263,6 +268,7 @@ func newTmplData(cfg *config.Config) (*tmplData, error) { WorkerPoolArch: workerPoolArch(), SubstrateNodeKey: config.SubstrateNodeKey, GitHubToken: gitHubTokenWired(cfg), + GitHubApp: gitHubAppWired(cfg), ModelManagerEnabled: cfg.ModelManagerEnabled(), LegacyChart: cfg.LegacyChart(), FamilylessChart: cfg.FamilylessChart(), diff --git a/internal/lab/templates/agent-platform-values.yaml.tmpl b/internal/lab/templates/agent-platform-values.yaml.tmpl index 66307042..2fb5de04 100644 --- a/internal/lab/templates/agent-platform-values.yaml.tmpl +++ b/internal/lab/templates/agent-platform-values.yaml.tmpl @@ -681,7 +681,17 @@ kagent: # carry the `kubernetes` audience to pass the kind apiserver — the lab Dex # lists agent-platform as a trusted peer of that client. agent-manager: -{{- if .GitHubToken }} +{{- if .GitHubApp }} + # The skills GitHub App agent-manager resolves skills with (list_skills, + # the create_agent pin, refreshSkills): installation tokens at the App's + # rate limit, from the Secret the lab places from githubApp (githubapp.go: + # the public ids, the private key through the secret tooling). The chart + # refuses it together with tokenSecret, so the App wins here. + skills: + github: + app: + secretName: agentlab-github-app +{{- else if .GitHubToken }} # The token agent-manager resolves skills with (list_skills, the # create_agent pin, refreshSkills): the Secret the lab places from # githubToken.source or $GITHUB_TOKEN (githubtoken.go), lifting the unauthenticated 60-an-hour diff --git a/main.go b/main.go index 81ce0b0f..246edfb1 100644 --- a/main.go +++ b/main.go @@ -802,6 +802,7 @@ func configureCmd() *cobra.Command { var workspacesProvider, workspacesGitHubAppID, workspacesGitHubClientID string var githubSignInOrgs, modelManagerBackends []string var vmManagerImageDir, aiKeySource, gitHubTokenSource string + var gitHubAppID, gitHubAppInstallationID, gitHubAppPrivateKeySource string var chartVersion, chartPath, chartBranch string var upgradeSeed, adoptChart bool cmd := &cobra.Command{ @@ -932,6 +933,15 @@ func configureCmd() *cobra.Command { if cmd.Flags().Changed("github-token-source") { cfg.GitHubToken.Source = gitHubTokenSource } + if cmd.Flags().Changed("github-app-id") { + cfg.GitHubApp.AppID = gitHubAppID + } + if cmd.Flags().Changed("github-app-installation-id") { + cfg.GitHubApp.InstallationID = gitHubAppInstallationID + } + if cmd.Flags().Changed("github-app-private-key-source") { + cfg.GitHubApp.PrivateKeySource = gitHubAppPrivateKeySource + } // Every run discovers the machine — an existing agentlab.yaml // follows the host too: a server that appeared is added, one that // is gone drops out, ports move while no cluster holds them. @@ -999,6 +1009,9 @@ func configureCmd() *cobra.Command { cmd.Flags().BoolVar(&serving, "serving", false, "serve models on llm-d in the lab: the KServe llmisvc controller and its CRDs, the well-known runtime configs, the connectivity chart's serving slice with the models Gateway, model-manager's kserve backend and one CPU preset of the lab's (needs agents; installs cert-manager); --serving=false turns it off") cmd.Flags().StringVar(&aiKeySource, "ai-key-source", "", "where the Anthropic key of the agents' default ModelConfig lives, a reference `beekeeper secret copy` resolves (op:////, or # of a SOPS file), never a value: every up and platform place it into the Secret kagent/kagent-anthropic through beekeeper, so a recreated lab carries it without a manual step; \"\" clears it (the key then comes from $ANTHROPIC_API_KEY, else a placeholder)") cmd.Flags().StringVar(&gitHubTokenSource, "github-token-source", "", "where the GitHub token of the portal's skill discovery and agent-manager's skill resolution lives, a reference `beekeeper secret copy` resolves (op:////, or # of a SOPS file), never a value: every up and platform place it into the Secret agentlab-github-token through beekeeper, lifting GitHub's anonymous 60 requests an hour this machine shares; \"\" clears it (the token then comes from $GITHUB_TOKEN, else GitHub is called unauthenticated)") + cmd.Flags().StringVar(&gitHubAppID, "github-app-id", "", "the skills GitHub App agent-manager resolves skills with (installation tokens at the App's rate limit instead of a token or GitHub's anonymous window): its numeric App id (public); set with --github-app-installation-id and --github-app-private-key-source, \"\" on all three clears it") + cmd.Flags().StringVar(&gitHubAppInstallationID, "github-app-installation-id", "", "the skills GitHub App: the numeric id of its installation (public)") + cmd.Flags().StringVar(&gitHubAppPrivateKeySource, "github-app-private-key-source", "", "the skills GitHub App: where its private key lives, a reference `beekeeper secret copy` resolves (op:////, or # of a SOPS file), never a value: every up and platform place it into the Secret agentlab-github-app through beekeeper") cmd.Flags().BoolVar(&accessible, "accessible", false, "prompt-per-question form mode (for screen readers and plain terminals)") return cmd }