Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0f9c066
fix: correct GreenNode spelling (greenode -> greennode)
anhpg Jul 17, 2026
3dca7ed
feat(agentbase): vendor self-contained internal packages
anhpg Jul 20, 2026
65be7c9
feat(agentbase): add cmd/agentbase scaffold and context group
anhpg Jul 20, 2026
2375c8c
feat(agentbase): register agentbase behind opt-in build tag
anhpg Jul 20, 2026
b7e1e00
feat(agentbase): port identity command group
anhpg Jul 20, 2026
a7c2f00
test(cmd): exempt gated agentbase subtree from convention lint
anhpg Jul 20, 2026
dd7b80e
docs(agentbase): document gated agentbase subcommand
anhpg Jul 20, 2026
45feb3d
merge main
Jul 29, 2026
1221c90
feat(login): scaffold internal/login package
Jul 29, 2026
83bcf99
feat(login): add PKCE verifier/S256 generation
Jul 29, 2026
a39b1e5
feat(login): add IAM authorize-URL builder + token extraction
Jul 29, 2026
86b1122
feat(login): add secret-optional token exchange + refresh
Jul 29, 2026
269ee50
feat(login): add refresh-token file store (0600)
Jul 29, 2026
c4e27de
feat(login): add loopback one-shot callback listener
Jul 29, 2026
1047f3e
feat(login): add Login orchestrator with PKCE + refresh persistence
Jul 29, 2026
7ff5725
fix(login): serve failure page + ErrStateMismatch on state mismatch
Jul 29, 2026
ded9631
chore(login): batch-tidy from whole-branch review (T4/T5b/T5d)
Jul 29, 2026
9e7f7d2
feat(login): wire grn login/logout cobra commands
Jul 29, 2026
5280b77
fix(login): use dev-signin.vngcloud.tech for the dev preset authorize…
Jul 30, 2026
a7e1242
feat(login): add --debug stderr trace for the PKCE token exchange
Jul 30, 2026
be61f20
fix(login): always send client_secret_basic (VNG IAM requires it for …
Jul 30, 2026
fe3d8be
[login] consolidate auth identity into per-profile credentials INI
Jul 30, 2026
a982bc8
[login] embed per-env default client_id; add GRN_IAM_ENV
Jul 30, 2026
74c7b5d
[login] real prod PKCE client_id; move IAM endpoints to internal/login
Jul 31, 2026
b36b90a
[login] use login refresh token as Bearer for vks/vserver (user mode)
Jul 31, 2026
74133e7
[login] fix gofmt for idp test
Jul 31, 2026
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
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ go/
├── cmd/
│ ├── root.go # Root command + global flags + --version
│ ├── register.go # Blank-imports all product packages (triggers init())
│ ├── agentbase/ # grn agentbase (gated: -tags agentbase)
│ │ ├── agentbase.go # AgentbaseCmd subcommand root (self-registers)
│ │ ├── identity.go # identity group (login/workload/outbound-auth)
│ │ ├── context.go # context group (switch/current/headers/decorators)
│ │ └── helpers.go # mustLoadConfig / newAuthProvider
│ ├── configure/
│ │ ├── configure.go # Interactive setup
│ │ ├── list.go # grn configure list
Expand Down Expand Up @@ -66,6 +71,14 @@ go/
│ ├── resources/
│ │ └── vserver/
│ │ └── vserver.go # vserver resource completers (vpc/subnet/ssh-key/security-group/disk-type)
│ ├── agentbase/ # self-contained agentbase stack (own auth/config/client)
│ │ ├── auth/ # OAuth2 v2 clientcredentials
│ │ ├── client/ # bearer-token HTTP client
│ │ ├── config/ # ./.greennode.json loader
│ │ ├── identity/ # identity API client + models
│ │ ├── cliinput/ # interactive prompts
│ │ ├── jsonslice/ # typed JSON slice helper
│ │ └── output/ # table/json/id + color + banner
│ └── validator/validator.go # ID format validation
├── go.mod, go.sum
```
Expand Down Expand Up @@ -114,6 +127,10 @@ VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()`
5. Add `<service>_endpoint` for each region in `internal/config/config.go` REGIONS
6. root.go needs no change — it mounts everything in the registry

Note: `cmd/agentbase` is gated behind the opt-in `agentbase` build tag
(`cmd/register_agentbase.go`), the inverse of the `!vks_only` pattern — it is
excluded from default and release builds while still in development.

## Security rules

- **Credential masking**: `configure list` and `configure get` mask client_id/client_secret (last 4 chars only)
Expand All @@ -129,6 +146,9 @@ VKS wires its flags centrally in `cmd/vks/completion.go` `registerCompletions()`
cd go
CGO_ENABLED=0 go build -o grn .

# Build WITH the agentbase subcommand (dev/internal only; excluded from release):
CGO_ENABLED=0 go build -tags agentbase -o grn .

# Cross-compile
GOOS=linux GOARCH=amd64 go build -o grn-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o grn-darwin-arm64 .
Expand Down
16 changes: 16 additions & 0 deletions docs/development/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ A new product (e.g. `vserver`) is mounted without touching `root.go`:

`root.go` iterates `cli.Services()` and never needs editing per service.

## Gated subcommand: `agentbase`

`cmd/agentbase/` is a self-contained service stack that is **gated OFF** in the
default and release builds: it only compiles when you pass `-tags agentbase`
(see `cmd/register_agentbase.go`). This is the inverse of the `!vks_only`
pattern — agentbase is *included* only by an opt-in tag, so it stays out of
public binaries while still in development. Unlike `vks`/`vserver`, it does
not reuse the shared `internal/cli` infrastructure: it ships its own v2
OAuth2 client-credentials auth (`internal/agentbase/auth/`), its own
`./.greennode.json` config loader (`internal/agentbase/config/`), and its own
HTTP client + output helpers. `AgentbaseCmd` self-registers via
`cli.RegisterService(...)` in `init()`, so once the tag flips to default-on
it will appear under `grn` with no further wiring. Public command-reference
pages (`docs/commands/agentbase/*`) and `mkdocs.yml` nav entries are
intentionally omitted until that flip.

## Writing a command

Follow the existing `cmd/vks/*.go` files. Each command:
Expand Down
86 changes: 86 additions & 0 deletions go/cmd/agentbase/agentbase.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Package agentbase implements the `grn agentbase` subcommand group for the
// GreenNode AgentBase platform.
//
// It is intentionally self-contained: it carries its own OAuth2 (v2
// client-credentials) auth, its own ./.greennode.json config, and its own HTTP
// client. It does NOT share state with grn's core (the v1 TokenManager, the INI
// config in ~/.greennode, or the retry/refresh HTTP client). The two stacks are
// fully independent.
//
// Compiled in ONLY with `-tags agentbase`. The default grn binary and the
// public release build (`-tags vks_only`) both exclude it while agentbase is
// still under development.
package agentbase

import (
"os"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/vngcloud/greennode-cli/internal/agentbase/cliinput"
"github.com/vngcloud/greennode-cli/internal/agentbase/output"
"github.com/vngcloud/greennode-cli/internal/cli"
)

// Persistent-flag targets for the `grn agentbase` subtree. The --output flag
// shadows grn's inherited root --output for this subtree only (cobra lets a
// child flag shadow an inherited persistent flag); the other grn root flags are
// inherited but inert — agentbase never reads them.
var (
interactiveMode bool
envOverride string
outputFormat string
)

const greennodeASCIIArt = `
_____ _____ ______ ______ _ _ _ _ ____ _____ ______
/ ____| __ \| ____| ____| \ | | \ | |/ __ \| __ \| ____|
| | __| |__) | |__ | |__ | \| | \| | | | | | | | |__
| | |_ | _ /| __| | __| | . ` + "`" + ` | . ` + "`" + ` | | | | | | | __|
| |__| | | \ \| |____| |____| |\ | |\ | |__| | |__| | |____
\_____|_| \_\______|______|_| \_|_| \_|\____/|_____/|______| AGENTBASE
`

func printBanner() {
color.New(color.FgGreen, color.Bold).Fprint(os.Stderr, greennodeASCIIArt)
}

// skipBannerCommands suppresses the ASCII banner for non-product commands.
var skipBannerCommands = map[string]bool{
"help": true,
"completion": true,
}

// AgentbaseCmd is the `grn agentbase` subcommand. Its init() self-registers it
// with grn's service registry (cli.RegisterService), mirroring cmd/vks—so
// mounting requires no edit to root.go or main.go, only a build-tagged blank
// import in cmd/register_agentbase.go.
var AgentbaseCmd = &cobra.Command{
Use: "agentbase",
Short: "GreenNode AgentBase platform",
SilenceUsage: true,
SilenceErrors: true,
Long: `Manage the GreenNode AgentBase platform: agent identities and outbound
authentication providers (Phase 1). Runtime, memory, and deploy commands arrive
in later phases.

Configuration is read from ./.greennode.json in the current working directory
(separate from grn's ~/.greennode profile config). Run 'grn agentbase context
current' to see the active environment and endpoints.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
output.SetFormat(output.ParseFormat(outputFormat))
if !skipBannerCommands[cmd.Name()] && output.GetFormat() == output.FormatTable {
printBanner()
}
cliinput.SetInteractive(interactiveMode)
},
}

func init() {
AgentbaseCmd.PersistentFlags().BoolVarP(&interactiveMode, "interactive", "i", false, "Prompt for missing inputs instead of requiring flags")
AgentbaseCmd.PersistentFlags().StringVar(&envOverride, "env", "", `Target environment: "dev" or "prod" (overrides GREENNODE_ENV and ./.greennode.json)`)
AgentbaseCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", `Output format: "table", "json", or "id"`)

cli.RegisterService(AgentbaseCmd)
}
71 changes: 71 additions & 0 deletions go/cmd/agentbase/agentbase_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package agentbase

import (
"testing"

"github.com/vngcloud/greennode-cli/internal/agentbase/jsonslice"
)

// TestAgentbaseCmd_HasContextSubtree verifies the scaffold mounted the `context`
// group under `grn agentbase` with its expected children. No network, no creds.
func TestAgentbaseCmd_HasContextSubtree(t *testing.T) {
contextCmd, _, err := AgentbaseCmd.Find([]string{"context"})
if err != nil {
t.Fatalf("agentbase has no 'context' subcommand: %v", err)
}
for _, want := range []string{"switch", "current", "headers", "decorators"} {
if _, _, err := contextCmd.Find([]string{want}); err != nil {
t.Errorf("context missing subcommand %q: %v", want, err)
}
}
}

// TestAgentbaseCmd_PersistentFlags verifies the agentbase-specific persistent
// flags (and that -i/-o shorthands exist; --output shadows grn's root flag).
func TestAgentbaseCmd_PersistentFlags(t *testing.T) {
for _, flag := range []string{"interactive", "env", "output"} {
if AgentbaseCmd.PersistentFlags().Lookup(flag) == nil {
t.Errorf("agentbase missing persistent flag %q", flag)
}
}
if AgentbaseCmd.PersistentFlags().ShorthandLookup("i") == nil {
t.Error("agentbase missing -i shorthand for --interactive")
}
if AgentbaseCmd.PersistentFlags().ShorthandLookup("o") == nil {
t.Error("agentbase missing -o shorthand for --output")
}
}

// TestAgentbaseCmd_HasIdentitySubtree verifies the identity group and its
// workload CRUD subtree mounted under `grn agentbase`.
func TestAgentbaseCmd_HasIdentitySubtree(t *testing.T) {
identityCmd, _, err := AgentbaseCmd.Find([]string{"identity"})
if err != nil {
t.Fatalf("agentbase has no 'identity' subcommand: %v", err)
}
for _, want := range []string{"login", "logout", "whoami", "workload", "outbound-auth"} {
if _, _, err := identityCmd.Find([]string{want}); err != nil {
t.Errorf("identity missing subcommand %q: %v", want, err)
}
}
workloadCmd, _, err := identityCmd.Find([]string{"workload"})
if err != nil {
t.Fatalf("identity has no 'workload' subcommand: %v", err)
}
for _, want := range []string{"create", "list", "get", "update", "use", "delete"} {
if _, _, err := workloadCmd.Find([]string{want}); err != nil {
t.Errorf("workload missing subcommand %q: %v", want, err)
}
}
}

// TestJoinStrings_jsonsliceArray ports the agentbase helper test for joinStrings
// (defined in identity.go).
func TestJoinStrings_jsonsliceArray(t *testing.T) {
if got := joinStrings(jsonslice.Array[string]{"a", "b"}, ", "); got != "a, b" {
t.Errorf("got %q", got)
}
if got := joinStrings(jsonslice.Array[string]{}, "|"); got != "" {
t.Errorf("empty: got %q", got)
}
}
128 changes: 128 additions & 0 deletions go/cmd/agentbase/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package agentbase

import (
"fmt"
"os"

"github.com/spf13/cobra"

"github.com/vngcloud/greennode-cli/internal/agentbase/config"
"github.com/vngcloud/greennode-cli/internal/agentbase/output"
)

var contextCmd = &cobra.Command{
Use: "context",
Short: "Manage the active environment context",
Long: `Manage the active environment context (dev or prod).

The environment controls which API endpoints are used for all commands.
Resolution order: GREENNODE_ENV env var → ./.greennode.json → default (prod)`,
}

var contextSwitchCmd = &cobra.Command{
Use: "switch <dev|prod>",
Short: "Switch the active environment",
Long: `Switch the active environment context to 'dev' or 'prod'.

This writes the 'env' field to ./.greennode.json.
To override without modifying the config file, set GREENNODE_ENV instead.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
env := config.Env(args[0])
if err := config.SetEnv(env); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "Switched to environment: %s\n", env)
return nil
},
}

var contextCurrentCmd = &cobra.Command{
Use: "current",
Short: "Show the active environment and resolved endpoints",
Long: `Display the currently active environment and all resolved API base URLs.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadWithEnv(envOverride)
if err != nil {
return err
}

source := "config file (./.greennode.json)"
switch {
case envOverride != "":
source = "--env flag"
case os.Getenv("GREENNODE_ENV") != "":
source = "environment variable (GREENNODE_ENV)"
}

fmt.Fprintf(os.Stdout, "Environment : %s\n", cfg.Env)
fmt.Fprintf(os.Stdout, "Source : %s\n\n", source)

output.Table(
[]string{"Service", "Base URL"},
[][]string{
{"Identity", cfg.Endpoints.Identity},
{"Runtime", cfg.Endpoints.Runtime},
{"Memory", cfg.Endpoints.Memory},
{"OAuth2 Token", cfg.Endpoints.OAuth2Token},
},
)
return nil
},
}

var contextHeadersCmd = &cobra.Command{
Use: "headers",
Short: "Show platform request headers reference",
Long: `Display the standard X-GreenNode-AgentBase-* HTTP request headers used by the platform.`,
Run: func(cmd *cobra.Command, args []string) {
output.Table(
[]string{"Header", "Description"},
[][]string{
{"X-GreenNode-AgentBase-Session-Id", "Unique session identifier for conversation continuity"},
{"X-GreenNode-AgentBase-Request-Id", "Unique request identifier for tracing"},
{"X-GreenNode-AgentBase-Access-Token", "User access token for 3LO OAuth2 flows"},
{"X-GreenNode-AgentBase-User-Id", "User identifier forwarded to the agent"},
{"X-GreenNode-AgentBase-OAuth2-Callback-Url", "Callback URL for OAuth2 redirect flows"},
{"Authorization", "Bearer token (client credentials OAuth2 token)"},
{"X-GreenNode-AgentBase-Custom-*", "Arbitrary custom headers forwarded to the agent"},
},
)
},
}

var contextDecoratorsCmd = &cobra.Command{
Use: "decorators",
Short: "Show SDK decorator reference",
Long: `Display the GreenNode AgentBase SDK decorators and their purpose.`,
Run: func(cmd *cobra.Command, args []string) {
output.Table(
[]string{"Decorator", "Module", "Description"},
[][]string{
{
"@entrypoint",
"GreenNodeAgentBaseApp",
"Registers the main handler. Extracts AgentBase context from incoming request headers.",
},
{
"@requires_api_key",
"identity",
"Fetches a static or delegated API key before the handler is invoked.",
},
{
"@requires_access_token",
"identity",
"Fetches an M2M (client credentials) or 3LO OAuth2 token before the handler is invoked.",
},
},
)
},
}

func init() {
AgentbaseCmd.AddCommand(contextCmd)
contextCmd.AddCommand(contextSwitchCmd)
contextCmd.AddCommand(contextCurrentCmd)
contextCmd.AddCommand(contextHeadersCmd)
contextCmd.AddCommand(contextDecoratorsCmd)
}
36 changes: 36 additions & 0 deletions go/cmd/agentbase/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package agentbase

import (
"fmt"
"os"

"github.com/vngcloud/greennode-cli/internal/agentbase/auth"
"github.com/vngcloud/greennode-cli/internal/agentbase/config"
)

// mustLoadConfig loads config, applying the --env flag override when set, and
// exits on any error. Commands use this (not config.Load() directly) so the
// persistent --env flag is respected.
func mustLoadConfig() *config.Config {
cfg, err := config.LoadWithEnv(envOverride)
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
return cfg
}

// mustLoadConfigWithCreds loads config and ensures credentials are present.
func mustLoadConfigWithCreds() *config.Config {
cfg := mustLoadConfig()
if err := cfg.RequireCredentials(); err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
return cfg
}

// newAuthProvider builds an auth provider from the loaded config.
func newAuthProvider(cfg *config.Config) *auth.Provider {
return auth.NewProvider(cfg.ClientID, cfg.ClientSecret, cfg.Endpoints.OAuth2Token)
}
Loading
Loading