diff --git a/README.md b/README.md index 6c8c2ff..60af309 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,37 @@ nan runs ls [--ws NAME] [--state S] [--limit 20] [--json] # newest first nan runs show [--json] # state, result, prompt nan runs logs [-f] [--after N] [--json] # -f follows until it ends nan runs cancel [--json] +nan runs proposal [--diff] [--json] # what an automation run proposed +nan runs reject [--reason R] # turn the proposal down ``` +### Automations, approvals and secrets + +Automations are created and edited in the portal. From the CLI you can list, +inspect and run them, read their webhook deliveries, see what waits for your +approval, and list your secrets (names and metadata, never values): + +```bash +nan automations ls [--json] +nan automations show [--json] +nan automations run [--input @payload.json | --input - | --input JSON] [--detach] [--json] +nan automations deliveries [--limit 50] [--json] +nan approvals ls [--state pending|approved|rejected|expired|cancelled|auto|all] [--limit 20] [--json] +nan approvals show [--diff] # same as nan runs proposal +nan secrets ls [--json] # needs nan auth login +``` + +Approving a proposal, writing a secret and creating or editing an automation +happen only in the portal at , signed in through +the browser. Each of them can hand a secret to an agent or let it act on your +repository without asking, so none is possible with a credential an agent or +a CI job could read: this machine's CLI session, a platform token or an API +key. A platform token needs the `automations:read` scope to list and inspect +automations and `automations:run` to start one. + ### Exit codes -`nan run` and `nan runs logs -f` exit with the run's outcome, so a script can +`nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can branch on it without parsing text: | Code | Meaning | @@ -163,6 +189,7 @@ branch on it without parsing text: | 2 | timed out | | 3 | cancelled (after a double Ctrl-C: cancel requested — `nan runs show` has the final state) | | 4 | the workspace is not set up for it: agent not installed, no inference key, bad configuration | +| 10 | awaiting approval: an automation run whose proposed actions wait for you (`nan runs proposal `, approve in the portal) | | 64 | usage error (also: more than one workspace and no `--ws`) | | 65 | not signed in, session expired, or not allowed | | 69 | nan.builders unavailable, or the stream was lost after retries (the run goes on; the id is printed) | @@ -187,7 +214,8 @@ JSON object per line): . `nan auth login --api-token`). Create platform tokens in **Settings > Tokens** at -. A token works for runs and for listing your +. A token works for runs, for automations (with the +`automations:read` and `automations:run` scopes) and for listing your workspaces, and nothing else: it is not an inference key, and `nan me`, `nan metrics` and the dashboard still need `nan auth login`. diff --git a/cmd/approvals.go b/cmd/approvals.go new file mode 100644 index 0000000..91e7108 --- /dev/null +++ b/cmd/approvals.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "strings" + "text/tabwriter" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/spf13/cobra" +) + +var ( + approvalsState string + approvalsLimit int + approvalsJSON bool +) + +const approveInPortal = "there is no approve command in the CLI: approve in the portal at " + runs.ApprovalsURL + + " (approving needs a browser sign-in, so an agent or a CI job that can read this machine's session or tokens can never approve its own actions). " + + "Read the proposal with: nan runs proposal ; reject it with: nan runs reject " + +var approvalsCmd = &cobra.Command{ + Use: "approvals", + Short: "See the proposals waiting for your approval", + Long: `See the automation runs whose proposed actions wait for your approval. + +Read a proposal with nan approvals show (the same as nan runs +proposal), and reject it with nan runs reject . Approving happens +only in the portal, at ` + runs.ApprovalsURL + `: it needs you +signed in through the browser, so that nothing that can read this machine's +CLI session or tokens (an agent in a workspace, a CI job) can approve the +actions an agent proposed. + +Authentication is the same as nan run (see nan run --help); a platform token +needs the runs scope. + +Docs: https://nan.builders/docs/runs`, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && (args[0] == "approve" || args[0] == "accept") { + return usageErrorf("%s", approveInPortal) + } + if len(args) > 0 { + return usageErrorf("unknown command %q for \"nan approvals\"; try: nan approvals --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) + } + return cmd.Help() + }, +} + +var approvalsLsCmd = &cobra.Command{ + Use: "ls", + Short: "List approvals, pending ones by default", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + state, err := approvalsStateParam(approvalsState) + if err != nil { + return err + } + if approvalsLimit < 1 || approvalsLimit > 100 { + return usageErrorf("--limit must be between 1 and 100") + } + return withRunsEnv(func(env *runsEnv) error { return doApprovalsLs(cmd.Context(), env, state) }) + }, +} + +var approvalsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Print a run's proposal (the same as nan runs proposal)", + Args: oneRunID, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withRunsEnv(func(env *runsEnv) error { + return doRunsProposal(cmd.Context(), env, args[0], proposalDiff, proposalJSON) + }) + }, +} + +func init() { + rootCmd.AddCommand(approvalsCmd) + approvalsCmd.AddCommand(approvalsLsCmd, approvalsShowCmd) + approvalsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) + + approvalsLsCmd.Flags().StringVar(&approvalsState, "state", api.ApprovalPending, "Only approvals in this state: pending, approved, rejected, expired, cancelled, auto, or all") + approvalsLsCmd.Flags().IntVar(&approvalsLimit, "limit", 20, "How many approvals to list (1-100)") + approvalsLsCmd.Flags().BoolVar(&approvalsJSON, "json", false, "Print the API's list object as JSON") + + approvalsShowCmd.Flags().BoolVar(&proposalDiff, "diff", false, "Also print the full diff, when one is stored") + approvalsShowCmd.Flags().BoolVar(&proposalJSON, "json", false, "Print the approval object (proposal included) as JSON") +} + +// approvalsStateParam is the query value for --state: "all" is no filter. +func approvalsStateParam(state string) (string, error) { + if state == "all" { + return "", nil + } + for _, s := range api.ApprovalStates { + if s == state { + return state, nil + } + } + return "", usageErrorf("--state must be one of %s, or all", strings.Join(api.ApprovalStates, ", ")) +} + +func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { + list, err := env.client.ListApprovals(ctx, api.ListApprovalsParams{State: state, Limit: approvalsLimit}) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 404 { + return exitf(exitAuth, "automations are not enabled for your account") + } + return apiExit(err, env.cred) + } + if approvalsJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + if state == api.ApprovalPending { + fmt.Fprintln(env.stderr, "Nothing is waiting for your approval.") + } else { + fmt.Fprintln(env.stderr, "No approvals match.") + } + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "RUN\tSTATE\tAUTOMATION\tWAITING FOR APPROVAL\tEXPIRES") + for _, a := range list.Data { + expires := "-" + if a.ExpiresAt != nil && a.State == api.ApprovalPending { + expires = localTime(*a.ExpiresAt) + } + automation := runs.SanitizeLine(a.Automation.Label()) + if automation == "" { + automation = "-" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + runs.SanitizeLine(a.RunID), + runs.SanitizeLine(a.State), + runs.Truncate(automation, 40), + runs.Truncate(waitingKinds(a), 60), + expires) + } + if err := tw.Flush(); err != nil { + return err + } + if list.NextCursor != nil { + fmt.Fprintf(env.stderr, "showing the %d most recent; raise --limit (up to 100) for more\n", len(list.Data)) + } + if state == api.ApprovalPending { + fmt.Fprintf(env.stderr, "\nreview one: nan runs proposal \napprove: in the portal, %s (approving needs a browser sign-in)\nreject: nan runs reject \n", runs.ApprovalsURL) + } + return nil +} + +// waitingKinds is the kinds of the actions an approval covers: those from +// the first one that needs approval on, without repeats. +func waitingKinds(a api.Approval) string { + if a.Proposal == nil || a.ApprovalFrom == nil || *a.ApprovalFrom < 0 { + return "-" + } + var kinds []string + seen := map[string]bool{} + for i, act := range a.Proposal.Actions { + if i < *a.ApprovalFrom || seen[act.Kind] { + continue + } + seen[act.Kind] = true + kinds = append(kinds, runs.SanitizeLine(act.Kind)) + } + if len(kinds) == 0 { + return "-" + } + return strings.Join(kinds, ", ") +} diff --git a/cmd/automations.go b/cmd/automations.go new file mode 100644 index 0000000..cd26567 --- /dev/null +++ b/cmd/automations.go @@ -0,0 +1,528 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + "unicode/utf8" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/spf13/cobra" +) + +// maxInputBytes is the most input an automation run takes (the platform's +// MaxInputPayloadBytes), checked here so a mistake costs a clear message. +const maxInputBytes = 48 * 1024 + +var ( + autoLsJSON bool + autoShowJSON bool + autoRunInput string + autoRunDetach bool + autoRunJSON bool + autoRunIdemKey string + autoDeliveryLimit int + autoDeliveryJSON bool +) + +// portalOnlyAutomationVerbs are the writes a member may reach for. Each one +// answers with where to do it instead of "unknown command". +var portalOnlyAutomationVerbs = map[string]bool{ + "create": true, "new": true, "add": true, "edit": true, "update": true, "set": true, + "rm": true, "delete": true, "remove": true, "enable": true, "disable": true, + "triggers": true, "trigger": true, "rotate": true, +} + +const automationsPortalOnly = "automations are created and edited in the portal: " + runs.AutomationsURL + " (new: " + runs.NewAutomationURL + ")" + + ". An automation decides which of your secrets an agent receives and which of its actions need your approval, " + + "so writing one needs you signed in through the browser. From here you can list, inspect and run them " + + "(nan automations --help)" + +var automationsCmd = &cobra.Command{ + Use: "automations", + Short: "List, inspect and run your automations", + Long: `List, inspect and run the automations you set up in the portal, and read +their webhook delivery log. + +An automation is a saved agent task: a workspace, an agent, instructions, +the secrets it receives, and its triggers (webhooks, schedules). Its runs +can propose actions on your repository (a push, a pull request, a comment) +that apply automatically or wait for your approval, as you configured. + +Portal-only, and why: creating and editing automations, writing secrets and +approving proposals need you signed in through the browser at +` + runs.PortalURL + `. Each of them can hand a secret to an agent +or let an agent act without asking, so none can be done with a credential an +agent or a CI job could read: this machine's CLI session, a platform token or +an API key. + +Authentication is the same as nan run (see nan run --help). A platform token +needs the automations:read scope to list and inspect, and automations:run to +start a run. + +Docs: https://nan.builders/docs/runs`, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && portalOnlyAutomationVerbs[args[0]] { + return usageErrorf("%s", automationsPortalOnly) + } + if len(args) > 0 { + return usageErrorf("unknown command %q for \"nan automations\"; try: nan automations --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) + } + return cmd.Help() + }, +} + +var automationsLsCmd = &cobra.Command{ + Use: "ls", + Short: "List your automations", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withRunsEnv(func(env *runsEnv) error { return doAutomationsLs(cmd.Context(), env) }) + }, +} + +var automationsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show one automation: its settings, secrets (names only) and triggers", + Args: oneAutomation, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withRunsEnv(func(env *runsEnv) error { return doAutomationsShow(cmd.Context(), env, args[0]) }) + }, +} + +var automationsRunCmd = &cobra.Command{ + Use: "run [--input @FILE | --input - | --input JSON]", + Short: "Start a run of an automation and stream it", + Long: `Start a run of an automation now, with its saved instructions, secrets and +approval settings, and stream what it does. + +--input passes a JSON object to the run: @FILE reads it from a file, - +from stdin, or give the JSON itself. The agent receives it as untrusted data +next to the automation's instructions, which it never replaces. Up to 48 KiB. +Do not put secrets in it: the agent reads it; assign secrets in the portal. + +When the run proposes actions that need your approval, it stops in +awaiting_approval and this command exits 10: read the proposal with nan runs +proposal , approve it in the portal, or reject it with nan runs reject +. + +Exit codes are those of nan run (see nan run --help), plus 10 for awaiting +approval. A platform token needs the automations:run scope. + +Docs: https://nan.builders/docs/runs`, + Example: ` nan automations run "PR review" --input '{"pr": 42}' + nan automations run nightly-triage --detach + gh api repos/acme/api/pulls/42 | nan automations run "PR review" --input -`, + Args: oneAutomation, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if autoRunIdemKey != "" && !idempotencyPattern.MatchString(autoRunIdemKey) { + return usageErrorf("--idempotency-key must be 1-128 of letters, digits and . _ : -") + } + input, err := readAutomationInput(promptInput{r: os.Stdin, tty: isTerminal(os.Stdin), notice: os.Stderr}, autoRunInput) + if err != nil { + return err + } + return withRunsEnv(func(env *runsEnv) error { + return doAutomationsRun(cmd.Context(), env, args[0], input) + }) + }, +} + +var automationsDeliveriesCmd = &cobra.Command{ + Use: "deliveries ", + Short: "Show the webhook deliveries an automation received, newest first", + Long: `Show the webhook deliveries an automation received in the last 30 days, +newest first, and what became of each one. No payload or headers are kept. + +Outcomes: run_created, ignored (an event or actor filter did not match), +duplicate, loop_suppressed, skipped_active (a run was already going), +rate_limited, queue_full, too_large, not_accepted (your membership does not +include runs), bad_signature, secrets_unavailable. + +Docs: https://nan.builders/docs/runs`, + Args: oneAutomation, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if autoDeliveryLimit < 1 || autoDeliveryLimit > 100 { + return usageErrorf("--limit must be between 1 and 100") + } + return withRunsEnv(func(env *runsEnv) error { return doAutomationsDeliveries(cmd.Context(), env, args[0]) }) + }, +} + +func init() { + rootCmd.AddCommand(automationsCmd) + automationsCmd.AddCommand(automationsLsCmd, automationsShowCmd, automationsRunCmd, automationsDeliveriesCmd) + automationsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) + + automationsLsCmd.Flags().BoolVar(&autoLsJSON, "json", false, "Print the API's list object as JSON") + automationsShowCmd.Flags().BoolVar(&autoShowJSON, "json", false, "Print the automation object as JSON") + + f := automationsRunCmd.Flags() + f.StringVar(&autoRunInput, "input", "", `JSON object for the run: @FILE, "-" for stdin, or the JSON itself`) + f.BoolVar(&autoRunDetach, "detach", false, "Start the run, print its id and return without streaming") + f.BoolVar(&autoRunJSON, "json", false, "Print events as JSON lines, then the run (with --detach: the run)") + f.StringVar(&autoRunIdemKey, "idempotency-key", "", "Retry-safe key: the same key returns the run already created") + + automationsDeliveriesCmd.Flags().IntVar(&autoDeliveryLimit, "limit", 50, "How many deliveries to list (1-100)") + automationsDeliveriesCmd.Flags().BoolVar(&autoDeliveryJSON, "json", false, "Print the deliveries as JSON") +} + +// oneAutomation is cobra.ExactArgs(1) with a message that says what is +// missing. +func oneAutomation(cmd *cobra.Command, args []string) error { + switch { + case len(args) == 0: + return usageErrorf("missing automation id or name; see: nan automations ls") + case len(args) > 1: + return usageErrorf("one automation at a time (quote a name with spaces)") + case strings.TrimSpace(args[0]) == "": + return usageErrorf("empty automation name; see: nan automations ls") + } + return nil +} + +// resolveAutomation turns an id or a name into the automation's id. An id is +// used as it is; a name is looked up among the member's automations. +func resolveAutomation(ctx context.Context, env *runsEnv, ref string) (string, error) { + if runIDPattern.MatchString(ref) { + return ref, nil + } + list, err := env.client.ListAutomations(ctx) + if err != nil { + return "", automationExit(err, ref, env.cred) + } + var folded []string + for _, a := range list.Data { + if a.Name == ref { + return a.ID, nil + } + if strings.EqualFold(a.Name, ref) { + folded = append(folded, a.Name) + } + } + shown := runs.Truncate(runs.SanitizeLine(ref), 64) + if len(folded) == 1 { + return "", usageErrorf("no automation named %q; did you mean %q? See: nan automations ls", shown, runs.SanitizeLine(folded[0])) + } + return "", usageErrorf("no automation named %q; see: nan automations ls", shown) +} + +// automationExit is apiExit with a 404 that names the automation. +func automationExit(err error, ref string, cred credential) *ExitError { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 404 { + return usageErrorf("automation %q not found (or automations are not enabled for your account)", runs.Truncate(runs.SanitizeLine(ref), 64)) + } + return apiExit(err, cred) +} + +func doAutomationsLs(ctx context.Context, env *runsEnv) error { + list, err := env.client.ListAutomations(ctx) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 404 { + return exitf(exitAuth, "automations are not enabled for your account") + } + return apiExit(err, env.cred) + } + if autoLsJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintf(env.stderr, "No automations yet. Create one in the portal: %s\n", runs.NewAutomationURL) + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tNAME\tWORKSPACE\tAGENT\tSTATUS\tAPPROVAL") + for _, a := range list.Data { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + runs.SanitizeLine(a.ID), + runs.Truncate(runs.SanitizeLine(a.Name), 40), + runs.SanitizeLine(a.Workspace.Label()), + runs.SanitizeLine(a.Agent), + automationStatus(a), + runs.Truncate(approvalSummary(a.ApprovalKinds), 40)) + } + return tw.Flush() +} + +func automationStatus(a api.Automation) string { + if a.Enabled { + return "enabled" + } + if a.DisabledReason != nil && *a.DisabledReason != "" && *a.DisabledReason != "member" { + return "disabled (" + strings.ReplaceAll(runs.SanitizeLine(*a.DisabledReason), "_", " ") + ")" + } + return "disabled" +} + +// approvalSummary says which actions wait for the member. None means every +// action applies automatically, pushes and pull requests included. +func approvalSummary(kinds []string) string { + if len(kinds) == 0 { + return "none (automatic)" + } + clean := make([]string, len(kinds)) + for i, k := range kinds { + clean[i] = runs.SanitizeLine(k) + } + return strings.Join(clean, ", ") +} + +func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { + id, err := resolveAutomation(ctx, env, ref) + if err != nil { + return err + } + a, err := env.client.GetAutomation(ctx, id) + if err != nil { + return automationExit(err, ref, env.cred) + } + if autoShowJSON { + return printJSON(env.stdout, a, true) + } + triggers, trigErr := env.client.ListTriggers(ctx, id) + + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + row := func(k, v string) { + if v != "" { + fmt.Fprintf(tw, "%s\t%s\n", k, v) + } + } + row("id", runs.SanitizeLine(a.ID)) + row("name", runs.SanitizeLine(a.Name)) + row("description", runs.Truncate(runs.SanitizeLine(a.Description), 200)) + row("status", automationStatus(*a)) + if a.Template != "" && a.Template != "custom" { + row("template", runs.SanitizeLine(a.Template)) + } + row("workspace", runs.SanitizeLine(a.Workspace.Label())) + agent := runs.SanitizeLine(a.Agent) + if a.Model != nil && *a.Model != "" { + agent += " · model " + runs.SanitizeLine(*a.Model) + } + row("agent", agent) + row("cwd", runs.SanitizeLine(a.Cwd)) + worktree := "auto" + if a.GitIsolation != nil { + worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation] + } + row("worktree", worktree) + if a.TimeoutSeconds > 0 { + row("timeout", (time.Duration(a.TimeoutSeconds) * time.Second).String()) + } + if a.GitHubRepo != nil { + row("repository", runs.SanitizeLine(*a.GitHubRepo)) + } + row("approval", approvalSummary(a.ApprovalKinds)) + if len(a.ApprovalKinds) > 0 && a.ApprovalTTLSeconds > 0 { + row("approval ttl", (time.Duration(a.ApprovalTTLSeconds) * time.Second).String()) + } + if a.ConcurrencyPolicy != "" { + row("concurrency", strings.ReplaceAll(runs.SanitizeLine(a.ConcurrencyPolicy), "_", " ")) + } + if a.MaxRunsPerHour > 0 { + row("runs per hour", fmt.Sprintf("up to %d", a.MaxRunsPerHour)) + } + for _, s := range a.Secrets { + row("secret", fmt.Sprintf("%s ← %s (%s)", runs.SanitizeLine(s.Env), runs.SanitizeLine(s.Secret), exposureNote(s.Exposure))) + } + if trigErr == nil { + for _, t := range triggers.Data { + row("trigger", triggerLine(t)) + } + } + row("updated", localTime(a.UpdatedAt)) + if err := tw.Flush(); err != nil { + return err + } + if trigErr != nil { + fmt.Fprintf(env.stderr, "could not read the triggers: %s\n", runs.SanitizeLine(apiExit(trigErr, env.cred).Error())) + } + + printBlock(env, "instructions", a.Instructions) + fmt.Fprintf(env.stdout, "\nrun it: nan automations run %s\nedit it in the portal: %s\n", runs.SanitizeLine(a.ID), runs.AutomationURL(a.ID)) + return nil +} + +func exposureNote(exposure string) string { + switch exposure { + case "agent": + return "the agent" + case "actions": + return "actions only, never the agent" + } + return runs.SanitizeLine(exposure) +} + +func triggerLine(t api.Trigger) string { + var b strings.Builder + b.WriteString(runs.SanitizeLine(t.Kind)) + switch t.Kind { + case "schedule": + if t.Cron != nil { + b.WriteString(" " + runs.SanitizeLine(*t.Cron)) + } + if t.Timezone != nil { + b.WriteString(" (" + runs.SanitizeLine(*t.Timezone) + ")") + } + if t.NextFireAt != nil && t.Enabled { + b.WriteString(" · next " + localTime(*t.NextFireAt)) + } + case "webhook": + if t.SignatureScheme != nil { + b.WriteString(" " + runs.SanitizeLine(*t.SignatureScheme)) + } + if t.TokenPrefix != nil { + b.WriteString(" · " + runs.SanitizeLine(*t.TokenPrefix) + "…") + } + } + if !t.Enabled { + b.WriteString(" · disabled") + } + return b.String() +} + +// readAutomationInput reads --input: "" for none, "-" for stdin, @FILE, or +// the JSON itself. It has to be a JSON object; it goes out compacted. +func readAutomationInput(in promptInput, flag string) (json.RawMessage, error) { + if flag == "" { + return nil, nil + } + var ( + raw []byte + err error + ) + switch { + case flag == "-": + if in.tty && in.notice != nil { + fmt.Fprintln(in.notice, "reading the input from stdin; end it with Ctrl-D") + } + if in.r == nil { + return nil, usageErrorf("no stdin to read the input from") + } + raw, err = io.ReadAll(io.LimitReader(in.r, 4*maxInputBytes+1)) + if err != nil { + return nil, usageErrorf("could not read the input from stdin: %v", err) + } + case strings.HasPrefix(flag, "@"): + path := flag[1:] + f, openErr := os.Open(path) + if openErr != nil { + return nil, usageErrorf("could not read the input: %v", unwrapPath(openErr)) + } + raw, err = io.ReadAll(io.LimitReader(f, 4*maxInputBytes+1)) + f.Close() + if err != nil { + return nil, usageErrorf("could not read the input: %v", unwrapPath(err)) + } + default: + raw = []byte(flag) + } + switch { + case len(raw) > 4*maxInputBytes: + return nil, usageErrorf("--input is over %d KiB, the most a run takes", maxInputBytes/1024) + case !utf8.Valid(raw): + return nil, usageErrorf("--input is not UTF-8 text") + case !json.Valid(raw): + return nil, usageErrorf("--input is not valid JSON") + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil { + return nil, usageErrorf("--input is not valid JSON") + } + if compact.Len() == 0 || compact.Bytes()[0] != '{' { + return nil, usageErrorf("--input must be a JSON object, like {\"pr\": 42}") + } + if compact.Len() > maxInputBytes { + return nil, usageErrorf("--input is over %d KiB, the most a run takes", maxInputBytes/1024) + } + return compact.Bytes(), nil +} + +func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json.RawMessage) error { + id, err := resolveAutomation(ctx, env, ref) + if err != nil { + return err + } + run, replayed, err := env.client.RunAutomation(ctx, id, input, autoRunIdemKey) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Code == "workspace_not_running" { + return exitf(exitUnavailable, "the automation's workspace is not running; start it at %s and try again", runs.PortalURL) + } + return automationExit(err, ref, env.cred) + } + if !runIDPattern.MatchString(run.ID) { + return exitf(exitUnavailable, "nan.builders answered with something that is not a run id") + } + if replayed { + fmt.Fprintf(env.stderr, "run %s already exists for this idempotency key (%s)\n", run.ID, runs.SanitizeLine(run.State)) + } + if autoRunDetach { + if autoRunJSON { + if err := printJSON(env.stdout, run, true); err != nil { + return err + } + } else { + fmt.Fprintln(env.stdout, run.ID) + fmt.Fprintf(env.stderr, "run %s %s\n", run.ID, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) + printFollowHints(env.stderr, run.ID) + } + return runExit(run) + } + if !replayed { + name := runs.SanitizeLine(run.Automation.Label()) + if name == "" { + name = runs.Truncate(runs.SanitizeLine(ref), 64) + } + fmt.Fprintf(env.stderr, "run %s of %s queued on %s; Ctrl-C to detach\n", run.ID, name, runs.SanitizeLine(run.Workspace.Name)) + } + return follow(ctx, env, run.ID, 0, autoRunJSON, true) +} + +func doAutomationsDeliveries(ctx context.Context, env *runsEnv, ref string) error { + id, err := resolveAutomation(ctx, env, ref) + if err != nil { + return err + } + list, err := env.client.ListDeliveries(ctx, id, autoDeliveryLimit) + if err != nil { + return automationExit(err, ref, env.cred) + } + if autoDeliveryJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintln(env.stderr, "No webhook deliveries in the last 30 days.") + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "RECEIVED\tOUTCOME\tRUN\tDELIVERY") + for _, d := range list.Data { + run := "-" + if d.RunID != nil { + run = runs.SanitizeLine(*d.RunID) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + localTime(d.ReceivedAt), + runs.SanitizeLine(d.Outcome), + run, + runs.Truncate(runs.SanitizeLine(d.DeliveryID), 40)) + } + return tw.Flush() +} diff --git a/cmd/automations_test.go b/cmd/automations_test.go new file mode 100644 index 0000000..bf87ef5 --- /dev/null +++ b/cmd/automations_test.go @@ -0,0 +1,770 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/spf13/cobra" +) + +// The Phase 2 contract (AGENT-AUTOMATIONS-PHASE2 §3.4, §4.4, §7.6, §11) as a +// test server, in front of fakePlatform for everything /v1/runs. +const ( + testAutoID = "a0000000-0000-4000-8000-000000000001" + testWSID = "0e000000-0000-4000-8000-0000000000aa" +) + +type fakePhase2 struct { + runs *fakePlatform + + mu sync.Mutex + status int // when set, every Phase 2 route answers it with errBody + errBody string // the error envelope for status + automations string // GET /v1/automations body + runBody map[string]any + runIdem string + approvalsQ string + rejectBody map[string]any + rejectStatus int + runGet string // GET /v1/runs/{id} body, overriding fakePlatform + diff string + diffRequests int + secretsCookie string + secretsAuthz string + paths []string +} + +const automationJSON = `{"id":"` + testAutoID + `","object":"automation","name":"PR review","description":"Reviews PRs\u001b[2J", +"template":"pr_review","workspace":{"id":"` + testWSID + `","name":"develop"},"agent":"pi","model":null, +"instructions":"Review for correctness.\n\u001b]0;pwn\u0007Be brief.","cwd":"/home/nan/projects/api","git_isolation":true, +"timeout_seconds":1800,"approval_kinds":["git_push","github_create_pr"],"approval_ttl_seconds":259200, +"inference":{"source":"nan"},"github_repo":"acme/api", +"secrets":[{"secret":"GITHUB_READ_TOKEN","env":"GITHUB_TOKEN","exposure":"agent"},{"secret":"GITHUB_WRITE_TOKEN","env":"GITHUB_TOKEN","exposure":"actions"}], +"max_runs_per_hour":30,"concurrency_policy":"skip_if_active","max_trigger_depth":2,"enabled":true,"disabled_reason":null, +"created_at":"2026-10-10T12:00:00Z","updated_at":"2026-10-10T12:00:00Z"}` + +func (f *fakePhase2) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.paths = append(f.paths, r.Method+" "+r.URL.RequestURI()) + p := r.URL.Path + phase2 := strings.HasPrefix(p, "/v1/automations") || strings.HasPrefix(p, "/v1/approvals") || + strings.HasPrefix(p, "/api/account/secrets") || strings.HasSuffix(p, "/reject") || strings.HasSuffix(p, "/proposal.diff") + if phase2 && f.status != 0 { + w.WriteHeader(f.status) + _, _ = io.WriteString(w, f.errBody) + f.mu.Unlock() + return + } + switch { + case r.Method == http.MethodGet && p == "/v1/automations": + body := f.automations + if body == "" { + body = `{"object":"list","data":[` + automationJSON + `]}` + } + _, _ = io.WriteString(w, body) + case r.Method == http.MethodGet && p == "/v1/automations/"+testAutoID: + _, _ = io.WriteString(w, automationJSON) + case r.Method == http.MethodGet && p == "/v1/automations/"+testAutoID+"/triggers": + _, _ = io.WriteString(w, `{"data":[{"id":"t1","kind":"webhook","enabled":true,"token_prefix":"nan_whk_AbC1","signature_scheme":"github"}, + {"id":"t2","kind":"schedule","enabled":true,"cron":"0 9 * * 1-5","timezone":"Europe/Madrid","next_fire_at":"2026-10-12T07:00:00Z"}]}`) + case r.Method == http.MethodGet && p == "/v1/automations/"+testAutoID+"/deliveries": + _, _ = io.WriteString(w, `{"data":[{"delivery_id":"gh-\u001b[31m1","received_at":"2026-10-10T12:00:00Z","outcome":"run_created","run_id":"`+testRunID+`"}, + {"delivery_id":"gh-2","received_at":"2026-10-10T11:00:00Z","outcome":"ignored","run_id":null}]}`) + case r.Method == http.MethodPost && p == "/v1/automations/"+testAutoID+"/runs": + f.runIdem = r.Header.Get("Idempotency-Key") + _ = json.NewDecoder(r.Body).Decode(&f.runBody) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, f.runs.run(api.StateQueued)) + case r.Method == http.MethodGet && p == "/v1/approvals": + f.approvalsQ = r.URL.RawQuery + _, _ = io.WriteString(w, `{"object":"list","data":[{"run_id":"`+testRunID+`","state":"pending","approval_from":1, + "expires_at":"2026-10-14T12:00:00Z","proposal":{"v":1,"actions":[{"kind":"github_pr_comment","repo":"acme/api","number":4,"body":"x"}, + {"kind":"git_push","base":"main","branch":"b","commit":"c","commit_message":"m"},{"kind":"github_create_pr","repo":"acme/api","base":"main","head":"b","title":"t","body":"b"}, + {"kind":"git_push","base":"main","branch":"b2","commit":"c","commit_message":"m"}]},"proposal_sha256":"ff","has_diff":true, + "run_state":"awaiting_approval","automation":{"id":"`+testAutoID+`","name":"PR review\u001b[31m"}}],"next_cursor":null}`) + case r.Method == http.MethodPost && p == "/v1/runs/"+testRunID+"/reject": + f.rejectBody = nil + _ = json.NewDecoder(r.Body).Decode(&f.rejectBody) + if f.rejectStatus != 0 { + w.WriteHeader(f.rejectStatus) + _, _ = io.WriteString(w, `{"error":{"message":"the run is not awaiting approval","type":"invalid_request_error","code":"not_awaiting_approval"}}`) + break + } + _, _ = io.WriteString(w, strings.Replace(f.runs.run(api.StateCancelled), `"error_code":null`, `"error_code":"approval_rejected"`, 1)) + case r.Method == http.MethodGet && p == "/v1/runs/"+testRunID && f.runGet != "": + _, _ = io.WriteString(w, f.runGet) + case r.Method == http.MethodGet && p == "/v1/runs/"+testRunID+"/proposal.diff": + f.diffRequests++ + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = io.WriteString(w, f.diff) + case r.Method == http.MethodGet && p == "/api/account/secrets": + f.secretsCookie, f.secretsAuthz = r.Header.Get("Cookie"), r.Header.Get("Authorization") + _, _ = io.WriteString(w, `{"data":[{"id":"s1","object":"secret","name":"GITHUB_TOKEN","kind":"generic","provider":null,"hint":"…a1b2", + "value_bytes":93,"description":"fine-grained","used_by":[{"id":"`+testAutoID+`","name":"PR review"}], + "created_at":"2026-10-10T12:00:00Z","updated_at":"2026-10-10T12:00:00Z","last_used_at":null}, + {"id":"s2","object":"secret","name":"STRIPE\u001b[31m","kind":"generic","provider":null,"hint":null,"value_bytes":20,"description":"", + "used_by":[],"created_at":"2026-10-10T12:00:00Z","updated_at":"2026-10-10T12:00:00Z","last_used_at":"2026-10-11T08:00:00Z"}]}`) + default: + f.mu.Unlock() + f.runs.ServeHTTP(w, r) + return + } + f.mu.Unlock() +} + +func newPhase2Harness(t *testing.T, fake *fakePhase2) *harness { + t.Helper() + if fake.runs == nil { + fake.runs = &fakePlatform{} + } + h := newHarness(t, fake.runs) + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + client := api.NewRunsClient("session-token", "").WithBaseURL(srv.URL) + client.Backoff = func(int) time.Duration { return time.Millisecond } + client.IdleTimeout = 2 * time.Second + h.env.client = client + h.url = srv.URL + return h +} + +func resetPhase2Flags() { + resetRunsFlags() + autoLsJSON, autoShowJSON = false, false + autoRunInput, autoRunDetach, autoRunJSON, autoRunIdemKey = "", false, false, "" + autoDeliveryLimit, autoDeliveryJSON = 50, false + approvalsState, approvalsLimit, approvalsJSON = api.ApprovalPending, 20, false + secretsLsJSON = false +} + +func assertNoControls(t *testing.T, what, out string) { + t.Helper() + if strings.ContainsAny(out, "\x1b\x07") { + t.Errorf("%s carries control characters: %q", what, out) + } +} + +func TestAutomationsLs(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{}) + if err := doAutomationsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + out := h.stdout.String() + for _, want := range []string{"NAME", "PR review", "develop", "enabled", "git_push, github_create_pr", testAutoID} { + if !strings.Contains(out, want) { + t.Errorf("table lacks %q:\n%s", want, out) + } + } + + h = newPhase2Harness(t, &fakePhase2{automations: `{"object":"list","data":[]}`}) + if err := doAutomationsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + if !strings.Contains(h.stderr.String(), "https://cloud.nan.builders/automations") { + t.Errorf("empty list does not point at the portal: %q", h.stderr.String()) + } + + h = newPhase2Harness(t, &fakePhase2{}) + autoLsJSON = true + if err := doAutomationsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + var list api.AutomationList + if err := json.Unmarshal(h.stdout.Bytes(), &list); err != nil || len(list.Data) != 1 || list.Data[0].ID != testAutoID { + t.Errorf("--json: %v %q", err, h.stdout.String()) + } + assertNoControls(t, "--json", h.stdout.String()) +} + +func TestAutomationsShowByName(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + if err := doAutomationsShow(context.Background(), h.env, "PR review"); err != nil { + t.Fatal(err) + } + out := h.stdout.String() + for _, want := range []string{ + testAutoID, "PR review", "develop", "acme/api", "git_push, github_create_pr", "72h0m0s", "skip if active", + "GITHUB_TOKEN ← GITHUB_READ_TOKEN (the agent)", "GITHUB_TOKEN ← GITHUB_WRITE_TOKEN (actions only, never the agent)", + "webhook github · nan_whk_AbC1…", "schedule 0 9 * * 1-5 (Europe/Madrid)", + "Review for correctness.", "Be brief.", + "nan automations run " + testAutoID, "https://cloud.nan.builders/automations/" + testAutoID, + } { + if !strings.Contains(out, want) { + t.Errorf("show lacks %q:\n%s", want, out) + } + } + assertNoControls(t, "show", out) + if fake.paths[0] != "GET /v1/automations" || fake.paths[1] != "GET /v1/automations/"+testAutoID { + t.Errorf("paths = %q", fake.paths) + } +} + +func TestAutomationsShowByIDSkipsTheList(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + if err := doAutomationsShow(context.Background(), h.env, testAutoID); err != nil { + t.Fatal(err) + } + if fake.paths[0] != "GET /v1/automations/"+testAutoID { + t.Errorf("paths = %q", fake.paths) + } +} + +func TestAutomationsUnknownName(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{}) + err := doAutomationsShow(context.Background(), h.env, "pr REVIEW") + if exitCode(err) != exitUsage || !strings.Contains(err.Error(), `did you mean "PR review"`) { + t.Fatalf("exit %d: %v", exitCode(err), err) + } + err = doAutomationsShow(context.Background(), h.env, "nope\x1b[2J") + if exitCode(err) != exitUsage || strings.Contains(err.Error(), "\x1b") { + t.Fatalf("exit %d: %q", exitCode(err), err) + } +} + +// A run that stops for approval exits 10 and says where to go next +// (AGENT-AUTOMATIONS-PHASE2 §9.4, finding 12). +func TestAutomationsRunExitsTenOnAwaitingApproval(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{runs: &fakePlatform{finalState: api.StateAwaitingApproval, events: []string{ + sse(1, "started", `{"agent":"pi","agent_version":"1.0.2","cwd":"/home/nan"}`), + sse(2, "approval_required", `{"proposal_sha256":"abcdef0123456789","kinds":["git_push"]}`), + }}} + h := newPhase2Harness(t, fake) + autoRunIdemKey = "retry-1" + err := doAutomationsRun(context.Background(), h.env, "PR review", json.RawMessage(`{"pr":42}`)) + if exitCode(err) != exitAwaitingApproval || exitAwaitingApproval != 10 { + t.Fatalf("exit %d (%v), want 10", exitCode(err), err) + } + if fake.runIdem != "retry-1" || fmt.Sprint(fake.runBody["input"]) != "map[pr:42]" { + t.Errorf("request: idem %q body %v", fake.runIdem, fake.runBody) + } + out := h.stdout.String() + h.stderr.String() + for _, want := range []string{"approval required for: git_push", "awaiting approval", "nan runs proposal " + testRunID, "nan runs reject " + testRunID, "browser sign-in"} { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "nan runs approve") { + t.Error("the output names an approve command the CLI does not have") + } +} + +func TestAutomationsRunDetach(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + autoRunDetach = true + err := doAutomationsRun(context.Background(), h.env, testAutoID, nil) + if exitCode(err) != exitDetached { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + if strings.TrimSpace(h.stdout.String()) != testRunID { + t.Errorf("stdout = %q", h.stdout.String()) + } + if _, ok := fake.runBody["input"]; ok || fake.runs.streams != 0 { + t.Errorf("body %v, streams %d", fake.runBody, fake.runs.streams) + } +} + +func TestAutomationsRunWithoutTheScope(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{status: http.StatusForbidden, + errBody: `{"error":{"message":"this token lacks the automations:run scope","type":"permission_error","code":"token_scope_denied"}}`}) + h.env.cred = credential{kind: credEnv, bearer: testPAT} + err := doAutomationsRun(context.Background(), h.env, testAutoID, nil) + if exitCode(err) != exitAuth { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + for _, want := range []string{"automations:run", "Settings > Tokens"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("message lacks %q: %v", want, err) + } + } + if strings.Contains(err.Error(), testPAT) { + t.Error("the message shows the token") + } +} + +func TestAutomationsRunErrors(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + for code, want := range map[string]int{ + `{"error":{"message":"disabled","code":"automation_disabled"}}`: exitConfig, + `{"error":{"message":"limit","code":"automation_rate_limited"}}`: exitUnavailable, + `{"error":{"message":"active","code":"skipped_active"}}`: exitUnavailable, + `{"error":{"message":"no","code":"browser_session_required"}}`: exitAuth, + `{"error":{"message":"gone","type":"invalid_request_error"}}`: exitUsage, + `{"error":{"message":"not running","code":"workspace_not_running"}}`: exitUnavailable, + } { + status := http.StatusConflict + if strings.Contains(code, "rate_limited") { + status = http.StatusTooManyRequests + } + if strings.Contains(code, "browser") { + status = http.StatusForbidden + } + if strings.Contains(code, "gone") { + status = http.StatusNotFound + } + h := newPhase2Harness(t, &fakePhase2{status: status, errBody: code}) + err := doAutomationsRun(context.Background(), h.env, testAutoID, nil) + if exitCode(err) != want { + t.Errorf("%s: exit %d (%v), want %d", code, exitCode(err), err, want) + } + } +} + +func TestReadAutomationInput(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "in.json") + if err := os.WriteFile(file, []byte("{\n \"pr\": 42\n}\n"), 0o600); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + flag, stdin, want string + code int + }{ + {"", "", "", 0}, + {"@" + file, "", `{"pr":42}`, 0}, + {"-", `{"a": [1, 2]}`, `{"a":[1,2]}`, 0}, + {`{"x": "y"}`, "", `{"x":"y"}`, 0}, + {`[1,2]`, "", "", exitUsage}, + {`"text"`, "", "", exitUsage}, + {`{"x":`, "", "", exitUsage}, + {"@" + filepath.Join(dir, "missing.json"), "", "", exitUsage}, + {`{"x":"` + strings.Repeat("a", maxInputBytes) + `"}`, "", "", exitUsage}, + {"-", "{\"x\":\"\xff\"}", "", exitUsage}, + } { + got, err := readAutomationInput(promptInput{r: strings.NewReader(tc.stdin)}, tc.flag) + if exitCode(err) != tc.code || string(got) != tc.want { + t.Errorf("%.40q: got %q, exit %d (%v)", tc.flag, got, exitCode(err), err) + } + } +} + +func TestAutomationsDeliveries(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + autoDeliveryLimit = 10 + if err := doAutomationsDeliveries(context.Background(), h.env, testAutoID); err != nil { + t.Fatal(err) + } + out := h.stdout.String() + for _, want := range []string{"OUTCOME", "run_created", testRunID, "ignored", "gh-1"} { + if !strings.Contains(out, want) { + t.Errorf("table lacks %q:\n%s", want, out) + } + } + assertNoControls(t, "deliveries", out) + if fake.paths[0] != "GET /v1/automations/"+testAutoID+"/deliveries?limit=10" { + t.Errorf("paths = %q", fake.paths) + } +} + +func TestApprovalsLs(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + if err := doApprovalsLs(context.Background(), h.env, api.ApprovalPending); err != nil { + t.Fatal(err) + } + if fake.approvalsQ != "limit=20&state=pending" { + t.Errorf("query = %q", fake.approvalsQ) + } + out := h.stdout.String() + // From approval_from on, distinct: the comment before it applied on its own. + for _, want := range []string{testRunID, "pending", "PR review", "git_push, github_create_pr"} { + if !strings.Contains(out, want) { + t.Errorf("table lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "github_pr_comment") || strings.Count(out, "git_push") != 1 { + t.Errorf("waiting kinds wrong:\n%s", out) + } + assertNoControls(t, "approvals ls", out) + for _, want := range []string{"nan runs proposal ", "https://cloud.nan.builders/automations?tab=approvals", "nan runs reject "} { + if !strings.Contains(h.stderr.String(), want) { + t.Errorf("hints lack %q:\n%s", want, h.stderr.String()) + } + } +} + +func TestApprovalsStateFlag(t *testing.T) { + if s, err := approvalsStateParam("all"); err != nil || s != "" { + t.Errorf("all -> %q %v", s, err) + } + if s, err := approvalsStateParam("expired"); err != nil || s != "expired" { + t.Errorf("expired -> %q %v", s, err) + } + if _, err := approvalsStateParam("waiting"); exitCode(err) != exitUsage { + t.Errorf("waiting -> %v", err) + } +} + +func awaitingRunJSON() string { + return `{"id":"` + testRunID + `","object":"run","state":"awaiting_approval","phase":"propose","workspace":{"id":"` + testWSID + `","name":"develop"}, + "agent":"pi","trigger":"webhook","automation":{"id":"` + testAutoID + `","name":"PR review"},"created_at":"2026-10-10T12:00:00Z", + "apply_run_ids":["` + testRunID[:35] + `2"],"github_repo":"acme/api","secrets":[{"env":"GITHUB_TOKEN","exposure":"agent"}], + "result":{"status":"ok","summary":"","branch":"","commit":"","changed_files":0,"artifacts":[],"actions_applied":[{"kind":"github_pr_comment","ok":true,"ref":"https://github.com/acme/api/pull/4#c1"}]}, + "approval":{"run_id":"` + testRunID + `","state":"pending","approval_from":0,"expires_at":"2026-10-14T12:00:00Z", + "proposal":{"v":1,"actions":[{"kind":"git_push","base":"main","branch":"fix/x","commit":"0123456789abcdef","commit_message":"fix\u001b[2J it"}], + "base":"main","merge_base":"m","diffstat":{"files":[{"path":"a.go","added":1,"deleted":0,"binary":false}],"files_changed":1,"insertions":1,"deletions":0,"truncated":false}}, + "proposal_sha256":"ff00","has_diff":true,"diff_masked":false,"prefix_apply_run_id":null,"apply_run_id":null}}` +} + +func TestRunsProposalWithDiff(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{runGet: awaitingRunJSON(), diff: "diff --git a/a.go b/a.go\n+\x1b]52;c;ZXZpbA==\x07evil\n"} + h := newPhase2Harness(t, fake) + if err := doRunsProposal(context.Background(), h.env, testRunID, true, false); err != nil { + t.Fatal(err) + } + out := h.stdout.String() + for _, want := range []string{"waiting for your approval", "git_push fix/x onto main", "fix it", "diff:", "+evil", "/workspaces/" + testWSID + "/runs/" + testRunID} { + if !strings.Contains(out, want) { + t.Errorf("proposal lacks %q:\n%s", want, out) + } + } + assertNoControls(t, "proposal", out) + if fake.diffRequests != 1 { + t.Errorf("diff requests = %d", fake.diffRequests) + } +} + +func TestRunsProposalWithoutAStoredDiff(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{runGet: strings.Replace(awaitingRunJSON(), `"has_diff":true`, `"has_diff":false`, 1)} + h := newPhase2Harness(t, fake) + if err := doRunsProposal(context.Background(), h.env, testRunID, true, false); err != nil { + t.Fatal(err) + } + if fake.diffRequests != 0 || !strings.Contains(h.stderr.String(), "no diff is stored") { + t.Errorf("requests %d, stderr %q", fake.diffRequests, h.stderr.String()) + } +} + +func TestRunsProposalJSONAndMissing(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{runGet: awaitingRunJSON()}) + if err := doRunsProposal(context.Background(), h.env, testRunID, false, true); err != nil { + t.Fatal(err) + } + var a api.Approval + if err := json.Unmarshal(h.stdout.Bytes(), &a); err != nil || a.ProposalSHA256 != "ff00" { + t.Errorf("%v %q", err, h.stdout.String()) + } + assertNoControls(t, "--json", h.stdout.String()) + + h = newPhase2Harness(t, &fakePhase2{}) + err := doRunsProposal(context.Background(), h.env, testRunID, false, false) + if exitCode(err) != exitUsage || !strings.Contains(err.Error(), "has no proposal") { + t.Errorf("exit %d: %v", exitCode(err), err) + } +} + +func TestRunsShowPhase2(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{runGet: awaitingRunJSON()}) + if err := doRunsShow(context.Background(), h.env, testRunID); err != nil { + t.Fatal(err) + } + out := h.stdout.String() + for _, want := range []string{"phase", "propose", "automation", "PR review", "approval", "pending", "1 actions (nan runs proposal", "apply run", testRunID[:35] + "2", "awaiting your approval", "nan runs reject " + testRunID, + "repository", "acme/api", "GITHUB_TOKEN (the agent)", "1. github_pr_comment ok · https://github.com/acme/api/pull/4#c1"} { + if !strings.Contains(out, want) { + t.Errorf("show lacks %q:\n%s", want, out) + } + } +} + +func TestRunsReject(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + fake := &fakePhase2{} + h := newPhase2Harness(t, fake) + rejectReason = "wrong branch" + if err := doRunsReject(context.Background(), h.env, testRunID); err != nil { + t.Fatal(err) + } + if fake.rejectBody["reason"] != "wrong branch" || !strings.Contains(h.stdout.String(), "rejected") { + t.Errorf("body %v out %q", fake.rejectBody, h.stdout.String()) + } + + fake = &fakePhase2{rejectStatus: http.StatusConflict} + h = newPhase2Harness(t, fake) + err := doRunsReject(context.Background(), h.env, testRunID) + if exitCode(err) != exitUsage || !strings.Contains(err.Error(), "not awaiting approval") { + t.Errorf("exit %d: %v", exitCode(err), err) + } +} + +func TestRejectReasonValidation(t *testing.T) { + for reason, ok := range map[string]bool{ + "": true, + "line one\nline two": true, + strings.Repeat("é", 500): true, + strings.Repeat("a", 501): false, + "bell\x07": false, + "esc\x1b[2J": false, + "c1\u009b": false, + "bad utf8 \xff": false, + } { + err := checkRejectReason(reason) + if (err == nil) != ok { + t.Errorf("%.20q: %v", reason, err) + } + } +} + +func TestRunsLsAcceptsAwaitingApproval(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + lsState = api.StateAwaitingApproval + if err := checkLsFlags(); err != nil { + t.Error(err) + } +} + +// nan secrets ls goes with the session even when a token is set: the +// platform refuses tokens and API keys there (§3.4). +func TestSecretsUseTheSessionOnly(t *testing.T) { + fakeHome(t, `{"token":"","platformToken":"`+testPAT+`","apiKey":"`+testKey+`"}`) + t.Setenv(tokenEnvVar, testPAT) + _, err := newSessionEnv() + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), "nan auth login") || strings.Contains(err.Error(), testPAT) { + t.Fatalf("exit %d: %v", exitCode(err), err) + } + + fakeHome(t, `{"token":"sess-1","platformToken":"`+testPAT+`"}`) + t.Setenv(tokenEnvVar, testPAT) + env, err := newSessionEnv() + if err != nil { + t.Fatal(err) + } + fake := &fakePhase2{runs: &fakePlatform{}} + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + env.client.WithBaseURL(srv.URL) + var out, errOut bytes.Buffer + env.stdout, env.stderr = &out, &errOut + resetPhase2Flags() + if err := doSecretsLs(context.Background(), env); err != nil { + t.Fatal(err) + } + if fake.secretsCookie != "nan_session=sess-1" || fake.secretsAuthz != "" { + t.Errorf("cookie %q authz %q", fake.secretsCookie, fake.secretsAuthz) + } + for _, want := range []string{"NAME", "GITHUB_TOKEN", "…a1b2", "PR review", "never", "STRIPE"} { + if !strings.Contains(out.String(), want) { + t.Errorf("table lacks %q:\n%s", want, out.String()) + } + } + assertNoControls(t, "secrets ls", out.String()) + if !strings.Contains(errOut.String(), "Settings > Secrets") { + t.Errorf("no portal hint: %q", errOut.String()) + } +} + +func TestSecretsLsJSONHasNoValues(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{}) + secretsLsJSON = true + if err := doSecretsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + if strings.Contains(h.stdout.String(), `"value"`) || !strings.Contains(h.stdout.String(), `"value_bytes": 93`) { + t.Errorf("%s", h.stdout.String()) + } +} + +func TestBrowserSessionRequiredSaysWhy(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{status: http.StatusForbidden, errBody: `{"error":"browser_session_required","message":"sign in again in the browser"}`}) + err := doSecretsLs(context.Background(), h.env) + if exitCode(err) != exitAuth { + t.Fatalf("exit %d: %v", exitCode(err), err) + } + for _, want := range []string{"browser", "https://cloud.nan.builders", runsDocs} { + if !strings.Contains(err.Error(), want) { + t.Errorf("message lacks %q: %v", want, err) + } + } +} + +// execute runs the real command tree, as main does, and returns how it +// would exit. +func execute(t *testing.T, args ...string) (int, string) { + t.Helper() + var out bytes.Buffer + rootCmd.SetArgs(args) + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + cmd, err := rootCmd.ExecuteC() + if err == nil { + return 0, out.String() + } + code, msg := exitCodeFor(cmd, err) + return code, msg +} + +// Approving, writing secrets and writing automations are portal-only in +// Phase 2 (§9.4, §11.2, owner decision 10): no command does them, and +// reaching for one says where to go and why. +func TestPortalOnlyWritesPointToThePortal(t *testing.T) { + fakeHome(t, "") + for _, args := range [][]string{ + {"runs", "approve", testRunID}, + {"approvals", "approve", testRunID}, + {"secrets", "set", "GITHUB_TOKEN"}, + {"secrets", "rm", "GITHUB_TOKEN"}, + {"automations", "create"}, + {"automations", "edit", "PR review"}, + {"automations", "rm", "PR review"}, + {"automations", "triggers", "add"}, + } { + code, msg := execute(t, args...) + if code != exitUsage || !strings.Contains(msg, "https://cloud.nan.builders") || !strings.Contains(msg, "browser") { + t.Errorf("nan %s: exit %d: %s", strings.Join(args, " "), code, msg) + } + } +} + +func TestNoCommandWrites(t *testing.T) { + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + path := sub.CommandPath() + switch sub.Name() { + case "approve", "set", "create", "edit", "rm", "delete", "triggers": + if !strings.HasPrefix(path, "nan auth") { + t.Errorf("%q exists", path) + } + } + walk(sub) + } + } + walk(rootCmd) +} + +func TestPhase2Help(t *testing.T) { + for name, text := range map[string]string{ + "automations": automationsCmd.Long, + "automations run": automationsRunCmd.Long + automationsRunCmd.Example, + "automations deliveries": automationsDeliveriesCmd.Long, + "approvals": approvalsCmd.Long, + "secrets": secretsCmd.Long, + "runs proposal": runsProposalCmd.Long, + "runs reject": runsRejectCmd.Long, + "runs": runsCmd.Long, + } { + if !strings.Contains(text, runsDocs) { + t.Errorf("nan %s --help does not link %s", name, runsDocs) + } + if strings.Contains(text, "—") { + t.Errorf("nan %s --help has an em-dash", name) + } + } + for name, text := range map[string]string{ + "automations": automationsCmd.Long, + "approvals": approvalsCmd.Long, + "secrets": secretsCmd.Long, + } { + if !strings.Contains(text, "portal") || !strings.Contains(text, "browser") { + t.Errorf("nan %s --help does not say what is portal-only and why", name) + } + } + for _, want := range []string{"automations:read", "automations:run"} { + if !strings.Contains(automationsCmd.Long, want) { + t.Errorf("nan automations --help does not name the %s scope", want) + } + } + if !strings.Contains(runCmd.Long, "10 awaiting approval") || !strings.Contains(runsLogsCmd.Long, "10 awaiting approval") { + t.Error("exit code 10 is not documented") + } + for _, c := range append(automationsCmd.Commands(), approvalsCmd.Commands()...) { + if c.InheritedFlags().Lookup("token-file") == nil { + t.Errorf("%s has no --token-file", c.CommandPath()) + } + } + if secretsLsCmd.Flags().Lookup("token-file") != nil || secretsLsCmd.InheritedFlags().Lookup("token-file") != nil { + t.Error("nan secrets ls takes --token-file, which the platform would refuse") + } +} + +// nan runs logs -f exits 10 on a run that stops for approval, like nan run. +func TestRunsLogsFollowExitsTenOnAwaitingApproval(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newHarness(t, &fakePlatform{finalState: api.StateAwaitingApproval}) + logsFollow = true + err := doRunsLogs(context.Background(), h.env, testRunID) + if exitCode(err) != exitAwaitingApproval { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + if !strings.Contains(h.stderr.String(), "nan runs proposal "+testRunID) { + t.Errorf("no hint: %s", h.stderr.String()) + } +} + +func TestRunsApproveNamesTheRun(t *testing.T) { + fakeHome(t, "") + for _, verb := range []string{"approve", "accept"} { + code, msg := execute(t, "runs", verb, testRunID) + if code != exitUsage || !strings.Contains(msg, "nan runs proposal "+testRunID) || !strings.Contains(msg, "?tab=approvals") { + t.Errorf("%s: exit %d: %s", verb, code, msg) + } + } + code, msg := execute(t, "runs", "bogus\x1b[2J") + if code != exitUsage || strings.Contains(msg, "\x1b") { + t.Errorf("unknown verb: exit %d: %q", code, msg) + } +} + +func TestNotEnabledIsNotAUsageError(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{status: http.StatusNotFound, errBody: `{"error":{"message":"not found"}}`}) + for name, err := range map[string]error{ + "automations ls": doAutomationsLs(context.Background(), h.env), + "approvals ls": doApprovalsLs(context.Background(), h.env, ""), + "secrets ls": doSecretsLs(context.Background(), h.env), + } { + if exitCode(err) != exitAuth || !strings.Contains(err.Error(), "not enabled") { + t.Errorf("%s: exit %d: %v", name, exitCode(err), err) + } + } +} diff --git a/cmd/exit.go b/cmd/exit.go index 0ca9c52..e194c9f 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -14,15 +14,18 @@ import ( // know how it ended without parsing text, so these are a contract: the first // five are the run's own outcome, the rest follow sysexits.h. const ( - exitSucceeded = 0 - exitFailed = 1 - exitTimedOut = 2 - exitCancelled = 3 - exitConfig = 4 - exitUsage = 64 - exitAuth = 65 - exitUnavailable = 69 - exitDetached = 75 + exitSucceeded = 0 + exitFailed = 1 + exitTimedOut = 2 + exitCancelled = 3 + exitConfig = 4 + // exitAwaitingApproval: the agent is done and its proposed actions wait + // for the member's approval in the portal (runs.ExitAwaitingApproval). + exitAwaitingApproval = runs.ExitAwaitingApproval + exitUsage = 64 + exitAuth = 65 + exitUnavailable = 69 + exitDetached = 75 ) // ExitError ends the process with Code, printing Err first when there is one. @@ -93,6 +96,20 @@ func apiExit(err error, cred credential) *ExitError { return exitf(exitUnavailable, "too many runs are queued — wait for some to finish (%s)", msg) case "rate_limited": return exitf(exitUnavailable, "too many runs started in the last hour — try again later") + case "browser_session_required": + return exitf(exitAuth, "%s", browserOnly) + case "token_scope_denied": + return exitf(exitAuth, "%s: %s", msg, scopeHint(cred)) + case "automation_disabled": + return exitf(exitConfig, "that automation is disabled: enable it in the portal at %s", runs.AutomationsURL) + case "automation_rate_limited": + return exitf(exitUnavailable, "that automation has started as many runs as its hourly limit allows; try again later (%s)", msg) + case "skipped_active": + return exitf(exitUnavailable, "that automation already has a run in progress, and it is set to skip a new one while one is active") + case "not_awaiting_approval": + return usageErrorf("that run is not awaiting approval; see its state with: nan runs show ") + case "secrets_unavailable": + return exitf(exitUnavailable, "the secrets store is unavailable right now; try again in a few minutes") } switch { case apiErr.Status == 400: @@ -110,6 +127,22 @@ func apiExit(err error, cred credential) *ExitError { return exitf(exitUnavailable, "nan.builders could not do that right now: %s", msg) } +// browserOnly explains the one class of refusal no credential of this CLI +// can get past. +var browserOnly = "this needs you signed in through the browser: do it in the portal at " + runs.PortalURL + + ". Approving a proposal, writing a secret and creating or editing an automation are portal-only, so that " + + "nothing that can read this machine's CLI session or tokens (an agent in a workspace, a CI job) can do them. " + + "See https://nan.builders/docs/runs" + +// scopeHint is what to do about a platform token missing a scope. +func scopeHint(cred credential) string { + switch cred.kind { + case credEnv, credFile, credStoredToken: + return "create a token with that scope in Settings > Tokens at " + runs.PortalURL + " (existing tokens keep the scopes they were created with)" + } + return "sign in with: nan auth login" +} + // runExit is the exit for a run that ended - or the detach code for one // that has not. func runExit(run *api.Run) error { diff --git a/cmd/golden_test.go b/cmd/golden_test.go new file mode 100644 index 0000000..a1bb779 --- /dev/null +++ b/cmd/golden_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// goldenEnv serves cloud-api's own golden responses (copied into +// internal/api/testdata/phase2) on the paths that return them. +func goldenEnv(t *testing.T) (*runsEnv, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + const run = "6f1c2a9b-0000-4000-8000-000000000001" + const auto = "a0000000-0000-4000-8000-000000000001" + routes := map[string]string{ + "/v1/runs/" + run: "run_get_propose.json", + "/v1/approvals": "approvals_list.json", + "/v1/automations": "automations_list.json", + "/v1/automations/" + auto: "automation_get.json", + "/v1/automations/" + auto + "/triggers": "automation_triggers.json", + "/v1/automations/" + auto + "/deliveries": "automation_deliveries.json", + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, ok := routes[r.URL.Path] + if !ok || r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "phase2", name)) + if err != nil { + t.Error(err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + var out, errOut bytes.Buffer + env := &runsEnv{client: api.NewRunsClient("s", "").WithBaseURL(srv.URL), stdout: &out, stderr: &errOut} + return env, &out, &errOut +} + +func wantAll(t *testing.T, what, out string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("%s lacks %q:\n%s", what, w, out) + } + } +} + +func TestGoldenRender(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + const run = "6f1c2a9b-0000-4000-8000-000000000001" + const ws = "11111111-2222-4333-8444-555555555555" + ctx := context.Background() + + env, out, _ := goldenEnv(t) + if err := doRunsShow(ctx, env, run); err != nil { + t.Fatal(err) + } + wantAll(t, "runs show", out.String(), "awaiting_approval", "propose", "a0000000-0000-4000-8000-000000000001", + "acme/api", "GITHUB_TOKEN (the agent)", "pending", "3 actions", "d0000000-0000-4000-8000-000000000004", + "github_pr_review ok", "nan runs reject "+run, "/workspaces/"+ws+"/runs/"+run) + + env, out, _ = goldenEnv(t) + if err := doRunsProposal(ctx, env, run, false, false); err != nil { + t.Fatal(err) + } + wantAll(t, "runs proposal", out.String(), "waiting for your approval", "[automatic]", "[needs approval]", + "2. github_pr_comment acme/api#42 [automatic]", " looks fine", + "3. github_pr_review acme/api#42 COMMENT [needs approval]", + "+3 -1", "a.go", "/workspaces/"+ws+"/runs/"+run) + // approval_from 2: only the review waits, the push is automatic, so no + // diff is stored and none is offered. + if strings.Contains(out.String(), "--diff") { + t.Errorf("runs proposal offers a diff that is not stored:\n%s", out.String()) + } + + env, out, _ = goldenEnv(t) + if err := doApprovalsLs(ctx, env, api.ApprovalPending); err != nil { + t.Fatal(err) + } + wantAll(t, "approvals ls", out.String(), run, "pending", "a0000000-0000-4000-8000-000000000001", "github_pr_review") + if strings.Contains(out.String(), "github_pr_comment") { + t.Errorf("approvals ls lists an action that applied automatically:\n%s", out.String()) + } + + env, out, _ = goldenEnv(t) + if err := doAutomationsLs(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "automations ls", out.String(), "PR review api", ws, "enabled", "github_pr_review") + + env, out, _ = goldenEnv(t) + if err := doAutomationsShow(ctx, env, "PR review api"); err != nil { + t.Fatal(err) + } + wantAll(t, "automations show", out.String(), "glm5.3", "GITHUB_TOKEN ← GITHUB_WRITE_TOKEN (actions only, never the agent)", + "webhook github · nan_whk_AbC1…", "Review for correctness.", "https://cloud.nan.builders/automations/a0000000-0000-4000-8000-000000000001") + + env, out, _ = goldenEnv(t) + if err := doAutomationsDeliveries(ctx, env, "PR review api"); err != nil { + t.Fatal(err) + } + wantAll(t, "deliveries", out.String(), "run_created", run, "72d3162e-cc78-11e3-81ab-4c9367dc0958") +} diff --git a/cmd/run.go b/cmd/run.go index 17bd7a2..732223f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -104,6 +104,8 @@ Exit codes: 3 the run was cancelled (after a double Ctrl-C: cancel requested) 4 the workspace is not set up for it (agent not installed, no inference key, bad configuration) + 10 awaiting approval: an automation run whose proposed actions wait for + you (nan runs proposal ; approve in the portal) 64 usage error 65 not signed in, or not allowed 69 nan.builders unavailable, or the stream was lost (the run goes on) diff --git a/cmd/runs.go b/cmd/runs.go index b8b13cb..b521a73 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -7,6 +7,7 @@ import ( "strings" "text/tabwriter" "time" + "unicode/utf8" "github.com/nxssie/nan-cli/internal/api" "github.com/nxssie/nan-cli/internal/runs" @@ -29,6 +30,11 @@ var ( logsAfter int64 logsJSON bool cancelJSON bool + + proposalDiff bool + proposalJSON bool + rejectReason string + rejectJSON bool ) var runsCmd = &cobra.Command{ @@ -40,12 +46,27 @@ way as nan run: NAN_TOKEN, --token-file PATH, a token saved with nan auth login --api-token, or your nan auth login session (the full order is in nan run --help). +A run of an automation can stop in awaiting_approval: the agent is done and +the actions it proposed (a push, a pull request, a comment) wait for you. +Read the proposal with nan runs proposal , reject it with nan runs +reject , and approve it in the portal. There is no approve command: +approving needs you signed in through the browser, so that an agent or a CI +job that can read this machine's session or tokens can never approve its +own actions. + Docs: https://nan.builders/docs/runs`, Annotations: sysexits, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && (args[0] == "approve" || args[0] == "accept") { + id := "" + if len(args) > 1 && runIDPattern.MatchString(args[1]) { + id = args[1] + } + return usageErrorf("there is no nan runs approve: approve on the run's page in the portal (inbox: %s); approving needs a browser sign-in. Read the proposal and its link with: nan runs proposal %s", runs.ApprovalsURL, id) + } if len(args) > 0 { - return usageErrorf("unknown command %q for \"nan runs\" — try: nan runs --help", args[0]) + return usageErrorf("unknown command %q for \"nan runs\"; try: nan runs --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) } return cmd.Help() }, @@ -83,7 +104,8 @@ error, truncated (output was cut), and finished. With -f, follow the run until it ends and exit with its code, the same codes as nan run: 0 succeeded, 1 failed, 2 timed out, 3 cancelled, 4 workspace -not set up for it. Ctrl-C stops following; the run goes on. +not set up for it, 10 awaiting approval. Ctrl-C stops following; the run +goes on. With --json, print one event per line as a JSON object (seq, ts, type, data), then, with -f, the finished run. The event types and their data are @@ -108,13 +130,59 @@ var runsCancelCmd = &cobra.Command{ }, } +var runsProposalCmd = &cobra.Command{ + Use: "proposal ", + Short: "Print the actions a run proposed, and where to approve them", + Long: `Print what an automation run proposed to do - pushes, pull requests, +comments, reviews, labels - as plain text: which actions apply automatically, +which wait for your approval, the files a push changes, and the bodies the +agent wrote. --diff prints the full diff too, when one is stored (only when +your approval covers a push); without one it says so on stderr and exits 0. + +Everything here was written by the agent and is shown as text, never run or +rendered. Values of your secrets are shown as [masked]. + +Approve in the portal (the run's page): approving needs a browser sign-in. +Reject from here with nan runs reject . + +Docs: https://nan.builders/docs/runs`, + Args: oneRunID, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withRunsEnv(func(env *runsEnv) error { + return doRunsProposal(cmd.Context(), env, args[0], proposalDiff, proposalJSON) + }) + }, +} + +var runsRejectCmd = &cobra.Command{ + Use: "reject ", + Short: "Reject the proposal of a run awaiting approval", + Long: `Reject the proposal of a run awaiting approval: the run ends cancelled +(approval_rejected) and none of the actions waiting for approval is applied. +Actions that applied automatically before it stay applied. + +Rejecting is always safe, so any credential that can read runs can do it. +Approving is not, and happens only in the portal. + +Docs: https://nan.builders/docs/runs`, + Args: oneRunID, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if err := checkRejectReason(rejectReason); err != nil { + return err + } + return withRunsEnv(func(env *runsEnv) error { return doRunsReject(cmd.Context(), env, args[0]) }) + }, +} + func init() { rootCmd.AddCommand(runsCmd) - runsCmd.AddCommand(runsLsCmd, runsShowCmd, runsLogsCmd, runsCancelCmd) + runsCmd.AddCommand(runsLsCmd, runsShowCmd, runsLogsCmd, runsCancelCmd, runsProposalCmd, runsRejectCmd) runsCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) runsLsCmd.Flags().StringVar(&lsWorkspace, "ws", "", "Only runs in this workspace") - runsLsCmd.Flags().StringVar(&lsState, "state", "", "Only runs in this state: queued, starting, running, succeeded, failed, cancelled, timed_out") + runsLsCmd.Flags().StringVar(&lsState, "state", "", "Only runs in this state: queued, starting, running, awaiting_approval, succeeded, failed, cancelled, timed_out") runsLsCmd.Flags().IntVar(&lsLimit, "limit", 20, "How many runs to list (1-100)") runsLsCmd.Flags().BoolVar(&lsJSON, "json", false, "Print the API's list object as JSON") @@ -125,6 +193,12 @@ func init() { runsLogsCmd.Flags().BoolVar(&logsJSON, "json", false, "Print events as JSON lines (with -f, then the finished run)") runsCancelCmd.Flags().BoolVar(&cancelJSON, "json", false, "Print the run object as JSON") + + runsProposalCmd.Flags().BoolVar(&proposalDiff, "diff", false, "Also print the full diff, when one is stored") + runsProposalCmd.Flags().BoolVar(&proposalJSON, "json", false, "Print the approval object (proposal included) as JSON") + + runsRejectCmd.Flags().StringVar(&rejectReason, "reason", "", "Why, for your own record (up to 500 characters)") + runsRejectCmd.Flags().BoolVar(&rejectJSON, "json", false, "Print the run object as JSON") } func withRunsEnv(fn func(*runsEnv) error) error { @@ -176,13 +250,13 @@ func notFound(err error, id string, cred credential) *ExitError { } var validStates = map[string]bool{ - api.StateQueued: true, api.StateStarting: true, api.StateRunning: true, + api.StateQueued: true, api.StateStarting: true, api.StateRunning: true, api.StateAwaitingApproval: true, api.StateSucceeded: true, api.StateFailed: true, api.StateCancelled: true, api.StateTimedOut: true, } func checkLsFlags() error { if lsState != "" && !validStates[lsState] { - return usageErrorf("--state must be one of queued, starting, running, succeeded, failed, cancelled, timed_out") + return usageErrorf("--state must be one of queued, starting, running, awaiting_approval, succeeded, failed, cancelled, timed_out") } if lsLimit < 1 || lsLimit > 100 { return usageErrorf("--limit must be between 1 and 100") @@ -278,6 +352,34 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { } row("agent", agent) row("trigger", runs.SanitizeLine(run.Trigger)) + if run.Phase != "" && run.Phase != "run" { + row("phase", runs.SanitizeLine(run.Phase)+phaseNote(run.Phase)) + } + if run.Automation != nil { + row("automation", runs.SanitizeLine(run.Automation.Label())) + } + if run.GitHubRepo != nil { + row("repository", runs.SanitizeLine(*run.GitHubRepo)) + } + for _, sec := range run.Secrets { + row("secret", runs.SanitizeLine(sec.Env)+" ("+exposureNote(sec.Exposure)+")") + } + if run.ParentRunID != nil { + row("parent run", runs.SanitizeLine(*run.ParentRunID)) + } + if a := run.Approval; a != nil { + approval := runs.SanitizeLine(a.State) + if a.State == api.ApprovalPending && a.ExpiresAt != nil { + approval += ", expires " + localTime(*a.ExpiresAt) + } + if a.Proposal != nil { + approval += fmt.Sprintf(" · %d actions (nan runs proposal %s)", len(a.Proposal.Actions), runs.SanitizeLine(run.ID)) + } + row("approval", approval) + } + for _, apply := range applyRuns(run) { + row("apply run", runs.SanitizeLine(apply)) + } row("cwd", runs.SanitizeLine(run.Cwd)) worktree := "auto" if run.GitIsolation != nil { @@ -303,6 +405,16 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { for _, a := range res.Artifacts { row("artifact", fmt.Sprintf("%s (%d bytes)", runs.SanitizeLine(a.Path), a.Bytes)) } + for i, a := range res.ActionsApplied { + outcome := "ok" + if !a.OK { + outcome = "failed" + } + if a.Ref != "" { + outcome += " · " + runs.Truncate(runs.SanitizeLine(a.Ref), 80) + } + row("action", fmt.Sprintf("%d. %s %s", i+1, runs.SanitizeLine(a.Kind), outcome)) + } } if u := run.Usage; u != nil && (u.TokensIn != nil || u.TokensOut != nil) { row("tokens", fmt.Sprintf("%s in, %s out", optInt(u.TokensIn), optInt(u.TokensOut))) @@ -310,6 +422,11 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { if err := tw.Flush(); err != nil { return err } + if run.State == api.StateAwaitingApproval { + fmt.Fprintln(env.stdout) + fmt.Fprintln(env.stdout, "awaiting your approval:") + fmt.Fprintln(env.stdout, runs.ApprovalHint(run)) + } prompt := run.Prompt if prompt == "" { @@ -399,3 +516,113 @@ func doRunsCancel(ctx context.Context, env *runsEnv, id string) error { fmt.Fprintf(env.stdout, "cancel requested for run %s — it stops within seconds; check with: nan runs show %s\n", id, id) return nil } + +// phaseNote says what a phase means, once, where it is shown. +func phaseNote(phase string) string { + switch phase { + case "propose": + return " (the agent proposes actions; they apply after approval or automatically)" + case "apply": + return " (applies approved or automatic actions; no agent)" + } + return "" +} + +// applyRuns lists a run's apply runs, oldest first, without repeats: the +// platform may name them in the run and in its approval. +func applyRuns(run *api.Run) []string { + var ids []string + seen := map[string]bool{} + add := func(id *string) { + if id != nil && *id != "" && !seen[*id] { + seen[*id] = true + ids = append(ids, *id) + } + } + if a := run.Approval; a != nil { + add(a.PrefixApplyRunID) + } + for i := range run.ApplyRunIDs { + add(&run.ApplyRunIDs[i]) + } + if a := run.Approval; a != nil { + add(a.ApplyRunID) + } + add(run.ApplyRunID) + return ids +} + +func doRunsProposal(ctx context.Context, env *runsEnv, id string, withDiff, asJSON bool) error { + if err := checkRunID(id); err != nil { + return err + } + run, err := env.client.GetRun(ctx, id) + if err != nil { + return notFound(err, id, env.cred) + } + if run.Approval == nil { + if asJSON { + return usageErrorf("run %s has no proposal", id) + } + return usageErrorf("run %s has no proposal: only automation runs that proposed actions have one (state: %s)", id, runs.SanitizeLine(run.State)) + } + if asJSON { + return printJSON(env.stdout, run.Approval, true) + } + runs.Proposal(env.stdout, run) + if !withDiff { + return nil + } + if !run.Approval.HasDiff { + fmt.Fprintln(env.stderr, "no diff is stored for this proposal: one is kept only when your approval covers a push") + return nil + } + diff, err := env.client.ProposalDiff(ctx, id) + if err != nil { + return notFound(err, id, env.cred) + } + fmt.Fprintln(env.stdout) + fmt.Fprintln(env.stdout, "diff:") + runs.Diff(env.stdout, diff) + return nil +} + +// maxRejectReason is the platform's limit, in characters. +const maxRejectReason = 500 + +func checkRejectReason(reason string) error { + if utf8.RuneCountInString(reason) > maxRejectReason { + return usageErrorf("--reason is over %d characters", maxRejectReason) + } + if !utf8.ValidString(reason) { + return usageErrorf("--reason is not UTF-8 text") + } + for _, r := range reason { + if r == '\n' { + continue + } + if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) { + return usageErrorf("--reason contains a control character") + } + } + return nil +} + +func doRunsReject(ctx context.Context, env *runsEnv, id string) error { + if err := checkRunID(id); err != nil { + return err + } + run, err := env.client.RejectRun(ctx, id, rejectReason) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Code == "not_awaiting_approval" { + return usageErrorf("run %s is not awaiting approval; see its state with: nan runs show %s", id, id) + } + return notFound(err, id, env.cred) + } + if rejectJSON { + return printJSON(env.stdout, run, true) + } + fmt.Fprintf(env.stdout, "rejected: run %s is %s; none of the actions waiting for approval will be applied (actions that applied automatically before it stay applied)\n", id, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) + return nil +} diff --git a/cmd/runs_test.go b/cmd/runs_test.go index 9ee78d3..a987820 100644 --- a/cmd/runs_test.go +++ b/cmd/runs_test.go @@ -15,6 +15,8 @@ func resetRunsFlags() { showJSON = false logsFollow, logsAfter, logsJSON = false, 0, false cancelJSON = false + proposalDiff, proposalJSON = false, false + rejectReason, rejectJSON = "", false } func TestRunsLsJSON(t *testing.T) { diff --git a/cmd/secrets.go b/cmd/secrets.go new file mode 100644 index 0000000..8601a24 --- /dev/null +++ b/cmd/secrets.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/nxssie/nan-cli/internal/session" + "github.com/spf13/cobra" +) + +var secretsLsJSON bool + +// secretsURL is where secrets are written: Settings > Secrets in the portal. +const secretsURL = runs.PortalURL + "/settings" + +// portalOnlySecretVerbs are the writes a member may reach for. Each one +// answers with where to do it instead of "unknown command". +var portalOnlySecretVerbs = map[string]bool{ + "set": true, "add": true, "create": true, "new": true, "put": true, "update": true, "edit": true, + "rotate": true, "replace": true, "rm": true, "delete": true, "remove": true, "get": true, "show": true, "reveal": true, +} + +const secretsPortalOnly = "secrets are written in the portal only: Settings > Secrets at " + secretsURL + + ". Writing or replacing a secret needs you signed in through the browser, so that nothing that can read this " + + "machine's CLI session or tokens (an agent in a workspace, a CI job) can swap one for its own. " + + "A secret's value is never shown again after it is saved, anywhere: nan secrets ls lists names and metadata" + +var secretsCmd = &cobra.Command{ + Use: "secrets", + Short: "List your secrets (names and metadata only)", + Long: `List the secrets you keep for your automations: names, kinds, the last +characters as a hint, and which automations use each one. Values are never +shown, here or anywhere else, after they are saved. + +Portal-only, and why: adding, replacing and deleting secrets happen in the +portal, Settings > Secrets at ` + runs.PortalURL + `, signed in +through the browser. A secret written from a credential an agent or a CI job +could read (this machine's CLI session, a token) could be swapped for one +the attacker controls. + +nan secrets ls needs your nan auth login session: platform tokens and API +keys cannot read secrets. + +Docs: https://nan.builders/docs/runs`, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && portalOnlySecretVerbs[args[0]] { + return usageErrorf("%s", secretsPortalOnly) + } + if len(args) > 0 { + return usageErrorf("unknown command %q for \"nan secrets\"; try: nan secrets --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) + } + return cmd.Help() + }, +} + +var secretsLsCmd = &cobra.Command{ + Use: "ls", + Short: "List your secrets: names and metadata, never values", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + env, err := newSessionEnv() + if err != nil { + return err + } + return runtimeError(doSecretsLs(cmd.Context(), env)) + }, +} + +func init() { + rootCmd.AddCommand(secretsCmd) + secretsCmd.AddCommand(secretsLsCmd) + secretsLsCmd.Flags().BoolVar(&secretsLsJSON, "json", false, "Print the list as JSON (metadata only)") +} + +// newSessionEnv is newRunsEnv for the commands only a signed-in session can +// use. NAN_TOKEN, --token-file and a saved token are skipped rather than +// sent: the platform refuses them there, and the saved session is the one +// credential that works. +func newSessionEnv() (*runsEnv, error) { + sess, err := session.Load() + if err != nil && !errors.Is(err, session.ErrNotLoggedIn) { + return nil, exitf(exitAuth, "could not read your session (%v): run nan auth login", err) + } + if sess == nil || sess.Token == "" { + return nil, exitf(exitAuth, "nan secrets needs your session: run nan auth login (platform tokens and API keys cannot read secrets)") + } + cred := credential{kind: credSession, session: sess.Token} + return &runsEnv{client: cred.client(), cred: cred, stdout: os.Stdout, stderr: os.Stderr}, nil +} + +func doSecretsLs(ctx context.Context, env *runsEnv) error { + list, err := env.client.ListSecrets(ctx) + if err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.Status == 404 { + return exitf(exitAuth, "secrets are not enabled for your account") + } + return apiExit(err, env.cred) + } + if secretsLsJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintf(env.stderr, "No secrets yet. Add them in the portal: Settings > Secrets at %s\n", secretsURL) + return nil + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tKIND\tHINT\tUSED BY\tLAST USED\tUPDATED") + for _, s := range list.Data { + hint := "-" + if s.Hint != nil && *s.Hint != "" { + hint = runs.Truncate(runs.SanitizeLine(*s.Hint), 12) + } + kind := runs.SanitizeLine(s.Kind) + if s.Provider != nil && *s.Provider != "" { + kind += " (" + runs.SanitizeLine(*s.Provider) + ")" + } + used := make([]string, 0, len(s.UsedBy)) + for _, u := range s.UsedBy { + used = append(used, runs.SanitizeLine(u.Label())) + } + usedBy := "-" + if len(used) > 0 { + usedBy = runs.Truncate(strings.Join(used, ", "), 40) + } + lastUsed := "never" + if s.LastUsedAt != nil { + lastUsed = localTime(*s.LastUsedAt) + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", + runs.Truncate(runs.SanitizeLine(s.Name), 64), + kind, hint, usedBy, lastUsed, localTime(s.UpdatedAt)) + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintf(env.stderr, "add, replace or delete secrets in the portal: Settings > Secrets at %s\n", secretsURL) + return nil +} diff --git a/internal/api/approvals.go b/internal/api/approvals.go new file mode 100644 index 0000000..c529d39 --- /dev/null +++ b/internal/api/approvals.go @@ -0,0 +1,244 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strconv" +) + +// Ref names another object: a workspace or an automation. The platform may +// send it as an object ({"id","name"}) or as a bare id; both decode here, so +// a change of shape on that side does not break this one. +type Ref struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +func (r *Ref) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if len(b) > 0 && b[0] == '"' { + return json.Unmarshal(b, &r.ID) + } + type plain Ref + var p plain + if err := json.Unmarshal(b, &p); err != nil { + return err + } + *r = Ref(p) + return nil +} + +// Label is the name when there is one, else the id. +func (r *Ref) Label() string { + if r == nil { + return "" + } + if r.Name != "" { + return r.Name + } + return r.ID +} + +// Approval states, as the platform names them. +const ( + ApprovalAuto = "auto" + ApprovalPending = "pending" + ApprovalApproved = "approved" + ApprovalRejected = "rejected" + ApprovalExpired = "expired" + ApprovalCancelled = "cancelled" +) + +// ApprovalStates is every approval state, for flag validation. +var ApprovalStates = []string{ApprovalPending, ApprovalApproved, ApprovalRejected, ApprovalExpired, ApprovalCancelled, ApprovalAuto} + +// ProposalAction is one action an automation run proposed. Every string in +// it except Kind was written by the agent (and masked by the platform), so +// it is printed as plain text through runs.Sanitize, never interpreted. +type ProposalAction struct { + Kind string `json:"kind"` + Repo string `json:"repo,omitempty"` + Number int64 `json:"number,omitempty"` + Base string `json:"base,omitempty"` + Branch string `json:"branch,omitempty"` + Head string `json:"head,omitempty"` + Commit string `json:"commit,omitempty"` + CommitMessage string `json:"commit_message,omitempty"` + Force bool `json:"force,omitempty"` + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` + Draft bool `json:"draft,omitempty"` + Event string `json:"event,omitempty"` + Labels []string `json:"labels,omitempty"` +} + +type DiffstatFile struct { + Path string `json:"path"` + Added int64 `json:"added"` + Deleted int64 `json:"deleted"` + Binary bool `json:"binary"` +} + +type Diffstat struct { + Files []DiffstatFile `json:"files"` + FilesChanged int64 `json:"files_changed"` + Insertions int64 `json:"insertions"` + Deletions int64 `json:"deletions"` + Truncated bool `json:"truncated"` +} + +// Proposal is the stored, masked proposal of a propose run. +type Proposal struct { + V int `json:"v"` + Actions []ProposalAction `json:"actions"` + Base *string `json:"base"` + MergeBase *string `json:"merge_base"` + BundleSHA256 *string `json:"bundle_sha256"` + DiffSHA256 *string `json:"diff_sha256"` + Diffstat *Diffstat `json:"diffstat"` +} + +// Approval is a run's proposal and the member's decision on it (the approval +// object of the platform's /v1/approvals and of a run's "approval" field). +type Approval struct { + RunID string `json:"run_id"` + State string `json:"state"` + ApprovalFrom *int `json:"approval_from"` + ExpiresAt *string `json:"expires_at"` + Proposal *Proposal `json:"proposal"` + ProposalSHA256 string `json:"proposal_sha256"` + Diffstat *Diffstat `json:"diffstat"` + HasDiff bool `json:"has_diff"` + DiffMasked bool `json:"diff_masked"` + DecidedAt *string `json:"decided_at"` + RejectReason *string `json:"reject_reason,omitempty"` + PrefixApplyRunID *string `json:"prefix_apply_run_id"` + ApplyRunID *string `json:"apply_run_id"` + CreatedAt string `json:"created_at,omitempty"` + + // On the items of GET /v1/approvals only. + RunState string `json:"run_state,omitempty"` + Workspace *Ref `json:"workspace,omitempty"` + Automation *Ref `json:"automation,omitempty"` +} + +// UnmarshalJSON reads an approval, accepting workspace_id and automation_id +// where the platform sends ids instead of {id,name} objects. +func (a *Approval) UnmarshalJSON(b []byte) error { + type plain Approval + var aux struct { + *plain + WorkspaceID string `json:"workspace_id"` + AutomationID string `json:"automation_id"` + } + aux.plain = (*plain)(a) + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + if a.Workspace == nil && aux.WorkspaceID != "" { + a.Workspace = &Ref{ID: aux.WorkspaceID} + } + if a.Automation == nil && aux.AutomationID != "" { + a.Automation = &Ref{ID: aux.AutomationID} + } + return nil +} + +type ApprovalList struct { + Object string `json:"object"` + Data []Approval `json:"data"` + NextCursor *string `json:"next_cursor"` +} + +type ListApprovalsParams struct { + State string // "" = every state + Limit int + Cursor string +} + +// ListApprovals is GET /v1/approvals. +func (c *RunsClient) ListApprovals(ctx context.Context, p ListApprovalsParams) (*ApprovalList, error) { + q := url.Values{} + if p.State != "" { + q.Set("state", p.State) + } + if p.Limit > 0 { + q.Set("limit", strconv.Itoa(p.Limit)) + } + if p.Cursor != "" { + q.Set("cursor", p.Cursor) + } + path := "/v1/approvals" + if len(q) > 0 { + path += "?" + q.Encode() + } + req, err := c.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + list := &ApprovalList{} + if _, err := c.do(req, list); err != nil { + return nil, err + } + return list, nil +} + +type rejectBody struct { + Reason string `json:"reason,omitempty"` +} + +// RejectRun turns down the proposal of a run awaiting approval: the run ends +// cancelled (approval_rejected) and nothing it proposed is applied. +// Rejecting is always safe, so the platform accepts it from any credential +// that can read runs; approving is not, and has no counterpart here. +func (c *RunsClient) RejectRun(ctx context.Context, id, reason string) (*Run, error) { + req, err := c.newRequest(ctx, http.MethodPost, "/v1/runs/"+url.PathEscape(id)+"/reject", rejectBody{Reason: reason}) + if err != nil { + return nil, err + } + run := &Run{} + if _, err := c.do(req, run); err != nil { + return nil, err + } + return run, nil +} + +// maxDiff bounds the proposal diff this process holds. The platform stores +// at most 256 KiB; masking can only shorten what is longer than its marker. +const maxDiff = 1 << 20 + +// ProposalDiff is GET /v1/runs/{id}/proposal.diff: the stored, masked diff +// of a proposal whose approval covers a push. It is text written by the +// agent; the caller sanitises it before it reaches a terminal. +func (c *RunsClient) ProposalDiff(ctx context.Context, id string) (string, error) { + req, err := c.newRequest(ctx, http.MethodGet, "/v1/runs/"+url.PathEscape(id)+"/proposal.diff", nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "text/plain") + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiff+1)) + if err != nil { + return "", fmt.Errorf("could not read the answer from nan.builders: %w", err) + } + if err := c.statusError(resp.StatusCode, body); err != nil { + return "", err + } + if len(body) > maxDiff { + return "", fmt.Errorf("the diff is over %d bytes, more than the platform stores", maxDiff) + } + if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "" && mt != "text/plain" { + return "", fmt.Errorf("expected a text diff, got %q", resp.Header.Get("Content-Type")) + } + return string(body), nil +} diff --git a/internal/api/automations.go b/internal/api/automations.go new file mode 100644 index 0000000..692773d --- /dev/null +++ b/internal/api/automations.go @@ -0,0 +1,154 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strconv" +) + +// SecretAssignment is a member secret an automation's runs receive, by name: +// never a value. Exposure "agent" puts it in the agent's environment; +// "actions" gives it only to the platform's action executor. +type SecretAssignment struct { + Secret string `json:"secret"` + Env string `json:"env"` + Exposure string `json:"exposure"` +} + +type Inference struct { + Source string `json:"source"` +} + +// Automation mirrors the automation object of /v1/automations. It is the +// member's own configuration, read here and never written: creating and +// editing automations happens in the portal. +type Automation struct { + ID string `json:"id"` + Object string `json:"object,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Template string `json:"template"` + Workspace *Ref `json:"workspace"` + Agent string `json:"agent"` + Model *string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Cwd string `json:"cwd"` + GitIsolation *bool `json:"git_isolation"` + TimeoutSeconds int `json:"timeout_seconds"` + ApprovalKinds []string `json:"approval_kinds"` + ApprovalTTLSeconds int `json:"approval_ttl_seconds"` + Inference *Inference `json:"inference,omitempty"` + GitHubRepo *string `json:"github_repo"` + Secrets []SecretAssignment `json:"secrets"` + MaxRunsPerHour int `json:"max_runs_per_hour"` + ConcurrencyPolicy string `json:"concurrency_policy"` + MaxTriggerDepth int `json:"max_trigger_depth"` + Enabled bool `json:"enabled"` + DisabledReason *string `json:"disabled_reason"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type AutomationList struct { + Object string `json:"object"` + Data []Automation `json:"data"` +} + +// Trigger is a webhook or a schedule of an automation. The webhook URL and +// its signing secret are shown once, in the portal, and never again: only +// the URL's prefix is here. +type Trigger struct { + ID string `json:"id"` + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + TokenPrefix *string `json:"token_prefix,omitempty"` + SignatureScheme *string `json:"signature_scheme,omitempty"` + Cron *string `json:"cron,omitempty"` + Timezone *string `json:"timezone,omitempty"` + NextFireAt *string `json:"next_fire_at,omitempty"` + LastFiredAt *string `json:"last_fired_at,omitempty"` + LastOutcome *string `json:"last_outcome,omitempty"` + PrevSecretUntil *string `json:"prev_secret_until,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +type TriggerList struct { + Data []Trigger `json:"data"` +} + +// Delivery is one entry of an automation's webhook delivery log: what came +// in and what became of it. No payload and no headers, by design. +type Delivery struct { + DeliveryID string `json:"delivery_id"` + TriggerID string `json:"trigger_id,omitempty"` + ReceivedAt string `json:"received_at"` + Outcome string `json:"outcome"` + RunID *string `json:"run_id"` +} + +type DeliveryList struct { + Data []Delivery `json:"data"` +} + +func (c *RunsClient) getJSON(ctx context.Context, path string, out any) error { + req, err := c.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + _, err = c.do(req, out) + return err +} + +// ListAutomations is GET /v1/automations. +func (c *RunsClient) ListAutomations(ctx context.Context) (*AutomationList, error) { + list := &AutomationList{} + return list, c.getJSON(ctx, "/v1/automations", list) +} + +// GetAutomation is GET /v1/automations/{id}. +func (c *RunsClient) GetAutomation(ctx context.Context, id string) (*Automation, error) { + a := &Automation{} + return a, c.getJSON(ctx, "/v1/automations/"+url.PathEscape(id), a) +} + +// ListTriggers is GET /v1/automations/{id}/triggers. +func (c *RunsClient) ListTriggers(ctx context.Context, id string) (*TriggerList, error) { + list := &TriggerList{} + return list, c.getJSON(ctx, "/v1/automations/"+url.PathEscape(id)+"/triggers", list) +} + +// ListDeliveries is GET /v1/automations/{id}/deliveries. +func (c *RunsClient) ListDeliveries(ctx context.Context, id string, limit int) (*DeliveryList, error) { + path := "/v1/automations/" + url.PathEscape(id) + "/deliveries" + if limit > 0 { + path += "?limit=" + strconv.Itoa(limit) + } + list := &DeliveryList{} + return list, c.getJSON(ctx, path, list) +} + +type runAutomationBody struct { + Input json.RawMessage `json:"input,omitempty"` +} + +// RunAutomation starts a run of an automation by hand. input, when given, +// is a JSON object the agent receives as untrusted data next to the +// automation's own instructions, which it never replaces. replayed is true +// when the platform answered an Idempotency-Key it had already seen. +func (c *RunsClient) RunAutomation(ctx context.Context, id string, input json.RawMessage, idempotencyKey string) (run *Run, replayed bool, err error) { + req, err := c.newRequest(ctx, http.MethodPost, "/v1/automations/"+url.PathEscape(id)+"/runs", runAutomationBody{Input: input}) + if err != nil { + return nil, false, err + } + if idempotencyKey != "" { + req.Header.Set("Idempotency-Key", idempotencyKey) + } + run = &Run{} + status, err := c.do(req, run) + if err != nil { + return nil, false, err + } + return run, status == http.StatusOK, nil +} diff --git a/internal/api/automations_test.go b/internal/api/automations_test.go new file mode 100644 index 0000000..9e1d4d0 --- /dev/null +++ b/internal/api/automations_test.go @@ -0,0 +1,254 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +const automationID = "a0000000-0000-4000-8000-000000000001" + +// The Phase 2 fields of a run (AGENT-AUTOMATIONS-PHASE2 §4.4, §7.6, §10). +func TestRunDecodesPhase2Fields(t *testing.T) { + body := `{"id":"r1","state":"awaiting_approval","phase":"propose","inference_source":"nan", + "automation":{"id":"a1","name":"PR review"},"parent_run_id":null,"apply_run_ids":["x1","x2"], + "approval":{"run_id":"r1","state":"pending","approval_from":1,"expires_at":"2026-10-14T12:00:00Z", + "proposal":{"v":1,"actions":[{"kind":"git_push","base":"main","branch":"fix/x","commit":"abc","commit_message":"fix","force":true}, + {"kind":"github_add_labels","repo":"acme/api","number":7,"labels":["bug"]}],"base":"main","merge_base":"m", + "bundle_sha256":"b","diff_sha256":"d","diffstat":{"files":[{"path":"a.go","added":3,"deleted":1,"binary":false}],"files_changed":1,"insertions":3,"deletions":1,"truncated":false}}, + "proposal_sha256":"ff","has_diff":true,"diff_masked":true,"decided_at":null,"prefix_apply_run_id":"x1","apply_run_id":null}}` + var run Run + if err := json.Unmarshal([]byte(body), &run); err != nil { + t.Fatal(err) + } + if run.Phase != "propose" || run.Inference.Source != "nan" || run.Automation.Label() != "PR review" || len(run.ApplyRunIDs) != 2 { + t.Errorf("run = %+v", run) + } + a := run.Approval + if a == nil || a.State != ApprovalPending || *a.ApprovalFrom != 1 || !a.HasDiff || !a.DiffMasked || *a.PrefixApplyRunID != "x1" { + t.Fatalf("approval = %+v", a) + } + p := a.Proposal + if len(p.Actions) != 2 || !p.Actions[0].Force || p.Actions[1].Labels[0] != "bug" || p.Diffstat.Files[0].Added != 3 { + t.Errorf("proposal = %+v", p) + } +} + +// The contract's spellings, and the older ones, decode to the same fields. +func TestRunAndApprovalAcceptBothSpellings(t *testing.T) { + var run Run + if err := json.Unmarshal([]byte(`{"id":"r","inference":{"source":"nan"},"automation":null,"automation_id":"a9", + "secrets":[{"env":"GITHUB_TOKEN","exposure":"agent"}],"result":{"status":"ok","actions_applied":[{"kind":"git_push","ok":true,"ref":"abc"}]}}`), &run); err != nil { + t.Fatal(err) + } + if run.Inference.Source != "nan" || run.Automation.ID != "a9" || run.Secrets[0].Env != "GITHUB_TOKEN" || !run.Result.ActionsApplied[0].OK { + t.Errorf("%+v", run) + } + var a Approval + if err := json.Unmarshal([]byte(`{"run_id":"r","state":"pending","workspace_id":"w1","automation_id":"a1"}`), &a); err != nil { + t.Fatal(err) + } + if a.Workspace.ID != "w1" || a.Automation.ID != "a1" || a.RunID != "r" { + t.Errorf("%+v", a) + } +} + +func TestRefAcceptsAnIDOrAnObject(t *testing.T) { + var v struct { + A *Ref `json:"a"` + B *Ref `json:"b"` + C *Ref `json:"c"` + } + if err := json.Unmarshal([]byte(`{"a":"id-1","b":{"id":"id-2","name":"two"},"c":null}`), &v); err != nil { + t.Fatal(err) + } + if v.A.ID != "id-1" || v.A.Label() != "id-1" || v.B.Label() != "two" || v.C != nil || v.C.Label() != "" { + t.Errorf("%+v %+v %+v", v.A, v.B, v.C) + } +} + +func TestListApprovalsQuery(t *testing.T) { + var path, query string + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path, query = r.URL.Path, r.URL.RawQuery + _, _ = io.WriteString(w, `{"object":"list","data":[{"run_id":"r1","state":"pending","run_state":"awaiting_approval","automation":"a1"}],"next_cursor":null}`) + }), "", "nan_pat_x") + list, err := c.ListApprovals(context.Background(), ListApprovalsParams{State: "pending", Limit: 5}) + if err != nil { + t.Fatal(err) + } + if path != "/v1/approvals" || query != "limit=5&state=pending" { + t.Errorf("GET %s?%s", path, query) + } + if len(list.Data) != 1 || list.Data[0].RunState != "awaiting_approval" || list.Data[0].Automation.ID != "a1" { + t.Errorf("list = %+v", list) + } +} + +func TestRejectRunSendsTheReason(t *testing.T) { + var method, path string + var body map[string]any + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method, path = r.Method, r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&body) + _, _ = io.WriteString(w, runJSON("cancelled")) + }), "sess", "") + run, err := c.RejectRun(context.Background(), runID, "not now") + if err != nil { + t.Fatal(err) + } + if method != http.MethodPost || path != "/v1/runs/"+runID+"/reject" || body["reason"] != "not now" || run.State != "cancelled" { + t.Errorf("%s %s %v -> %+v", method, path, body, run) + } + + // No reason: an empty object, not "reason":"". + body = nil + _, _ = c.RejectRun(context.Background(), runID, "") + if _, ok := body["reason"]; ok { + t.Errorf("empty reason was sent: %v", body) + } +} + +func TestRejectRunNotAwaitingIsAnAPIError(t *testing.T) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":{"message":"the run is not awaiting approval","type":"invalid_request_error","code":"not_awaiting_approval"}}`) + }), "sess", "") + _, err := c.RejectRun(context.Background(), runID, "") + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != 409 || apiErr.Code != "not_awaiting_approval" { + t.Fatalf("err = %#v", err) + } +} + +func TestProposalDiff(t *testing.T) { + ctype := "text/plain; charset=utf-8" + body := "diff --git a/a b/a\n+x\n" + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/runs/"+runID+"/proposal.diff" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", ctype) + _, _ = io.WriteString(w, body) + }), "sess", "") + got, err := c.ProposalDiff(context.Background(), runID) + if err != nil || got != body { + t.Fatalf("%q %v", got, err) + } + + ctype = "text/html" + if _, err := c.ProposalDiff(context.Background(), runID); err == nil { + t.Error("an HTML answer was taken for a diff") + } + + ctype = "text/plain" + body = strings.Repeat("x", maxDiff+1) + if _, err := c.ProposalDiff(context.Background(), runID); err == nil { + t.Error("an oversized diff was accepted") + } +} + +func TestAutomationReads(t *testing.T) { + var paths []string + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.RequestURI()) + switch r.URL.Path { + case "/v1/automations": + _, _ = io.WriteString(w, `{"object":"list","data":[{"id":"`+automationID+`","name":"PR review","workspace":"develop","enabled":true,"approval_kinds":["git_push"]}]}`) + case "/v1/automations/" + automationID: + _, _ = io.WriteString(w, `{"id":"`+automationID+`","name":"PR review","workspace":{"id":"w1","name":"develop"},"secrets":[{"secret":"GH","env":"GITHUB_TOKEN","exposure":"actions"}]}`) + case "/v1/automations/" + automationID + "/triggers": + _, _ = io.WriteString(w, `{"data":[{"id":"t1","kind":"schedule","enabled":true,"cron":"0 9 * * 1-5","timezone":"Europe/Madrid"}]}`) + case "/v1/automations/" + automationID + "/deliveries": + _, _ = io.WriteString(w, `{"data":[{"delivery_id":"d1","received_at":"2026-10-10T12:00:00Z","outcome":"run_created","run_id":"r1"}]}`) + default: + http.NotFound(w, r) + } + }), "", "nan_pat_x") + ctx := context.Background() + list, err := c.ListAutomations(ctx) + if err != nil || len(list.Data) != 1 || list.Data[0].Workspace.Label() != "develop" { + t.Fatalf("%+v %v", list, err) + } + a, err := c.GetAutomation(ctx, automationID) + if err != nil || a.Workspace.Label() != "develop" || a.Secrets[0].Exposure != "actions" { + t.Fatalf("%+v %v", a, err) + } + tr, err := c.ListTriggers(ctx, automationID) + if err != nil || *tr.Data[0].Cron != "0 9 * * 1-5" { + t.Fatalf("%+v %v", tr, err) + } + d, err := c.ListDeliveries(ctx, automationID, 50) + if err != nil || d.Data[0].Outcome != "run_created" { + t.Fatalf("%+v %v", d, err) + } + if paths[3] != "/v1/automations/"+automationID+"/deliveries?limit=50" { + t.Errorf("paths = %q", paths) + } +} + +func TestRunAutomation(t *testing.T) { + var idem string + var raw []byte + status := http.StatusCreated + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/automations/"+automationID+"/runs" { + http.NotFound(w, r) + return + } + idem = r.Header.Get("Idempotency-Key") + raw, _ = io.ReadAll(r.Body) + w.WriteHeader(status) + _, _ = io.WriteString(w, runJSON("queued")) + }), "", "nan_pat_x") + run, replayed, err := c.RunAutomation(context.Background(), automationID, json.RawMessage(`{"pr":42}`), "k1") + if err != nil || replayed || run.ID != runID { + t.Fatalf("%+v %v %v", run, replayed, err) + } + if idem != "k1" || string(raw) != `{"input":{"pr":42}}` { + t.Errorf("idem %q body %s", idem, raw) + } + + status = http.StatusOK + _, replayed, _ = c.RunAutomation(context.Background(), automationID, nil, "") + if !replayed || string(raw) != `{}` { + t.Errorf("replayed %v body %s", replayed, raw) + } +} + +func TestListSecretsUsesTheSession(t *testing.T) { + var cookie, path string + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, path = r.Header.Get("Cookie"), r.URL.Path + _, _ = io.WriteString(w, `{"data":[{"id":"s1","object":"secret","name":"GITHUB_TOKEN","kind":"generic","provider":null,"hint":"…a1b2","value_bytes":93,"description":"d","used_by":[{"id":"a1","name":"PR review"}],"created_at":"2026-10-10T12:00:00Z","updated_at":"2026-10-10T12:00:00Z","last_used_at":null}]}`) + }), "sess", "") + list, err := c.ListSecrets(context.Background()) + if err != nil { + t.Fatal(err) + } + if cookie != "nan_session=sess" || path != "/api/account/secrets" { + t.Errorf("%q %q", cookie, path) + } + if len(list.Data) != 1 || *list.Data[0].Hint != "…a1b2" || list.Data[0].UsedBy[0].Name != "PR review" { + t.Errorf("%+v", list) + } +} + +// /api's newer envelope carries the code in "error" and the text in +// "message"; the code is what callers branch on. +func TestBareEnvelopeWithCodeAndMessage(t *testing.T) { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"error":"browser_session_required","message":"sign in in the browser","param":"x"}`) + }), "sess", "") + _, err := c.ListSecrets(context.Background()) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code != "browser_session_required" || apiErr.Message != "sign in in the browser" || apiErr.Param != "x" { + t.Fatalf("err = %#v", err) + } +} diff --git a/internal/api/golden_test.go b/internal/api/golden_test.go new file mode 100644 index 0000000..7280c50 --- /dev/null +++ b/internal/api/golden_test.go @@ -0,0 +1,118 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// The golden responses below are copied verbatim from cloud-api's handler +// tests (internal/handlers/testdata/phase2 on helmcode/nan-cloud-api), so +// this client decodes exactly what the platform serialises. +func golden(t *testing.T, name string, out any) { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "phase2", name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("%s: %v", name, err) + } +} + +const ( + goldenRun = "6f1c2a9b-0000-4000-8000-000000000001" + goldenAutomation = "a0000000-0000-4000-8000-000000000001" + goldenWorkspace = "11111111-2222-4333-8444-555555555555" + goldenApplyRun = "d0000000-0000-4000-8000-000000000004" +) + +func checkGoldenApproval(t *testing.T, a *Approval) { + t.Helper() + if a == nil || a.RunID != goldenRun || a.State != ApprovalPending || a.ApprovalFrom == nil || *a.ApprovalFrom != 2 || + a.HasDiff || a.DiffMasked || a.ProposalSHA256 == "" || a.ExpiresAt == nil || + a.PrefixApplyRunID == nil || *a.PrefixApplyRunID != goldenApplyRun || a.ApplyRunID != nil { + t.Fatalf("approval = %+v", a) + } + p := a.Proposal + if p == nil || p.V != 1 || len(p.Actions) != 3 || p.Actions[0].Kind != "git_push" || p.Actions[0].CommitMessage != "fix login" || + p.Actions[1].Number != 42 || p.Actions[1].Body != " looks fine" || p.Actions[2].Event != "COMMENT" || + *p.Base != "main" || p.Diffstat.Files[0].Path != "a.go" || a.Diffstat.Insertions != 3 { + t.Fatalf("proposal = %+v", p) + } +} + +func TestGoldenRunPropose(t *testing.T) { + var run Run + golden(t, "run_get_propose.json", &run) + if run.ID != goldenRun || run.State != StateAwaitingApproval || run.Phase != "propose" || run.Trigger != "webhook" || + run.Inference == nil || run.Inference.Source != "nan" || run.Automation == nil || run.Automation.ID != goldenAutomation || + run.Workspace.ID != goldenWorkspace || run.Workspace.Name != "develop" || + len(run.ApplyRunIDs) != 1 || run.ApplyRunIDs[0] != goldenApplyRun || *run.GitHubRepo != "acme/api" || + len(run.ApprovalKinds) != 1 || len(run.Secrets) != 1 || run.Secrets[0].Exposure != "agent" || + len(run.Result.ActionsApplied) != 1 || !run.Result.ActionsApplied[0].OK { + t.Fatalf("run = %+v", run) + } + checkGoldenApproval(t, run.Approval) +} + +func TestGoldenRunAdhoc(t *testing.T) { + var run Run + golden(t, "run_get_adhoc.json", &run) + if run.Phase != "run" || run.Approval != nil || run.Automation != nil || len(run.ApplyRunIDs) != 0 || + run.State != StateSucceeded || run.Result.Summary != "done" || run.Prompt == "" { + t.Fatalf("run = %+v", run) + } +} + +func TestGoldenRunList(t *testing.T) { + var list RunList + golden(t, "run_list_propose.json", &list) + if len(list.Data) != 1 || list.Data[0].Approval == nil || list.Data[0].Automation.ID != goldenAutomation { + t.Fatalf("list = %+v", list) + } +} + +func TestGoldenApprovals(t *testing.T) { + var list ApprovalList + golden(t, "approvals_list.json", &list) + if list.Object != "list" || list.NextCursor == nil || *list.NextCursor != "cursor-2" || len(list.Data) != 1 { + t.Fatalf("list = %+v", list) + } + a := list.Data[0] + checkGoldenApproval(t, &a) + if a.RunState != StateAwaitingApproval || a.Workspace.ID != goldenWorkspace || a.Automation.ID != goldenAutomation { + t.Fatalf("item = %+v", a) + } +} + +func TestGoldenAutomations(t *testing.T) { + var list AutomationList + golden(t, "automations_list.json", &list) + var one Automation + golden(t, "automation_get.json", &one) + for _, a := range []Automation{list.Data[0], one} { + if a.ID != goldenAutomation || a.Name != "PR review api" || a.Workspace.ID != goldenWorkspace || a.Agent != "pi" || + *a.Model != "glm5.3" || a.Inference.Source != "nan" || *a.GitHubRepo != "acme/api" || !a.Enabled || + len(a.Secrets) != 1 || a.Secrets[0].Secret != "GITHUB_WRITE_TOKEN" || a.Secrets[0].Exposure != "actions" || + a.ApprovalTTLSeconds != 259200 || a.ConcurrencyPolicy != "skip_if_active" || a.Instructions == "" { + t.Fatalf("automation = %+v", a) + } + } +} + +func TestGoldenTriggersAndDeliveries(t *testing.T) { + var triggers TriggerList + golden(t, "automation_triggers.json", &triggers) + tr := triggers.Data[0] + if tr.Kind != "webhook" || *tr.TokenPrefix != "nan_whk_AbC1" || *tr.SignatureScheme != "github" || tr.Cron != nil || !tr.Enabled { + t.Fatalf("trigger = %+v", tr) + } + var deliveries DeliveryList + golden(t, "automation_deliveries.json", &deliveries) + d := deliveries.Data[0] + if d.Outcome != "run_created" || *d.RunID != goldenRun || d.TriggerID == "" || d.DeliveryID == "" { + t.Fatalf("delivery = %+v", d) + } +} diff --git a/internal/api/runs.go b/internal/api/runs.go index 80aea33..0d19c73 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -31,6 +31,12 @@ const ( StateFailed = "failed" StateCancelled = "cancelled" StateTimedOut = "timed_out" + + // StateAwaitingApproval is a run whose agent finished and proposed + // actions that wait for the member's decision. It is not terminal: the + // run goes on to succeeded (approved, in the portal), cancelled + // (rejected or expired) or failed (an earlier automatic action failed). + StateAwaitingApproval = "awaiting_approval" ) // Terminal reports whether a run in this state will never change again. @@ -53,13 +59,21 @@ type RunArtifact struct { SHA256 string `json:"sha256"` } +// ActionApplied is the outcome of one action of an apply run. +type ActionApplied struct { + Kind string `json:"kind"` + OK bool `json:"ok"` + Ref string `json:"ref,omitempty"` +} + type RunResult struct { - Status string `json:"status"` - Summary string `json:"summary"` - Branch string `json:"branch"` - Commit string `json:"commit"` - ChangedFiles int `json:"changed_files"` - Artifacts []RunArtifact `json:"artifacts"` + ActionsApplied []ActionApplied `json:"actions_applied,omitempty"` + Status string `json:"status"` + Summary string `json:"summary"` + Branch string `json:"branch"` + Commit string `json:"commit"` + ChangedFiles int `json:"changed_files"` + Artifacts []RunArtifact `json:"artifacts"` } type RunUsage struct { @@ -94,6 +108,48 @@ type Run struct { CreatedAt string `json:"created_at"` StartedAt *string `json:"started_at"` FinishedAt *string `json:"finished_at"` + + // Phase 2 (automations). Absent on an ad-hoc run of an older platform. + Phase string `json:"phase,omitempty"` // run | propose | apply + Inference *Inference `json:"inference,omitempty"` + Automation *Ref `json:"automation,omitempty"` + ParentRunID *string `json:"parent_run_id,omitempty"` + Approval *Approval `json:"approval,omitempty"` + ApplyRunID *string `json:"apply_run_id,omitempty"` + ApplyRunIDs []string `json:"apply_run_ids,omitempty"` + ApprovalKinds []string `json:"approval_kinds,omitempty"` + GitHubRepo *string `json:"github_repo,omitempty"` + TriggerDepth int `json:"trigger_depth,omitempty"` + Secrets []RunSecret `json:"secrets,omitempty"` +} + +// RunSecret is a secret a run received: its env name and who saw it. +type RunSecret struct { + Env string `json:"env"` + Exposure string `json:"exposure"` +} + +// UnmarshalJSON reads a run, accepting the older spellings of two Phase 2 +// fields: inference_source for inference.source, and automation_id for +// automation. +func (r *Run) UnmarshalJSON(b []byte) error { + type plain Run + var aux struct { + *plain + InferenceSource string `json:"inference_source"` + AutomationID string `json:"automation_id"` + } + aux.plain = (*plain)(r) + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + if r.Inference == nil && aux.InferenceSource != "" { + r.Inference = &Inference{Source: aux.InferenceSource} + } + if r.Automation == nil && aux.AutomationID != "" { + r.Automation = &Ref{ID: aux.AutomationID} + } + return nil } // CreateRunRequest is the body of POST /v1/runs. Pointers are the fields the @@ -316,7 +372,9 @@ func (c *RunsClient) statusError(status int, body []byte) error { } `json:"error"` } var bare struct { - Error string `json:"error"` + Error string `json:"error"` + Message string `json:"message"` + Param *string `json:"param"` } switch { case json.Unmarshal(body, &openai) == nil && (openai.Error.Message != "" || openai.Error.Code != nil): @@ -332,7 +390,16 @@ func (c *RunsClient) statusError(status int, body []byte) error { apiErr.Code = strconv.Itoa(int(code)) } case json.Unmarshal(body, &bare) == nil && bare.Error != "": + // /api answers {"error":"text"} on older routes and + // {"error":"code","message":"text"} on newer ones (secrets). apiErr.Message = bare.Error + if bare.Message != "" { + apiErr.Code = bare.Error + apiErr.Message = bare.Message + } + if bare.Param != nil { + apiErr.Param = *bare.Param + } } return apiErr } diff --git a/internal/api/secrets.go b/internal/api/secrets.go new file mode 100644 index 0000000..31de6f9 --- /dev/null +++ b/internal/api/secrets.go @@ -0,0 +1,34 @@ +package api + +import ( + "context" +) + +// Secret is a member secret's metadata. The platform never returns a value +// (nor its ciphertext): Hint is at most the last characters, as the portal +// shows them, so a member can tell two tokens apart. +type Secret struct { + ID string `json:"id"` + Object string `json:"object,omitempty"` + Name string `json:"name"` + Kind string `json:"kind"` + Provider *string `json:"provider"` + Hint *string `json:"hint"` + ValueBytes int `json:"value_bytes"` + Description string `json:"description"` + UsedBy []Ref `json:"used_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastUsedAt *string `json:"last_used_at"` +} + +type SecretList struct { + Data []Secret `json:"data"` +} + +// ListSecrets is GET /api/account/secrets. The platform answers it for a +// signed-in session only: API keys and platform tokens are refused there. +func (c *RunsClient) ListSecrets(ctx context.Context) (*SecretList, error) { + list := &SecretList{} + return list, c.getJSON(ctx, "/api/account/secrets", list) +} diff --git a/internal/api/testdata/phase2/approvals_list.json b/internal/api/testdata/phase2/approvals_list.json new file mode 100644 index 0000000..330b433 --- /dev/null +++ b/internal/api/testdata/phase2/approvals_list.json @@ -0,0 +1,79 @@ +{ + "data": [ + { + "apply_run_id": null, + "approval_from": 2, + "automation_id": "a0000000-0000-4000-8000-000000000001", + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "run_state": "awaiting_approval", + "state": "pending", + "workspace_id": "11111111-2222-4333-8444-555555555555" + } + ], + "next_cursor": "cursor-2", + "object": "list" +} diff --git a/internal/api/testdata/phase2/automation_deliveries.json b/internal/api/testdata/phase2/automation_deliveries.json new file mode 100644 index 0000000..be68146 --- /dev/null +++ b/internal/api/testdata/phase2/automation_deliveries.json @@ -0,0 +1,12 @@ +{ + "data": [ + { + "delivery_id": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "outcome": "run_created", + "received_at": "2026-10-11T09:00:00Z", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "trigger_id": "b0000000-0000-4000-8000-000000000002" + } + ], + "object": "list" +} diff --git a/internal/api/testdata/phase2/automation_get.json b/internal/api/testdata/phase2/automation_get.json new file mode 100644 index 0000000..c265359 --- /dev/null +++ b/internal/api/testdata/phase2/automation_get.json @@ -0,0 +1,36 @@ +{ + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "glm5.3", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "workspace": "11111111-2222-4333-8444-555555555555" +} diff --git a/internal/api/testdata/phase2/automation_triggers.json b/internal/api/testdata/phase2/automation_triggers.json new file mode 100644 index 0000000..b9ce112 --- /dev/null +++ b/internal/api/testdata/phase2/automation_triggers.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "actor_filter": { + "author_associations": [], + "exclude_bots": false, + "logins": [ + "octocat" + ] + }, + "automation_id": "a0000000-0000-4000-8000-000000000001", + "created_at": "2026-10-11T09:00:00Z", + "cron": null, + "enabled": true, + "event_filter": { + "events": [ + "pull_request" + ] + }, + "id": "b0000000-0000-4000-8000-000000000002", + "kind": "webhook", + "last_fired_at": null, + "last_outcome": null, + "next_fire_at": null, + "object": "trigger", + "prev_secret_until": null, + "signature_scheme": "github", + "timezone": null, + "token_prefix": "nan_whk_AbC1", + "updated_at": "2026-10-11T09:00:00Z" + } + ], + "object": "list" +} diff --git a/internal/api/testdata/phase2/automations_list.json b/internal/api/testdata/phase2/automations_list.json new file mode 100644 index 0000000..8c87409 --- /dev/null +++ b/internal/api/testdata/phase2/automations_list.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "git_isolation": true, + "github_repo": "acme/api", + "id": "a0000000-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "instructions": "Review for correctness.", + "max_runs_per_hour": 30, + "max_trigger_depth": 2, + "model": "glm5.3", + "name": "PR review api", + "object": "automation", + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "actions", + "secret": "GITHUB_WRITE_TOKEN" + } + ], + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "workspace": "11111111-2222-4333-8444-555555555555" + } + ], + "object": "list" +} diff --git a/internal/api/testdata/phase2/run_get_adhoc.json b/internal/api/testdata/phase2/run_get_adhoc.json new file mode 100644 index 0000000..37eca16 --- /dev/null +++ b/internal/api/testdata/phase2/run_get_adhoc.json @@ -0,0 +1,52 @@ +{ + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": null, + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": null, + "phase": "run", + "prompt": "review PR 42 in full", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [], + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } +} diff --git a/internal/api/testdata/phase2/run_get_propose.json b/internal/api/testdata/phase2/run_get_propose.json new file mode 100644 index 0000000..0c79820 --- /dev/null +++ b/internal/api/testdata/phase2/run_get_propose.json @@ -0,0 +1,147 @@ +{ + "agent": "pi", + "agent_finished_at": "2026-10-11T09:00:00Z", + "apply_run_ids": [ + "d0000000-0000-4000-8000-000000000004" + ], + "approval": { + "apply_run_id": null, + "approval_from": 2, + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "state": "pending" + }, + "approval_kinds": [ + "github_pr_review" + ], + "automation_id": "a0000000-0000-4000-8000-000000000001", + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "input": { + "delivery": "72d3162e-cc78-11e3-81ab-4c9367dc0958", + "event": "pull_request", + "payload": { + "number": 42, + "title": "\u003cb\u003efix\u003c/b\u003e" + }, + "source": "webhook:github", + "untrusted": true + }, + "instructions": "Review for correctness.", + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": "e0000000-0000-4000-8000-000000000005", + "phase": "propose", + "prompt": "review PR 42 in full", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [ + { + "kind": "github_pr_review", + "ok": true, + "ref": "https://github.com/acme/api/pull/42#pullrequestreview-1" + } + ], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "started_at": "2026-10-10T09:00:01Z", + "state": "awaiting_approval", + "timeout_seconds": 1800, + "trigger": "webhook", + "trigger_depth": 1, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } +} diff --git a/internal/api/testdata/phase2/run_list_propose.json b/internal/api/testdata/phase2/run_list_propose.json new file mode 100644 index 0000000..2ec4578 --- /dev/null +++ b/internal/api/testdata/phase2/run_list_propose.json @@ -0,0 +1,141 @@ +{ + "data": [ + { + "agent": "pi", + "agent_finished_at": "2026-10-11T09:00:00Z", + "apply_run_ids": [ + "d0000000-0000-4000-8000-000000000004" + ], + "approval": { + "apply_run_id": null, + "approval_from": 2, + "decided_at": null, + "diff_masked": false, + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "expires_at": "2026-10-14T09:00:00Z", + "has_diff": false, + "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", + "proposal": { + "actions": [ + { + "base": "main", + "branch": "nan-run/6f1c2a9b", + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "commit_message": "fix login", + "force": false, + "kind": "git_push" + }, + { + "body": "\u003cimg onerror=x\u003e looks fine", + "kind": "github_pr_comment", + "number": 42, + "repo": "acme/api" + }, + { + "body": "LGTM", + "event": "COMMENT", + "kind": "github_pr_review", + "number": 42, + "repo": "acme/api" + } + ], + "base": "main", + "bundle_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "diff_sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "diffstat": { + "deletions": 1, + "files": [ + { + "added": 3, + "binary": false, + "deleted": 1, + "path": "a.go" + } + ], + "files_changed": 1, + "insertions": 3, + "truncated": false + }, + "merge_base": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "v": 1 + }, + "proposal_sha256": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "run_id": "6f1c2a9b-0000-4000-8000-000000000001", + "state": "pending" + }, + "approval_kinds": [ + "github_pr_review" + ], + "automation_id": "a0000000-0000-4000-8000-000000000001", + "cancel_requested": false, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error_code": null, + "exit_code": null, + "finished_at": null, + "git_isolation": null, + "github_repo": "acme/api", + "id": "6f1c2a9b-0000-4000-8000-000000000001", + "inference": { + "source": "nan" + }, + "last_seq": 42, + "model": null, + "object": "run", + "parent_run_id": "e0000000-0000-4000-8000-000000000005", + "phase": "propose", + "prompt_preview": "review PR 42", + "queue_position": null, + "result": { + "actions_applied": [ + { + "kind": "github_pr_review", + "ok": true, + "ref": "https://github.com/acme/api/pull/42#pullrequestreview-1" + } + ], + "artifacts": [], + "branch": "nan-run/6f1c2a9b", + "changed_files": 3, + "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "status": "succeeded", + "summary": "done" + }, + "secrets": [ + { + "env": "GITHUB_TOKEN", + "exposure": "agent" + } + ], + "started_at": "2026-10-10T09:00:01Z", + "state": "awaiting_approval", + "timeout_seconds": 1800, + "trigger": "webhook", + "trigger_depth": 1, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": { + "id": "11111111-2222-4333-8444-555555555555", + "name": "develop" + } + } + ], + "next_cursor": null, + "object": "list" +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ace9c59..b4b61e7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -21,7 +21,6 @@ import ( const ( loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" - loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" sessionCookie = "nan_session" // The domain the platform sends its sign-in links from. Subdomains count: // the link lands on the web app, the API lives next door. @@ -35,6 +34,21 @@ const ( LinkValidity = "15 minutes" ) +// loginVerifyURL is a variable so a test can point the exchange at a server +// of its own. +var loginVerifyURL = "https://cloud-api.nan.builders/api/auth/login/verify" + +// clientKind is what this CLI declares on the verify POST, so the platform +// records the session it hands back as a CLI session. A CLI session can +// read, start runs and reject proposals; approving a proposal, writing a +// secret and editing an automation need a session signed in through the +// browser. The session file this CLI keeps is readable by anything running +// as the member - an agent in a workspace included - and must never be able +// to do those. The platform never takes a client's word for "browser": that +// kind is proven by the browser flow itself, so declaring "cli" here only +// ever narrows what the session can do. +const clientKind = "cli" + // A timeout, because http.DefaultClient has none: a connection that is // accepted and then never answered - a captive portal, a hotel network, a // firewall that drops instead of refusing - hangs the panel on a spinner with @@ -112,7 +126,7 @@ func ExchangeToken(token string) (string, error) { return http.ErrUseLastResponse }, } - form := url.Values{"token": {token}} + form := url.Values{"token": {token}, "client": {clientKind}} resp, err := client.PostForm(loginVerifyURL, form) if err != nil { return "", fmt.Errorf("could not reach nan.builders: %w", err) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 4070f93..3edb67b 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,6 +1,9 @@ package auth import ( + "net/http" + "net/http/httptest" + "net/url" "strings" "testing" ) @@ -93,3 +96,28 @@ func TestRefusedLinkSaysWhatToDoAboutIt(t *testing.T) { }) } } + +// The verify POST declares this client as the CLI (AGENT-AUTOMATIONS-PHASE2 +// §11.3), so the session is recorded as a CLI session, never as a browser +// one. +func TestExchangeTokenDeclaresTheCLI(t *testing.T) { + var form url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + form = r.PostForm + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: "sess"}) + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) + })) + defer srv.Close() + old := loginVerifyURL + loginVerifyURL = srv.URL + defer func() { loginVerifyURL = old }() + + got, err := ExchangeToken("tok") + if err != nil || got != "sess" { + t.Fatalf("%q %v", got, err) + } + if form.Get("token") != "tok" || form.Get("client") != "cli" || form.Has("flow") { + t.Errorf("form = %v", form) + } +} diff --git a/internal/runs/proposal_test.go b/internal/runs/proposal_test.go new file mode 100644 index 0000000..a6c8b2b --- /dev/null +++ b/internal/runs/proposal_test.go @@ -0,0 +1,131 @@ +package runs + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +const ( + testRun = "6f1c2a9b-0000-4000-8000-000000000001" + testWS = "0e000000-0000-4000-8000-0000000000aa" +) + +func awaitingRun(t *testing.T) *api.Run { + t.Helper() + body := `{"id":"` + testRun + `","state":"awaiting_approval","phase":"propose","workspace":{"id":"` + testWS + `","name":"develop"}, + "automation":{"id":"a1","name":"PR review \u001b[31mred"}, + "approval":{"run_id":"` + testRun + `","state":"pending","approval_from":1,"expires_at":"2026-10-14T12:00:00Z", + "proposal":{"v":1,"actions":[ + {"kind":"git_push","base":"main","branch":"fix/login","commit":"0123456789abcdef0123456789abcdef01234567","commit_message":"fix: login\u001b]52;c;ZXZpbA==\u0007","force":true}, + {"kind":"github_create_pr","repo":"acme/api","base":"main","head":"fix/login","title":"Fix \u001b[2Jlogin","body":"line one\n\n\u001b[1Aoverwrite","draft":true}, + {"kind":"github_add_labels","repo":"acme/api","number":7,"labels":["bug","needs\u202erepro"]}], + "base":"main","merge_base":"fedcba9876543210","diffstat":{"files":[{"path":"src/\u001b[31mlogin.go","added":10,"deleted":2,"binary":false},{"path":"logo.png","added":0,"deleted":0,"binary":true}],"files_changed":2,"insertions":10,"deletions":2,"truncated":true}}, + "proposal_sha256":"abc123","has_diff":true,"diff_masked":true}}` + var run api.Run + if err := json.Unmarshal([]byte(body), &run); err != nil { + t.Fatal(err) + } + return &run +} + +func TestProposalIsPlainTextAndSaysWhereToDecide(t *testing.T) { + var b bytes.Buffer + Proposal(&b, awaitingRun(t)) + out := b.String() + if strings.ContainsAny(out, "\x1b\x07\u202e\u009b") { + t.Fatalf("control characters reached the output: %q", out) + } + for _, want := range []string{ + "waiting for your approval", + "abc123", + "main (merge base fedcba987654…)", + "2 files, +10 -2", + "1. git_push fix/login onto main (commit 0123456789ab…) FORCE [automatic]", + `2. github_create_pr acme/api fix/login -> main "Fix login" (draft) [needs approval]`, + "3. github_add_labels acme/api#7 bug, needsrepro [needs approval]", + "", // text, not markup: printed as it is + "overwrite", + "binary", + "src/login.go", + "more files than the summary lists", + "nan runs proposal " + testRun + " --diff", + "[masked]", + "https://cloud.nan.builders/workspaces/" + testWS + "/runs/" + testRun, + "nan runs reject " + testRun, + "browser sign-in", + } { + if !strings.Contains(out, want) { + t.Errorf("proposal lacks %q:\n%s", want, out) + } + } +} + +func TestProposalOfADecidedRunHasNoApproveHint(t *testing.T) { + run := awaitingRun(t) + run.State = api.StateSucceeded + run.Approval.State = api.ApprovalAuto + run.Approval.ApprovalFrom = nil + run.Approval.HasDiff, run.Approval.DiffMasked = false, false + var b bytes.Buffer + Proposal(&b, run) + out := b.String() + if strings.Contains(out, "approve:") || strings.Contains(out, "--diff") || strings.Contains(out, "needs approval") { + t.Errorf("decided proposal still offers a decision:\n%s", out) + } + if !strings.Contains(out, "applied automatically") { + t.Errorf("auto proposal not described:\n%s", out) + } +} + +func TestAwaitingApprovalExitAndOutcome(t *testing.T) { + run := awaitingRun(t) + code, ok := ExitCode(run) + if !ok || code != 10 { + t.Fatalf("ExitCode = %d %v", code, ok) + } + out := Outcome(run) + for _, want := range []string{"awaiting approval", "nan runs proposal " + testRun, "nan runs reject " + testRun, "/runs/" + testRun} { + if !strings.Contains(out, want) { + t.Errorf("outcome lacks %q:\n%s", want, out) + } + } + if strings.Contains(out, "nan runs approve") { + t.Error("the hint names an approve command the CLI does not have") + } +} + +func TestRunURLFallsBackWithoutAWorkspaceID(t *testing.T) { + run := awaitingRun(t) + run.Workspace.ID = "../../evil" + if got := RunURL(run); got != "https://cloud.nan.builders/automations?tab=approvals" { + t.Errorf("RunURL = %q", got) + } +} + +func TestPhase2Events(t *testing.T) { + var b bytes.Buffer + r := &Renderer{Out: &b} + r.Event(api.Event{Type: "approval_required", Data: json.RawMessage(`{"proposal_sha256":"0123456789abcdef0123","kinds":["git_push","github_create_pr\u001b[2J"]}`)}) + r.Event(api.Event{Type: "action", Data: json.RawMessage(`{"index":0,"kind":"git_push","status":"failed","message":"the base branch moved\u001b]0;x\u0007"}`)}) + out := b.String() + for _, want := range []string{"approval required for: git_push, github_create_pr (proposal 0123456789ab…)", "action 1 git_push failed: the base branch moved"} { + if !strings.Contains(out, want) { + t.Errorf("lacks %q:\n%s", want, out) + } + } + if strings.ContainsAny(out, "\x1b\x07") { + t.Errorf("control characters: %q", out) + } +} + +func TestDiffIsSanitised(t *testing.T) { + var b bytes.Buffer + Diff(&b, "+ok\x1b[2J\r\n-gone") + if b.String() != "+ok\n-gone\n" { + t.Errorf("%q", b.String()) + } +} diff --git a/internal/runs/render.go b/internal/runs/render.go index c90594f..001abd3 100644 --- a/internal/runs/render.go +++ b/internal/runs/render.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "strings" + "time" "unicode/utf8" "github.com/nxssie/nan-cli/internal/api" @@ -243,6 +244,30 @@ func (r *Renderer) Event(ev api.Event) { } _ = json.Unmarshal(ev.Data, &d) r.line("── output truncated (%s): the run went on, its log did not", SanitizeLine(d.Reason)) + case "approval_required": + var d struct { + ProposalSHA256 string `json:"proposal_sha256"` + Kinds []string `json:"kinds"` + } + _ = json.Unmarshal(ev.Data, &d) + kinds := make([]string, 0, len(d.Kinds)) + for _, k := range d.Kinds { + kinds = append(kinds, SanitizeLine(k)) + } + r.line("── approval required for: %s (proposal %s)", strings.Join(kinds, ", "), Truncate(SanitizeLine(d.ProposalSHA256), 13)) + case "action": + var d struct { + Index int `json:"index"` + Kind string `json:"kind"` + Status string `json:"status"` + Message string `json:"message"` + } + _ = json.Unmarshal(ev.Data, &d) + msg := "" + if m := Truncate(SanitizeLine(strings.TrimSpace(d.Message)), 200); m != "" { + msg = ": " + m + } + r.line("── action %d %s %s%s", d.Index+1, SanitizeLine(d.Kind), SanitizeLine(d.Status), msg) case "finished": // The end of the stream carries the authoritative outcome; Finish // prints it, so this would only say it twice. @@ -253,12 +278,20 @@ func (r *Renderer) Event(ev api.Event) { // Flush ends a line left open by assistant text. func (r *Renderer) Flush() { r.newline() } +// ExitAwaitingApproval is the exit code for a run whose agent is done and +// whose proposed actions wait for the member's approval. The run is not +// over, but nothing more happens until the member decides, so a script +// waiting on it has its answer. +const ExitAwaitingApproval = 10 + // ExitCode is the process exit code for a run, as `nan run` documents it. // A run that has not finished yet has none; ok is false. func ExitCode(run *api.Run) (code int, ok bool) { switch run.State { case api.StateSucceeded: return 0, true + case api.StateAwaitingApproval: + return ExitAwaitingApproval, true case api.StateTimedOut: return 2, true case api.StateCancelled: @@ -290,9 +323,78 @@ func Outcome(run *api.Run) string { fmt.Fprintf(&b, " · %d files changed", run.Result.ChangedFiles) } } + if run.State == api.StateAwaitingApproval { + b.WriteString("\n" + ApprovalHint(run)) + } return b.String() } +// PortalURL is the member portal, where approving, writing secrets and +// creating or editing automations happen. +const PortalURL = "https://cloud.nan.builders" + +// AutomationsURL is the portal's list of automations. +const AutomationsURL = PortalURL + "/automations" + +// ApprovalsURL is the portal's approvals inbox, a tab of the automations page. +const ApprovalsURL = AutomationsURL + "?tab=approvals" + +// NewAutomationURL is where an automation is created. +const NewAutomationURL = AutomationsURL + "/new" + +// AutomationURL is one automation's page in the portal, or the list when +// the id is not well-formed. +func AutomationURL(id string) string { + if !isUUID(id) { + return AutomationsURL + } + return AutomationsURL + "/" + strings.ToLower(id) +} + +// RunURL is a run's page in the portal, where its proposal is approved. +// The workspace id is part of the path; without a well-formed one this +// falls back to the approvals inbox. +func RunURL(run *api.Run) string { + if !isUUID(run.ID) || !isUUID(run.Workspace.ID) { + return ApprovalsURL + } + return PortalURL + "/workspaces/" + strings.ToLower(run.Workspace.ID) + "/runs/" + strings.ToLower(run.ID) +} + +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') { + return false + } + } + } + return true +} + +// ApprovalHint tells a member what to do with a run awaiting approval: +// where to read the proposal, where to approve it, and how to turn it down. +func ApprovalHint(run *api.Run) string { + return " review: nan runs proposal " + SanitizeLine(run.ID) + "\n" + approveLine(run) + "\n" + rejectLine(run) +} + +func approveLine(run *api.Run) string { + return " approve: in the portal, " + RunURL(run) + " (approving needs a browser sign-in)" +} + +func rejectLine(run *api.Run) string { + return " reject: nan runs reject " + SanitizeLine(run.ID) +} + // jsonUnsafe is what encoding/json leaves raw and a terminal may act on. func jsonUnsafe(r rune) bool { return r == 0x7f || (r >= 0x80 && unsafeRune(r)) } @@ -333,3 +435,169 @@ func SafeJSON(v any, indent bool) ([]byte, error) { } return []byte(b.String()), nil } + +// Proposal prints a run's proposal as plain text: what the agent proposed +// to do, which of it applies automatically and which waits for the member, +// and where to decide. Every string in a proposal except the action kinds +// was written by the agent; it goes through Sanitize like any other text +// from a run, so a body cannot carry an escape sequence to the terminal. +func Proposal(w io.Writer, run *api.Run) { + a := run.Approval + id := SanitizeLine(run.ID) + row := func(k, v string) { fmt.Fprintf(w, "%-12s%s\n", k, v) } + + row("run", id+" ("+strings.ReplaceAll(SanitizeLine(run.State), "_", " ")+")") + if run.Automation != nil { + row("automation", SanitizeLine(run.Automation.Label())) + } + state := SanitizeLine(a.State) + switch a.State { + case api.ApprovalPending: + state = "waiting for your approval" + if a.ExpiresAt != nil { + state += ", expires " + localTime(*a.ExpiresAt) + } + case api.ApprovalAuto: + state = "applied automatically (no action needed approval)" + } + row("approval", state) + if a.RejectReason != nil && *a.RejectReason != "" { + row("reason", Truncate(SanitizeLine(*a.RejectReason), 200)) + } + row("sha256", SanitizeLine(a.ProposalSHA256)) + + var p api.Proposal + if a.Proposal != nil { + p = *a.Proposal + } + if p.Base != nil { + base := SanitizeLine(*p.Base) + if p.MergeBase != nil { + base += " (merge base " + Truncate(SanitizeLine(*p.MergeBase), 13) + ")" + } + row("base", base) + } + ds := a.Diffstat + if ds == nil { + ds = p.Diffstat + } + if ds != nil { + row("changes", fmt.Sprintf("%d files, +%d -%d", ds.FilesChanged, ds.Insertions, ds.Deletions)) + } + + from := -1 + if a.ApprovalFrom != nil { + from = *a.ApprovalFrom + } + fmt.Fprintln(w) + fmt.Fprintln(w, "actions:") + if len(p.Actions) == 0 { + fmt.Fprintln(w, " (none)") + } + for i, act := range p.Actions { + mode := "automatic" + if from >= 0 && i >= from { + mode = "needs approval" + } + fmt.Fprintf(w, " %d. %s [%s]\n", i+1, actionLine(act), mode) + if act.CommitMessage != "" { + indented(w, "commit message", act.CommitMessage) + } + if act.Body != "" { + indented(w, "body", act.Body) + } + } + + if ds != nil && len(ds.Files) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "files:") + for _, f := range ds.Files { + change := fmt.Sprintf("+%d -%d", f.Added, f.Deleted) + if f.Binary { + change = "binary" + } + fmt.Fprintf(w, " %-12s %s\n", change, SanitizeLine(f.Path)) + } + if ds.Truncated { + fmt.Fprintln(w, " … (more files than the summary lists)") + } + } + + fmt.Fprintln(w) + if a.HasDiff { + fmt.Fprintf(w, "The full diff is stored: nan runs proposal %s --diff\n", id) + } + if a.DiffMasked { + fmt.Fprintln(w, "The diff contains the value of one of your secrets (shown here as [masked]); approving pushes it to your repository.") + } + if a.State == api.ApprovalPending && run.State == api.StateAwaitingApproval { + fmt.Fprintln(w, approveLine(run)) + fmt.Fprintln(w, rejectLine(run)) + } +} + +func actionLine(a api.ProposalAction) string { + s := SanitizeLine + ref := func() string { + if a.Number > 0 { + return fmt.Sprintf("%s#%d", s(a.Repo), a.Number) + } + return s(a.Repo) + } + switch a.Kind { + case "git_push": + line := "git_push " + s(a.Branch) + " onto " + s(a.Base) + if a.Commit != "" { + line += " (commit " + Truncate(s(a.Commit), 13) + ")" + } + if a.Force { + line += " FORCE" + } + return line + case "github_create_pr": + line := fmt.Sprintf("github_create_pr %s %s -> %s %q", s(a.Repo), s(a.Head), s(a.Base), Truncate(s(a.Title), 120)) + if a.Draft { + line += " (draft)" + } + return line + case "github_pr_review": + return "github_pr_review " + ref() + " " + s(a.Event) + case "github_add_labels": + labels := make([]string, len(a.Labels)) + for i, l := range a.Labels { + labels[i] = s(l) + } + return "github_add_labels " + ref() + " " + strings.Join(labels, ", ") + case "github_pr_comment", "github_issue_comment": + return s(a.Kind) + " " + ref() + } + return s(a.Kind) + " " + ref() +} + +func indented(w io.Writer, title, text string) { + text = strings.TrimRight(Sanitize(text), "\n") + if strings.TrimSpace(text) == "" { + return + } + fmt.Fprintf(w, " %s:\n", title) + for _, l := range strings.Split(text, "\n") { + fmt.Fprintf(w, " %s\n", l) + } +} + +// Diff prints a proposal's diff, sanitised: it is text the agent produced. +func Diff(w io.Writer, diff string) { + text := Sanitize(diff) + fmt.Fprint(w, text) + if text != "" && !strings.HasSuffix(text, "\n") { + fmt.Fprintln(w) + } +} + +func localTime(ts string) string { + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { + return SanitizeLine(ts) + } + return t.Local().Format("2006-01-02 15:04") +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0065a6a..892f7a0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3559,7 +3559,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.25" +const Version = "0.1.26" func renderAbout(l layout) string { var b strings.Builder