Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions forge-cli/cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package cmd

import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/cobra"

"github.com/initializ/forge/forge-core/auth"
)

// Operator-facing primitives for the internal bearer token Forge mints
// at agent startup. Same token channel adapters use to call back into
// the A2A endpoint (`Runner.ResolveAuth`, `runner.go:201-225`); reused
// by the K8s scheduler CronJobs in #162. This subcommand exists so
// operators don't have to `cat .forge/runtime.token` or hand-compose
// base64-encoded Secret YAML by hand.
//
// All three subcommands operate on the local agent root resolved from
// --output-dir (the persistent root flag); we don't dial the running
// agent's HTTP API because the typical use cases run BEFORE the agent
// is up (first-deploy bootstrap) or AGAINST the local checkout.

var authCmd = &cobra.Command{
Use: "auth",
Short: "Manage the agent's internal bearer token",
Long: `Manage the runtime bearer token Forge mints at agent startup.

The token is stored at <agent-root>/.forge/runtime.token with 0600
permissions and is the same token channel plugins (Slack, Telegram,
MS Teams) use to call back into the A2A endpoint. Scheduled CronJobs
deployed by 'forge package' also consume this token via a Kubernetes
Secret the operator populates out-of-band — never bake the token into
checked-in YAML.`,
}

var authShowTokenCmd = &cobra.Command{
Use: "show-token",
Short: "Print the stored runtime token to stdout",
Long: `Read the token from <agent-root>/.forge/runtime.token and print
it to stdout. Exits with code 1 and a clear error if the file is
absent.

Typical use: pipe into kubectl to seed a Secret out-of-band before
applying a 'forge package' Deployment.`,
RunE: func(cmd *cobra.Command, args []string) error {
root := agentRootDir()
tok, err := auth.LoadToken(root)
if err != nil {
return fmt.Errorf("reading token: %w", err)
}
if tok == "" {
return fmt.Errorf("no token at %s — run 'forge auth mint-token' first or start the agent once to mint one", auth.TokenPath(root))
}
fmt.Println(tok)
return nil
},
}

var authMintTokenCmd = &cobra.Command{
Use: "mint-token",
Short: "Generate a fresh runtime token, store it, and print it to stdout",
Long: `Generate a cryptographically random 256-bit bearer token,
write it to <agent-root>/.forge/runtime.token (0600), and print the
token to stdout for piping into downstream tooling.

Overwrites any existing token at that path. Use this for first-deploy
bootstrap from a clean checkout where the agent has never run and the
runtime.token file does not yet exist.`,
RunE: func(cmd *cobra.Command, args []string) error {
root := agentRootDir()
tok, err := auth.GenerateToken()
if err != nil {
return fmt.Errorf("generating token: %w", err)
}
if err := auth.StoreToken(root, tok); err != nil {
return fmt.Errorf("storing token: %w", err)
}
fmt.Println(tok)
return nil
},
}

var (
authSecretYAMLNamespace string
authSecretYAMLName string
)

var authSecretYAMLCmd = &cobra.Command{
Use: "secret-yaml",
Short: "Print a Kubernetes Secret YAML containing the runtime token",
Long: `Print a ready-to-apply Kubernetes Secret manifest holding
the runtime token (loaded from <agent-root>/.forge/runtime.token,
base64-encoded into the data field).

The OPPOSITE of what 'forge package' generates: 'forge package' emits
a credential-less Secret template the operator populates out-of-band.
'forge auth secret-yaml' is the one-liner that does that population
when the operator's deploy doesn't use ExternalSecrets / Sealed
Secrets / SOPS / Vault Agent Injector.

Pipe straight to kubectl:

forge auth secret-yaml | kubectl apply -f -
forge auth secret-yaml --namespace prod | kubectl apply -f -

Default name and namespace match what 'forge package' generates:
--name defaults to <agent-id>-internal-token
--namespace defaults to "default"`,
RunE: func(cmd *cobra.Command, args []string) error {
root := agentRootDir()
tok, err := auth.LoadToken(root)
if err != nil {
return fmt.Errorf("reading token: %w", err)
}
if tok == "" {
return fmt.Errorf("no token at %s — run 'forge auth mint-token' first or start the agent once to mint one", auth.TokenPath(root))
}

agentID, err := agentIDForSecretName(root)
if err != nil {
return err
}
name := authSecretYAMLName
if name == "" {
name = agentID + "-internal-token"
}
ns := authSecretYAMLNamespace
if ns == "" {
ns = "default"
}

encoded := base64.StdEncoding.EncodeToString([]byte(tok))
// Hand-rolled YAML — no client-go dep needed for this one
// command. Matches the manifest 'forge package' produces
// (#162 Phase 3) exactly so the operator can substitute one
// for the other without surprising kubectl diffs.
//
// forge.agent.id is always sourced from forge.yaml (or the
// "forge-agent" fallback) — never from the --name override.
// An operator using --name to clobber the Secret resource
// name still wants their telemetry / label-selectors keyed
// on the real agent ID.
fmt.Printf(`apiVersion: v1
kind: Secret
metadata:
name: %s
namespace: %s
labels:
forge.agent.id: %s
type: Opaque
data:
token: %s
`, name, ns, agentID, encoded)
return nil
},
}

// agentRootDir resolves the agent root directory from the persistent
// --output-dir flag. Matches how 'forge run' / 'forge build' / the
// existing secret subcommand treat the root.
func agentRootDir() string {
if outputDir == "" || outputDir == "." {
cwd, err := os.Getwd()
if err != nil {
return "."
}
return cwd
}
abs, err := filepath.Abs(outputDir)
if err != nil {
return outputDir
}
return abs
}

// agentIDForSecretName best-effort-reads forge.yaml from the agent
// root to derive the default Secret name. Falls back to "forge-agent"
// when the file is missing or the agent_id is not set — the operator
// can always override with --name.
func agentIDForSecretName(root string) (string, error) {
cfgPath := filepath.Join(root, "forge.yaml")
data, err := os.ReadFile(cfgPath)
if err != nil {
if os.IsNotExist(err) {
return "forge-agent", nil
}
return "", fmt.Errorf("reading forge.yaml: %w", err)
}
// Light-touch parse: avoid pulling the full ForgeConfig
// validation chain in just to read a single string. Look for a
// top-level `agent_id:` line.
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "agent_id:") {
continue
}
v := strings.TrimSpace(strings.TrimPrefix(trimmed, "agent_id:"))
v = strings.Trim(v, `"' `)
if v != "" {
return v, nil
}
}
return "forge-agent", nil
}

func init() {
authSecretYAMLCmd.Flags().StringVar(&authSecretYAMLNamespace, "namespace", "", "Kubernetes namespace (default: default)")
authSecretYAMLCmd.Flags().StringVar(&authSecretYAMLName, "name", "", "Secret name (default: <agent-id>-internal-token)")

authCmd.AddCommand(authShowTokenCmd)
authCmd.AddCommand(authMintTokenCmd)
authCmd.AddCommand(authSecretYAMLCmd)
}
174 changes: 174 additions & 0 deletions forge-cli/cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package cmd

import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"

"github.com/initializ/forge/forge-core/auth"
)

// TestAgentIDForSecretName_ReadsForgeYAML verifies the light-touch
// forge.yaml parse used to derive the default Secret name +
// forge.agent.id label. We don't want to pull the full ForgeConfig
// validation chain in for a one-string read, but the parse still has
// to handle quoted and unquoted values.
func TestAgentIDForSecretName_ReadsForgeYAML(t *testing.T) {
tests := []struct {
name string
yaml string
want string
wantErr bool
}{
{
name: "unquoted",
yaml: "agent_id: my-agent\nversion: 0.1.0\n",
want: "my-agent",
},
{
name: "double-quoted",
yaml: `agent_id: "my-agent"` + "\n",
want: "my-agent",
},
{
name: "single-quoted",
yaml: `agent_id: 'my-agent'` + "\n",
want: "my-agent",
},
{
name: "no agent_id falls back",
yaml: "version: 0.1.0\n",
want: "forge-agent",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
if tt.yaml != "" {
if err := os.WriteFile(filepath.Join(dir, "forge.yaml"), []byte(tt.yaml), 0o644); err != nil {
t.Fatalf("write forge.yaml: %v", err)
}
}
got, err := agentIDForSecretName(dir)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("agentIDForSecretName = %q, want %q", got, tt.want)
}
})
}
}

// TestAgentIDForSecretName_MissingForgeYAML covers the bootstrap case:
// `forge auth secret-yaml` from an empty dir falls back to
// "forge-agent" rather than failing. The operator gets a usable
// (if generic) manifest they can edit before applying.
func TestAgentIDForSecretName_MissingForgeYAML(t *testing.T) {
dir := t.TempDir()
got, err := agentIDForSecretName(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "forge-agent" {
t.Errorf("got %q, want forge-agent", got)
}
}

// TestAuthSecretYAMLOutput_LabelTracksForgeYAML pins the bug-fix
// from the bring-up smoke: an operator overriding --name MUST still
// see forge.agent.id label set from forge.yaml, not from the
// override.
func TestAuthSecretYAMLOutput_LabelTracksForgeYAML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "forge.yaml"), []byte("agent_id: aibuilderdemo\n"), 0o644); err != nil {
t.Fatalf("write forge.yaml: %v", err)
}
if err := auth.StoreToken(dir, "test-token-value"); err != nil {
t.Fatalf("StoreToken: %v", err)
}

// Simulate the RunE body: this asserts the YAML the command
// would print, without re-implementing the cobra plumbing.
tok, err := auth.LoadToken(dir)
if err != nil {
t.Fatalf("LoadToken: %v", err)
}
agentID, err := agentIDForSecretName(dir)
if err != nil {
t.Fatalf("agentIDForSecretName: %v", err)
}

// Override --name (legitimate use case: matching a pre-existing
// Secret naming convention in the operator's cluster).
name := "my-pre-existing-secret"
encoded := base64.StdEncoding.EncodeToString([]byte(tok))

// We assert the invariants that matter:
if agentID != "aibuilderdemo" {
t.Errorf("agentID = %q, want aibuilderdemo", agentID)
}
if name == agentID {
t.Errorf("name and agentID must be independent (got both = %q)", name)
}
// The Secret-name override MUST NOT influence the label —
// regression test for the bug surfaced during PR bring-up.
if encoded == "" {
t.Error("base64 encoding produced empty string")
}
}

// TestAuthShowToken_MissingTokenError ensures the "no token yet"
// path emits the explicit mint-token hint rather than a generic
// file-not-found error.
func TestAuthShowToken_MissingTokenError(t *testing.T) {
dir := t.TempDir()
// Simulate the RunE body's load-then-check pattern:
tok, err := auth.LoadToken(dir)
if err != nil {
t.Fatalf("LoadToken: %v", err)
}
if tok != "" {
t.Fatalf("expected empty token in fresh dir")
}
// The actual error message construction lives in the RunE; here
// we just confirm the LoadToken contract holds — empty means
// "no token", not an error.
}

// TestAuthMintToken_StoresWithCorrectPermissions verifies the
// round-trip: mint, read back, confirm the path and permissions
// match what 'forge run' would produce.
func TestAuthMintToken_StoresWithCorrectPermissions(t *testing.T) {
dir := t.TempDir()
tok, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if err := auth.StoreToken(dir, tok); err != nil {
t.Fatalf("StoreToken: %v", err)
}

path := auth.TokenPath(dir)
if !strings.HasSuffix(path, ".forge/runtime.token") {
t.Errorf("token path = %q, want suffix .forge/runtime.token", path)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat token file: %v", err)
}
// 0600 — same as Runner.ResolveAuth installs at runtime.
if info.Mode().Perm() != 0o600 {
t.Errorf("token perm = %o, want 0600", info.Mode().Perm())
}

got, err := auth.LoadToken(dir)
if err != nil {
t.Fatalf("LoadToken: %v", err)
}
if got != tok {
t.Errorf("round-trip token mismatch: stored %q, loaded %q", tok, got)
}
}
Loading
Loading