Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
13a1243
docs(spec): read-only cloud-context MCP (GCP and AWS) design
sourcehawk May 30, 2026
74e86cf
docs(plan): cloud-context MCP implementation plan and orchestration s…
sourcehawk May 30, 2026
c32b8fe
chore(state): enter developing, dispatch #45 scaffold wave
sourcehawk May 30, 2026
6a15a36
docs(claude): require testify assertions in tests; log #45 env + test…
sourcehawk May 30, 2026
2a0d6d4
feat(cloud): scaffold the read-only cloud-context MCP package and saf…
sourcehawk May 30, 2026
98f7d40
chore(state): #45 self-merged (#48), contracts locked, Phase 2 unblocked
sourcehawk May 30, 2026
189362d
chore(state): dispatch Wave 2 (#43/#46/#47); log serve.go resource co…
sourcehawk May 30, 2026
7a36b2a
chore(state): re-sequence #47 to Wave 2b (depends on #43+#46); add pr…
sourcehawk May 30, 2026
3b7dcc2
feat(cloud/gcp): GCP provider for the cloud-context MCP (#49)
sourcehawk May 30, 2026
cffa192
chore(state): #43 self-merged (#49); log probe-env, expected-identity…
sourcehawk May 30, 2026
09c0946
feat(cloud/aws): AWS provider for the cloud-context MCP (#50)
sourcehawk May 30, 2026
5aa093d
chore(state): #46 self-merged (#50); Wave 2a complete
sourcehawk May 30, 2026
72c9afa
chore(state): Wave 2a checkpoint clean; dispatch Wave 2b (#47 + probe…
sourcehawk May 30, 2026
16aca72
fix(cloud): probe with a minimal subprocess env instead of inheriting…
sourcehawk May 30, 2026
60185f3
chore(state): probe-env remediation self-merged (#51)
sourcehawk May 30, 2026
9f8c72f
feat(cloud): launcher integration for the cloud-context MCP (#52)
sourcehawk May 30, 2026
c921b13
chore(state): #47 self-merged (#52); all sub-PRs merged, status=review
sourcehawk May 30, 2026
3ffad0f
chore(state): integration PR #53 opened
sourcehawk May 30, 2026
c714e40
chore: remove cloud-context-mcp scratch plan and state (shipped)
sourcehawk May 30, 2026
eddc127
docs(cloud): document configuring the GCP and AWS cloud providers (#54)
sourcehawk May 30, 2026
a31ff0c
refactor(cloud): thread probe identity explicitly instead of pinning …
sourcehawk May 30, 2026
25986bd
fix(cloud): enforce cloud-source degrade and carry the source alias (…
sourcehawk May 30, 2026
435219e
fix(cloud): address review findings in the harness, allowlist, and pr…
sourcehawk May 30, 2026
ff3d568
fix(cloud): second review pass — GCP probe regression, profile valida…
sourcehawk May 30, 2026
9f59281
docs(cloud): recommend a minimal read-only IAM grant for the pinned i…
sourcehawk May 30, 2026
de57731
fix(cloud): third review pass — deny-floor nested exfil paths, file-f…
sourcehawk May 30, 2026
ad8b311
fix(cloud): close deny-floor prefix bypass and bound the identity pro…
sourcehawk May 31, 2026
a32040f
docs(cloud): clarify scope is a guardrail and read-only rests on the …
sourcehawk May 31, 2026
453a5ac
fix(cloud): review round 2 — bounded output, degraded-identity report…
sourcehawk May 31, 2026
5117d4c
docs(spec): bounded target selection (set_active_target) for the clou…
sourcehawk May 31, 2026
b6c4c5a
docs(spec): keep the base cloud-context ADR as-built; switching desig…
sourcehawk May 31, 2026
267a752
docs(spec): cloud active-target-selection ADR (extends the cloud-cont…
sourcehawk May 31, 2026
6ab5072
docs(plan): implementation plan for cloud active-target selection
sourcehawk May 31, 2026
ab11261
feat(cloud): active-target selection core (set_active_target) (#67)
sourcehawk May 31, 2026
2c01ab1
feat(cloud): AWS multi-account active-target wiring and docs (#68)
sourcehawk May 31, 2026
a249699
fix(mcp/cloud): break selectableTargets↔run recursion via ungated exe…
sourcehawk May 31, 2026
d2c0489
fix(mcp/cloud): deny-floor the CLI wire-tracing flags
sourcehawk May 31, 2026
be45f0e
fix(server): probe cloud connections through the shared preflight prober
sourcehawk May 31, 2026
9d0d354
docs(spec): document the set_active_target tool the cloud MCP ships
sourcehawk May 31, 2026
a6d19b2
fix(mcp/cloud): deny-floor --project so set_active_target is the only…
sourcehawk May 31, 2026
4896772
Revert "docs(spec): document the set_active_target tool the cloud MCP…
sourcehawk May 31, 2026
1f11a9c
refactor(cloud): one AWS account model, validate the active account's…
sourcehawk May 31, 2026
9a56d0f
fix(mcp/cloud): ungate list_inventory and let the active target win t…
sourcehawk May 31, 2026
4056d82
fix(mcp/cloud): serialize AWS managed-profile generation against conc…
sourcehawk May 31, 2026
03c1c9d
fix(profile): default profile.yaml AWS example uses the accounts model
sourcehawk May 31, 2026
c72d72b
refactor(cloud): drop assumed_identity for AWS; provider-specific con…
sourcehawk May 31, 2026
0af6dba
refactor(web): unify the cloud pill to one principal + reach shape
sourcehawk May 31, 2026
4f62fe2
chore(docs): remove cloud-context design/plan artifacts from the PR
sourcehawk May 31, 2026
99613fc
feat(cloud): deployment tags on selectable targets, returned by list_…
sourcehawk Jun 1, 2026
e4162ec
docs(mcp/cloud): list_inventory description surfaces deployment tags
sourcehawk Jun 2, 2026
ed0ba97
feat(server): surface cloud-context MCP wiring on the investigation DTO
sourcehawk Jun 2, 2026
306c772
feat(web): render cloud-context MCPs in the action toolbar
sourcehawk Jun 2, 2026
fa65e3d
refactor(web): cap the mcp status bar height and let it scroll
sourcehawk Jun 2, 2026
7b606c4
feat(web): brand AWS/GCP marks on cloud connection pills
sourcehawk Jun 2, 2026
d7b3443
refactor(web): trim cloud connections copy, fold reauth into the pill
sourcehawk Jun 2, 2026
3a99faa
docs(cloud): add visual references to the cloud-providers page
sourcehawk Jun 2, 2026
5281351
fix(server): rehydrate wires cloud MCPs from the live profile
sourcehawk Jun 2, 2026
c226b25
refactor(web): raise the mcp status bar max height by a third
sourcehawk Jun 2, 2026
77bc611
fix(web): make cloud-providers docs section routable
sourcehawk Jun 2, 2026
17a8489
docs(cloud): clarify GCP service-account host vs target projects
sourcehawk Jun 2, 2026
3abbf7e
docs(cloud): give GCP setup a config sample to match AWS
sourcehawk Jun 2, 2026
394f491
docs(cloud): raise cloud-providers page to OSS-grade for newcomers an…
sourcehawk Jun 2, 2026
216adaa
docs(cloud): show the GCP impersonation-grant command, not just prose
sourcehawk Jun 2, 2026
38d08fa
docs(cloud): make AWS role setup runnable, matching GCP
sourcehawk Jun 2, 2026
8befcfe
docs(spec): cloud-triage playbook design
sourcehawk Jun 2, 2026
6f956ca
feat(playbooks): add cloud_triage sub-flow with a signal gate
sourcehawk Jun 2, 2026
72a899a
docs(cloud): align run_cli example and scope/identity docs with enfor…
sourcehawk Jun 2, 2026
c3780b0
fix(mcp/cloud): fail closed if AWS managed-profile write would be unp…
sourcehawk Jun 2, 2026
5ee148b
docs(spec): per-profile AWS managed config file
sourcehawk Jun 2, 2026
b461d16
feat(profile): per-profile CloudCacheDir path for the AWS managed config
sourcehawk Jun 2, 2026
689d180
feat(mcp/cloud): AWS provider writes a triagent-owned config, not ~/.…
sourcehawk Jun 2, 2026
ff183f1
feat(cloud): thread the per-profile AWS config target/source through …
sourcehawk Jun 2, 2026
32e59e6
feat(cloud): strip legacy triagent blocks from ~/.aws/config on start
sourcehawk Jun 2, 2026
35ea923
docs(cloud): document the triagent-owned AWS config
sourcehawk Jun 2, 2026
d1595c0
fix(cloud): drop the ~/.aws/config migration; it cleans never-shipped…
sourcehawk Jun 2, 2026
186789f
fix(cloud): correct AWS test shape and connections comment
sourcehawk Jun 2, 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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ These skills live in the `feature-dev-workflow` plugin (`github.com/sourcehawk/f
## Operational rules

- **TDD is the standard.** Failing test → watch it fail for the right reason → implement. One commit per task.
- **Tests assert with `testify`.** Use `github.com/stretchr/testify/assert` for checks the test should keep running past, and `require` for preconditions a failure must stop at (a non-nil error before a dereference, setup that must succeed). Bare `t.Fatal` / `t.Errorf` is the rare exception, not the default.
- **Before claiming done: `make test` + `make lint`; if `frontend/` touched, also `cd frontend && npm run typecheck`.** CI gates all three; local is the cheapest place to catch failures. Race-clean is non-negotiable.
- **Commit conventions:** `feat(<area>): ...`, `fix(<area>): ...`, `refactor(<area>): ...`, `test(<area>): ...`, `chore(<area>): ...`. Area mirrors the module path.
- **Never `--no-verify`, never `git add -A` / `git add .`.** Stage by name; pre-commit hooks exist for a reason.
Expand Down
175 changes: 150 additions & 25 deletions cmd/triagent-mcp/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"
"syscall"

"github.com/charmbracelet/log"
"github.com/sourcehawk/triagent/pkg/mcp/agentoperator"
"github.com/sourcehawk/triagent/pkg/mcp/cloud"
"github.com/sourcehawk/triagent/pkg/mcp/cloud/providers"
"github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/aws"
"github.com/sourcehawk/triagent/pkg/mcp/cloud/providers/gcp"
"github.com/sourcehawk/triagent/pkg/mcp/git"
"github.com/sourcehawk/triagent/pkg/mcp/incidentio"
"github.com/sourcehawk/triagent/pkg/mcp/k8s"
Expand All @@ -21,28 +27,27 @@ import (
"github.com/sourcehawk/triagent/pkg/mcp/strategies"
"github.com/sourcehawk/triagent/pkg/mcp/teleport"
"github.com/sourcehawk/triagent/pkg/mcp/wiki"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)

// Environment variable names. Flags override env when both are set.
const (
envKubeconfig = "TRIAGENT_MCP_KUBECONFIG"
envCRDsFile = "TRIAGENT_MCP_CRDS_FILE"
envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS"
envSessionDir = "TRIAGENT_MCP_SESSION_DIR"
envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR"
envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR"
envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR"
envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL"
envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH"
envGitRepo = "TRIAGENT_MCP_GIT_REPO"
envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR"
envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY"
envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES"
envWikiServePath = "TRIAGENT_MCP_WIKI_PATH"
envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH"
envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY"
envKubeconfig = "TRIAGENT_MCP_KUBECONFIG"
envCRDsFile = "TRIAGENT_MCP_CRDS_FILE"
envCrossplaneGroups = "TRIAGENT_MCP_CROSSPLANE_GROUPS"
envSessionDir = "TRIAGENT_MCP_SESSION_DIR"
envUserPlaybooksDir = "TRIAGENT_MCP_USER_PLAYBOOKS_DIR"
envPluginPlaybooksDir = "TRIAGENT_MCP_PLUGIN_PLAYBOOKS_DIR"
envSystemPlaybooksDir = "TRIAGENT_MCP_SYSTEM_PLAYBOOKS_DIR"
envStrategiesSubagentModel = "TRIAGENT_MCP_STRATEGIES_SUBAGENT_MODEL"
envMCPConfigPath = "TRIAGENT_MCP_CONFIG_PATH"
envGitRepo = "TRIAGENT_MCP_GIT_REPO"
envGitCacheDir = "TRIAGENT_MCP_GIT_CACHE_DIR"
envGitClaudeBinary = "TRIAGENT_MCP_GIT_CLAUDE_BINARY"
envGitFilterPrereleases = "TRIAGENT_MCP_GIT_FILTER_PRERELEASES"
envWikiServePath = "TRIAGENT_MCP_WIKI_PATH"
envWikiServeProposalsPath = "TRIAGENT_MCP_WIKI_PROPOSALS_PATH"
envWikiServeClaudeBinary = "TRIAGENT_MCP_WIKI_CLAUDE_BINARY"

envSessionsProposalsPath = "TRIAGENT_MCP_SESSIONS_PROPOSALS_PATH"
envSessionsClaudeBinary = "TRIAGENT_MCP_SESSIONS_CLAUDE_BINARY"
Expand Down Expand Up @@ -71,10 +76,10 @@ type serveFlags struct {
systemPlaybooksDir string

// git flags
gitRepo string
gitCacheDir string
gitClaudeBinary string
gitFilterPrereleases bool
gitRepo string
gitCacheDir string
gitClaudeBinary string
gitFilterPrereleases bool

// wiki flags
wikiPath string
Expand All @@ -95,6 +100,9 @@ type serveFlags struct {
promURL string
promBearer string
promBasic string

// cloud flags
cloudProvider string
}

func serveCmd() *cobra.Command {
Expand All @@ -104,14 +112,14 @@ func serveCmd() *cobra.Command {
Short: "Run one of the triagent-mcp MCP servers over stdio",
Long: "Run one of the triagent-mcp MCP servers over stdio.\n\n" +
"Select the server via --kind. Supported kinds:\n" +
" k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom",
" k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runServe(cmd.Context(), resolveFlags(f))
},
}
cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, or prom (required)")
cmd.Flags().StringVar(&f.kind, "kind", "", "which MCP server to run: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, or cloud (required)")

// k8s flags
cmd.Flags().StringVar(&f.kubeconfig, "kubeconfig", "", "path to kubeconfig (defaults to $"+envKubeconfig+", then $KUBECONFIG, then ~/.kube/config) [kind=k8s]")
Expand Down Expand Up @@ -150,6 +158,9 @@ func serveCmd() *cobra.Command {
cmd.Flags().StringVar(&f.promBearer, "prom-bearer", "", "Authorization: Bearer token for Prometheus (defaults to $"+envPromBearer+") [kind=prom]")
cmd.Flags().StringVar(&f.promBasic, "prom-basic", "", "Basic auth credentials user:pass for Prometheus (defaults to $"+envPromBasic+") [kind=prom]")

// cloud flags
cmd.Flags().StringVar(&f.cloudProvider, "provider", "", "cloud provider to serve: gcp or aws; required (defaults to $"+cloud.EnvProvider+") [kind=cloud]")

return cmd
}

Expand Down Expand Up @@ -215,6 +226,9 @@ func resolveFlags(f *serveFlags) serveFlags {
if out.promBasic == "" {
out.promBasic = os.Getenv(envPromBasic)
}
if out.cloudProvider == "" {
out.cloudProvider = os.Getenv(cloud.EnvProvider)
}
// Bool env override: only consider when the operator hasn't passed
// the flag explicitly. Cobra preserves the flag default (true) when
// unset, so we can't distinguish "operator passed --filter-prereleases=true"
Expand Down Expand Up @@ -263,10 +277,12 @@ func runServe(ctx context.Context, f serveFlags) error {
return runParallel(ctx, f)
case "prom":
return runProm(ctx, f)
case "cloud":
return runCloud(ctx, f)
case "":
return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)")
return fmt.Errorf("--kind is required (one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)")
default:
return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom)", f.kind)
return fmt.Errorf("unknown --kind %q (want one of: k8s, teleport, strategies, git, wiki, slack, incidentio, sessions, meta, agent-operator, signal-ingest, parallel, prom, cloud)", f.kind)
}
}

Expand Down Expand Up @@ -423,6 +439,115 @@ func runProm(ctx context.Context, f serveFlags) error {
return srv.Run(ctx)
}

// runCloud wires the read-only cloud-context MCP. --provider selects the
// concrete backend; New plugs it in behind cloud.Provider. The launcher passes
// the allowlist override path, target scope, and pinned identity through the
// subprocess env (cloud.EnvAllowlistPath, cloud.EnvScope,
// cloud.EnvExpectedIdentity), never argv. An aws source carries its accounts,
// source_profile, and alias (cloud.EnvAWSAccounts, _SOURCE_PROFILE, _ALIAS); New
// generates the per-account assume-role profiles and surfaces the accounts as the
// agent's selectable targets (a single-account source is a one-entry list).
func runCloud(ctx context.Context, f serveFlags) error {
if f.cloudProvider == "" {
return fmt.Errorf("--provider is required (gcp or aws) (set --provider or $%s)", cloud.EnvProvider)
}
scope, err := parseCloudScope(os.Getenv(cloud.EnvScope))
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
accounts, err := parseAWSAccounts(os.Getenv(cloud.EnvAWSAccounts))
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
gcpProjects, err := parseGCPProjects(os.Getenv(cloud.EnvGCPProjects))
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
provider, err := providers.New(f.cloudProvider, providers.Options{
AWSAlias: os.Getenv(cloud.EnvAWSAlias),
AWSSourceProfile: os.Getenv(cloud.EnvAWSSourceProfile),
AWSAccounts: accounts,
GCPProjects: gcpProjects,
AWSConfigTarget: os.Getenv(cloud.EnvAWSConfigFile),
AWSConfigSource: os.Getenv(cloud.EnvAWSSourceConfig),
})
if err != nil {
return err
}
srv, err := cloud.New(cloud.Options{
Provider: provider,
AllowlistPath: os.Getenv(cloud.EnvAllowlistPath),
Scope: scope,
ExpectedIdentity: os.Getenv(cloud.EnvExpectedIdentity),
})
if err != nil {
return fmt.Errorf("build cloud mcp server: %w", err)
}
log.Info("mcp serve --kind=cloud starting", "provider", f.cloudProvider)
return srv.Run(ctx)
}

// parseCloudScope decodes the JSON-encoded target scope the launcher froze into
// a cloud.ScopeAllowlist. An empty value yields an empty scope, which leaves the
// target axes unconstrained. A malformed value is an error that aborts startup:
// failing closed, since a misconfigured scope must never silently widen run_cli
// by dropping the deployment's restrictions.
func parseCloudScope(raw string) (cloud.ScopeAllowlist, error) {
var scope cloud.ScopeAllowlist
if raw == "" {
return scope, nil
}
if err := json.Unmarshal([]byte(raw), &scope); err != nil {
return cloud.ScopeAllowlist{}, fmt.Errorf("malformed cloud scope in $%s: %w", cloud.EnvScope, err)
}
return scope, nil
}

// parseAWSAccounts decodes the JSON-encoded aws multi-account set the launcher
// froze into the aws provider's account list. An empty value yields nil, the
// single-account / single-identity form. A malformed value is an error that
// aborts startup: failing closed, since a misconfigured accounts list must never
// silently drop accounts the agent should be able to select.
func parseAWSAccounts(raw string) ([]aws.Account, error) {
if raw == "" {
return nil, nil
}
var wire []struct {
AccountID string `json:"account_id"`
RoleARN string `json:"role_arn"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, fmt.Errorf("malformed cloud aws accounts in $%s: %w", cloud.EnvAWSAccounts, err)
}
accounts := make([]aws.Account, 0, len(wire))
for _, w := range wire {
accounts = append(accounts, aws.Account{ID: w.AccountID, RoleARN: w.RoleARN, Tags: w.Tags})
}
return accounts, nil
}

// parseGCPProjects decodes the JSON-encoded gcp project set the launcher froze
// into []gcp.Project. Empty yields nil (the unconstrained form). A malformed
// value aborts startup, failing closed like parseAWSAccounts.
func parseGCPProjects(raw string) ([]gcp.Project, error) {
if raw == "" {
return nil, nil
}
var wire []struct {
ID string `json:"id"`
Tags []string `json:"tags"`
}
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, fmt.Errorf("malformed cloud gcp projects in $%s: %w", cloud.EnvGCPProjects, err)
}
projects := make([]gcp.Project, 0, len(wire))
for _, w := range wire {
projects = append(projects, gcp.Project{ID: w.ID, Tags: w.Tags})
}
return projects, nil
}

func runGit(ctx context.Context, f serveFlags) error {
if f.gitRepo == "" {
return fmt.Errorf("--repo is required (owner/name) (set --repo or $%s)", envGitRepo)
Expand Down
120 changes: 120 additions & 0 deletions cmd/triagent-mcp/serve_cloud_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRunServe_CloudKindRequiresProvider(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "cloud"})
require.Error(t, err, "expected error when --provider is missing")
assert.Contains(t, err.Error(), "provider", "error should mention --provider")
}

func TestRunServe_CloudKindRejectsUnknownProvider(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "cloud", cloudProvider: "azure"})
require.Error(t, err, "expected error for an unknown provider")
assert.Contains(t, err.Error(), "azure", "error should name the rejected provider")
}

func TestRunServe_UnknownKindErrorListsCloud(t *testing.T) {
t.Parallel()
err := runServe(context.Background(), serveFlags{kind: "bogus"})
require.Error(t, err, "expected error for unknown kind")
assert.Contains(t, err.Error(), "cloud", "kind list should include cloud")
}

func TestServeCmd_KnowsCloudKind(t *testing.T) {
t.Parallel()
cmd := serveCmd()
assert.Contains(t, cmd.Long, "cloud", "serve --help should list cloud")
}

func TestParseCloudScope_EmptyYieldsUnconstrained(t *testing.T) {
t.Parallel()
scope, err := parseCloudScope("")
require.NoError(t, err)
assert.Empty(t, scope.Regions)
assert.Empty(t, scope.Accounts)
}

func TestParseCloudScope_ValidJSON(t *testing.T) {
t.Parallel()
scope, err := parseCloudScope(`{"regions":["us-central1"],"accounts":["123456789012"]}`)
require.NoError(t, err)
assert.Equal(t, []string{"us-central1"}, scope.Regions)
assert.Equal(t, []string{"123456789012"}, scope.Accounts)
}

func TestParseGCPProjects_EmptyYieldsNil(t *testing.T) {
t.Parallel()
got, err := parseGCPProjects("")
require.NoError(t, err)
assert.Nil(t, got)
}

func TestParseGCPProjects_DecodesIDsAndTags(t *testing.T) {
t.Parallel()
got, err := parseGCPProjects(`[{"id":"prod-a","tags":["prod","payments"]},{"id":"prod-b"}]`)
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, "prod-a", got[0].ID)
assert.Equal(t, []string{"prod", "payments"}, got[0].Tags)
assert.Equal(t, "prod-b", got[1].ID)
assert.Empty(t, got[1].Tags)
}

func TestParseGCPProjects_MalformedFailsClosed(t *testing.T) {
t.Parallel()
_, err := parseGCPProjects(`[{"id":`)
require.Error(t, err)
}

func TestParseCloudScope_MalformedFailsClosed(t *testing.T) {
t.Parallel()
_, err := parseCloudScope(`{"projects":`)
require.Error(t, err, "a malformed scope must fail closed, not silently drop restrictions")
}

func TestRunCloud_MalformedScopeAborts(t *testing.T) {
t.Setenv("TRIAGENT_CLOUD_PROVIDER", "gcp")
t.Setenv("TRIAGENT_CLOUD_SCOPE", `{"projects":`)
err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "gcp"})
require.Error(t, err, "a malformed scope must abort cloud-server startup")
assert.Contains(t, err.Error(), "scope", "the error should name the scope")
}

func TestParseAWSAccounts_EmptyYieldsNil(t *testing.T) {
t.Parallel()
accs, err := parseAWSAccounts("")
require.NoError(t, err)
assert.Nil(t, accs)
}

func TestParseAWSAccounts_DecodesJSON(t *testing.T) {
t.Parallel()
accs, err := parseAWSAccounts(`[{"account_id":"111111111111","role_arn":"arn:aws:iam::111111111111:role/r"},{"account_id":"222222222222","role_arn":"arn:aws:iam::222222222222:role/r"}]`)
require.NoError(t, err)
require.Len(t, accs, 2)
assert.Equal(t, "111111111111", accs[0].ID)
assert.Equal(t, "arn:aws:iam::222222222222:role/r", accs[1].RoleARN)
}

func TestParseAWSAccounts_MalformedFailsClosed(t *testing.T) {
t.Parallel()
_, err := parseAWSAccounts(`[{"account_id":`)
require.Error(t, err, "a malformed accounts list must fail closed, not silently drop accounts")
}

func TestRunCloud_MalformedAWSAccountsAborts(t *testing.T) {
t.Setenv("TRIAGENT_CLOUD_PROVIDER", "aws")
t.Setenv("TRIAGENT_CLOUD_AWS_ACCOUNTS", `[{"account_id":`)
err := runCloud(context.Background(), serveFlags{kind: "cloud", cloudProvider: "aws"})
require.Error(t, err, "a malformed accounts list must abort cloud-server startup")
assert.Contains(t, err.Error(), "accounts", "the error should name the accounts")
}
Loading
Loading