From 8546af25236bb08cecf87338b102f47c06eb6390 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 15:59:29 +0200 Subject: [PATCH 01/11] chore: start Agent Automations Phase 2 CLI (T9) Co-Authored-By: Claude Opus 5.5 From 57fee08302e358c66341a7c6646f00eb5412d0c2 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:03:30 +0200 Subject: [PATCH 02/11] feat(api): Phase 2 client for automations, approvals, proposals and secrets Co-Authored-By: Claude Opus 5.5 --- internal/api/approvals.go | 222 +++++++++++++++++++++++++++++ internal/api/automations.go | 152 ++++++++++++++++++++ internal/api/automations_test.go | 235 +++++++++++++++++++++++++++++++ internal/api/runs.go | 28 +++- internal/api/secrets.go | 34 +++++ 5 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 internal/api/approvals.go create mode 100644 internal/api/automations.go create mode 100644 internal/api/automations_test.go create mode 100644 internal/api/secrets.go diff --git a/internal/api/approvals.go b/internal/api/approvals.go new file mode 100644 index 0000000..4309334 --- /dev/null +++ b/internal/api/approvals.go @@ -0,0 +1,222 @@ +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"` +} + +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..9548df6 --- /dev/null +++ b/internal/api/automations.go @@ -0,0 +1,152 @@ +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"` +} + +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..2d44019 --- /dev/null +++ b/internal/api/automations_test.go @@ -0,0 +1,235 @@ +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.InferenceSource != "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) + } +} + +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/runs.go b/internal/api/runs.go index 80aea33..736484a 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. @@ -94,6 +100,15 @@ 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 + InferenceSource string `json:"inference_source,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"` } // CreateRunRequest is the body of POST /v1/runs. Pointers are the fields the @@ -316,7 +331,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 +349,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) +} From ed4cc2282f1c11772fbfc1db89e442f01ed01235 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:04:53 +0200 Subject: [PATCH 03/11] feat(runs): render proposals, approval and action events, exit 10 for awaiting_approval Co-Authored-By: Claude Opus 5.5 --- internal/runs/proposal_test.go | 131 +++++++++++++++++ internal/runs/render.go | 253 +++++++++++++++++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 internal/runs/proposal_test.go diff --git a/internal/runs/proposal_test.go b/internal/runs/proposal_test.go new file mode 100644 index 0000000..0f6d5b9 --- /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 != AutomationsURL { + 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..5371e53 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,63 @@ 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 automations page, with the approvals inbox. +const AutomationsURL = PortalURL + "/automations" + +// 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 automations page. +func RunURL(run *api.Run) string { + if !isUUID(run.ID) || !isUUID(run.Workspace.ID) { + return AutomationsURL + } + 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 +420,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") +} From d3884340f4e35fa9011f4c7f99977a21c3e6328d Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:05:21 +0200 Subject: [PATCH 04/11] feat(auth): declare client=cli on the magic-link verify; 0.1.26 Co-Authored-By: Claude Opus 5.5 --- internal/auth/auth.go | 18 ++++++++++++++++-- internal/auth/auth_test.go | 28 ++++++++++++++++++++++++++++ internal/tui/tui.go | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) 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/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 From 2fe3a3960d5f60a3bb982bd90833066af6c9c5cd Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:08:27 +0200 Subject: [PATCH 05/11] feat(cmd): nan automations, nan approvals, nan secrets ls, nan runs proposal/reject Co-Authored-By: Claude Opus 5.5 --- cmd/approvals.go | 178 +++++++++++++++ cmd/automations.go | 527 +++++++++++++++++++++++++++++++++++++++++++++ cmd/exit.go | 51 ++++- cmd/run.go | 2 + cmd/runs.go | 217 ++++++++++++++++++- cmd/runs_test.go | 2 + cmd/secrets.go | 148 +++++++++++++ 7 files changed, 1111 insertions(+), 14 deletions(-) create mode 100644 cmd/approvals.go create mode 100644 cmd/automations.go create mode 100644 cmd/secrets.go diff --git a/cmd/approvals.go b/cmd/approvals.go new file mode 100644 index 0000000..d7ef1e1 --- /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.AutomationsURL + + " (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.AutomationsURL + `: 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 usageErrorf("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.AutomationsURL) + } + 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..1a191a4 --- /dev/null +++ b/cmd/automations.go @@ -0,0 +1,527 @@ +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 + + ". 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. + +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 usageErrorf("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.AutomationsURL) + 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.AutomationsURL) + 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/exit.go b/cmd/exit.go index 0ca9c52..c164bb1 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/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..b5d94b2 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,10 +46,21 @@ 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" { + return usageErrorf("there is no nan runs approve: approve in the portal at %s (approving needs a browser sign-in). Read the proposal first with: nan runs proposal ", runs.AutomationsURL) + } if len(args) > 0 { return usageErrorf("unknown command %q for \"nan runs\" — try: nan runs --help", args[0]) } @@ -83,7 +100,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 +126,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). + +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 +189,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 +246,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 +348,28 @@ 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.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 { @@ -310,6 +402,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 +496,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, and none of the actions waiting for approval will be 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..14fd126 --- /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 usageErrorf("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 +} From 0dc40a8d7b969f829668d21b023d12f9bb1f6e70 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:11:06 +0200 Subject: [PATCH 06/11] test(cmd): Phase 2 commands against the spec's API shapes; docs and help Co-Authored-By: Claude Opus 5.5 --- README.md | 29 +- cmd/approvals.go | 4 +- cmd/automations.go | 16 +- cmd/automations_test.go | 724 ++++++++++++++++++++++++++++++++++++++++ cmd/exit.go | 10 +- cmd/runs.go | 4 +- cmd/secrets.go | 2 +- 7 files changed, 770 insertions(+), 19 deletions(-) create mode 100644 cmd/automations_test.go diff --git a/README.md b/README.md index 6c8c2ff..031c0be 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] [--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) | diff --git a/cmd/approvals.go b/cmd/approvals.go index d7ef1e1..81e301f 100644 --- a/cmd/approvals.go +++ b/cmd/approvals.go @@ -45,7 +45,7 @@ Docs: https://nan.builders/docs/runs`, 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 usageErrorf("unknown command %q for \"nan approvals\"; try: nan approvals --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) } return cmd.Help() }, @@ -148,7 +148,7 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { 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)) + 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.AutomationsURL) diff --git a/cmd/automations.go b/cmd/automations.go index 1a191a4..c44d045 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -76,7 +76,7 @@ Docs: https://nan.builders/docs/runs`, 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 usageErrorf("unknown command %q for \"nan automations\"; try: nan automations --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) } return cmd.Help() }, @@ -185,11 +185,11 @@ func init() { func oneAutomation(cmd *cobra.Command, args []string) error { switch { case len(args) == 0: - return usageErrorf("missing automation id or name — see: nan automations ls") + 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 usageErrorf("empty automation name; see: nan automations ls") } return nil } @@ -215,9 +215,9 @@ func resolveAutomation(ctx context.Context, env *runsEnv, ref string) (string, e } 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; did you mean %q? See: nan automations ls", shown, runs.SanitizeLine(folded[0])) } - return "", usageErrorf("no automation named %q — see: nan automations ls", shown) + return "", usageErrorf("no automation named %q; see: nan automations ls", shown) } // automationExit is apiExit with a 404 that names the automation. @@ -409,7 +409,7 @@ func readAutomationInput(in promptInput, flag string) (json.RawMessage, error) { switch { case flag == "-": if in.tty && in.notice != nil { - fmt.Fprintln(in.notice, "reading the input from stdin — end it with Ctrl-D") + 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") @@ -462,7 +462,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. 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 exitf(exitUnavailable, "the automation's workspace is not running; start it at %s and try again", runs.PortalURL) } return automationExit(err, ref, env.cred) } @@ -489,7 +489,7 @@ func doAutomationsRun(ctx context.Context, env *runsEnv, ref string, input json. 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)) + 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) } diff --git a/cmd/automations_test.go b/cmd/automations_test.go new file mode 100644 index 0000000..428e070 --- /dev/null +++ b/cmd/automations_test.go @@ -0,0 +1,724 @@ +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", + } { + 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) != 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", "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"], + "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} { + 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") + } +} diff --git a/cmd/exit.go b/cmd/exit.go index c164bb1..e194c9f 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -99,17 +99,17 @@ func apiExit(err error, cred credential) *ExitError { case "browser_session_required": return exitf(exitAuth, "%s", browserOnly) case "token_scope_denied": - return exitf(exitAuth, "%s — %s", msg, scopeHint(cred)) + 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) + 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) + 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 ") + 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") + return exitf(exitUnavailable, "the secrets store is unavailable right now; try again in a few minutes") } switch { case apiErr.Status == 400: diff --git a/cmd/runs.go b/cmd/runs.go index b5d94b2..b99f0e1 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -544,7 +544,7 @@ func doRunsProposal(ctx context.Context, env *runsEnv, id string, withDiff, asJS 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)) + 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) @@ -596,7 +596,7 @@ func doRunsReject(ctx context.Context, env *runsEnv, id string) error { 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 usageErrorf("run %s is not awaiting approval; see its state with: nan runs show %s", id, id) } return notFound(err, id, env.cred) } diff --git a/cmd/secrets.go b/cmd/secrets.go index 14fd126..a4896cb 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -55,7 +55,7 @@ Docs: https://nan.builders/docs/runs`, 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 usageErrorf("unknown command %q for \"nan secrets\"; try: nan secrets --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) } return cmd.Help() }, From 7c30b8200aa753c47c9f373c5d4747aa64fd48d0 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:11:41 +0200 Subject: [PATCH 07/11] docs(readme): token scopes for automations Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 031c0be..9e17328 100644 --- a/README.md +++ b/README.md @@ -214,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`. From 52299703a28bdd730a43d90344b98372c337c8bf Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:12:24 +0200 Subject: [PATCH 08/11] fix(api): align with the Phase 2 wire contract (inference object, id fallbacks, actions_applied, run secrets) Co-Authored-By: Claude Opus 5.5 --- cmd/automations_test.go | 6 ++- cmd/runs.go | 16 ++++++++ internal/api/approvals.go | 22 +++++++++++ internal/api/automations.go | 2 + internal/api/automations_test.go | 21 +++++++++- internal/api/runs.go | 67 +++++++++++++++++++++++++------- 6 files changed, 118 insertions(+), 16 deletions(-) diff --git a/cmd/automations_test.go b/cmd/automations_test.go index 428e070..d7c3a8f 100644 --- a/cmd/automations_test.go +++ b/cmd/automations_test.go @@ -432,7 +432,8 @@ func TestApprovalsStateFlag(t *testing.T) { 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"], + "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}}, @@ -500,7 +501,8 @@ func TestRunsShowPhase2(t *testing.T) { 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} { + 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) } diff --git a/cmd/runs.go b/cmd/runs.go index b99f0e1..e84aefb 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -354,6 +354,12 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { 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)) } @@ -395,6 +401,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))) diff --git a/internal/api/approvals.go b/internal/api/approvals.go index 4309334..c529d39 100644 --- a/internal/api/approvals.go +++ b/internal/api/approvals.go @@ -128,6 +128,28 @@ type Approval struct { 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"` diff --git a/internal/api/automations.go b/internal/api/automations.go index 9548df6..692773d 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -70,6 +70,8 @@ type Trigger struct { 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 { diff --git a/internal/api/automations_test.go b/internal/api/automations_test.go index 2d44019..9e1d4d0 100644 --- a/internal/api/automations_test.go +++ b/internal/api/automations_test.go @@ -25,7 +25,7 @@ func TestRunDecodesPhase2Fields(t *testing.T) { if err := json.Unmarshal([]byte(body), &run); err != nil { t.Fatal(err) } - if run.Phase != "propose" || run.InferenceSource != "nan" || run.Automation.Label() != "PR review" || len(run.ApplyRunIDs) != 2 { + 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 @@ -38,6 +38,25 @@ func TestRunDecodesPhase2Fields(t *testing.T) { } } +// 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"` diff --git a/internal/api/runs.go b/internal/api/runs.go index 736484a..0d19c73 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -59,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 { @@ -102,13 +110,46 @@ type Run struct { 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 - InferenceSource string `json:"inference_source,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"` + 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 From ae850abec1221fa2a4ff62675a84cd1713e81fcf Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:15:03 +0200 Subject: [PATCH 09/11] fix: portal links per cloud-ui routes (approvals inbox, automation page, new); review nits - approvals inbox: /automations?tab=approvals; automation page: /automations/; new: /automations/new - nan runs approve|accept names the run and points to its proposal - not-enabled 404 exits 65, not 64; reject message says automatic actions stay applied - --input help warns against secrets; exit 10 covered for nan runs logs -f Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/approvals.go | 8 +++--- cmd/automations.go | 9 +++--- cmd/automations_test.go | 50 ++++++++++++++++++++++++++++++++-- cmd/runs.go | 14 ++++++---- cmd/secrets.go | 2 +- internal/runs/proposal_test.go | 2 +- internal/runs/render.go | 21 ++++++++++++-- 8 files changed, 86 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9e17328..60af309 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ 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] [--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 ``` diff --git a/cmd/approvals.go b/cmd/approvals.go index 81e301f..91e7108 100644 --- a/cmd/approvals.go +++ b/cmd/approvals.go @@ -18,7 +18,7 @@ var ( approvalsJSON bool ) -const approveInPortal = "there is no approve command in the CLI: approve in the portal at " + runs.AutomationsURL + +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 " @@ -29,7 +29,7 @@ var approvalsCmd = &cobra.Command{ 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.AutomationsURL + `: it needs you +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. @@ -111,7 +111,7 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { - return usageErrorf("automations are not enabled for your account") + return exitf(exitAuth, "automations are not enabled for your account") } return apiExit(err, env.cred) } @@ -151,7 +151,7 @@ func doApprovalsLs(ctx context.Context, env *runsEnv, state string) error { 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.AutomationsURL) + 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 } diff --git a/cmd/automations.go b/cmd/automations.go index c44d045..cd26567 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -41,7 +41,7 @@ var portalOnlyAutomationVerbs = map[string]bool{ "triggers": true, "trigger": true, "rotate": true, } -const automationsPortalOnly = "automations are created and edited in the portal: " + runs.AutomationsURL + +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)" @@ -111,6 +111,7 @@ 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 @@ -234,7 +235,7 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { - return usageErrorf("automations are not enabled for your account") + return exitf(exitAuth, "automations are not enabled for your account") } return apiExit(err, env.cred) } @@ -242,7 +243,7 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { 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.AutomationsURL) + 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) @@ -354,7 +355,7 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) 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.AutomationsURL) + 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 } diff --git a/cmd/automations_test.go b/cmd/automations_test.go index d7c3a8f..bf87ef5 100644 --- a/cmd/automations_test.go +++ b/cmd/automations_test.go @@ -204,7 +204,7 @@ func TestAutomationsShowByName(t *testing.T) { "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", + "nan automations run " + testAutoID, "https://cloud.nan.builders/automations/" + testAutoID, } { if !strings.Contains(out, want) { t.Errorf("show lacks %q:\n%s", want, out) @@ -255,7 +255,7 @@ func TestAutomationsRunExitsTenOnAwaitingApproval(t *testing.T) { h := newPhase2Harness(t, fake) autoRunIdemKey = "retry-1" err := doAutomationsRun(context.Background(), h.env, "PR review", json.RawMessage(`{"pr":42}`)) - if exitCode(err) != 10 { + 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]" { @@ -410,7 +410,7 @@ func TestApprovalsLs(t *testing.T) { 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", "nan runs reject "} { + 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()) } @@ -724,3 +724,47 @@ func TestPhase2Help(t *testing.T) { 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/runs.go b/cmd/runs.go index e84aefb..b521a73 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -58,11 +58,15 @@ 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" { - return usageErrorf("there is no nan runs approve: approve in the portal at %s (approving needs a browser sign-in). Read the proposal first with: nan runs proposal ", runs.AutomationsURL) + 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() }, @@ -133,7 +137,7 @@ var runsProposalCmd = &cobra.Command{ 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). +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]. @@ -619,6 +623,6 @@ func doRunsReject(ctx context.Context, env *runsEnv, id string) error { if rejectJSON { return printJSON(env.stdout, run, true) } - fmt.Fprintf(env.stdout, "rejected: run %s is %s, and none of the actions waiting for approval will be applied\n", id, strings.ReplaceAll(runs.SanitizeLine(run.State), "_", " ")) + 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/secrets.go b/cmd/secrets.go index a4896cb..8601a24 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -102,7 +102,7 @@ func doSecretsLs(ctx context.Context, env *runsEnv) error { if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) && apiErr.Status == 404 { - return usageErrorf("secrets are not enabled for your account") + return exitf(exitAuth, "secrets are not enabled for your account") } return apiExit(err, env.cred) } diff --git a/internal/runs/proposal_test.go b/internal/runs/proposal_test.go index 0f6d5b9..a6c8b2b 100644 --- a/internal/runs/proposal_test.go +++ b/internal/runs/proposal_test.go @@ -101,7 +101,7 @@ func TestAwaitingApprovalExitAndOutcome(t *testing.T) { func TestRunURLFallsBackWithoutAWorkspaceID(t *testing.T) { run := awaitingRun(t) run.Workspace.ID = "../../evil" - if got := RunURL(run); got != AutomationsURL { + if got := RunURL(run); got != "https://cloud.nan.builders/automations?tab=approvals" { t.Errorf("RunURL = %q", got) } } diff --git a/internal/runs/render.go b/internal/runs/render.go index 5371e53..001abd3 100644 --- a/internal/runs/render.go +++ b/internal/runs/render.go @@ -333,15 +333,30 @@ func Outcome(run *api.Run) string { // creating or editing automations happen. const PortalURL = "https://cloud.nan.builders" -// AutomationsURL is the portal's automations page, with the approvals inbox. +// 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 automations page. +// falls back to the approvals inbox. func RunURL(run *api.Run) string { if !isUUID(run.ID) || !isUUID(run.Workspace.ID) { - return AutomationsURL + return ApprovalsURL } return PortalURL + "/workspaces/" + strings.ToLower(run.Workspace.ID) + "/runs/" + strings.ToLower(run.ID) } From 52a0b5ac949b1158e013aada3e33375547d82f8c Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:19:42 +0200 Subject: [PATCH 10/11] test: decode and render cloud-api's Phase 2 golden responses Copied verbatim from nan-cloud-api feat/automations-http-wiring (bafa111), internal/handlers/testdata/phase2. No non-test code changes. Co-Authored-By: Claude Opus 5.5 --- cmd/golden_test.go | 105 +++++++++++++ internal/api/golden_test.go | 118 ++++++++++++++ .../api/testdata/phase2/approvals_list.json | 79 ++++++++++ .../phase2/automation_deliveries.json | 12 ++ .../api/testdata/phase2/automation_get.json | 36 +++++ .../testdata/phase2/automation_triggers.json | 34 ++++ .../api/testdata/phase2/automations_list.json | 41 +++++ .../api/testdata/phase2/run_get_adhoc.json | 52 +++++++ .../api/testdata/phase2/run_get_propose.json | 147 ++++++++++++++++++ .../api/testdata/phase2/run_list_propose.json | 141 +++++++++++++++++ 10 files changed, 765 insertions(+) create mode 100644 cmd/golden_test.go create mode 100644 internal/api/golden_test.go create mode 100644 internal/api/testdata/phase2/approvals_list.json create mode 100644 internal/api/testdata/phase2/automation_deliveries.json create mode 100644 internal/api/testdata/phase2/automation_get.json create mode 100644 internal/api/testdata/phase2/automation_triggers.json create mode 100644 internal/api/testdata/phase2/automations_list.json create mode 100644 internal/api/testdata/phase2/run_get_adhoc.json create mode 100644 internal/api/testdata/phase2/run_get_propose.json create mode 100644 internal/api/testdata/phase2/run_list_propose.json diff --git a/cmd/golden_test.go b/cmd/golden_test.go new file mode 100644 index 0000000..6b26a58 --- /dev/null +++ b/cmd/golden_test.go @@ -0,0 +1,105 @@ +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]", + "github_pr_comment acme/api#42", " looks fine", "github_pr_review acme/api#42 COMMENT", + "+3 -1", "a.go", "--diff", "/workspaces/"+ws+"/runs/"+run) + + 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_comment, github_pr_review") + + 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/internal/api/golden_test.go b/internal/api/golden_test.go new file mode 100644 index 0000000..52b869f --- /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 != 1 || + !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/testdata/phase2/approvals_list.json b/internal/api/testdata/phase2/approvals_list.json new file mode 100644 index 0000000..c512d5f --- /dev/null +++ b/internal/api/testdata/phase2/approvals_list.json @@ -0,0 +1,79 @@ +{ + "data": [ + { + "apply_run_id": null, + "approval_from": 1, + "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": true, + "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..7fc5675 --- /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": 1, + "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": true, + "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..0bb92e7 --- /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": 1, + "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": true, + "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" +} From 06d6c9ed88e36dfd48fc5aac11fafb38eb4d7891 Mon Sep 17 00:00:00 2001 From: barckcode Date: Sun, 11 Oct 2026 16:43:14 +0200 Subject: [PATCH 11/11] test: recopy Phase 2 golden fixtures from cloud-api ae4649b (approval_from 2, has_diff false) Co-Authored-By: Claude Opus 5.5 --- cmd/golden_test.go | 15 ++++++++++++--- internal/api/golden_test.go | 4 ++-- internal/api/testdata/phase2/approvals_list.json | 4 ++-- internal/api/testdata/phase2/run_get_propose.json | 4 ++-- .../api/testdata/phase2/run_list_propose.json | 4 ++-- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cmd/golden_test.go b/cmd/golden_test.go index 6b26a58..a1bb779 100644 --- a/cmd/golden_test.go +++ b/cmd/golden_test.go @@ -75,14 +75,23 @@ func TestGoldenRender(t *testing.T) { t.Fatal(err) } wantAll(t, "runs proposal", out.String(), "waiting for your approval", "[automatic]", "[needs approval]", - "github_pr_comment acme/api#42", " looks fine", "github_pr_review acme/api#42 COMMENT", - "+3 -1", "a.go", "--diff", "/workspaces/"+ws+"/runs/"+run) + "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_comment, github_pr_review") + 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 { diff --git a/internal/api/golden_test.go b/internal/api/golden_test.go index 52b869f..7280c50 100644 --- a/internal/api/golden_test.go +++ b/internal/api/golden_test.go @@ -30,8 +30,8 @@ const ( func checkGoldenApproval(t *testing.T, a *Approval) { t.Helper() - if a == nil || a.RunID != goldenRun || a.State != ApprovalPending || a.ApprovalFrom == nil || *a.ApprovalFrom != 1 || - !a.HasDiff || a.DiffMasked || a.ProposalSHA256 == "" || a.ExpiresAt == nil || + 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) } diff --git a/internal/api/testdata/phase2/approvals_list.json b/internal/api/testdata/phase2/approvals_list.json index c512d5f..330b433 100644 --- a/internal/api/testdata/phase2/approvals_list.json +++ b/internal/api/testdata/phase2/approvals_list.json @@ -2,7 +2,7 @@ "data": [ { "apply_run_id": null, - "approval_from": 1, + "approval_from": 2, "automation_id": "a0000000-0000-4000-8000-000000000001", "decided_at": null, "diff_masked": false, @@ -21,7 +21,7 @@ "truncated": false }, "expires_at": "2026-10-14T09:00:00Z", - "has_diff": true, + "has_diff": false, "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", "proposal": { "actions": [ diff --git a/internal/api/testdata/phase2/run_get_propose.json b/internal/api/testdata/phase2/run_get_propose.json index 7fc5675..0c79820 100644 --- a/internal/api/testdata/phase2/run_get_propose.json +++ b/internal/api/testdata/phase2/run_get_propose.json @@ -6,7 +6,7 @@ ], "approval": { "apply_run_id": null, - "approval_from": 1, + "approval_from": 2, "decided_at": null, "diff_masked": false, "diffstat": { @@ -24,7 +24,7 @@ "truncated": false }, "expires_at": "2026-10-14T09:00:00Z", - "has_diff": true, + "has_diff": false, "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", "proposal": { "actions": [ diff --git a/internal/api/testdata/phase2/run_list_propose.json b/internal/api/testdata/phase2/run_list_propose.json index 0bb92e7..2ec4578 100644 --- a/internal/api/testdata/phase2/run_list_propose.json +++ b/internal/api/testdata/phase2/run_list_propose.json @@ -8,7 +8,7 @@ ], "approval": { "apply_run_id": null, - "approval_from": 1, + "approval_from": 2, "decided_at": null, "diff_masked": false, "diffstat": { @@ -26,7 +26,7 @@ "truncated": false }, "expires_at": "2026-10-14T09:00:00Z", - "has_diff": true, + "has_diff": false, "prefix_apply_run_id": "d0000000-0000-4000-8000-000000000004", "proposal": { "actions": [