From 977174134c9cb5e64d77764781d0a85094b7d5e7 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:36:33 +0200 Subject: [PATCH 01/11] feat(api): ephemeral run fields, usage and settings client Run carries executor, size and cost; CreateRunRequest can ask for an ephemeral run (no workspace); Automation carries executor, size and checkout; features.ephemeral; GET /api/ephemeral/usage and settings. Golden responses rendered by cloud-api's own handlers. Co-Authored-By: Claude Opus 5.5 --- internal/api/automations.go | 10 +- internal/api/ephemeral.go | 67 ++++++ internal/api/ephemeral_test.go | 149 ++++++++++++++ internal/api/runs.go | 36 +++- .../ephemeral/automation_get_ephemeral.json | 42 ++++ .../ephemeral/ephemeral_settings.json | 10 + .../testdata/ephemeral/ephemeral_usage.json | 19 ++ .../testdata/ephemeral/run_get_ephemeral.json | 66 ++++++ .../ephemeral/run_list_ephemeral.json | 190 ++++++++++++++++++ internal/api/write.go | 3 + 10 files changed, 589 insertions(+), 3 deletions(-) create mode 100644 internal/api/ephemeral.go create mode 100644 internal/api/ephemeral_test.go create mode 100644 internal/api/testdata/ephemeral/automation_get_ephemeral.json create mode 100644 internal/api/testdata/ephemeral/ephemeral_settings.json create mode 100644 internal/api/testdata/ephemeral/ephemeral_usage.json create mode 100644 internal/api/testdata/ephemeral/run_get_ephemeral.json create mode 100644 internal/api/testdata/ephemeral/run_list_ephemeral.json diff --git a/internal/api/automations.go b/internal/api/automations.go index eb88c94..836a3c3 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -64,8 +64,14 @@ type Automation struct { Enabled bool `json:"enabled"` DisabledReason *string `json:"disabled_reason"` UpdatedBy string `json:"updated_by,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // Checkout is "workspace" or "fresh" (a new clone of github_repo per run). + Checkout string `json:"checkout,omitempty"` + // Executor is where its runs execute ("workspace" or "ephemeral"), Size + // the container size of an ephemeral automation (nil otherwise). + Executor string `json:"executor,omitempty"` + Size *string `json:"size,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type AutomationList struct { diff --git a/internal/api/ephemeral.go b/internal/api/ephemeral.go new file mode 100644 index 0000000..af13ca7 --- /dev/null +++ b/internal/api/ephemeral.go @@ -0,0 +1,67 @@ +package api + +import "context" + +// Ephemeral runs: an agent in a fresh, throwaway container instead of a +// workspace, billed per started minute by container size. The member's +// limits and this month's metering live under /api/ephemeral, which takes a +// session only (the CLI's or the browser's) and answers 404 to a member who +// does not have ephemeral runs. Writing the limits needs the browser. + +// Container sizes, as the platform names them. +const ( + SizeS = "s" + SizeM = "m" + SizeL = "l" +) + +// EphemeralUsage is GET /api/ephemeral/usage: this calendar month (UTC). +// Money is in EUR, and also in micros (millionths of a euro) to stay exact. +type EphemeralUsage struct { + Month string `json:"month"` + Seconds int64 `json:"seconds"` + Minutes int64 `json:"minutes"` + CostEUR float64 `json:"cost_eur"` + CostEURMicros int64 `json:"cost_eur_micros"` // chargeable: after the free minutes + Plan string `json:"plan"` + + // The free allowance, priced in S-minutes: one M minute uses two, one L + // minute four. + FreeEURMicrosTotal int64 `json:"free_eur_micros_total"` + FreeEURMicrosUsed int64 `json:"free_eur_micros_used"` + FreeEURMicrosLeft int64 `json:"free_eur_micros_left"` + FreeMinutesLeft int64 `json:"free_minutes_left"` // S-minutes + + MonthlyCapEURCents int `json:"monthly_cap_eur_cents"` + PricesEURMicrosPerMinute map[string]int64 `json:"prices_eur_micros_per_minute"` + // BillingEnabled false: usage is metered and shown, not charged. + BillingEnabled bool `json:"billing_enabled"` +} + +// EphemeralLimits are the ranges the platform accepts for the settings. +type EphemeralLimits struct { + MonthlyCapEURCentsMax int `json:"monthly_cap_eur_cents_max"` + MaxConcurrentMax int `json:"max_concurrent_max"` +} + +// EphemeralSettings is GET /api/ephemeral/settings: the member's own +// limits. They are changed in the portal only. +type EphemeralSettings struct { + MonthlyCapEURCents int `json:"monthly_cap_eur_cents"` + MonthlyCapEUR float64 `json:"monthly_cap_eur"` + MaxConcurrent int `json:"max_concurrent"` + Limits EphemeralLimits `json:"limits"` + BillingEnabled bool `json:"billing_enabled"` +} + +// EphemeralUsage is GET /api/ephemeral/usage (a session only). +func (c *RunsClient) EphemeralUsage(ctx context.Context) (*EphemeralUsage, error) { + u := &EphemeralUsage{} + return u, c.getJSON(ctx, "/api/ephemeral/usage", u) +} + +// EphemeralSettings is GET /api/ephemeral/settings (a session only). +func (c *RunsClient) EphemeralSettings(ctx context.Context) (*EphemeralSettings, error) { + s := &EphemeralSettings{} + return s, c.getJSON(ctx, "/api/ephemeral/settings", s) +} diff --git a/internal/api/ephemeral_test.go b/internal/api/ephemeral_test.go new file mode 100644 index 0000000..5eb3519 --- /dev/null +++ b/internal/api/ephemeral_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +// The golden responses in testdata/ephemeral were rendered by cloud-api's +// own handlers (renderRun, renderAutomation, EphemeralHandler on +// helmcode/nan-cloud-api main), so this client decodes exactly what the +// platform serialises for ephemeral runs. +func ephemeralGolden(t *testing.T, name string, out any) { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "ephemeral", name)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, out); err != nil { + t.Fatalf("%s: %v", name, err) + } +} + +func TestGoldenEphemeralRun(t *testing.T) { + var run Run + ephemeralGolden(t, "run_get_ephemeral.json", &run) + if !run.Ephemeral() || run.Size == nil || *run.Size != SizeM || run.Workspace.ID != "" || run.Workspace.Name != "" || + run.GitHubRepo == nil || *run.GitHubRepo != "acme/api" || run.Cost == nil || + run.Cost.EURMicros != 18000 || run.Cost.EUR != 0.018 || run.Cost.BilledSeconds != 180 { + t.Fatalf("run = %+v (cost %+v)", run, run.Cost) + } +} + +func TestGoldenEphemeralRunList(t *testing.T) { + var list RunList + ephemeralGolden(t, "run_list_ephemeral.json", &list) + if len(list.Data) != 3 { + t.Fatalf("list = %+v", list) + } + metered, unmetered, ws := list.Data[0], list.Data[1], list.Data[2] + if !metered.Ephemeral() || metered.Cost == nil || !unmetered.Ephemeral() || unmetered.Cost != nil { + t.Errorf("ephemeral runs: %+v / %+v", metered, unmetered) + } + if ws.Ephemeral() || ws.Executor != ExecutorWorkspace || ws.Size != nil || ws.Cost != nil || ws.Workspace.Name != "develop" { + t.Errorf("workspace run: %+v", ws) + } +} + +func TestGoldenEphemeralAutomation(t *testing.T) { + var a Automation + ephemeralGolden(t, "automation_get_ephemeral.json", &a) + if a.Executor != ExecutorEphemeral || a.Size == nil || *a.Size != SizeL || a.Workspace != nil || a.Checkout != "fresh" { + t.Fatalf("automation = %+v", a) + } +} + +func TestGoldenEphemeralUsageAndSettings(t *testing.T) { + var u EphemeralUsage + ephemeralGolden(t, "ephemeral_usage.json", &u) + if u.Month != "2026-10" || u.Minutes != 49 || u.Seconds != 2950 || u.Plan != "inference" || u.BillingEnabled || + u.FreeEURMicrosTotal != 900000 || u.FreeEURMicrosUsed != 159000 || u.FreeEURMicrosLeft != 741000 || u.FreeMinutesLeft != 247 || + u.MonthlyCapEURCents != 10000 || u.PricesEURMicrosPerMinute[SizeS] != 3000 || u.PricesEURMicrosPerMinute[SizeM] != 6000 || + u.PricesEURMicrosPerMinute[SizeL] != 12000 { + t.Fatalf("usage = %+v", u) + } + var s EphemeralSettings + ephemeralGolden(t, "ephemeral_settings.json", &s) + if s.MonthlyCapEURCents != 10000 || s.MonthlyCapEUR != 100 || s.MaxConcurrent != 50 || s.BillingEnabled || + s.Limits.MonthlyCapEURCentsMax != 100000 || s.Limits.MaxConcurrentMax != 50 { + t.Fatalf("settings = %+v", s) + } +} + +// An ephemeral run names no workspace: the platform refuses one ("an +// ephemeral run has no workspace"), so the field must be left out. +func TestCreateEphemeralRunBody(t *testing.T) { + repo := "acme/api" + model := "glm5.3" + b, err := json.Marshal(CreateRunRequest{Agent: "pi", Prompt: "x", Model: &model, Executor: ExecutorEphemeral, Size: SizeM, GitHubRepo: &repo}) + if err != nil { + t.Fatal(err) + } + var got map[string]any + _ = json.Unmarshal(b, &got) + if _, ok := got["workspace"]; ok { + t.Errorf("an ephemeral run sends a workspace: %s", b) + } + if got["executor"] != "ephemeral" || got["size"] != "m" || got["github_repo"] != "acme/api" || got["model"] != "glm5.3" { + t.Errorf("body = %s", b) + } + // A workspace run keeps its shape: no executor, size or repository. + b, _ = json.Marshal(CreateRunRequest{Workspace: "develop", Agent: "pi", Prompt: "x"}) + if s := string(b); !strings.Contains(s, `"workspace":"develop"`) || strings.Contains(s, "executor") || + strings.Contains(s, "size") || strings.Contains(s, "github_repo") { + t.Errorf("workspace body = %s", s) + } +} + +func TestEphemeralClientPathsAndSession(t *testing.T) { + var paths, cookies []string + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.Method+" "+r.URL.Path) + cookies = append(cookies, r.Header.Get("Cookie")) + name := "ephemeral_settings.json" + if strings.HasSuffix(r.URL.Path, "/usage") { + name = "ephemeral_usage.json" + } + b, _ := os.ReadFile(filepath.Join("testdata", "ephemeral", name)) + _, _ = w.Write(b) + }), "sess", "") + ctx := context.Background() + if u, err := c.EphemeralUsage(ctx); err != nil || u.FreeMinutesLeft != 247 { + t.Fatalf("usage: %+v %v", u, err) + } + if s, err := c.EphemeralSettings(ctx); err != nil || s.MaxConcurrent != 50 { + t.Fatalf("settings: %+v %v", s, err) + } + if strings.Join(paths, ",") != "GET /api/ephemeral/usage,GET /api/ephemeral/settings" { + t.Errorf("paths = %v", paths) + } + for _, ck := range cookies { + if ck != "nan_session=sess" { + t.Errorf("cookie = %q", ck) + } + } +} + +// Off for the member: the platform answers 404 {"error":"not_found"}; a +// token gets 403 {"error":"session_required"}. Both reach the caller as an +// APIError with the status, which is what it branches on. +func TestEphemeralClientRefusals(t *testing.T) { + for status, body := range map[int]string{404: `{"error":"not_found"}`, 403: `{"error":"session_required"}`} { + c := testRunsClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + }), "", "nan_pat_x") + _, err := c.EphemeralUsage(context.Background()) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != status { + t.Errorf("%d: %v", status, err) + } + } +} diff --git a/internal/api/runs.go b/internal/api/runs.go index e54062b..575fd6e 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -121,6 +121,32 @@ type Run struct { GitHubRepo *string `json:"github_repo,omitempty"` TriggerDepth int `json:"trigger_depth,omitempty"` Secrets []RunSecret `json:"secrets,omitempty"` + + // Ephemeral executors: where the run executes ("workspace" or + // "ephemeral"; absent on an older platform), the container size of an + // ephemeral run (s, m or l) and its metered cost (nil until metered, + // and always nil for workspace runs). An ephemeral run has no + // workspace: the platform sends "workspace": null. + Executor string `json:"executor,omitempty"` + Size *string `json:"size,omitempty"` + Cost *RunCost `json:"cost,omitempty"` +} + +// Executors, as the platform names them. +const ( + ExecutorWorkspace = "workspace" + ExecutorEphemeral = "ephemeral" +) + +// Ephemeral reports whether the run executes in an ephemeral container. +func (r *Run) Ephemeral() bool { return r != nil && r.Executor == ExecutorEphemeral } + +// RunCost is an ephemeral run's metered price: the gross price of its +// billed (started) minutes, before the month's free minutes are applied. +type RunCost struct { + EUR float64 `json:"eur"` + EURMicros int64 `json:"eur_micros"` + BilledSeconds int64 `json:"billed_seconds"` } // RunSecret is a secret a run received: its env name and who saw it. @@ -155,7 +181,8 @@ func (r *Run) UnmarshalJSON(b []byte) error { // CreateRunRequest is the body of POST /v1/runs. Pointers are the fields the // platform defaults when they are left out, which is not the same as zero. type CreateRunRequest struct { - Workspace string `json:"workspace"` + // Workspace is left out for an ephemeral run, which has none. + Workspace string `json:"workspace,omitempty"` Agent string `json:"agent"` Prompt string `json:"prompt"` Cwd *string `json:"cwd,omitempty"` @@ -167,6 +194,13 @@ type CreateRunRequest struct { // Inference is left out for NaN inference (the platform's default). // With BYOK the model goes here, never in Model. Inference *RunInference `json:"inference,omitempty"` + + // Executor "ephemeral" runs the agent in a fresh container of Size + // (s, m or l; the platform defaults to s) on a fresh checkout of + // GitHubRepo, with no workspace. Left out: a workspace run. + Executor string `json:"executor,omitempty"` + Size string `json:"size,omitempty"` + GitHubRepo *string `json:"github_repo,omitempty"` } // RunInference is the inference choice of POST /v1/runs: {"source":"byok"} diff --git a/internal/api/testdata/ephemeral/automation_get_ephemeral.json b/internal/api/testdata/ephemeral/automation_get_ephemeral.json new file mode 100644 index 0000000..998b9bc --- /dev/null +++ b/internal/api/testdata/ephemeral/automation_get_ephemeral.json @@ -0,0 +1,42 @@ +{ + "agent": "pi", + "approval_kinds": [ + "github_pr_review" + ], + "approval_ttl_seconds": 259200, + "checkout": "fresh", + "checkout_untrusted_heads": false, + "concurrency_policy": "skip_if_active", + "created_at": "2026-10-11T09:00:00Z", + "cwd": "/home/nan/projects/api", + "description": "reviews", + "disabled_reason": null, + "enabled": true, + "executor": "ephemeral", + "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" + } + ], + "size": "l", + "sticky_pr_comment": false, + "template": "pr_review", + "timeout_seconds": 1800, + "updated_at": "2026-10-11T09:00:00Z", + "updated_by": "browser_session", + "workspace": null +} diff --git a/internal/api/testdata/ephemeral/ephemeral_settings.json b/internal/api/testdata/ephemeral/ephemeral_settings.json new file mode 100644 index 0000000..9e645ea --- /dev/null +++ b/internal/api/testdata/ephemeral/ephemeral_settings.json @@ -0,0 +1,10 @@ +{ + "billing_enabled": false, + "limits": { + "max_concurrent_max": 50, + "monthly_cap_eur_cents_max": 100000 + }, + "max_concurrent": 50, + "monthly_cap_eur": 100, + "monthly_cap_eur_cents": 10000 +} diff --git a/internal/api/testdata/ephemeral/ephemeral_usage.json b/internal/api/testdata/ephemeral/ephemeral_usage.json new file mode 100644 index 0000000..edbf295 --- /dev/null +++ b/internal/api/testdata/ephemeral/ephemeral_usage.json @@ -0,0 +1,19 @@ +{ + "billing_enabled": false, + "cost_eur": 0, + "cost_eur_micros": 0, + "free_eur_micros_left": 741000, + "free_eur_micros_total": 900000, + "free_eur_micros_used": 159000, + "free_minutes_left": 247, + "minutes": 49, + "month": "2026-10", + "monthly_cap_eur_cents": 10000, + "plan": "inference", + "prices_eur_micros_per_minute": { + "l": 12000, + "m": 6000, + "s": 3000 + }, + "seconds": 2950 +} diff --git a/internal/api/testdata/ephemeral/run_get_ephemeral.json b/internal/api/testdata/ephemeral/run_get_ephemeral.json new file mode 100644 index 0000000..1be5334 --- /dev/null +++ b/internal/api/testdata/ephemeral/run_get_ephemeral.json @@ -0,0 +1,66 @@ +{ + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": { + "billed_seconds": 180, + "eur": 0.018, + "eur_micros": 18000 + }, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "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": "glm5.3", + "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": [], + "size": "m", + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": null +} diff --git a/internal/api/testdata/ephemeral/run_list_ephemeral.json b/internal/api/testdata/ephemeral/run_list_ephemeral.json new file mode 100644 index 0000000..0d803e4 --- /dev/null +++ b/internal/api/testdata/ephemeral/run_list_ephemeral.json @@ -0,0 +1,190 @@ +{ + "data": [ + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": { + "billed_seconds": 180, + "eur": 0.018, + "eur_micros": 18000 + }, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "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": "glm5.3", + "object": "run", + "parent_run_id": null, + "phase": "run", + "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": [], + "size": "m", + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": null + }, + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": { + "base_ref": null, + "label": "fresh @ default branch", + "mode": "fresh", + "pr_number": null, + "sha": null + }, + "cost": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/work", + "error": null, + "error_code": null, + "executor": "ephemeral", + "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": "glm5.3", + "object": "run", + "parent_run_id": null, + "phase": "run", + "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": [], + "size": "m", + "started_at": "2026-10-10T09:00:01Z", + "state": "running", + "sticky_pr_comment": false, + "superseded_by": null, + "timeout_seconds": 1800, + "trigger": "cli", + "trigger_depth": 0, + "usage": { + "source": null, + "tokens_in": null, + "tokens_out": null + }, + "workspace": null + }, + { + "agent": "pi", + "agent_finished_at": null, + "apply_run_ids": [], + "approval": null, + "approval_kinds": null, + "automation_id": null, + "cancel_requested": false, + "checkout": null, + "cost": null, + "created_at": "2026-10-10T09:00:00Z", + "cwd": "/home/nan/projects/api", + "error": null, + "error_code": null, + "executor": "workspace", + "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_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": [], + "size": null, + "started_at": "2026-10-10T09:00:01Z", + "state": "succeeded", + "sticky_pr_comment": false, + "superseded_by": null, + "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" + } + } + ], + "next_cursor": null, + "object": "list" +} diff --git a/internal/api/write.go b/internal/api/write.go index a13a259..cf19e24 100644 --- a/internal/api/write.go +++ b/internal/api/write.go @@ -22,6 +22,9 @@ type Features struct { // Notifications is features.notifications: notification channels are // available to the member. Notifications bool `json:"notifications"` + // Ephemeral is features.ephemeral: runs in ephemeral containers are + // available to the member. + Ephemeral bool `json:"ephemeral"` } // Features is GET /api/auth/me, read for its feature flags. From 23d2604d4b9815c93dc9d37a01d5daef183d465f Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:40:05 +0200 Subject: [PATCH 02/11] feat(ephemeral): nan run --ephemeral, nan ephemeral usage/settings (WIP) Co-Authored-By: Claude Opus 5.5 --- cmd/automations.go | 26 +- cmd/automations_write.go | 28 ++ cmd/ephemeral.go | 601 +++++++++++++++++++++++++++++++++++++++ cmd/exit.go | 3 + cmd/run.go | 77 ++++- cmd/runs.go | 52 +++- cmd/writeapi.go | 3 + 7 files changed, 758 insertions(+), 32 deletions(-) create mode 100644 cmd/ephemeral.go diff --git a/cmd/automations.go b/cmd/automations.go index ec2f9ed..8778549 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -251,7 +251,7 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { 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.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40), + automationWhere(a), runs.SanitizeLine(a.Agent), automationStatus(a), runs.Truncate(approvalSummary(a.ApprovalKinds), 40)) @@ -259,6 +259,14 @@ func doAutomationsLs(ctx context.Context, env *runsEnv) error { return tw.Flush() } +// automationWhere is where an automation's runs execute, for a table. +func automationWhere(a api.Automation) string { + if a.Executor == api.ExecutorEphemeral { + return "ephemeral " + sizeShort(a.Size) + } + return runs.Truncate(runs.SanitizeLine(a.Workspace.Label()), 40) +} + func automationStatus(a api.Automation) string { if a.Enabled { return "enabled" @@ -313,18 +321,24 @@ func doAutomationsShow(ctx context.Context, env *runsEnv, ref string) error { if a.Workspace != nil { row("workspace", runs.NameAndID(a.Workspace.Name, a.Workspace.ID)) } + if a.Executor == api.ExecutorEphemeral { + row("executor", "ephemeral: a fresh container per run, size "+sizeLabel(a.Size)) + } agent := runs.SanitizeLine(a.Agent) if a.Model != nil && *a.Model != "" { agent += " · model " + runs.SanitizeLine(*a.Model) } row("agent", agent) row("inference", inferenceLabel(a.Inference)) - row("cwd", runs.SanitizeLine(a.Cwd)) - worktree := "auto" - if a.GitIsolation != nil { - worktree = map[bool]string{true: "yes", false: "no"}[*a.GitIsolation] + if a.Executor != api.ExecutorEphemeral { + // An ephemeral run works in its fresh checkout: neither applies. + 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) } - row("worktree", worktree) if a.TimeoutSeconds > 0 { row("timeout", (time.Duration(a.TimeoutSeconds) * time.Second).String()) } diff --git a/cmd/automations_write.go b/cmd/automations_write.go index ec971b7..1d4af1f 100644 --- a/cmd/automations_write.go +++ b/cmd/automations_write.go @@ -47,6 +47,24 @@ API's automation object, for example: exposure: actions approval_kinds: [github_pr_review] +An ephemeral automation runs in a fresh, throwaway container instead of a +workspace: no workspace, a fresh checkout of its repository, and a model. + + name: nightly review + executor: ephemeral + size: m + checkout: fresh + github_repo: acme/api + agent: pi + model: glm5.3 + instructions: Review the open pull requests. + +size is s (1 vCPU, 4 GiB), m (2 vCPU, 8 GiB) or l (4 vCPU, 12 GiB); +s when left out. A private repository needs an agent secret GITHUB_TOKEN +that can read it. ` + freeMinutesHelp + `. +Creating or running one with a platform token needs the runs:ephemeral +scope; see: nan ephemeral --help. + The full list of fields is in the API reference: https://nan.builders/docs/runs` var automationsCreateCmd = &cobra.Command{ @@ -85,6 +103,10 @@ before pull request reviews are posted: approval_kinds: [] +or, for an ephemeral automation, to run it in a bigger container: + + size: l + ` + writeCredentialHelp + ` Docs: ` + writeDocs, @@ -337,6 +359,9 @@ func resolveForWrite(ctx context.Context, env *runsEnv, ref string) (string, err } func doAutomationsCreate(ctx context.Context, env *runsEnv, body json.RawMessage) error { + if err := checkAutomationExecutor(body, true); err != nil { + return err + } if err := requireWriteAPI(ctx, env, "automations"); err != nil { return err } @@ -363,6 +388,9 @@ func warnAutomatic(env *runsEnv, a *api.Automation) { } func doAutomationsUpdate(ctx context.Context, env *runsEnv, ref string, body json.RawMessage, verb string) error { + if err := checkAutomationExecutor(body, false); err != nil { + return err + } id, err := resolveForWrite(ctx, env, ref) if err != nil { return err diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go new file mode 100644 index 0000000..60b4b11 --- /dev/null +++ b/cmd/ephemeral.go @@ -0,0 +1,601 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "strconv" + "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" +) + +// Ephemeral runs (helmcode/nan-devops AGENT-EPHEMERAL-EXECUTORS.md): an +// agent in a fresh, throwaway container instead of a workspace. It clones +// the GitHub repository the run names, runs the agent with the model the +// run names, streams like any run and is destroyed at the end. Billed per +// started minute by container size, with free minutes every month. + +// ephemeralDocs is where ephemeral runs are explained. +const ephemeralDocs = "https://nan.builders/docs/runs" + +// ephemeralSettingsURL is where the member changes their ephemeral limits: +// writing them needs a browser sign-in. +const ephemeralSettingsURL = runs.PortalURL + "/settings" + +// freeMinutesHelp is how free minutes count, said wherever they are shown +// (owner request). +const freeMinutesHelp = "Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L)" + +// notChargedNote is what a cost means while billing is off. +const notChargedNote = "not charged yet: billing is not active, usage is metered and shown only" + +// ephemeralScope is the platform-token scope that may start ephemeral work. +const ephemeralScope = "runs:ephemeral" + +// ephemeralSizes are the container sizes, smallest first, with what each +// one is. Prices are the platform's (nan ephemeral usage shows the current +// ones); the help quotes them too. +var ephemeralSizes = []struct { + name, spec string + sMinutes int // S-minutes one minute of this size uses +}{ + {api.SizeS, "1 vCPU, 4 GiB RAM, 10 GB disk", 1}, + {api.SizeM, "2 vCPU, 8 GiB RAM, 16 GB disk", 2}, + {api.SizeL, "4 vCPU, 12 GiB RAM, 20 GB disk", 4}, +} + +func sizeSpec(size string) string { + for _, s := range ephemeralSizes { + if s.name == size { + return s.spec + } + } + return "" +} + +// sizeLabel is a container size as people read it: "M (2 vCPU, ...)". +func sizeLabel(size *string) string { + if size == nil || *size == "" { + return "" + } + label := strings.ToUpper(runs.SanitizeLine(*size)) + if spec := sizeSpec(*size); spec != "" { + label += " (" + spec + ")" + } + return label +} + +// sizeShort is a container size for a table: "M". +func sizeShort(size *string) string { + if size == nil || *size == "" { + return "?" + } + return strings.ToUpper(runs.SanitizeLine(*size)) +} + +// formatEUR prints an amount in micros (millionths of a euro) with two +// decimals, or more when the amount needs them: €0.018, €1.50, €0.00. +func formatEUR(micros int64) string { + sign := "" + if micros < 0 { + sign, micros = "-", -micros + } + frac := strconv.FormatInt(micros%1_000_000+1_000_000, 10)[1:] // six digits + frac = strings.TrimRight(frac, "0") + for len(frac) < 2 { + frac += "0" + } + return sign + "€" + strconv.FormatInt(micros/1_000_000, 10) + "." + frac +} + +// formatCents prints an amount in euro cents: €100.00. +func formatCents(cents int) string { return formatEUR(int64(cents) * 10_000) } + +// startedMinutes is a billed duration in started minutes. +func startedMinutes(seconds int64) int64 { return (seconds + 59) / 60 } + +// runCostLabel is an ephemeral run's metered cost, for show. +func runCostLabel(run *api.Run) string { + c := run.Cost + if c == nil { + if api.Terminal(run.State) { + return "not metered yet: check again in a minute" + } + return "metered when the run ends" + } + label := formatEUR(c.EURMicros) + " for " + plural(int(startedMinutes(c.BilledSeconds)), "started minute") + if run.Size != nil && *run.Size != "" { + label += " of size " + sizeShort(run.Size) + } + return label + ", before your free minutes (nan ephemeral usage)" +} + +// runCostShort is an ephemeral run's cost, for a table. +func runCostShort(run *api.Run) string { + switch { + case !run.Ephemeral(): + return "-" + case run.Cost == nil: + return "pending" + } + return formatEUR(run.Cost.EURMicros) +} + +// runWhere is where a run executes, for a table. +func runWhere(run *api.Run) string { + if run.Ephemeral() { + return "ephemeral " + sizeShort(run.Size) + } + return runs.Truncate(runs.SanitizeLine(refLabel(run.Workspace.Name, run.Workspace.ID)), 40) +} + +// githubRepoPattern is the platform's owner/name grammar. +var githubRepoPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}$`) + +func validGitHubRepo(r string) bool { + if !githubRepoPattern.MatchString(r) { + return false + } + owner, name, _ := strings.Cut(r, "/") + for _, h := range []string{owner, name} { + if h == "." || h == ".." { + return false + } + } + return true +} + +// repoFromURL turns a GitHub URL pasted into --repo into owner/name, when +// it is one. +func repoFromURL(v string) (string, bool) { + for _, p := range []string{"https://github.com/", "http://github.com/", "github.com/", "git@github.com:"} { + if rest, ok := strings.CutPrefix(v, p); ok { + rest = strings.TrimSuffix(strings.TrimSuffix(rest, "/"), ".git") + if validGitHubRepo(rest) { + return rest, true + } + } + } + return "", false +} + +// ephemeralUnavailable is the answer when the member does not have +// ephemeral runs: the platform does not say why, on purpose. +func ephemeralUnavailable() *ExitError { + return exitf(exitAuth, "ephemeral runs are not available for your account") +} + +// ephemeralExit maps the platform's ephemeral refusals onto what to do +// about them. nil: not one of them. +// +// - 400 invalid_request on executor: the member does not have ephemeral +// runs (the platform answers exactly as for an unknown executor, so +// nobody outside can tell the executor exists). +// - 403 token_scope_denied on executor: a credential that may not start +// billable work (an sk- key, or a token without runs:ephemeral). +// - 402 ephemeral_cap_reached: the month's spend plus the worst case of +// the runs going on could pass the member's cap. +func ephemeralExit(apiErr *api.APIError, cred credential) *ExitError { + switch { + case apiErr.Code == "ephemeral_cap_reached" || apiErr.Status == 402: + return exitf(exitUnavailable, "this run could take your ephemeral spend past your monthly cap: raise the cap in the portal, "+ + "Settings at %s (signed in through the browser), or wait for your ephemeral runs going on to finish. "+ + "See this month's spend with: nan ephemeral usage", ephemeralSettingsURL) + case apiErr.Code == "token_scope_denied" && apiErr.Param == "executor": + return exitf(exitAuth, "ephemeral runs cost money, so they need your session (nan auth login) or a platform token "+ + "with the %s scope; API keys (sk-) cannot start them. %s", ephemeralScope, ephemeralScopeHint(cred)) + case apiErr.Status == 400 && apiErr.Param == "executor": + return ephemeralUnavailable() + } + return nil +} + +// ephemeralScopeHint is what to do about the credential an ephemeral run +// was refused for. +func ephemeralScopeHint(cred credential) string { + switch cred.kind { + case credEnv, credFile, credStoredToken: + return fmt.Sprintf("%s does not have it: create a token with %s in Settings > Tokens at %s, signed in through the browser "+ + "(existing tokens keep the scopes they were created with)", cred.describe(), ephemeralScope, runs.PortalURL) + case credStoredKey: + return cred.describe() + " is an API key: sign in with nan auth login" + } + return "Sign in again with: nan auth login" +} + +// ─── automation files ────────────────────────────────────────────────────── + +// checkAutomationExecutor checks the executor fields of an automation file +// before it is sent, so a mistake costs a clear message instead of a round +// trip: executor is workspace or ephemeral, size is s, m or l and only for +// ephemeral, and (on create, where the file is the whole automation) an +// ephemeral automation has no workspace, a fresh checkout of a github_repo +// and a model. An edit holds only the fields that change: the platform +// checks the result. +func checkAutomationExecutor(body json.RawMessage, create bool) error { + var f map[string]json.RawMessage + if err := json.Unmarshal(body, &f); err != nil { + return nil // not an object: the platform says so + } + str := func(k string) (string, bool, bool) { + raw, ok := f[k] + if !ok || string(raw) == "null" { + return "", ok, true + } + var s string + if json.Unmarshal(raw, &s) != nil { + return "", true, false + } + return s, true, true + } + executor, hasExecutor, ok := str("executor") + if !ok || (hasExecutor && executor != api.ExecutorWorkspace && executor != api.ExecutorEphemeral) { + return usageErrorf(`executor must be "workspace" (the default: runs in one of your workspaces) or "ephemeral" ` + + `(runs in a fresh container, no workspace); see: nan run --help`) + } + size, hasSize, ok := str("size") + if !ok || (hasSize && size != "" && size != api.SizeS && size != api.SizeM && size != api.SizeL) { + return usageErrorf(`size must be "s", "m" or "l" (the container of an ephemeral automation: %s); %s`, + sizeList(), freeMinutesHelp) + } + if hasSize && size != "" && hasExecutor && executor == api.ExecutorWorkspace { + return usageErrorf(`size is for ephemeral automations only: set executor: ephemeral, or remove size`) + } + if !create || executor != api.ExecutorEphemeral { + return nil + } + if hasSize && size != "" && !hasExecutor { + return usageErrorf(`size is for ephemeral automations only: add executor: ephemeral, or remove size`) + } + if ws, has, _ := str("workspace"); has && ws != "" { + return usageErrorf("an ephemeral automation has no workspace: remove workspace (it runs in a fresh container)") + } + if checkout, _, _ := str("checkout"); checkout != "fresh" { + return usageErrorf(`an ephemeral automation works on a fresh checkout: set checkout: fresh and github_repo: OWNER/NAME`) + } + if repo, _, _ := str("github_repo"); repo == "" { + return usageErrorf(`an ephemeral automation needs github_repo: OWNER/NAME, the repository each run clones`) + } + if cwd, has, _ := str("cwd"); has && cwd != "" { + return usageErrorf("an ephemeral automation works in its fresh checkout: remove cwd") + } + model, _, _ := str("model") + if model == "" { + var inf struct { + Model string `json:"model"` + } + _ = json.Unmarshal(f["inference"], &inf) + model = inf.Model + } + if model == "" { + return usageErrorf("an ephemeral automation must name its model (model: ID): a container has no workspace configuration to default to") + } + return nil +} + +func sizeList() string { + parts := make([]string, len(ephemeralSizes)) + for i, s := range ephemeralSizes { + parts[i] = s.name + " " + s.spec + } + return strings.Join(parts, "; ") +} + +// ─── nan ephemeral ───────────────────────────────────────────────────────── + +var ( + ephemeralUsageJSON bool + ephemeralSettingsJSON bool +) + +const ephemeralCredentialHelp = `These commands need your session (nan auth login): platform tokens and +API keys cannot read them.` + +var ephemeralCmd = &cobra.Command{ + Use: "ephemeral", + Short: "Your ephemeral runs: this month's minutes and cost, and your limits", + Long: `Ephemeral runs execute an agent in a fresh, throwaway container instead of +a workspace: start one with nan run --ephemeral --repo OWNER/NAME --model ID. + +Sizes, billed per started minute (a 14 s run is 1 minute): +` + sizeTable() + ` +Free minutes every calendar month: Community 100, Inference 300, Premium +1000. ` + freeMinutesHelp + `. +While billing is not active, usage is metered and shown, not charged: +nan ephemeral usage says which. + +Your limits, a monthly spend cap (default €100) and how many ephemeral runs +may go on at once (default 50), are changed in the browser only: Settings +at ` + ephemeralSettingsURL + `. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return usageErrorf("unknown command %q for \"nan ephemeral\"; try: nan ephemeral --help", runs.Truncate(runs.SanitizeLine(args[0]), 64)) + } + return cmd.Help() + }, +} + +var ephemeralUsageCmd = &cobra.Command{ + Use: "usage", + Short: "This month's ephemeral minutes, cost and free minutes left", + Long: `This calendar month's ephemeral runs: container minutes, cost after your +free minutes, the free minutes left, your monthly cap and the prices. + +` + freeMinutesHelp + `. +While billing is not active, usage is metered and shown, not charged. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withEphemeralEnv(func(env *runsEnv) error { return doEphemeralUsage(cmd.Context(), env) }) + }, +} + +var ephemeralSettingsCmd = &cobra.Command{ + Use: "settings", + Short: "Your ephemeral limits: monthly spend cap and concurrent runs (read only)", + Long: `Your ephemeral limits: the monthly spend cap (€0 to €1000, default €100) +and how many ephemeral runs may go on at once (1 to 50, default 50). + +They are read only here: change them in the browser, Settings at +` + ephemeralSettingsURL + `, signed in through the browser, so that nothing +that can read this machine's CLI session (an agent, a CI job) can raise +what you spend. + +` + ephemeralCredentialHelp + ` + +Docs: ` + ephemeralDocs, + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withEphemeralEnv(func(env *runsEnv) error { return doEphemeralSettings(cmd.Context(), env) }) + }, +} + +func init() { + rootCmd.AddCommand(ephemeralCmd) + ephemeralCmd.AddCommand(ephemeralUsageCmd, ephemeralSettingsCmd) + ephemeralUsageCmd.Flags().BoolVar(&ephemeralUsageJSON, "json", false, "Print the API's usage object as JSON") + ephemeralSettingsCmd.Flags().BoolVar(&ephemeralSettingsJSON, "json", false, "Print the API's settings object as JSON") +} + +// sizeTable is the sizes and their prices, for help text. +func sizeTable() string { + prices := map[string]string{api.SizeS: "€0.003", api.SizeM: "€0.006", api.SizeL: "€0.012"} + var b strings.Builder + for _, s := range ephemeralSizes { + fmt.Fprintf(&b, " %-2s %-32s %s per minute\n", s.name, s.spec, prices[s.name]) + } + return b.String() +} + +// ephemeralSession is the credential of nan ephemeral: the session from +// nan auth login, and nothing else (the platform takes no token here). +func ephemeralSession(getenv func(string) string, tokenFile string) (credential, error) { + const why = "nan ephemeral needs your session: sign in with nan auth login (platform tokens and API keys cannot read ephemeral usage or settings)" + if tokenFile != "" { + return credential{}, exitf(exitAuth, "--token-file was given, and %s", why) + } + sess, err := session.Load() + if err != nil && !errors.Is(err, session.ErrNotLoggedIn) { + return credential{}, exitf(exitAuth, "could not read your session (%v): %s", err, why) + } + if sess == nil || sess.Token == "" { + if getenv(tokenEnvVar) != "" { + return credential{}, exitf(exitAuth, "%s is set, and %s", tokenEnvVar, why) + } + return credential{}, exitf(exitAuth, "%s", why) + } + return credential{kind: credSession, session: sess.Token}, nil +} + +func withEphemeralEnv(fn func(*runsEnv) error) error { + cred, err := ephemeralSession(os.Getenv, tokenFileFlag) + if err != nil { + return err + } + return runtimeError(fn(&runsEnv{client: cred.client(), cred: cred, stdout: writeStdout, stderr: writeStderr})) +} + +// ephemeralReadExit maps a refused read of /api/ephemeral. +func ephemeralReadExit(err error, cred credential) *ExitError { + var apiErr *api.APIError + if errors.As(err, &apiErr) { + switch apiErr.Status { + case 404: + return ephemeralUnavailable() + case 403: + return exitf(exitAuth, "nan.builders did not take %s for this: sign in with nan auth login", cred.describe()) + } + } + return apiExit(err, cred) +} + +func doEphemeralUsage(ctx context.Context, env *runsEnv) error { + u, err := env.client.EphemeralUsage(ctx) + if err != nil { + return ephemeralReadExit(err, env.cred) + } + if ephemeralUsageJSON { + return printJSON(env.stdout, u, true) + } + priceS := u.PricesEURMicrosPerMinute[api.SizeS] + sMinutes := func(micros int64) int64 { + if priceS <= 0 { + return 0 + } + return micros / priceS + } + + month := runs.SanitizeLine(u.Month) + if u.Plan != "" { + month += " (" + runs.SanitizeLine(u.Plan) + " plan)" + } + fmt.Fprintf(env.stdout, "ephemeral runs, %s\n\n", month) + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "container time\t%s\n", plural(int(u.Minutes), "minute")) + if priceS > 0 { + total, used := sMinutes(u.FreeEURMicrosTotal), sMinutes(u.FreeEURMicrosUsed) + fmt.Fprintf(tw, "free minutes left\t%d S-minutes of %d (= %d M or %d L minutes); %d used\n", + u.FreeMinutesLeft, total, u.FreeMinutesLeft/2, u.FreeMinutesLeft/4, used) + } + cost := formatEUR(u.CostEURMicros) + " after your free minutes" + if !u.BillingEnabled { + cost += " (" + notChargedNote + ")" + } + fmt.Fprintf(tw, "cost this month\t%s\n", cost) + fmt.Fprintf(tw, "monthly cap\t%s (change it in the browser, Settings at %s)\n", formatCents(u.MonthlyCapEURCents), ephemeralSettingsURL) + if err := tw.Flush(); err != nil { + return err + } + + fmt.Fprintln(env.stdout, "\nprices per started minute (a 14 s run is 1 minute):") + tw = tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + for _, s := range ephemeralSizes { + price, ok := u.PricesEURMicrosPerMinute[s.name] + if !ok { + continue + } + fmt.Fprintf(tw, " %s\t%s\t%s\tuses %s\n", strings.ToUpper(s.name), s.spec, formatEUR(price), + plural(s.sMinutes, "free S-minute")) + } + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintf(env.stdout, "\n%s.\n", freeMinutesHelp) + return nil +} + +func doEphemeralSettings(ctx context.Context, env *runsEnv) error { + s, err := env.client.EphemeralSettings(ctx) + if err != nil { + return ephemeralReadExit(err, env.cred) + } + if ephemeralSettingsJSON { + return printJSON(env.stdout, s, true) + } + tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(tw, "monthly spend cap\t%s (€0 to %s)\n", formatCents(s.MonthlyCapEURCents), formatCents(s.Limits.MonthlyCapEURCentsMax)) + fmt.Fprintf(tw, "concurrent runs\tup to %d at once (1 to %d)\n", s.MaxConcurrent, s.Limits.MaxConcurrentMax) + billing := "active: ephemeral usage past your free minutes is charged" + if !s.BillingEnabled { + billing = "not active yet: usage is metered and shown, not charged" + } + fmt.Fprintf(tw, "billing\t%s\n", billing) + if err := tw.Flush(); err != nil { + return err + } + fmt.Fprintf(env.stderr, "change these in the browser: Settings at %s (signed in through the browser; the CLI only reads them)\n", ephemeralSettingsURL) + return nil +} + +// billingNote says, under an ephemeral run's cost, whether that cost is +// charged. Only a session can ask (GET /api/ephemeral/usage); the lookup is +// bounded like a name lookup and never fails the command. +func (env *runsEnv) billingNote(ctx context.Context) { + known, enabled := false, false + if env.cred.kind == credSession { + lctx, cancel := context.WithTimeout(ctx, nameLookupTimeout) + defer cancel() + if u, err := env.client.EphemeralUsage(lctx); err == nil { + known, enabled = true, u.BillingEnabled + } + } + switch { + case known && enabled: + case known: + fmt.Fprintf(env.stdout, "\nnote: %s\n", notChargedNote) + default: + fmt.Fprintln(env.stdout, "\nnote: while billing is not active, ephemeral costs are metered and shown, not charged (nan ephemeral usage says which)") + } +} + +// ─── nan run --ephemeral ─────────────────────────────────────────────────── + +// checkEphemeralFlags checks the flags of an ephemeral run (and that the +// ephemeral-only flags are not given without --ephemeral). It normalises +// --size to the platform's lowercase name. +func checkEphemeralFlags(opts *runOptions) error { + if !opts.ephemeral { + switch { + case opts.repo != "": + return usageErrorf("--repo is for ephemeral runs: add --ephemeral (a workspace run works in the workspace's own folders; pick one with --cwd)") + case opts.size != "": + return usageErrorf("--size is for ephemeral runs: add --ephemeral (a workspace has its own size, set in the portal)") + } + return nil + } + switch { + case opts.workspace != "": + return usageErrorf("an ephemeral run has no workspace: drop --ws (it runs in a fresh container on a clone of --repo)") + case opts.cwd != "": + return usageErrorf("an ephemeral run works in its fresh clone of --repo: drop --cwd") + case opts.worktree || opts.noWorktree: + return usageErrorf("an ephemeral run works on a fresh clone, never a git worktree: drop --worktree/--no-worktree") + case opts.repo == "": + return usageErrorf("--ephemeral needs --repo OWNER/NAME: the GitHub repository the container clones") + } + if !validGitHubRepo(opts.repo) { + if repo, ok := repoFromURL(opts.repo); ok { + return usageErrorf("--repo takes OWNER/NAME, not a URL: did you mean --repo %s?", repo) + } + return usageErrorf("--repo %q is not a GitHub repository: give it as OWNER/NAME, like acme/api", runs.Truncate(runs.SanitizeLine(opts.repo), 120)) + } + if opts.model == "" { + return usageErrorf("--ephemeral needs --model ID: a container has no workspace configuration to pick a model from") + } + if opts.size != "" { + size := strings.ToLower(opts.size) + if sizeSpec(size) == "" { + return usageErrorf("--size must be s, m or l, not %q: %s. %s", runs.Truncate(runs.SanitizeLine(opts.size), 16), sizeList(), freeMinutesHelp) + } + opts.size = size + } + return nil +} + +// runPlace says where a run executes, for the line that announces it: +// "on develop", or "in an ephemeral container (size M) for acme/api". +func runPlace(run *api.Run, req *api.CreateRunRequest) string { + if run.Ephemeral() || req.Executor == api.ExecutorEphemeral { + size := run.Size + if size == nil || *size == "" { + s := req.Size + if s == "" { + s = api.SizeS + } + size = &s + } + repo := run.GitHubRepo + if repo == nil { + repo = req.GitHubRepo + } + place := "in an ephemeral container (size " + sizeShort(size) + ")" + if repo != nil && *repo != "" { + place += " for " + runs.SanitizeLine(*repo) + } + return place + } + where := runs.SanitizeLine(run.Workspace.Name) + if where == "" { + where = runs.SanitizeLine(req.Workspace) + } + return "on " + where +} diff --git a/cmd/exit.go b/cmd/exit.go index fe02077..f214562 100644 --- a/cmd/exit.go +++ b/cmd/exit.go @@ -82,6 +82,9 @@ func apiExit(err error, cred credential) *ExitError { if !errors.As(err, &apiErr) { return &ExitError{Code: exitUnavailable, Err: err} } + if exit := ephemeralExit(apiErr, cred); exit != nil { + return exit + } msg := runs.SanitizeLine(apiErr.Error()) switch apiErr.Code { case "tier_restricted": diff --git a/cmd/run.go b/cmd/run.go index 1808573..c11e31f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -49,14 +49,18 @@ type runOptions struct { idempotencyKey string file string byokSecret string + ephemeral bool + repo string + size string } var runOpts runOptions var runCmd = &cobra.Command{ Use: `run [flags] ( "prompt" | -f FILE | - )`, - Short: "Run an agent in one of your workspaces", - Long: `Run an agent in one of your workspaces and stream what it does. + Short: "Run an agent in one of your workspaces, or in an ephemeral container", + Long: `Run an agent in one of your workspaces, or in an ephemeral container, and +stream what it does. The prompt is the argument, the contents of -f FILE, or stdin when the argument is "-". The run happens in the workspace, not on this machine: it @@ -87,6 +91,25 @@ Your own provider key (BYOK): it holds. A token can trigger an automation that uses your key instead. Hermes cannot use an Anthropic key yet: use pi or pi_gentle with it. +Ephemeral runs (no workspace): + --ephemeral --repo OWNER/NAME --model ID runs the agent in a fresh, + throwaway container instead of a workspace: it clones the GitHub + repository, runs the agent (pi, pi_gentle or hermes) with the model you + name, streams like any run, and is destroyed at the end. The container + can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). A run + started here gets no secrets, so the repository has to be public; for a + private one, use an ephemeral automation with an agent secret + GITHUB_TOKEN (nan automations create --help). --ws, --cwd, --worktree + and --no-worktree do not apply. + --size picks the container (default s), billed per started minute (a + 14 s run is 1 minute): +` + sizeTable() + ` Free minutes every month: Community 100, Inference 300, Premium 1000. + ` + freeMinutesHelp + `. + While billing is not active, usage is metered and shown, not charged. + Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. + Starting one needs your session (nan auth login) or a platform token with + the runs:ephemeral scope; API keys (sk-) cannot start them. + In the portal: Folder = --cwd, Separate branch = --worktree/--no-worktree, Time limit = --timeout, Task = the prompt. @@ -123,8 +146,10 @@ Exit codes: 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 (a token for a run with your own key) - 69 nan.builders unavailable, or the stream was lost (the run goes on) + 65 not signed in, or not allowed (a token for a run with your own key, a + token without runs:ephemeral, ephemeral runs not available to you) + 69 nan.builders unavailable, the stream was lost (the run goes on), or + an ephemeral run would pass your monthly spend cap 75 detached: the run was accepted and is still going Docs: https://nan.builders/docs/runs`, @@ -133,7 +158,8 @@ Docs: https://nan.builders/docs/runs`, git diff | nan run - id=$(nan run --detach "upgrade the dependencies") nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret - nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test"`, + nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" + nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test"`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -162,8 +188,11 @@ func init() { rootCmd.AddCommand(runCmd) f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") + f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --repo and --model)") + f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the public GitHub repository to clone, `OWNER/NAME`") + f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs below)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") - f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret)") + f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret and --ephemeral)") f.StringVar(&runOpts.byokSecret, "byok-secret", "", "Run on your own provider key: the `NAME` of a provider key secret saved in the portal (never the key itself); needs --model") f.StringVar(&runOpts.cwd, "cwd", "", "Folder in the workspace to run in (default: /home/nan)") f.BoolVar(&runOpts.worktree, "worktree", false, "Always work on a separate branch, nan-run/ (fails outside a git repository)") @@ -216,7 +245,8 @@ func newRunsEnv() (*runsEnv, error) { } func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.CreateRunRequest) error { - if req.Workspace == "" { + ephemeral := req.Executor == api.ExecutorEphemeral + if req.Workspace == "" && !ephemeral { name, err := defaultWorkspace(ctx, env) if err != nil { return err @@ -231,6 +261,12 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if exit := byokRefusal(apiErr, req); exit != nil { return exit } + if exit := ephemeralExit(apiErr, env.cred); exit != nil { + return exit + } + } + if ephemeral && errors.As(err, &apiErr) && apiErr.Status == 404 { + return exitf(exitAuth, "runs are not enabled for your account") } if errors.As(err, &apiErr) && apiErr.Status == 404 { return usageErrorf("workspace %q not found (or runs are not enabled for your account)", runs.SanitizeLine(req.Workspace)) @@ -256,22 +292,25 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat } } else { fmt.Fprintln(env.stdout, id) - fmt.Fprintf(env.stderr, "run %s %s on %s\n", id, runs.SanitizeLine(run.State), runs.SanitizeLine(run.Workspace.Name)) + fmt.Fprintf(env.stderr, "run %s %s %s\n", id, runs.SanitizeLine(run.State), runPlace(run, req)) printFollowHints(env.stderr, id) } return runExit(run) } if !replayed { - where := runs.SanitizeLine(run.Workspace.Name) - if where == "" { - where = runs.SanitizeLine(req.Workspace) - } agent := runs.SanitizeLine(run.Agent) - if in := req.Inference; in != nil && in.Source == api.InferenceBYOK { + switch in := req.Inference; { + case in != nil && in.Source == api.InferenceBYOK: agent += ", your key " + runs.SanitizeLine(in.Secret) + ", model " + runs.SanitizeLine(in.Model) + case ephemeral && req.Model != nil: + agent += ", model " + runs.SanitizeLine(*req.Model) + } + if ephemeral { + fmt.Fprintf(env.stderr, "run %s queued %s (%s), Ctrl-C to detach\n", id, runPlace(run, req), agent) + } else { + fmt.Fprintf(env.stderr, "run %s queued %s (%s) — Ctrl-C to detach\n", id, runPlace(run, req), agent) } - fmt.Fprintf(env.stderr, "run %s queued on %s (%s) — Ctrl-C to detach\n", id, where, agent) } return follow(ctx, env, run.ID, 0, opts.json, true) } @@ -353,6 +392,9 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d } } else { fmt.Fprintln(env.stderr, runs.Outcome(res.run)) + if res.run.Ephemeral() { + fmt.Fprintf(env.stderr, "cost: %s\n", runCostLabel(res.run)) + } } return runExit(res.run) } @@ -456,6 +498,9 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat return nil, err } } + if err := checkEphemeralFlags(&opts); err != nil { + return nil, err + } if opts.model != "" && !modelPattern.MatchString(opts.model) { return nil, usageErrorf("--model %q is not a model name (letters, digits and . _ : / -, up to 128)", opts.model) } @@ -490,6 +535,10 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat iso := opts.worktree req.GitIsolation = &iso } + if opts.ephemeral { + repo := opts.repo + req.Executor, req.Size, req.GitHubRepo = api.ExecutorEphemeral, opts.size, &repo + } if opts.timeoutSet { if opts.timeout < minTimeout || opts.timeout > maxTimeout { return nil, usageErrorf("--timeout must be between 1m and 2h, not %s", opts.timeout) diff --git a/cmd/runs.go b/cmd/runs.go index bfb313f..426e7da 100644 --- a/cmd/runs.go +++ b/cmd/runs.go @@ -284,17 +284,32 @@ func doRunsLs(ctx context.Context, env *runsEnv) error { for i := range list.Data { env.nameRunWorkspace(ctx, &list.Data[i]) } + // The COST column shows only when an ephemeral run is listed: a + // workspace run has no cost of its own. + withCost := false + for i := range list.Data { + withCost = withCost || list.Data[i].Ephemeral() + } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCREATED\tPROMPT") - for _, r := range list.Data { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + if withCost { + fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCOST\tCREATED\tPROMPT") + } else { + fmt.Fprintln(tw, "ID\tSTATE\tWORKSPACE\tAGENT\tINFERENCE\tCREATED\tPROMPT") + } + for i := range list.Data { + r := &list.Data[i] + cols := []string{ runs.SanitizeLine(r.ID), runs.SanitizeLine(r.State), - runs.Truncate(runs.SanitizeLine(refLabel(r.Workspace.Name, r.Workspace.ID)), 40), + runWhere(r), runs.SanitizeLine(r.Agent), inferenceShort(r.Inference), - localTime(r.CreatedAt), - runs.Truncate(runs.SanitizeLine(r.PromptPreview), 60)) + } + if withCost { + cols = append(cols, runCostShort(r)) + } + cols = append(cols, localTime(r.CreatedAt), runs.Truncate(runs.SanitizeLine(r.PromptPreview), 60)) + fmt.Fprintln(tw, strings.Join(cols, "\t")) } if err := tw.Flush(); err != nil { return err @@ -351,7 +366,11 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { if run.ExitCode != nil { row("exit code", fmt.Sprint(*run.ExitCode)) } - row("workspace", runs.NameAndID(run.Workspace.Name, run.Workspace.ID)) + if run.Ephemeral() { + row("executor", "ephemeral: a fresh container, size "+sizeLabel(run.Size)) + } else { + row("workspace", runs.NameAndID(run.Workspace.Name, run.Workspace.ID)) + } agent := runs.SanitizeLine(run.Agent) if run.Model != nil && *run.Model != "" { agent += " · model " + runs.SanitizeLine(*run.Model) @@ -387,12 +406,15 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { for _, apply := range applyRuns(run) { row("apply run", runs.SanitizeLine(apply)) } - row("cwd", runs.SanitizeLine(run.Cwd)) - worktree := "auto" - if run.GitIsolation != nil { - worktree = map[bool]string{true: "yes", false: "no"}[*run.GitIsolation] + if !run.Ephemeral() { + // An ephemeral run works in its fresh checkout: neither applies. + row("cwd", runs.SanitizeLine(run.Cwd)) + worktree := "auto" + if run.GitIsolation != nil { + worktree = map[bool]string{true: "yes", false: "no"}[*run.GitIsolation] + } + row("worktree", worktree) } - row("worktree", worktree) if run.TimeoutSeconds > 0 { row("timeout", (time.Duration(run.TimeoutSeconds) * time.Second).String()) } @@ -430,9 +452,15 @@ func doRunsShow(ctx context.Context, env *runsEnv, id string) error { } row("tokens", tokens) } + if run.Ephemeral() { + row("cost", runCostLabel(run)) + } if err := tw.Flush(); err != nil { return err } + if run.Ephemeral() && run.Cost != nil { + env.billingNote(ctx) + } if run.State == api.StateAwaitingApproval { fmt.Fprintln(env.stdout) fmt.Fprintln(env.stdout, "awaiting your approval:") diff --git a/cmd/writeapi.go b/cmd/writeapi.go index be63acd..525056a 100644 --- a/cmd/writeapi.go +++ b/cmd/writeapi.go @@ -151,6 +151,9 @@ func writeExit(err error, cred credential, what, scope string) *ExitError { if !errors.As(err, &apiErr) { return apiExit(err, cred) } + if exit := ephemeralExit(apiErr, cred); exit != nil { + return exit + } msg := runs.SanitizeLine(apiErr.Message) switch apiErr.Code { case "browser_session_required": From ec20b7324330e0f6e5499240a5b6ab784e6fa7e5 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:43:39 +0200 Subject: [PATCH 03/11] feat(ephemeral): tests, golden views, JSON contract and README Co-Authored-By: Claude Opus 5.5 --- README.md | 37 +- cmd/ephemeral.go | 2 +- cmd/ephemeral_test.go | 668 ++++++++++++++++++ cmd/json_output_test.go | 14 + cmd/run_test.go | 10 + cmd/testdata/ephemeral/automations_ls.golden | 2 + .../ephemeral/automations_show.golden | 23 + cmd/testdata/ephemeral/runs_ls.golden | 4 + cmd/testdata/ephemeral/runs_show.golden | 22 + cmd/testdata/ephemeral/settings.golden | 3 + cmd/testdata/ephemeral/usage.golden | 13 + 11 files changed, 794 insertions(+), 4 deletions(-) create mode 100644 cmd/ephemeral_test.go create mode 100644 cmd/testdata/ephemeral/automations_ls.golden create mode 100644 cmd/testdata/ephemeral/automations_show.golden create mode 100644 cmd/testdata/ephemeral/runs_ls.golden create mode 100644 cmd/testdata/ephemeral/runs_show.golden create mode 100644 cmd/testdata/ephemeral/settings.golden create mode 100644 cmd/testdata/ephemeral/usage.golden diff --git a/README.md b/README.md index fa9d30a..0f172bc 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,11 @@ id=$(nan run --detach "upgrade the dependencies") |---|---|---| | `--ws NAME` | your only workspace | Workspace to run in. Required when you have more than one. | | `--agent pi\|pi_gentle\|hermes` | `pi` | Agent to run: `pi_gentle` is Pi with Gentle Shell. The workspace must have it installed. | -| `--model M` | the agent's own | Model for the agent. Required with `--byok-secret`. | +| `--model M` | the agent's own | Model for the agent. Required with `--byok-secret` and `--ephemeral`. | | `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. | +| `--ephemeral` | | Run in a fresh, throwaway container instead of a workspace. See below. | +| `--repo OWNER/NAME` | | With `--ephemeral`: the public GitHub repository the container clones. | +| `--size s\|m\|l` | `s` | With `--ephemeral`: the container size. | | `--cwd PATH` | `/home/nan` | Folder in the workspace. | | `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | | `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. | @@ -137,6 +140,34 @@ nan secrets ls # KIND provider_key nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" ``` +**Ephemeral runs (no workspace).** `--ephemeral --repo OWNER/NAME --model ID` +runs the agent in a fresh, throwaway container: it clones the repository, runs +the agent with the model you name, streams like any run, and is destroyed at +the end. The container can only reach the internet over HTTP and HTTPS (no +SSH, no SMTP). A run started from the CLI gets no secrets, so the repository +has to be public; for a private one, use an ephemeral automation (`executor: +ephemeral`, `checkout: fresh`, `github_repo`, `model`, and an agent secret +`GITHUB_TOKEN`). Starting one needs your session or a platform token with the +`runs:ephemeral` scope; API keys cannot. + +| Size | Container | Price per started minute | +|---|---|---| +| `s` (default) | 1 vCPU, 4 GiB RAM, 10 GB disk | €0.003 | +| `m` | 2 vCPU, 8 GiB RAM, 16 GB disk | €0.006 | +| `l` | 4 vCPU, 12 GiB RAM, 20 GB disk | €0.012 | + +A 14 s run is 1 minute. Free minutes every month: Community 100, Inference 300, +Premium 1000. Free minutes are S-minutes: one M minute uses 2, one L minute 4 +(100 free = 100 S, 50 M or 25 L). While billing is not active, usage is +metered and shown, not charged. `nan runs ls` and `nan runs show` show each +ephemeral run's size and cost. + +```bash +nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" +nan ephemeral usage # this month's minutes, cost, free S-minutes left, prices +nan ephemeral settings # your monthly spend cap and concurrent runs (change them in the browser) +``` + **Queue.** If the workspace is already running as many runs as its size allows, the run waits as `queued` and starts when one of them ends. @@ -278,8 +309,8 @@ branch on it without parsing text: | 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) | +| 65 | not signed in, session expired, or not allowed (also: a token without `runs:ephemeral`, ephemeral runs not available to you) | +| 69 | nan.builders unavailable, the stream was lost after retries (the run goes on; the id is printed), or an ephemeral run would pass your monthly spend cap | | 75 | `--detach`, or Ctrl-C: the run was accepted and is still going | Full reference, including the event types `nan runs logs --json` prints (one diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index 60b4b11..0f11e80 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -473,7 +473,7 @@ func doEphemeralUsage(ctx context.Context, env *runsEnv) error { if !ok { continue } - fmt.Fprintf(tw, " %s\t%s\t%s\tuses %s\n", strings.ToUpper(s.name), s.spec, formatEUR(price), + fmt.Fprintf(tw, " %s\t%s\t%s\ta minute uses %s\n", strings.ToUpper(s.name), s.spec, formatEUR(price), plural(s.sMinutes, "free S-minute")) } if err := tw.Flush(); err != nil { diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go new file mode 100644 index 0000000..73a41f2 --- /dev/null +++ b/cmd/ephemeral_test.go @@ -0,0 +1,668 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// Ephemeral runs (AGENT-EPHEMERAL-EXECUTORS.md, E2): the CLI starts them +// with nan run --ephemeral, shows their executor, size and cost, accepts +// executor/size in automation files, and reads the member's minutes and +// limits with nan ephemeral usage/settings. Free minutes are S-minutes, +// and that is said wherever they are shown. + +func ephemeralOpts() runOptions { + o := defaultOpts() + o.ephemeral = true + o.repo = "acme/api" + o.model = "glm5.3" + return o +} + +func TestEphemeralRunSendsTheRequest(t *testing.T) { + // "not json": an ephemeral run never looks a workspace up. + h := newHarness(t, &fakePlatform{workspaces: "not json", ephemeral: true}) + opts := ephemeralOpts() + opts.size = "M" + opts.timeout, opts.timeoutSet = 3600e9, true + if err := doRun(context.Background(), h.env, opts, []string{"fix the flaky test"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if c["executor"] != "ephemeral" || c["size"] != "m" || c["github_repo"] != "acme/api" || c["model"] != "glm5.3" || + c["agent"] != "pi" || c["prompt"] != "fix the flaky test" || c["trigger"] != "cli" || c["timeout_seconds"] != float64(3600) { + t.Errorf("created with %v", c) + } + for _, k := range []string{"workspace", "cwd", "git_isolation", "inference"} { + if _, ok := c[k]; ok { + t.Errorf("an ephemeral run sent %s: %v", k, c) + } + } + wantAll(t, "stderr", h.stderr.String(), "in an ephemeral container (size M) for acme/api", "model glm5.3", + "cost: €0.018 for 3 started minutes of size M", "nan ephemeral usage") + if strings.Contains(h.stderr.String(), "—") { + t.Errorf("new output has an em-dash: %q", h.stderr.String()) + } + if !strings.Contains(h.stdout.String(), "all good") { + t.Errorf("did not stream: %q", h.stdout.String()) + } +} + +// Without --size the platform picks s: the CLI does not send a size. +func TestEphemeralRunDefaultSize(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + if err := doRun(context.Background(), h.env, ephemeralOpts(), []string{"x"}); err != nil { + t.Fatal(err) + } + if _, ok := h.fake.created["size"]; ok { + t.Errorf("sent a size nobody asked for: %v", h.fake.created) + } +} + +func TestEphemeralRunDetach(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.detach = true + err := doRun(context.Background(), h.env, opts, []string{"x"}) + 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()) + } + wantAll(t, "stderr", h.stderr.String(), "queued in an ephemeral container (size M) for acme/api", "nan runs logs "+testRunID) +} + +// BYOK and ephemeral combine: the model is the BYOK model. +func TestEphemeralRunWithBYOK(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.byokSecret, opts.model = "ANTHROPIC_KEY", "claude-sonnet-4-5" + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + inf, _ := h.fake.created["inference"].(map[string]any) + if h.fake.created["executor"] != "ephemeral" || inf["model"] != "claude-sonnet-4-5" || h.fake.created["model"] != nil { + t.Errorf("created with %v", h.fake.created) + } +} + +func TestEphemeralFlagMistakesAreUsageErrors(t *testing.T) { + for _, tc := range []struct { + name string + edit func(*runOptions) + wants []string + }{ + {"no repo", func(o *runOptions) { o.repo = "" }, []string{"--repo OWNER/NAME"}}, + {"no model", func(o *runOptions) { o.model = "" }, []string{"--model"}}, + {"bad repo", func(o *runOptions) { o.repo = "acme" }, []string{"OWNER/NAME", "acme/api"}}, + {"dot repo", func(o *runOptions) { o.repo = "acme/.." }, []string{"OWNER/NAME"}}, + {"repo URL", func(o *runOptions) { o.repo = "https://github.com/acme/api.git" }, []string{"did you mean --repo acme/api?"}}, + {"bad size", func(o *runOptions) { o.size = "xl" }, []string{"s, m or l", "4 vCPU", "S-minutes"}}, + {"with --ws", func(o *runOptions) { o.workspace = "develop" }, []string{"no workspace", "--ws"}}, + {"with --cwd", func(o *runOptions) { o.cwd = "/home/nan/x" }, []string{"--cwd"}}, + {"with --worktree", func(o *runOptions) { o.worktree = true }, []string{"--worktree"}}, + {"with --no-worktree", func(o *runOptions) { o.noWorktree = true }, []string{"--no-worktree"}}, + {"--repo without --ephemeral", func(o *runOptions) { o.ephemeral = false }, []string{"--repo is for ephemeral runs", "--ephemeral"}}, + {"--size without --ephemeral", func(o *runOptions) { o.ephemeral, o.repo, o.size = false, "", "m" }, []string{"--size is for ephemeral runs"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + tc.edit(&opts) + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +// The sizes are case-insensitive on the command line and sent lowercase. +func TestEphemeralSizeIsNormalised(t *testing.T) { + for in, want := range map[string]string{"S": "s", "m": "m", "L": "l"} { + opts := ephemeralOpts() + opts.size = in + req, err := buildRunRequest(promptInput{r: strings.NewReader("")}, opts, []string{"x"}) + if err != nil || req.Size != want || req.Executor != api.ExecutorEphemeral || req.Workspace != "" { + t.Errorf("%s: %+v %v", in, req, err) + } + } +} + +func TestEphemeralRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + cred credential + status int + code, param string + want int + wants, avoids []string + }{ + {"cap reached", credential{kind: credSession}, 402, "ephemeral_cap_reached", "", exitUnavailable, + []string{"monthly cap", "Settings at https://cloud.nan.builders/settings", "nan ephemeral usage"}, nil}, + {"token without scope", credential{kind: credEnv, bearer: testPAT}, 403, "token_scope_denied", "executor", exitAuth, + []string{"runs:ephemeral", "NAN_TOKEN", "Settings > Tokens", "nan auth login"}, []string{testPAT}}, + {"saved API key", credential{kind: credStoredKey, bearer: testKey}, 403, "token_scope_denied", "executor", exitAuth, + []string{"API keys (sk-) cannot", "nan auth login"}, []string{testKey}}, + {"not available", credential{kind: credSession}, 400, "invalid_request", "executor", exitAuth, + []string{"ephemeral runs are not available for your account"}, []string{"workspace"}}, + {"runs off", credential{kind: credSession}, 404, "not_found", "", exitAuth, + []string{"runs are not enabled"}, []string{"workspace"}}, + {"bad repo on the server", credential{kind: credSession}, 400, "invalid_request", "github_repo", exitUsage, + []string{"github_repo"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + param := "null" + if tc.param != "" { + param = fmt.Sprintf("%q", tc.param) + } + h := newHarness(t, &fakePlatform{ephemeral: true, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":"refused \u001b[2J","type":"invalid_request_error","param":%s,"code":%q}}`, param, tc.code)}) + h.env.cred = tc.cred + err := doRun(context.Background(), h.env, ephemeralOpts(), []string{"x"}) + if exitCode(err) != tc.want { + t.Fatalf("exit %d (%v), want %d", exitCode(err), err, tc.want) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q mentions %q", err.Error(), a) + } + } + assertNoControls(t, tc.name, err.Error()) + }) + } +} + +// An ephemeral automation run with a token missing runs:ephemeral reads +// the same, and so does the write API's refusal of an ephemeral automation. +func TestEphemeralRefusalsOnEveryPath(t *testing.T) { + cred := credential{kind: credStoredToken, bearer: testPAT} + scope := &api.APIError{Status: 403, Code: "token_scope_denied", Param: "executor", Message: "x"} + for name, exit := range map[string]*ExitError{ + "apiExit": apiExit(scope, cred), + "writeExit": writeExit(scope, cred, "automations", scopeAutomationsWrite), + } { + if exit.Code != exitAuth || !strings.Contains(exit.Error(), "runs:ephemeral") || strings.Contains(exit.Error(), "automations:write scope") { + t.Errorf("%s: %d %v", name, exit.Code, exit) + } + } + off := &api.APIError{Status: 400, Code: "invalid_request", Param: "executor", Message: `executor must be "workspace"`} + if exit := writeExit(off, cred, "automations", scopeAutomationsWrite); exit.Code != exitAuth || exit.Error() != "ephemeral runs are not available for your account" { + t.Errorf("writeExit off: %d %v", exit.Code, exit) + } + // A refusal that is not about ephemeral runs is left to the usual mapping. + other := &api.APIError{Status: 403, Code: "token_scope_denied", Param: "", Message: "needs runs"} + if exit := apiExit(other, cred); strings.Contains(exit.Error(), "runs:ephemeral") { + t.Errorf("a plain scope refusal reads as ephemeral: %v", exit) + } +} + +// ─── views ───────────────────────────────────────────────────────────────── + +// ephemeralGoldenEnv serves the ephemeral golden responses (rendered by +// cloud-api's own handlers, internal/api/testdata/ephemeral) on the paths +// that return them. usage answers /api/ephemeral/usage (nil: 404). +func ephemeralGoldenEnv(t *testing.T, cred credential, usage []byte) (*runsEnv, *bytes.Buffer, *bytes.Buffer, func() []string) { + t.Helper() + read := func(name string) []byte { + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", name)) + if err != nil { + t.Fatal(err) + } + return b + } + auto := read("automation_get_ephemeral.json") + routes := map[string][]byte{ + "/v1/runs/" + testRunID: read("run_get_ephemeral.json"), + "/v1/runs": read("run_list_ephemeral.json"), + "/v1/automations/" + testAutoID: auto, + "/v1/automations": []byte(`{"object":"list","data":[` + string(auto) + `]}`), + "/v1/automations/" + testAutoID + "/triggers": []byte(`{"data":[]}`), + "/api/ephemeral/settings": read("ephemeral_settings.json"), + } + if usage != nil { + routes["/api/ephemeral/usage"] = usage + } + var mu sync.Mutex + var seen []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.Method+" "+r.URL.Path) + mu.Unlock() + b, ok := routes[r.URL.Path] + if !ok || r.Method != http.MethodGet { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"not_found"}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + var out, errOut bytes.Buffer + env := &runsEnv{client: api.NewRunsClient(cred.session, cred.bearer).WithBaseURL(srv.URL), cred: cred, stdout: &out, stderr: &errOut} + return env, &out, &errOut, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), seen...) + } +} + +func goldenUsage(t *testing.T, billing bool) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", "ephemeral_usage.json")) + if err != nil { + t.Fatal(err) + } + if billing { + b = bytes.Replace(b, []byte(`"billing_enabled": false`), []byte(`"billing_enabled": true`), 1) + } + return b +} + +var sessionCred = credential{kind: credSession, session: "s"} + +// The views of an ephemeral run and an ephemeral automation, rendered from +// what cloud-api serialises. +func TestEphemeralGoldenViews(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + ctx := context.Background() + for _, tc := range []struct { + name string + do func(env *runsEnv) error + }{ + {"runs_show", func(env *runsEnv) error { return doRunsShow(ctx, env, testRunID) }}, + {"runs_ls", func(env *runsEnv) error { return doRunsLs(ctx, env) }}, + {"automations_show", func(env *runsEnv) error { return doAutomationsShow(ctx, env, testAutoID) }}, + {"automations_ls", func(env *runsEnv) error { return doAutomationsLs(ctx, env) }}, + {"usage", func(env *runsEnv) error { return doEphemeralUsage(ctx, env) }}, + {"settings", func(env *runsEnv) error { return doEphemeralSettings(ctx, env) }}, + } { + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := tc.do(env); err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + checkGolden(t, "ephemeral/"+tc.name+".golden", out.String()) + assertNoControls(t, tc.name, out.String()) + if strings.Contains(out.String(), "—") { + t.Errorf("%s has an em-dash", tc.name) + } + } +} + +func TestEphemeralRunsShowFacts(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + ctx := context.Background() + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + wantAll(t, "runs show", out.String(), "executor", "ephemeral", "M (2 vCPU, 8 GiB RAM, 16 GB disk)", "acme/api", + "€0.018 for 3 started minutes of size M", "before your free minutes", "not charged yet") + for _, gone := range []string{"workspace", "worktree", "cwd"} { + if strings.Contains(out.String(), gone+" ") { + t.Errorf("runs show of an ephemeral run shows %s:\n%s", gone, out.String()) + } + } + + // Billing on: no note. + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, true)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "not charged") { + t.Errorf("billing on, still says not charged:\n%s", out.String()) + } + + // A token cannot ask: the note says it conditionally, and nothing is + // asked of /api/ephemeral. + env, out, _, seen := ephemeralGoldenEnv(t, credential{kind: credEnv, bearer: testPAT}, goldenUsage(t, false)) + if err := doRunsShow(ctx, env, testRunID); err != nil { + t.Fatal(err) + } + wantAll(t, "runs show (token)", out.String(), "while billing is not active", "nan ephemeral usage") + for _, r := range seen() { + if strings.HasPrefix(r, "GET /api/ephemeral") { + t.Errorf("a token asked %s", r) + } + } + + // The usage lookup failing never fails the command. + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, nil) + if err := doRunsShow(ctx, env, testRunID); err != nil || !strings.Contains(out.String(), "while billing is not active") { + t.Errorf("usage 404: %v\n%s", err, out.String()) + } +} + +// A list of workspace runs only keeps its columns. +func TestRunsLsCostColumnOnlyWithEphemeralRuns(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newHarness(t, &fakePlatform{}) + if err := doRunsLs(context.Background(), h.env); err != nil { + t.Fatal(err) + } + if strings.Contains(h.stdout.String(), "COST") { + t.Errorf("workspace runs only, with a cost column:\n%s", h.stdout.String()) + } + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, nil) + if err := doRunsLs(context.Background(), env); err != nil { + t.Fatal(err) + } + lines := strings.Split(out.String(), "\n") + if !strings.Contains(lines[0], "COST") || !strings.Contains(lines[1], "ephemeral M") || !strings.Contains(lines[1], "€0.018") || + !strings.Contains(lines[2], "pending") || !strings.Contains(lines[3], "develop") || !strings.Contains(lines[3], " - ") { + t.Errorf("runs ls:\n%s", out.String()) + } +} + +func TestEphemeralUsageFacts(t *testing.T) { + ctx := context.Background() + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, false)) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage", out.String(), "2026-10 (inference plan)", "49 minutes", + "247 S-minutes of 300 (= 123 M or 61 L minutes); 53 used", "€0.00 after your free minutes", notChargedNote, + "€100.00", "€0.003", "€0.006", "€0.012", "a minute uses 2 free S-minutes", "a minute uses 4 free S-minutes", freeMinutesHelp) + + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, goldenUsage(t, true)) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "not charged") { + t.Errorf("billing on, still not charged:\n%s", out.String()) + } +} + +func TestEphemeralSettingsIsReadOnly(t *testing.T) { + env, out, errOut, seen := ephemeralGoldenEnv(t, sessionCred, nil) + if err := doEphemeralSettings(context.Background(), env); err != nil { + t.Fatal(err) + } + wantAll(t, "settings", out.String(), "€100.00 (€0 to €1000.00)", "up to 50 at once (1 to 50)", "not active yet") + wantAll(t, "settings stderr", errOut.String(), "change these in the browser", "https://cloud.nan.builders/settings") + for _, r := range seen() { + if !strings.HasPrefix(r, "GET ") { + t.Errorf("settings wrote: %s", r) + } + } + for _, c := range ephemeralSettingsCmd.Commands() { + t.Errorf("nan ephemeral settings has a subcommand %s: writes are browser-only", c.Name()) + } +} + +func TestEphemeralReadsWhenUnavailable(t *testing.T) { + ctx := context.Background() + for name, status := range map[string]int{"off": 404, "not a session": 403} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":"x"}`) + })) + env := &runsEnv{client: api.NewRunsClient("s", "").WithBaseURL(srv.URL), cred: sessionCred, stdout: io.Discard, stderr: io.Discard} + for cmd, do := range map[string]func() error{ + "usage": func() error { return doEphemeralUsage(ctx, env) }, + "settings": func() error { return doEphemeralSettings(ctx, env) }, + } { + err := do() + if exitCode(err) != exitAuth { + t.Errorf("%s %s: exit %d (%v)", name, cmd, exitCode(err), err) + } + if status == 404 && err.Error() != "ephemeral runs are not available for your account" { + t.Errorf("%s %s: %v", name, cmd, err) + } + } + srv.Close() + } +} + +// nan ephemeral takes the session only: the platform takes no token there. +func TestEphemeralNeedsTheSession(t *testing.T) { + fakeHome(t, cliSession) + cred, err := ephemeralSession(func(string) string { return testPAT }, "") + if err != nil || cred.kind != credSession || cred.session != "sess-cli" { + t.Fatalf("with a session: %+v %v", cred, err) + } + for name, tc := range map[string]struct { + session, env, file string + wants []string + }{ + "nobody": {"", "", "", []string{"nan auth login"}}, + "NAN_TOKEN": {"", testPAT, "", []string{"NAN_TOKEN is set", "nan auth login"}}, + "token-file": {cliSession, "", "/tmp/tok", []string{"--token-file", "nan auth login"}}, + "saved token": {`{"platform_token":"` + testPAT + `"}`, "", "", []string{"nan auth login"}}, + "corrupt file": {`{not json`, "", "", []string{"nan auth login"}}, + } { + fakeHome(t, tc.session) + _, err := ephemeralSession(func(k string) string { + if k == tokenEnvVar { + return tc.env + } + return "" + }, tc.file) + if exitCode(err) != exitAuth { + t.Errorf("%s: exit %d (%v)", name, exitCode(err), err) + continue + } + wantAll(t, name, err.Error(), tc.wants...) + if strings.Contains(err.Error(), testPAT) { + t.Errorf("%s: shows the token", name) + } + } +} + +// ─── automation files ────────────────────────────────────────────────────── + +func TestCheckAutomationExecutor(t *testing.T) { + const ok = `{"name":"n","executor":"ephemeral","size":"m","checkout":"fresh","github_repo":"acme/api","agent":"pi","model":"glm5.3"}` + for _, tc := range []struct { + name string + body string + create bool + want string // "" = accepted + }{ + {"ephemeral", ok, true, ""}, + {"ephemeral, size left out", `{"executor":"ephemeral","checkout":"fresh","github_repo":"acme/api","model":"m"}`, true, ""}, + {"ephemeral with BYOK model", `{"executor":"ephemeral","checkout":"fresh","github_repo":"acme/api","inference":{"source":"byok","secret":"K","model":"x"}}`, true, ""}, + {"workspace automation", `{"name":"n","workspace":"develop","agent":"pi"}`, true, ""}, + {"explicit workspace executor", `{"executor":"workspace","workspace":"develop"}`, true, ""}, + {"edit: size only", `{"size":"l"}`, false, ""}, + {"edit: switch to ephemeral", `{"executor":"ephemeral"}`, false, ""}, + {"not an object", `[1]`, true, ""}, + {"unknown executor", `{"executor":"container"}`, true, `executor must be "workspace"`}, + {"executor not a string", `{"executor":1}`, false, `executor must be`}, + {"unknown size", `{"executor":"ephemeral","size":"xl"}`, false, `size must be "s", "m" or "l"`}, + {"size on a workspace executor", `{"executor":"workspace","size":"m"}`, false, "ephemeral automations only"}, + {"size without executor on create", `{"workspace":"develop","size":"m"}`, true, ""}, + {"workspace on ephemeral", `{"executor":"ephemeral","workspace":"develop","checkout":"fresh","github_repo":"a/b","model":"m"}`, true, "no workspace"}, + {"no fresh checkout", `{"executor":"ephemeral","github_repo":"a/b","model":"m"}`, true, "checkout: fresh"}, + {"no repo", `{"executor":"ephemeral","checkout":"fresh","model":"m"}`, true, "github_repo: OWNER/NAME"}, + {"cwd", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b","model":"m","cwd":"/home/nan/x"}`, true, "remove cwd"}, + {"no model", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b"}`, true, "must name its model"}, + } { + err := checkAutomationExecutor(json.RawMessage(tc.body), tc.create) + switch { + case tc.want == "" && err != nil: + t.Errorf("%s: refused: %v", tc.name, err) + case tc.want != "" && (exitCode(err) != exitUsage || !strings.Contains(err.Error(), tc.want)): + t.Errorf("%s: %v, want %q", tc.name, err, tc.want) + } + } +} + +// A bad ephemeral automation file is refused before anything is sent, and a +// good one (YAML) goes out as written. +func TestAutomationsCreateEphemeralFile(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "bad.yaml") + good := filepath.Join(dir, "good.yaml") + for p, body := range map[string]string{ + bad: "name: nightly\nexecutor: ephemeral\nsize: xl\n", + good: "name: nightly\nexecutor: ephemeral\nsize: m\ncheckout: fresh\ngithub_repo: acme/api\nagent: pi\nmodel: glm5.3\ninstructions: Review.\n", + } { + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", bad) + if r.code != exitUsage || !strings.Contains(r.msg, `size must be "s", "m" or "l"`) || !strings.Contains(r.msg, "S-minutes") { + t.Fatalf("bad file: %d %s", r.code, r.msg) + } + if len(fake.reqs) != 0 { + t.Errorf("sent %d requests for a bad file", len(fake.reqs)) + } + + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", good) + if r.code != 0 { + t.Fatalf("good file: %d %s", r.code, r.msg) + } + var sent map[string]any + for _, q := range fake.reqs { + if q.Method == http.MethodPost && q.Path == "/v1/automations" { + _ = json.Unmarshal([]byte(q.Body), &sent) + } + } + if sent["executor"] != "ephemeral" || sent["size"] != "m" || sent["checkout"] != "fresh" { + t.Errorf("sent %v", sent) + } + + // edit: an unknown size never leaves the machine either. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "edit", testAutoID, "-f", bad) + if r.code != exitUsage || len(fake.reqs) != 0 { + t.Errorf("edit with a bad file: %d %s, %d requests", r.code, r.msg, len(fake.reqs)) + } +} + +// ─── formatting and help ─────────────────────────────────────────────────── + +func TestFormatEUR(t *testing.T) { + for micros, want := range map[int64]string{ + 0: "€0.00", 3000: "€0.003", 18000: "€0.018", 1_500_000: "€1.50", 100_000_000: "€100.00", + 123_456: "€0.123456", 10_000: "€0.01", -6000: "-€0.006", + } { + if got := formatEUR(micros); got != want { + t.Errorf("formatEUR(%d) = %q, want %q", micros, got, want) + } + } + if got := formatCents(10000); got != "€100.00" { + t.Errorf("formatCents = %q", got) + } + for secs, want := range map[int64]int64{0: 0, 1: 1, 14: 1, 60: 1, 61: 2, 180: 3} { + if got := startedMinutes(secs); got != want { + t.Errorf("startedMinutes(%d) = %d, want %d", secs, got, want) + } + } +} + +func TestRunCostLabels(t *testing.T) { + m := "m" + for _, tc := range []struct { + run api.Run + long, short string + }{ + {api.Run{Executor: "ephemeral", Size: &m, State: "running"}, "metered when the run ends", "pending"}, + {api.Run{Executor: "ephemeral", Size: &m, State: "succeeded"}, "not metered yet", "pending"}, + {api.Run{Executor: "ephemeral", Size: &m, State: "succeeded", Cost: &api.RunCost{EURMicros: 6000, BilledSeconds: 14}}, + "€0.006 for 1 started minute of size M", "€0.006"}, + {api.Run{Executor: "workspace", State: "succeeded"}, "", "-"}, + } { + if tc.long != "" && !strings.Contains(runCostLabel(&tc.run), tc.long) { + t.Errorf("%+v: %q", tc.run, runCostLabel(&tc.run)) + } + if got := runCostShort(&tc.run); got != tc.short { + t.Errorf("%+v short: %q", tc.run, got) + } + } + bad := "x\x1b[2J" + if got := sizeLabel(&bad); strings.ContainsAny(got, "\x1b") { + t.Errorf("sizeLabel = %q", got) + } +} + +// The owner asked for this sentence wherever free minutes are shown. +func TestEphemeralHelpExplainsSMinutes(t *testing.T) { + const want = "Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L)" + if freeMinutesHelp != want { + t.Fatalf("freeMinutesHelp = %q", freeMinutesHelp) + } + for name, text := range map[string]string{ + "nan ephemeral": ephemeralCmd.Long, + "nan ephemeral usage": ephemeralUsageCmd.Long, + "nan run": runCmd.Long, + "nan automations create": automationsCreateCmd.Long, + } { + if !strings.Contains(text, want) { + t.Errorf("%s --help does not explain S-minutes", name) + } + if strings.Contains(text, "—") { + t.Errorf("%s --help has an em-dash", name) + } + if strings.Contains(strings.ToLower(text), "eu only") { + t.Errorf("%s --help claims EU only", name) + } + } + wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "public", + "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", + "metered and shown, not charged", "nan ephemeral usage") + wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") + wantAll(t, "nan ephemeral settings --help", ephemeralSettingsCmd.Long, "read only", "browser", ephemeralSettingsURL) + wantAll(t, "nan ephemeral --help", ephemeralCmd.Long, "Community 100, Inference 300, Premium", "€100", "50", "nan auth login") + wantAll(t, "nan automations create --help", automationsCreateCmd.Long, "executor: ephemeral", "size: m", "checkout: fresh", "runs:ephemeral") + for _, f := range []string{"ephemeral", "repo", "size"} { + if runCmd.Flags().Lookup(f) == nil { + t.Errorf("nan run has no --%s", f) + } + } + if ephemeralUsageCmd.Annotations[sysexitsAnnotation] == "" || ephemeralSettingsCmd.Annotations[sysexitsAnnotation] == "" { + t.Error("nan ephemeral commands do not use the documented exit codes") + } +} + +// End to end through the command tree: an unknown subcommand and the +// usage command with the session. +func TestEphemeralCommandTree(t *testing.T) { + code, msg := execute(t, "ephemeral", "set-cap") + if code != exitUsage || !strings.Contains(msg, "nan ephemeral --help") { + t.Errorf("unknown subcommand: %d %s", code, msg) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Cookie") != "nan_session=sess-cli" { + w.WriteHeader(http.StatusForbidden) + return + } + _, _ = w.Write(goldenUsage(t, false)) + })) + t.Cleanup(srv.Close) + fakeHome(t, cliSession) + swap(t, &runsBaseURL, srv.URL) + var out, errOut bytes.Buffer + swap(t, &writeStdout, io.Writer(&out)) + swap(t, &writeStderr, io.Writer(&errOut)) + code, msg = execute(t, "ephemeral", "usage") + if code != 0 || !strings.Contains(out.String(), freeMinutesHelp) { + t.Errorf("usage: %d %s\n%s", code, msg, out.String()) + } +} diff --git a/cmd/json_output_test.go b/cmd/json_output_test.go index 720da25..1632bcd 100644 --- a/cmd/json_output_test.go +++ b/cmd/json_output_test.go @@ -57,6 +57,8 @@ var hintFragments = []string{ "send a test", "nan notifications", "turned off", + "change these in the browser", + "cost:", } // jsonFixtures writes the files the write commands read. @@ -112,6 +114,8 @@ func jsonCases(t *testing.T) map[string][]jsonCase { {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--json"}}, {args: []string{"automations", "triggers", "rotate", testAutoID, triggerUUID, "--url", "--secret", "--no-grace", "--json"}}, }, + "nan ephemeral settings": {{args: []string{"ephemeral", "settings", "--json"}}}, + "nan ephemeral usage": {{args: []string{"ephemeral", "usage", "--json"}}}, "nan notifications add": { {args: []string{"notifications", "add", "deploys", "--kind", "slack", "--url-file", hook, "--json"}}, // The one-time signing secret is in the JSON; the note about it @@ -134,6 +138,7 @@ func jsonCases(t *testing.T) map[string][]jsonCase { "nan run": { {args: []string{"run", "review", "--json"}, lines: true}, {args: []string{"run", "review", "--detach", "--json"}, code: exitDetached}, + {args: []string{"run", "--ephemeral", "--repo", "acme/api", "--model", "glm5.3", "--size", "m", "review", "--json"}, lines: true}, }, "nan runs cancel": {{args: []string{"runs", "cancel", testRunID, "--json"}}}, "nan runs logs": { @@ -290,6 +295,15 @@ func runJSONCase(t *testing.T, c jsonCase) (code int, msg, stdout, stderr string writeHandler := write.handler(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path + if name, ok := strings.CutPrefix(p, "/api/ephemeral/"); ok { + b, err := os.ReadFile(filepath.Join("..", "internal", "api", "testdata", "ephemeral", "ephemeral_"+name+".json")) + if err != nil { + http.NotFound(w, r) + return + } + _, _ = w.Write(b) + return + } toWrite := p == "/api/auth/me" || p == "/api/account/secrets" || strings.HasPrefix(p, "/v1/secrets") || strings.HasPrefix(p, "/v1/notification-channels") || strings.HasSuffix(p, "/triggers") || strings.Contains(p, "/triggers/") || diff --git a/cmd/run_test.go b/cmd/run_test.go index 313600d..9f04a9d 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -36,6 +36,9 @@ type fakePlatform struct { dropFirst bool // close the first stream after its events, without `end` streamStatus int cancelStatus int + // ephemeral makes every run an ephemeral one (size m, acme/api, no + // workspace), metered once it is terminal. + ephemeral bool created map[string]any idemKey string @@ -51,6 +54,13 @@ func (f *fakePlatform) run(state string) string { if f.finalError != "" && api.Terminal(state) { ec = fmt.Sprintf("%q", f.finalError) } + if f.ephemeral { + cost := "null" + if api.Terminal(state) { + cost = `{"eur":0.018,"eur_micros":18000,"billed_seconds":180}` + } + return fmt.Sprintf(`{"id":%q,"object":"run","workspace":null,"executor":"ephemeral","size":"m","cost":%s,"github_repo":"acme/api","agent":"pi","model":"glm5.3","trigger":"cli","state":%q,"queue_position":null,"prompt_preview":"review","cwd":"/home/nan/work","git_isolation":null,"timeout_seconds":1800,"cancel_requested":false,"exit_code":null,"error_code":%s,"result":null,"usage":null,"last_seq":2,"created_at":"2026-10-10T12:00:00Z","started_at":null,"finished_at":null}`, testRunID, cost, state, ec) + } return fmt.Sprintf(`{"id":%q,"object":"run","workspace":{"id":"w1","name":"develop"},"agent":"pi","model":null,"trigger":"cli","state":%q,"queue_position":null,"prompt_preview":"review","cwd":"/home/nan","git_isolation":null,"timeout_seconds":1800,"cancel_requested":false,"exit_code":null,"error_code":%s,"result":null,"usage":{"tokens_in":null,"tokens_out":null,"source":null},"last_seq":2,"created_at":"2026-10-10T12:00:00Z","started_at":null,"finished_at":null}`, testRunID, state, ec) } diff --git a/cmd/testdata/ephemeral/automations_ls.golden b/cmd/testdata/ephemeral/automations_ls.golden new file mode 100644 index 0000000..1d505fd --- /dev/null +++ b/cmd/testdata/ephemeral/automations_ls.golden @@ -0,0 +1,2 @@ +ID NAME WORKSPACE AGENT STATUS APPROVAL +a0000000-0000-4000-8000-000000000001 PR review api ephemeral L pi enabled github_pr_review diff --git a/cmd/testdata/ephemeral/automations_show.golden b/cmd/testdata/ephemeral/automations_show.golden new file mode 100644 index 0000000..34215d0 --- /dev/null +++ b/cmd/testdata/ephemeral/automations_show.golden @@ -0,0 +1,23 @@ +id a0000000-0000-4000-8000-000000000001 +name PR review api +description reviews +status enabled +template pr_review +executor ephemeral: a fresh container per run, size L (4 vCPU, 12 GiB RAM, 20 GB disk) +agent pi · model glm5.3 +inference NaN +timeout 30m0s +repository acme/api +approval github_pr_review +approval ttl 72h0m0s +concurrency skip if active +runs per hour up to 30 +secret GITHUB_TOKEN ← GITHUB_WRITE_TOKEN (actions only, never the agent) +updated 2026-10-11 09:00 +last changed from Browser + +instructions: + Review for correctness. + +run it: nan automations run a0000000-0000-4000-8000-000000000001 +change it: nan automations edit a0000000-0000-4000-8000-000000000001 -f FILE, or in the portal: https://cloud.nan.builders/automations/a0000000-0000-4000-8000-000000000001 diff --git a/cmd/testdata/ephemeral/runs_ls.golden b/cmd/testdata/ephemeral/runs_ls.golden new file mode 100644 index 0000000..7814d6f --- /dev/null +++ b/cmd/testdata/ephemeral/runs_ls.golden @@ -0,0 +1,4 @@ +ID STATE WORKSPACE AGENT INFERENCE COST CREATED PROMPT +6f1c2a9b-0000-4000-8000-000000000001 succeeded ephemeral M pi nan €0.018 2026-10-10 09:00 review PR 42 +6f1c2a9b-0000-4000-8000-000000000001 running ephemeral M pi nan pending 2026-10-10 09:00 review PR 42 +6f1c2a9b-0000-4000-8000-000000000001 succeeded develop pi nan - 2026-10-10 09:00 review PR 42 diff --git a/cmd/testdata/ephemeral/runs_show.golden b/cmd/testdata/ephemeral/runs_show.golden new file mode 100644 index 0000000..7e6a39b --- /dev/null +++ b/cmd/testdata/ephemeral/runs_show.golden @@ -0,0 +1,22 @@ +id 6f1c2a9b-0000-4000-8000-000000000001 +state succeeded +executor ephemeral: a fresh container, size M (2 vCPU, 8 GiB RAM, 16 GB disk) +agent pi · model glm5.3 +inference NaN +trigger cli +repository acme/api +timeout 30m0s +created 2026-10-10 09:00 +started 2026-10-10 09:00 +branch nan-run/6f1c2a9b +commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +changed files 3 +cost €0.018 for 3 started minutes of size M, before your free minutes (nan ephemeral usage) + +note: not charged yet: billing is not active, usage is metered and shown only + +prompt: + review PR 42 in full + +summary: + done diff --git a/cmd/testdata/ephemeral/settings.golden b/cmd/testdata/ephemeral/settings.golden new file mode 100644 index 0000000..61d100b --- /dev/null +++ b/cmd/testdata/ephemeral/settings.golden @@ -0,0 +1,3 @@ +monthly spend cap €100.00 (€0 to €1000.00) +concurrent runs up to 50 at once (1 to 50) +billing not active yet: usage is metered and shown, not charged diff --git a/cmd/testdata/ephemeral/usage.golden b/cmd/testdata/ephemeral/usage.golden new file mode 100644 index 0000000..655b3a5 --- /dev/null +++ b/cmd/testdata/ephemeral/usage.golden @@ -0,0 +1,13 @@ +ephemeral runs, 2026-10 (inference plan) + +container time 49 minutes +free minutes left 247 S-minutes of 300 (= 123 M or 61 L minutes); 53 used +cost this month €0.00 after your free minutes (not charged yet: billing is not active, usage is metered and shown only) +monthly cap €100.00 (change it in the browser, Settings at https://cloud.nan.builders/settings) + +prices per started minute (a 14 s run is 1 minute): + S 1 vCPU, 4 GiB RAM, 10 GB disk €0.003 a minute uses 1 free S-minute + M 2 vCPU, 8 GiB RAM, 16 GB disk €0.006 a minute uses 2 free S-minutes + L 4 vCPU, 12 GiB RAM, 20 GB disk €0.012 a minute uses 4 free S-minutes + +Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L). From 23c78cc09c334683c868ecd893e153b8c48ebecb Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:44:51 +0200 Subject: [PATCH 04/11] feat(ephemeral): portal link of an ephemeral run, size table in help Co-Authored-By: Claude Opus 5.5 --- cmd/ephemeral.go | 9 +++++---- cmd/run.go | 8 ++++---- internal/runs/proposal_test.go | 13 +++++++++++++ internal/runs/render.go | 8 ++++++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index 0f11e80..0cd52bb 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -306,7 +306,7 @@ var ephemeralCmd = &cobra.Command{ a workspace: start one with nan run --ephemeral --repo OWNER/NAME --model ID. Sizes, billed per started minute (a 14 s run is 1 minute): -` + sizeTable() + ` +` + sizeTable(" ") + ` Free minutes every calendar month: Community 100, Inference 300, Premium 1000. ` + freeMinutesHelp + `. While billing is not active, usage is metered and shown, not charged: @@ -376,12 +376,13 @@ func init() { ephemeralSettingsCmd.Flags().BoolVar(&ephemeralSettingsJSON, "json", false, "Print the API's settings object as JSON") } -// sizeTable is the sizes and their prices, for help text. -func sizeTable() string { +// sizeTable is the sizes and their prices, for help text, each line +// starting with indent. +func sizeTable(indent string) string { prices := map[string]string{api.SizeS: "€0.003", api.SizeM: "€0.006", api.SizeL: "€0.012"} var b strings.Builder for _, s := range ephemeralSizes { - fmt.Fprintf(&b, " %-2s %-32s %s per minute\n", s.name, s.spec, prices[s.name]) + fmt.Fprintf(&b, "%s%-2s %-32s %s per minute\n", indent, s.name, s.spec, prices[s.name]) } return b.String() } diff --git a/cmd/run.go b/cmd/run.go index c11e31f..103cd00 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -103,7 +103,7 @@ Ephemeral runs (no workspace): and --no-worktree do not apply. --size picks the container (default s), billed per started minute (a 14 s run is 1 minute): -` + sizeTable() + ` Free minutes every month: Community 100, Inference 300, Premium 1000. +` + sizeTable(" ") + ` Free minutes every month: Community 100, Inference 300, Premium 1000. ` + freeMinutesHelp + `. While billing is not active, usage is metered and shown, not charged. Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. @@ -190,7 +190,7 @@ func init() { f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --repo and --model)") f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the public GitHub repository to clone, `OWNER/NAME`") - f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs below)") + f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs above)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret and --ephemeral)") f.StringVar(&runOpts.byokSecret, "byok-secret", "", "Run on your own provider key: the `NAME` of a provider key secret saved in the portal (never the key itself); needs --model") @@ -413,7 +413,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d } renderer.Flush() safeID := runs.SanitizeLine(id) - fmt.Fprintf(env.stderr, "\ndetached — run %s goes on in the workspace\n", safeID) + fmt.Fprintf(env.stderr, "\ndetached — run %s goes on at nan.builders\n", safeID) printFollowHints(env.stderr, safeID) fmt.Fprintf(env.stderr, "press Ctrl-C again within %s to cancel it now\n", cancelWindow) @@ -443,7 +443,7 @@ func follow(ctx context.Context, env *runsEnv, id string, after int64, asJSON, d func streamExit(err error, env *runsEnv, id string) error { safeID := runs.SanitizeLine(id) if errors.Is(err, api.ErrStreamLost) { - return exitf(exitUnavailable, "%s — run %s goes on in the workspace; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) + return exitf(exitUnavailable, "%s — run %s goes on at nan.builders; follow it with: nan runs logs %s -f", runs.SanitizeLine(err.Error()), safeID, safeID) } exit := notFound(err, safeID, env.cred) if exit.Code == exitUnavailable { diff --git a/internal/runs/proposal_test.go b/internal/runs/proposal_test.go index 63f4c23..04d2673 100644 --- a/internal/runs/proposal_test.go +++ b/internal/runs/proposal_test.go @@ -106,6 +106,19 @@ func TestRunURLFallsBackWithoutAWorkspaceID(t *testing.T) { } } +// An ephemeral run has no workspace: its page is /runs/. +func TestRunURLOfAnEphemeralRun(t *testing.T) { + run := awaitingRun(t) + run.Executor, run.Workspace = api.ExecutorEphemeral, api.RunWorkspace{} + if got := RunURL(run); got != "https://cloud.nan.builders/runs/"+strings.ToLower(run.ID) { + t.Errorf("RunURL = %q", got) + } + run.ID = "../../evil" + if got := RunURL(run); got != "https://cloud.nan.builders/automations?tab=approvals" { + t.Errorf("RunURL with a bad id = %q", got) + } +} + func TestPhase2Events(t *testing.T) { var b bytes.Buffer r := &Renderer{Out: &b} diff --git a/internal/runs/render.go b/internal/runs/render.go index ce4d568..4929f45 100644 --- a/internal/runs/render.go +++ b/internal/runs/render.go @@ -366,9 +366,13 @@ func AutomationURL(id string) string { } // RunURL is a run's page in the portal, where its proposal is approved. -// The workspace id is part of the path; without a well-formed one this -// falls back to the approvals inbox. +// The workspace id is part of the path, except for an ephemeral run (no +// workspace: /runs/); without a well-formed id this falls back to the +// approvals inbox. func RunURL(run *api.Run) string { + if run.Ephemeral() && isUUID(run.ID) { + return PortalURL + "/runs/" + strings.ToLower(run.ID) + } if !isUUID(run.ID) || !isUUID(run.Workspace.ID) { return ApprovalsURL } From 3e5f6291f5d0d06c1a7b0191603942eada082e27 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:44:52 +0200 Subject: [PATCH 05/11] chore: 0.1.35 Co-Authored-By: Claude Opus 5.5 --- internal/tui/tui.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 581fae1..6ad5386 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3661,7 +3661,7 @@ func (m model) renderSetup(l layout) string { // ── about renderer ─────────────────────────────────────────────────────────── -const Version = "0.1.34" +const Version = "0.1.35" func renderAbout(l layout) string { var b strings.Builder From 90eb361df5b1e205c9bd830d55ae96c7cc6c2b89 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:46:25 +0200 Subject: [PATCH 06/11] docs(ephemeral): link the dedicated ephemeral runs page Co-Authored-By: Claude Opus 5.5 --- cmd/ephemeral.go | 2 +- cmd/ephemeral_test.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index 0cd52bb..b2f3972 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -24,7 +24,7 @@ import ( // started minute by container size, with free minutes every month. // ephemeralDocs is where ephemeral runs are explained. -const ephemeralDocs = "https://nan.builders/docs/runs" +const ephemeralDocs = "https://nan.builders/docs/ephemeral-runs" // ephemeralSettingsURL is where the member changes their ephemeral limits: // writing them needs a browser sign-in. diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index 73a41f2..20790b4 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -628,6 +628,10 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", "metered and shown, not charged", "nan ephemeral usage") wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") + for name, c := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, + "nan ephemeral settings": ephemeralSettingsCmd.Long} { + wantAll(t, name+" --help", c, "Docs: https://nan.builders/docs/ephemeral-runs") + } wantAll(t, "nan ephemeral settings --help", ephemeralSettingsCmd.Long, "read only", "browser", ephemeralSettingsURL) wantAll(t, "nan ephemeral --help", ephemeralCmd.Long, "Community 100, Inference 300, Premium", "€100", "50", "nan auth login") wantAll(t, "nan automations create --help", automationsCreateCmd.Long, "executor: ephemeral", "size: m", "checkout: fresh", "runs:ephemeral") From 581665feecbfb0650faa6aa72e93321e0ac23575 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 10:46:58 +0200 Subject: [PATCH 07/11] docs(ephemeral): link the ephemeral runs page from nan run --help and README Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- cmd/ephemeral_test.go | 2 +- cmd/run.go | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f172bc..1236399 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,8 @@ SSH, no SMTP). A run started from the CLI gets no secrets, so the repository has to be public; for a private one, use an ephemeral automation (`executor: ephemeral`, `checkout: fresh`, `github_repo`, `model`, and an agent secret `GITHUB_TOKEN`). Starting one needs your session or a platform token with the -`runs:ephemeral` scope; API keys cannot. +`runs:ephemeral` scope; API keys cannot. Docs: +. | Size | Container | Price per started minute | |---|---|---| diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index 20790b4..6cfd1f1 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -626,7 +626,7 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { } wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "public", "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", - "metered and shown, not charged", "nan ephemeral usage") + "metered and shown, not charged", "nan ephemeral usage", "Docs: https://nan.builders/docs/ephemeral-runs", runsDocs) wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") for name, c := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, "nan ephemeral settings": ephemeralSettingsCmd.Long} { diff --git a/cmd/run.go b/cmd/run.go index 103cd00..c0cc8a1 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -109,6 +109,7 @@ Ephemeral runs (no workspace): Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. Starting one needs your session (nan auth login) or a platform token with the runs:ephemeral scope; API keys (sk-) cannot start them. + Docs: ` + ephemeralDocs + ` In the portal: Folder = --cwd, Separate branch = --worktree/--no-worktree, From 9fd4fe394a0e0d750addd75d69b738fcb3bd68d1 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 12:14:22 +0200 Subject: [PATCH 08/11] fix(ephemeral): map only ephemeral 402 codes, say where an automation run goes Only ephemeral_cap_reached reads as the cap; payment_method_required and currency_migration_required say what to do; any other 402 keeps the usual mapping. nan automations run of an ephemeral automation names the container instead of an empty workspace. Free minutes come with a membership and a payment method is required (help, usage). Help within 80 columns, one Ctrl-C separator, settings range in euros, README note on --json. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 ++- cmd/automations.go | 2 +- cmd/ephemeral.go | 66 ++++++++++++++---- cmd/ephemeral_test.go | 95 ++++++++++++++++++++++++-- cmd/run.go | 10 ++- cmd/testdata/ephemeral/settings.golden | 2 +- cmd/testdata/ephemeral/usage.golden | 3 +- internal/api/ephemeral.go | 6 ++ 8 files changed, 164 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1236399..9c39110 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,14 @@ ephemeral`, `checkout: fresh`, `github_repo`, `model`, and an agent secret | `l` | 4 vCPU, 12 GiB RAM, 20 GB disk | €0.012 | A 14 s run is 1 minute. Free minutes every month: Community 100, Inference 300, -Premium 1000. Free minutes are S-minutes: one M minute uses 2, one L minute 4 -(100 free = 100 S, 50 M or 25 L). While billing is not active, usage is +Premium 1000; they come with a membership (accounts without one have none). +Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free +minutes = 100 minutes of S, 50 of M or 25 of L). Ephemeral runs need a payment +method on file, free minutes included. While billing is not active, usage is metered and shown, not charged. `nan runs ls` and `nan runs show` show each -ephemeral run's size and cost. +ephemeral run's size and cost. The COST column of `nan runs ls` appears only +when an ephemeral run is listed, so scripts should read `nan runs ls --json` +rather than the columns. ```bash nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" diff --git a/cmd/automations.go b/cmd/automations.go index 8778549..76ef844 100644 --- a/cmd/automations.go +++ b/cmd/automations.go @@ -528,7 +528,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 %s, Ctrl-C to detach\n", run.ID, name, runPlace(run, &api.CreateRunRequest{})) } return follow(ctx, env, run.ID, 0, autoRunJSON, true) } diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index b2f3972..f1335e1 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -32,7 +32,26 @@ const ephemeralSettingsURL = runs.PortalURL + "/settings" // freeMinutesHelp is how free minutes count, said wherever they are shown // (owner request). -const freeMinutesHelp = "Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L)" +const freeMinutesHelp = "Free minutes are S-minutes: one M minute uses 2, one L minute 4\n(100 free minutes = 100 minutes of S, 50 of M or 25 of L)" + +// freeMinutesLine is freeMinutesHelp on one line, for an error message. +var freeMinutesLine = strings.ReplaceAll(freeMinutesHelp, "\n", " ") + +// membershipHelp and paymentHelp are the owner's rules: free minutes come +// with a membership, and every ephemeral run needs a payment method. +const ( + membershipHelp = "Free minutes come with a membership (accounts without one have none)" + paymentHelp = "Ephemeral runs need a payment method on file, free minutes included" +) + +// paymentMethodRequired and currencyMigrationRequired are what to do about +// the two billing refusals (402) of an ephemeral run. +const ( + paymentMethodRequired = "add a payment method in the portal first: Settings at " + ephemeralSettingsURL + + " (ephemeral runs need one on file, free minutes included)" + currencyMigrationRequired = "your subscription bills in USD; ephemeral runs bill in EUR: switch it to EUR in the portal (Settings > Billing at " + + ephemeralSettingsURL + ")" +) // notChargedNote is what a cost means while billing is off. const notChargedNote = "not charged yet: billing is not active, usage is metered and shown only" @@ -183,9 +202,17 @@ func ephemeralUnavailable() *ExitError { // billable work (an sk- key, or a token without runs:ephemeral). // - 402 ephemeral_cap_reached: the month's spend plus the worst case of // the runs going on could pass the member's cap. +// - 402 payment_method_required: no payment method on file. +// - 402 currency_migration_required: the subscription bills in USD. +// +// Any other 402 is left to the usual mapping. func ephemeralExit(apiErr *api.APIError, cred credential) *ExitError { switch { - case apiErr.Code == "ephemeral_cap_reached" || apiErr.Status == 402: + case apiErr.Code == "payment_method_required": + return exitf(exitUnavailable, "%s", paymentMethodRequired) + case apiErr.Code == "currency_migration_required": + return exitf(exitUnavailable, "%s", currencyMigrationRequired) + case apiErr.Code == "ephemeral_cap_reached": return exitf(exitUnavailable, "this run could take your ephemeral spend past your monthly cap: raise the cap in the portal, "+ "Settings at %s (signed in through the browser), or wait for your ephemeral runs going on to finish. "+ "See this month's spend with: nan ephemeral usage", ephemeralSettingsURL) @@ -244,7 +271,7 @@ func checkAutomationExecutor(body json.RawMessage, create bool) error { size, hasSize, ok := str("size") if !ok || (hasSize && size != "" && size != api.SizeS && size != api.SizeM && size != api.SizeL) { return usageErrorf(`size must be "s", "m" or "l" (the container of an ephemeral automation: %s); %s`, - sizeList(), freeMinutesHelp) + sizeList(), freeMinutesLine) } if hasSize && size != "" && hasExecutor && executor == api.ExecutorWorkspace { return usageErrorf(`size is for ephemeral automations only: set executor: ephemeral, or remove size`) @@ -252,9 +279,6 @@ func checkAutomationExecutor(body json.RawMessage, create bool) error { if !create || executor != api.ExecutorEphemeral { return nil } - if hasSize && size != "" && !hasExecutor { - return usageErrorf(`size is for ephemeral automations only: add executor: ephemeral, or remove size`) - } if ws, has, _ := str("workspace"); has && ws != "" { return usageErrorf("an ephemeral automation has no workspace: remove workspace (it runs in a fresh container)") } @@ -308,7 +332,9 @@ a workspace: start one with nan run --ephemeral --repo OWNER/NAME --model ID. Sizes, billed per started minute (a 14 s run is 1 minute): ` + sizeTable(" ") + ` Free minutes every calendar month: Community 100, Inference 300, Premium -1000. ` + freeMinutesHelp + `. +1000. ` + membershipHelp + `. +` + freeMinutesHelp + `. +` + paymentHelp + `. While billing is not active, usage is metered and shown, not charged: nan ephemeral usage says which. @@ -336,6 +362,8 @@ var ephemeralUsageCmd = &cobra.Command{ free minutes, the free minutes left, your monthly cap and the prices. ` + freeMinutesHelp + `. +` + membershipHelp + `. +` + paymentHelp + `. While billing is not active, usage is metered and shown, not charged. ` + ephemeralCredentialHelp + ` @@ -355,9 +383,9 @@ var ephemeralSettingsCmd = &cobra.Command{ and how many ephemeral runs may go on at once (1 to 50, default 50). They are read only here: change them in the browser, Settings at -` + ephemeralSettingsURL + `, signed in through the browser, so that nothing -that can read this machine's CLI session (an agent, a CI job) can raise -what you spend. +` + ephemeralSettingsURL + `, +signed in through the browser, so that nothing that can read this +machine's CLI session (an agent, a CI job) can raise what you spend. ` + ephemeralCredentialHelp + ` @@ -452,7 +480,10 @@ func doEphemeralUsage(ctx context.Context, env *runsEnv) error { fmt.Fprintf(env.stdout, "ephemeral runs, %s\n\n", month) tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(tw, "container time\t%s\n", plural(int(u.Minutes), "minute")) - if priceS > 0 { + switch { + case u.FreeEURMicrosTotal == 0: + fmt.Fprintf(tw, "free minutes\tnone: free minutes come with a membership (%s/membership)\n", runs.PortalURL) + case priceS > 0: total, used := sMinutes(u.FreeEURMicrosTotal), sMinutes(u.FreeEURMicrosUsed) fmt.Fprintf(tw, "free minutes left\t%d S-minutes of %d (= %d M or %d L minutes); %d used\n", u.FreeMinutesLeft, total, u.FreeMinutesLeft/2, u.FreeMinutesLeft/4, used) @@ -481,6 +512,15 @@ func doEphemeralUsage(ctx context.Context, env *runsEnv) error { return err } fmt.Fprintf(env.stdout, "\n%s.\n", freeMinutesHelp) + if u.PaymentMethodRequired || u.CurrencyMigrationRequired { + fmt.Fprintln(env.stdout, "\nbefore your next ephemeral run:") + } + if u.PaymentMethodRequired { + fmt.Fprintf(env.stdout, " %s\n", paymentMethodRequired) + } + if u.CurrencyMigrationRequired { + fmt.Fprintf(env.stdout, " %s\n", currencyMigrationRequired) + } return nil } @@ -493,7 +533,7 @@ func doEphemeralSettings(ctx context.Context, env *runsEnv) error { return printJSON(env.stdout, s, true) } tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "monthly spend cap\t%s (€0 to %s)\n", formatCents(s.MonthlyCapEURCents), formatCents(s.Limits.MonthlyCapEURCentsMax)) + fmt.Fprintf(tw, "monthly spend cap\t%s (%s to %s)\n", formatCents(s.MonthlyCapEURCents), formatCents(0), formatCents(s.Limits.MonthlyCapEURCentsMax)) fmt.Fprintf(tw, "concurrent runs\tup to %d at once (1 to %d)\n", s.MaxConcurrent, s.Limits.MaxConcurrentMax) billing := "active: ephemeral usage past your free minutes is charged" if !s.BillingEnabled { @@ -565,7 +605,7 @@ func checkEphemeralFlags(opts *runOptions) error { if opts.size != "" { size := strings.ToLower(opts.size) if sizeSpec(size) == "" { - return usageErrorf("--size must be s, m or l, not %q: %s. %s", runs.Truncate(runs.SanitizeLine(opts.size), 16), sizeList(), freeMinutesHelp) + return usageErrorf("--size must be s, m or l, not %q: %s. %s", runs.Truncate(runs.SanitizeLine(opts.size), 16), sizeList(), freeMinutesLine) } opts.size = size } diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index 6cfd1f1..da467b8 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -157,6 +157,12 @@ func TestEphemeralRefusalsSayWhatToDo(t *testing.T) { }{ {"cap reached", credential{kind: credSession}, 402, "ephemeral_cap_reached", "", exitUnavailable, []string{"monthly cap", "Settings at https://cloud.nan.builders/settings", "nan ephemeral usage"}, nil}, + {"currency migration", credential{kind: credSession}, 402, "currency_migration_required", "", exitUnavailable, + []string{"USD", "EUR", "Settings > Billing"}, []string{"monthly cap"}}, + {"payment method", credential{kind: credSession}, 402, "payment_method_required", "", exitUnavailable, + []string{"add a payment method in the portal first", "https://cloud.nan.builders/settings", "free minutes included"}, []string{"monthly cap"}}, + {"another 402", credential{kind: credSession}, 402, "something_new", "", exitUnavailable, + nil, []string{"monthly cap", "payment method", "USD"}}, {"token without scope", credential{kind: credEnv, bearer: testPAT}, 403, "token_scope_denied", "executor", exitAuth, []string{"runs:ephemeral", "NAN_TOKEN", "Settings > Tokens", "nan auth login"}, []string{testPAT}}, {"saved API key", credential{kind: credStoredKey, bearer: testKey}, 403, "token_scope_denied", "executor", exitAuth, @@ -215,6 +221,37 @@ func TestEphemeralRefusalsOnEveryPath(t *testing.T) { } } +// nan automations run of an ephemeral automation says where it runs, not +// "queued on " with an empty workspace name. +func TestAutomationsRunEphemeralSaysWhere(t *testing.T) { + resetPhase2Flags() + defer resetPhase2Flags() + h := newPhase2Harness(t, &fakePhase2{runs: &fakePlatform{ephemeral: true}}) + if err := doAutomationsRun(context.Background(), h.env, testAutoID, nil); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued in an ephemeral container (size M) for acme/api, Ctrl-C to detach") + if strings.Contains(h.stderr.String(), "queued on ") || strings.Contains(h.stderr.String(), "—") { + t.Errorf("stderr = %q", h.stderr.String()) + } + // A workspace automation keeps its wording. + h = newPhase2Harness(t, &fakePhase2{}) + if err := doAutomationsRun(context.Background(), h.env, testAutoID, nil); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued on develop, Ctrl-C to detach") +} + +// The announcement of a workspace run uses the same separator as the +// others (no em-dash). +func TestRunAnnouncementSeparator(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + if err := doRun(context.Background(), h.env, defaultOpts(), []string{"x"}); err != nil { + t.Fatal(err) + } + wantAll(t, "stderr", h.stderr.String(), "queued on develop (pi), Ctrl-C to detach") +} + // ─── views ───────────────────────────────────────────────────────────────── // ephemeralGoldenEnv serves the ephemeral golden responses (rendered by @@ -395,12 +432,59 @@ func TestEphemeralUsageFacts(t *testing.T) { } } +// Free minutes come with a membership; a payment method is always needed; +// a USD subscription has to move to EUR. The usage view says each one. +func TestEphemeralUsageStates(t *testing.T) { + ctx := context.Background() + usage := goldenUsage(t, false) + noFree := bytes.Replace(bytes.Replace(bytes.Replace(usage, []byte(`"free_eur_micros_total": 900000`), []byte(`"free_eur_micros_total": 0`), 1), + []byte(`"free_eur_micros_left": 741000`), []byte(`"free_eur_micros_left": 0`), 1), []byte(`"free_minutes_left": 247`), []byte(`"free_minutes_left": 0`), 1) + noFree = bytes.Replace(noFree, []byte(`"plan": "inference"`), []byte(`"plan": "none"`), 1) + env, out, _, _ := ephemeralGoldenEnv(t, sessionCred, noFree) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage without membership", out.String(), "none: free minutes come with a membership") + if strings.Contains(out.String(), "S-minutes of 0") { + t.Errorf("usage without membership:\n%s", out.String()) + } + + flagged := bytes.Replace(usage, []byte(`"billing_enabled": false`), + []byte(`"billing_enabled": false, "payment_method_required": true, "currency_migration_required": true`), 1) + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, flagged) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + wantAll(t, "usage needing action", out.String(), "add a payment method in the portal first", "free minutes included", + "your subscription bills in USD; ephemeral runs bill in EUR", "Settings > Billing") + + env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, usage) + if err := doEphemeralUsage(ctx, env); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "payment method in the portal first") || strings.Contains(out.String(), "USD") { + t.Errorf("usage with nothing to do asks for something:\n%s", out.String()) + } +} + +// A help line stays within 80 columns (the S-minutes sentence included). +func TestEphemeralHelpFitsTheTerminal(t *testing.T) { + for name, text := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, + "nan ephemeral settings": ephemeralSettingsCmd.Long, "nan run": runCmd.Long} { + for _, l := range strings.Split(text, "\n") { + if n := len([]rune(l)); n > 80 { + t.Errorf("%s --help: a %d-column line: %q", name, n, l) + } + } + } +} + func TestEphemeralSettingsIsReadOnly(t *testing.T) { env, out, errOut, seen := ephemeralGoldenEnv(t, sessionCred, nil) if err := doEphemeralSettings(context.Background(), env); err != nil { t.Fatal(err) } - wantAll(t, "settings", out.String(), "€100.00 (€0 to €1000.00)", "up to 50 at once (1 to 50)", "not active yet") + wantAll(t, "settings", out.String(), "€100.00 (€0.00 to €1000.00)", "up to 50 at once (1 to 50)", "not active yet") wantAll(t, "settings stderr", errOut.String(), "change these in the browser", "https://cloud.nan.builders/settings") for _, r := range seen() { if !strings.HasPrefix(r, "GET ") { @@ -604,7 +688,7 @@ func TestRunCostLabels(t *testing.T) { // The owner asked for this sentence wherever free minutes are shown. func TestEphemeralHelpExplainsSMinutes(t *testing.T) { - const want = "Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L)" + const want = "Free minutes are S-minutes: one M minute uses 2, one L minute 4\n(100 free minutes = 100 minutes of S, 50 of M or 25 of L)" if freeMinutesHelp != want { t.Fatalf("freeMinutesHelp = %q", freeMinutesHelp) } @@ -614,7 +698,7 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { "nan run": runCmd.Long, "nan automations create": automationsCreateCmd.Long, } { - if !strings.Contains(text, want) { + if !strings.Contains(strings.Join(strings.Fields(text), " "), strings.Join(strings.Fields(want), " ")) { t.Errorf("%s --help does not explain S-minutes", name) } if strings.Contains(text, "—") { @@ -626,14 +710,15 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { } wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "public", "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", - "metered and shown, not charged", "nan ephemeral usage", "Docs: https://nan.builders/docs/ephemeral-runs", runsDocs) + "metered and shown, not charged", "nan ephemeral usage", "come with a membership", "payment method", "Docs: https://nan.builders/docs/ephemeral-runs", runsDocs) wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") for name, c := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, "nan ephemeral settings": ephemeralSettingsCmd.Long} { wantAll(t, name+" --help", c, "Docs: https://nan.builders/docs/ephemeral-runs") } wantAll(t, "nan ephemeral settings --help", ephemeralSettingsCmd.Long, "read only", "browser", ephemeralSettingsURL) - wantAll(t, "nan ephemeral --help", ephemeralCmd.Long, "Community 100, Inference 300, Premium", "€100", "50", "nan auth login") + wantAll(t, "nan ephemeral --help", ephemeralCmd.Long, "Community 100, Inference 300, Premium", "€100", "50", "nan auth login", + "without one have none", "payment method on file", "free minutes included") wantAll(t, "nan automations create --help", automationsCreateCmd.Long, "executor: ephemeral", "size: m", "checkout: fresh", "runs:ephemeral") for _, f := range []string{"ephemeral", "repo", "size"} { if runCmd.Flags().Lookup(f) == nil { diff --git a/cmd/run.go b/cmd/run.go index c0cc8a1..f9793bd 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -104,7 +104,9 @@ Ephemeral runs (no workspace): --size picks the container (default s), billed per started minute (a 14 s run is 1 minute): ` + sizeTable(" ") + ` Free minutes every month: Community 100, Inference 300, Premium 1000. - ` + freeMinutesHelp + `. + ` + membershipHelp + `. + ` + strings.ReplaceAll(freeMinutesHelp, "\n", "\n ") + `. + ` + paymentHelp + `. While billing is not active, usage is metered and shown, not charged. Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. Starting one needs your session (nan auth login) or a platform token with @@ -307,11 +309,7 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat case ephemeral && req.Model != nil: agent += ", model " + runs.SanitizeLine(*req.Model) } - if ephemeral { - fmt.Fprintf(env.stderr, "run %s queued %s (%s), Ctrl-C to detach\n", id, runPlace(run, req), agent) - } else { - fmt.Fprintf(env.stderr, "run %s queued %s (%s) — Ctrl-C to detach\n", id, runPlace(run, req), agent) - } + fmt.Fprintf(env.stderr, "run %s queued %s (%s), Ctrl-C to detach\n", id, runPlace(run, req), agent) } return follow(ctx, env, run.ID, 0, opts.json, true) } diff --git a/cmd/testdata/ephemeral/settings.golden b/cmd/testdata/ephemeral/settings.golden index 61d100b..55a12a9 100644 --- a/cmd/testdata/ephemeral/settings.golden +++ b/cmd/testdata/ephemeral/settings.golden @@ -1,3 +1,3 @@ -monthly spend cap €100.00 (€0 to €1000.00) +monthly spend cap €100.00 (€0.00 to €1000.00) concurrent runs up to 50 at once (1 to 50) billing not active yet: usage is metered and shown, not charged diff --git a/cmd/testdata/ephemeral/usage.golden b/cmd/testdata/ephemeral/usage.golden index 655b3a5..a7369b9 100644 --- a/cmd/testdata/ephemeral/usage.golden +++ b/cmd/testdata/ephemeral/usage.golden @@ -10,4 +10,5 @@ prices per started minute (a 14 s run is 1 minute): M 2 vCPU, 8 GiB RAM, 16 GB disk €0.006 a minute uses 2 free S-minutes L 4 vCPU, 12 GiB RAM, 20 GB disk €0.012 a minute uses 4 free S-minutes -Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free = 100 S, 50 M or 25 L). +Free minutes are S-minutes: one M minute uses 2, one L minute 4 +(100 free minutes = 100 minutes of S, 50 of M or 25 of L). diff --git a/internal/api/ephemeral.go b/internal/api/ephemeral.go index af13ca7..823f3c0 100644 --- a/internal/api/ephemeral.go +++ b/internal/api/ephemeral.go @@ -36,6 +36,12 @@ type EphemeralUsage struct { PricesEURMicrosPerMinute map[string]int64 `json:"prices_eur_micros_per_minute"` // BillingEnabled false: usage is metered and shown, not charged. BillingEnabled bool `json:"billing_enabled"` + + // What blocks the next ephemeral run (false on a platform that does not + // send them): no payment method on file (always needed, free minutes + // included), or a subscription billed in USD (ephemeral runs bill in EUR). + PaymentMethodRequired bool `json:"payment_method_required"` + CurrencyMigrationRequired bool `json:"currency_migration_required"` } // EphemeralLimits are the ranges the platform accepts for the settings. From 38c0d861f2fcbfc288b8f19bf6a38724fb094532 Mon Sep 17 00:00:00 2001 From: barckcode Date: Mon, 19 Oct 2026 12:44:35 +0200 Subject: [PATCH 09/11] fix(ephemeral): name the portal actions as the UI does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add a payment method" and "Switch billing to EUR" live in Settings → Ephemeral runs (the EUR switch is also offered in the New run dialog). Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- cmd/ephemeral.go | 10 +++++----- cmd/ephemeral_test.go | 10 +++++----- cmd/run.go | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9c39110..d28fd66 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,9 @@ A 14 s run is 1 minute. Free minutes every month: Community 100, Inference 300, Premium 1000; they come with a membership (accounts without one have none). Free minutes are S-minutes: one M minute uses 2, one L minute 4 (100 free minutes = 100 minutes of S, 50 of M or 25 of L). Ephemeral runs need a payment -method on file, free minutes included. While billing is not active, usage is +method on file, free minutes included ("Add a payment method" in the portal, +Settings → Ephemeral runs; a subscription billed in USD is moved with "Switch +billing to EUR" there). While billing is not active, usage is metered and shown, not charged. `nan runs ls` and `nan runs show` show each ephemeral run's size and cost. The COST column of `nan runs ls` appears only when an ephemeral run is listed, so scripts should read `nan runs ls --json` diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index f1335e1..4a6afa6 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -41,16 +41,16 @@ var freeMinutesLine = strings.ReplaceAll(freeMinutesHelp, "\n", " ") // with a membership, and every ephemeral run needs a payment method. const ( membershipHelp = "Free minutes come with a membership (accounts without one have none)" - paymentHelp = "Ephemeral runs need a payment method on file, free minutes included" + paymentHelp = "Ephemeral runs need a payment method on file, free minutes included\n(add one in the browser: Settings → Ephemeral runs)" ) // paymentMethodRequired and currencyMigrationRequired are what to do about // the two billing refusals (402) of an ephemeral run. const ( - paymentMethodRequired = "add a payment method in the portal first: Settings at " + ephemeralSettingsURL + - " (ephemeral runs need one on file, free minutes included)" - currencyMigrationRequired = "your subscription bills in USD; ephemeral runs bill in EUR: switch it to EUR in the portal (Settings > Billing at " + - ephemeralSettingsURL + ")" + paymentMethodRequired = `ephemeral runs need a payment method on file, free minutes included: use "Add a payment method" ` + + "in the portal, Settings → Ephemeral runs at " + ephemeralSettingsURL + currencyMigrationRequired = `your subscription bills in USD; ephemeral runs bill in EUR: use "Switch billing to EUR" ` + + "in the portal, Settings → Ephemeral runs at " + ephemeralSettingsURL + " (also offered in the New run dialog)" ) // notChargedNote is what a cost means while billing is off. diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index da467b8..a64429a 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -158,9 +158,9 @@ func TestEphemeralRefusalsSayWhatToDo(t *testing.T) { {"cap reached", credential{kind: credSession}, 402, "ephemeral_cap_reached", "", exitUnavailable, []string{"monthly cap", "Settings at https://cloud.nan.builders/settings", "nan ephemeral usage"}, nil}, {"currency migration", credential{kind: credSession}, 402, "currency_migration_required", "", exitUnavailable, - []string{"USD", "EUR", "Settings > Billing"}, []string{"monthly cap"}}, + []string{"USD", "EUR", `"Switch billing to EUR"`, "Settings → Ephemeral runs", "New run dialog"}, []string{"monthly cap", "Settings > Billing"}}, {"payment method", credential{kind: credSession}, 402, "payment_method_required", "", exitUnavailable, - []string{"add a payment method in the portal first", "https://cloud.nan.builders/settings", "free minutes included"}, []string{"monthly cap"}}, + []string{`"Add a payment method"`, "Settings → Ephemeral runs", "https://cloud.nan.builders/settings", "free minutes included"}, []string{"monthly cap"}}, {"another 402", credential{kind: credSession}, 402, "something_new", "", exitUnavailable, nil, []string{"monthly cap", "payment method", "USD"}}, {"token without scope", credential{kind: credEnv, bearer: testPAT}, 403, "token_scope_denied", "executor", exitAuth, @@ -455,14 +455,14 @@ func TestEphemeralUsageStates(t *testing.T) { if err := doEphemeralUsage(ctx, env); err != nil { t.Fatal(err) } - wantAll(t, "usage needing action", out.String(), "add a payment method in the portal first", "free minutes included", - "your subscription bills in USD; ephemeral runs bill in EUR", "Settings > Billing") + wantAll(t, "usage needing action", out.String(), `"Add a payment method"`, "free minutes included", + "your subscription bills in USD; ephemeral runs bill in EUR", `"Switch billing to EUR"`, "Settings → Ephemeral runs") env, out, _, _ = ephemeralGoldenEnv(t, sessionCred, usage) if err := doEphemeralUsage(ctx, env); err != nil { t.Fatal(err) } - if strings.Contains(out.String(), "payment method in the portal first") || strings.Contains(out.String(), "USD") { + if strings.Contains(out.String(), "Add a payment method") || strings.Contains(out.String(), "USD") { t.Errorf("usage with nothing to do asks for something:\n%s", out.String()) } } diff --git a/cmd/run.go b/cmd/run.go index f9793bd..1b998bc 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -106,7 +106,7 @@ Ephemeral runs (no workspace): ` + sizeTable(" ") + ` Free minutes every month: Community 100, Inference 300, Premium 1000. ` + membershipHelp + `. ` + strings.ReplaceAll(freeMinutesHelp, "\n", "\n ") + `. - ` + paymentHelp + `. + ` + strings.ReplaceAll(paymentHelp, "\n", "\n ") + `. While billing is not active, usage is metered and shown, not charged. Your minutes, cost and limits: nan ephemeral usage, nan ephemeral settings. Starting one needs your session (nan auth login) or a platform token with From 3303259f4f9358a7beec19461f116fe4e80136aa Mon Sep 17 00:00:00 2001 From: Helmcode SRE Date: Tue, 20 Oct 2026 20:23:36 +0200 Subject: [PATCH 10/11] feat(run): --github-token-secret for ephemeral runs on private repos (#59) nan run --ephemeral --repo OWNER/NAME --github-token-secret NAME names one of the member's secrets holding a GitHub token. The platform uses it only to clone the private repository and never gives it to the agent (helmcode/nan-cloud-api#382). - Usage error (64) without --ephemeral, without --repo, for an empty value or a value that is not a secret name; a pasted GitHub token is never echoed and the member is told to revoke it. - The platform's 404 not_found and 400 on github_token_secret map to clear usage errors instead of "runs are not enabled". - nan run --help and README no longer say an ephemeral run started from the CLI has to use a public repository. Co-authored-by: barckcode Co-authored-by: Claude Opus 5.5 --- README.md | 11 +-- cmd/ephemeral.go | 54 ++++++++++++++ cmd/ephemeral_ghtoken_test.go | 126 +++++++++++++++++++++++++++++++++ cmd/ephemeral_test.go | 4 +- cmd/run.go | 23 ++++-- internal/api/ephemeral_test.go | 10 ++- internal/api/runs.go | 4 ++ 7 files changed, 218 insertions(+), 14 deletions(-) create mode 100644 cmd/ephemeral_ghtoken_test.go diff --git a/README.md b/README.md index d28fd66..b12890b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,8 @@ id=$(nan run --detach "upgrade the dependencies") | `--model M` | the agent's own | Model for the agent. Required with `--byok-secret` and `--ephemeral`. | | `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. | | `--ephemeral` | | Run in a fresh, throwaway container instead of a workspace. See below. | -| `--repo OWNER/NAME` | | With `--ephemeral`: the public GitHub repository the container clones. | +| `--repo OWNER/NAME` | | With `--ephemeral`: the GitHub repository the container clones. | +| `--github-token-secret NAME` | | With `--ephemeral` and `--repo`: the name of your secret holding a GitHub token, to clone a private repository. | | `--size s\|m\|l` | `s` | With `--ephemeral`: the container size. | | `--cwd PATH` | `/home/nan` | Folder in the workspace. | | `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | @@ -144,10 +145,9 @@ nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky tes runs the agent in a fresh, throwaway container: it clones the repository, runs the agent with the model you name, streams like any run, and is destroyed at the end. The container can only reach the internet over HTTP and HTTPS (no -SSH, no SMTP). A run started from the CLI gets no secrets, so the repository -has to be public; for a private one, use an ephemeral automation (`executor: -ephemeral`, `checkout: fresh`, `github_repo`, `model`, and an agent secret -`GITHUB_TOKEN`). Starting one needs your session or a platform token with the +SSH, no SMTP). For a private repository, `--github-token-secret NAME` names +one of your secrets holding a GitHub token that can read it (`nan secrets ls`): +it is used only to clone and is never given to the agent. Starting one needs your session or a platform token with the `runs:ephemeral` scope; API keys cannot. Docs: . @@ -171,6 +171,7 @@ rather than the columns. ```bash nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" +nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review the open PRs" nan ephemeral usage # this month's minutes, cost, free S-minutes left, prices nan ephemeral settings # your monthly spend cap and concurrent runs (change them in the browser) ``` diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index 4a6afa6..e0bbd64 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -576,6 +576,9 @@ func (env *runsEnv) billingNote(ctx context.Context) { func checkEphemeralFlags(opts *runOptions) error { if !opts.ephemeral { switch { + case opts.githubTokenSecret != "" || opts.githubTokenSecretSet: + return usageErrorf("--github-token-secret is for ephemeral runs: add --ephemeral and --repo OWNER/NAME " + + "(a workspace run reads the repositories already in the workspace)") case opts.repo != "": return usageErrorf("--repo is for ephemeral runs: add --ephemeral (a workspace run works in the workspace's own folders; pick one with --cwd)") case opts.size != "": @@ -609,9 +612,60 @@ func checkEphemeralFlags(opts *runOptions) error { } opts.size = size } + if opts.githubTokenSecret != "" || opts.githubTokenSecretSet { + return checkGitHubTokenSecret(opts.githubTokenSecret) + } return nil } +// gitHubTokenPrefixes are how GitHub tokens start: a --github-token-secret +// that starts like one is the token itself, pasted by mistake. +var gitHubTokenPrefixes = []string{"ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_"} + +func looksLikeAGitHubToken(v string) bool { + for _, p := range gitHubTokenPrefixes { + if strings.HasPrefix(v, p) { + return true + } + } + return looksLikeAKey(v) +} + +// checkGitHubTokenSecret checks the NAME given to --github-token-secret. +// The value is never repeated in an error when it is not a secret name: it +// may be the token. +func checkGitHubTokenSecret(name string) error { + if secretNamePattern.MatchString(name) { + return nil + } + const what = "--github-token-secret takes the NAME of a secret you saved with your GitHub token (like GITHUB_TOKEN: " + + "capital letters, digits and _), never the token itself; your names are in: nan secrets ls" + switch upper := strings.ToUpper(name); { + case looksLikeAGitHubToken(name): + return usageErrorf("%s. If that was the token, it is now in your shell history: revoke it on GitHub, "+ + "then save the new one with nan secrets set GITHUB_TOKEN --value-stdin", what) + case name != "" && len(name) <= 24 && secretNamePattern.MatchString(upper): + return usageErrorf("%s. Did you mean %s?", what, upper) + } + return usageErrorf("%s", what) +} + +// githubTokenRefusal is the platform refusing the --github-token-secret of +// an ephemeral run: no secret by that name (404), or one that cannot hold a +// GitHub token, like a provider key (400). +func githubTokenRefusal(apiErr *api.APIError, req *api.CreateRunRequest) *ExitError { + if apiErr.Param != "github_token_secret" { + return nil + } + name := runs.SanitizeLine(req.GitHubTokenSecret) + if apiErr.Status == 404 { + return usageErrorf("you have no secret named %s: save your GitHub token in the portal, Settings > Secrets at %s, "+ + "or with nan secrets set NAME --value-stdin; your secret names are in: nan secrets ls", name, secretsURL) + } + return usageErrorf("--github-token-secret %s: %s. It takes a secret that holds a GitHub token, not a provider key "+ + "(see nan secrets ls)", name, runs.SanitizeLine(apiErr.Message)) +} + // runPlace says where a run executes, for the line that announces it: // "on develop", or "in an ephemeral container (size M) for acme/api". func runPlace(run *api.Run, req *api.CreateRunRequest) string { diff --git a/cmd/ephemeral_ghtoken_test.go b/cmd/ephemeral_ghtoken_test.go new file mode 100644 index 0000000..57ac786 --- /dev/null +++ b/cmd/ephemeral_ghtoken_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" +) + +// Ad-hoc ephemeral runs on a private repository (cloud-api #382): nan run +// --ephemeral --repo OWNER/NAME --github-token-secret NAME names one of the +// member's secrets holding a GitHub token. The platform clones with it and +// never gives it to the agent. The flag needs --ephemeral and --repo; the +// value is a secret NAME, never the token (a pasted token is not echoed). +// The platform answers 404 not_found (no such secret) or 400 (a provider +// key, an empty name) with param github_token_secret. + +func TestEphemeralGitHubTokenSecretIsSent(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + if err := doRun(context.Background(), h.env, opts, []string{"x"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if c["github_token_secret"] != "GH_READ" || c["github_repo"] != "acme/api" || c["executor"] != "ephemeral" { + t.Errorf("created with %v", c) + } +} + +func TestEphemeralWithoutGitHubTokenSecretSendsNone(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + if err := doRun(context.Background(), h.env, ephemeralOpts(), []string{"x"}); err != nil { + t.Fatal(err) + } + if _, ok := h.fake.created["github_token_secret"]; ok { + t.Errorf("sent a token secret nobody asked for: %v", h.fake.created) + } +} + +func TestGitHubTokenSecretFlagMistakesAreUsageErrors(t *testing.T) { + const pasted = "ghp_0123456789abcdefghijABCDEFGHIJ012345" + for _, tc := range []struct { + name string + edit func(*runOptions) + wants, avoids []string + }{ + {"without --ephemeral", func(o *runOptions) { o.ephemeral, o.repo, o.model = false, "", "" }, + []string{"--github-token-secret is for ephemeral runs", "--ephemeral", "--repo"}, nil}, + {"without --repo", func(o *runOptions) { o.repo = "" }, []string{"--repo OWNER/NAME"}, nil}, + {"empty", func(o *runOptions) { o.githubTokenSecret = "" }, []string{"--github-token-secret", "NAME"}, nil}, + {"lowercase name", func(o *runOptions) { o.githubTokenSecret = "gh_read" }, []string{"Did you mean GH_READ?"}, nil}, + {"bad name", func(o *runOptions) { o.githubTokenSecret = "my token" }, []string{"capital letters, digits and _", "nan secrets ls"}, []string{"my token"}}, + {"pasted token", func(o *runOptions) { o.githubTokenSecret = pasted }, []string{"never the token", "shell history", "revoke it"}, []string{pasted, "ghp_"}}, + {"pasted fine-grained token", func(o *runOptions) { o.githubTokenSecret = "github_pat_11ABCDEFG0123456789_abcdefghijk" }, + []string{"shell history"}, []string{"github_pat_11"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{ephemeral: true}) + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + tc.edit(&opts) + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q repeats %q", err.Error(), a) + } + } + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +func TestGitHubTokenSecretRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + status int + code, message string + wants, avoids []string + }{ + {"unknown secret", 404, "not_found", "secret not found", + []string{"no secret named GH_READ", "Settings > Secrets", "nan secrets ls"}, []string{"runs are not enabled"}}, + {"a provider key", 400, "invalid_request", "the secret is a provider key, not a GitHub token \u001b[2J", + []string{"--github-token-secret GH_READ", "provider key, not a GitHub token", "nan secrets ls"}, []string{"\u001b"}}, + } { + t.Run(tc.name, func(t *testing.T) { + msg, _ := json.Marshal(tc.message) + h := newHarness(t, &fakePlatform{ephemeral: true, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":%s,"type":"invalid_request_error","param":"github_token_secret","code":%q}}`, msg, tc.code)}) + h.env.cred = credential{kind: credSession} + opts := ephemeralOpts() + opts.githubTokenSecret, opts.githubTokenSecretSet = "GH_READ", true + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q mentions %q", err.Error(), a) + } + } + assertNoControls(t, tc.name, err.Error()) + }) + } +} + +// nan run --help tells how to run on a private repository, and no longer +// says an ephemeral run started here has to use a public one. +func TestRunHelpExplainsGitHubTokenSecret(t *testing.T) { + wantAll(t, "nan run --help", runCmd.Long, "--github-token-secret NAME", "private repository", "never") + if strings.Contains(runCmd.Long, "has to be public") { + t.Error("help still says the repository has to be public") + } + f := runCmd.Flags().Lookup("github-token-secret") + if f == nil || !strings.Contains(f.Usage, "--ephemeral") || !strings.Contains(f.Usage, "never the token") { + t.Fatalf("flag: %+v", f) + } +} diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index a64429a..64d8629 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -708,8 +708,8 @@ func TestEphemeralHelpExplainsSMinutes(t *testing.T) { t.Errorf("%s --help claims EU only", name) } } - wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "public", - "GITHUB_TOKEN", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", + wantAll(t, "nan run --help", runCmd.Long, "--ephemeral --repo OWNER/NAME --model ID", "HTTP and HTTPS", "private repository", + "--github-token-secret NAME", "1 vCPU, 4 GiB RAM, 10 GB disk", "€0.012 per minute", "14 s run is 1 minute", "runs:ephemeral", "metered and shown, not charged", "nan ephemeral usage", "come with a membership", "payment method", "Docs: https://nan.builders/docs/ephemeral-runs", runsDocs) wantAll(t, "nan run examples", runCmd.Example, "nan run --ephemeral --repo acme/api --model glm5.3 --size m") for name, c := range map[string]string{"nan ephemeral": ephemeralCmd.Long, "nan ephemeral usage": ephemeralUsageCmd.Long, diff --git a/cmd/run.go b/cmd/run.go index 1b998bc..d8cbe76 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -52,6 +52,10 @@ type runOptions struct { ephemeral bool repo string size string + // githubTokenSecret: with --ephemeral, the NAME of the member's secret + // holding the GitHub token that clones a private --repo. + githubTokenSecret string + githubTokenSecretSet bool } var runOpts runOptions @@ -96,10 +100,10 @@ Ephemeral runs (no workspace): throwaway container instead of a workspace: it clones the GitHub repository, runs the agent (pi, pi_gentle or hermes) with the model you name, streams like any run, and is destroyed at the end. The container - can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). A run - started here gets no secrets, so the repository has to be public; for a - private one, use an ephemeral automation with an agent secret - GITHUB_TOKEN (nan automations create --help). --ws, --cwd, --worktree + can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). + For a private repository, --github-token-secret NAME names one of your + secrets holding a GitHub token that can read it (nan secrets ls): it is + used only to clone, never given to the agent. --ws, --cwd, --worktree and --no-worktree do not apply. --size picks the container (default s), billed per started minute (a 14 s run is 1 minute): @@ -162,12 +166,14 @@ Docs: https://nan.builders/docs/runs`, id=$(nan run --detach "upgrade the dependencies") nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" - nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test"`, + nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" + nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review it"`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { opts := runOpts opts.timeoutSet = cmd.Flags().Changed("timeout") + opts.githubTokenSecretSet = cmd.Flags().Changed("github-token-secret") // The arguments first: a usage mistake is the thing to report, even // on a machine that is not signed in. req, err := buildRunRequest(promptInput{r: os.Stdin, tty: isTerminal(os.Stdin), notice: os.Stderr}, opts, args) @@ -192,7 +198,8 @@ func init() { f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --repo and --model)") - f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the public GitHub repository to clone, `OWNER/NAME`") + f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the GitHub repository to clone, `OWNER/NAME`") + f.StringVar(&runOpts.githubTokenSecret, "github-token-secret", "", "With --ephemeral and --repo: the `NAME` of your secret holding a GitHub token, to clone a private repository (never the token itself)") f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs above)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret and --ephemeral)") @@ -264,6 +271,9 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if exit := byokRefusal(apiErr, req); exit != nil { return exit } + if exit := githubTokenRefusal(apiErr, req); exit != nil { + return exit + } if exit := ephemeralExit(apiErr, env.cred); exit != nil { return exit } @@ -537,6 +547,7 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat if opts.ephemeral { repo := opts.repo req.Executor, req.Size, req.GitHubRepo = api.ExecutorEphemeral, opts.size, &repo + req.GitHubTokenSecret = opts.githubTokenSecret } if opts.timeoutSet { if opts.timeout < minTimeout || opts.timeout > maxTimeout { diff --git a/internal/api/ephemeral_test.go b/internal/api/ephemeral_test.go index 5eb3519..264147d 100644 --- a/internal/api/ephemeral_test.go +++ b/internal/api/ephemeral_test.go @@ -94,10 +94,18 @@ func TestCreateEphemeralRunBody(t *testing.T) { if got["executor"] != "ephemeral" || got["size"] != "m" || got["github_repo"] != "acme/api" || got["model"] != "glm5.3" { t.Errorf("body = %s", b) } + // No token secret named: the field is left out (an anonymous clone). + if _, ok := got["github_token_secret"]; ok { + t.Errorf("sends github_token_secret nobody named: %s", b) + } + b, _ = json.Marshal(CreateRunRequest{Agent: "pi", Prompt: "x", Model: &model, Executor: ExecutorEphemeral, GitHubRepo: &repo, GitHubTokenSecret: "GH_READ"}) + if !strings.Contains(string(b), `"github_token_secret":"GH_READ"`) { + t.Errorf("token secret body = %s", b) + } // A workspace run keeps its shape: no executor, size or repository. b, _ = json.Marshal(CreateRunRequest{Workspace: "develop", Agent: "pi", Prompt: "x"}) if s := string(b); !strings.Contains(s, `"workspace":"develop"`) || strings.Contains(s, "executor") || - strings.Contains(s, "size") || strings.Contains(s, "github_repo") { + strings.Contains(s, "size") || strings.Contains(s, "github_repo") || strings.Contains(s, "github_token_secret") { t.Errorf("workspace body = %s", s) } } diff --git a/internal/api/runs.go b/internal/api/runs.go index 575fd6e..3bc2006 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -201,6 +201,10 @@ type CreateRunRequest struct { Executor string `json:"executor,omitempty"` Size string `json:"size,omitempty"` GitHubRepo *string `json:"github_repo,omitempty"` + // GitHubTokenSecret (ephemeral, with GitHubRepo): the NAME of the + // member's secret holding a GitHub token, used only to clone a private + // repository and never given to the agent. Left out: anonymous clone. + GitHubTokenSecret string `json:"github_token_secret,omitempty"` } // RunInference is the inference choice of POST /v1/runs: {"source":"byok"} From 0a1f967ed870126758eaab8f05074fb4b3e27eab Mon Sep 17 00:00:00 2001 From: Helmcode SRE Date: Tue, 27 Oct 2026 16:46:01 +0100 Subject: [PATCH 11/11] feat(run,automations): --context and checkout none (agent contexts C2) (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: go 1.26.10 for GO-2026-6809 (#61) govulncheck, which CI runs as a failing check, reports GO-2026-6809 (crypto/x509 trusting compromised intermediates) on go1.26.9, reached through every HTTPS call this CLI makes. It is fixed in go1.26.10. The same finding is on main today, so without this the vulnerability job fails here and on the next push to main, and tag.yml publishes nothing from a commit whose CI did not pass. Only the go directive moves; no dependency changes. Co-authored-by: Pau Morillas Co-authored-by: Claude Opus 5.5 * feat(auth): authenticator code at sign-in when the account has two-factor, 0.1.35 (#60) nan auth login asks for the authenticator code (or a recovery code) after the emailed link when the account has two-factor on, without echo, again after a wrong one; --totp - reads it from stdin for scripts. The panel's sign-in does the same. The verify call now sends mfa=1 so the platform can answer with a challenge; with two-factor off the sign-in is unchanged. Bumps internal/tui.Version to 0.1.35, which publishes the release. Co-Authored-By: Claude Opus 5.5 * feat(context): nan context ls|show|create|push|pull|edit|rm (agent contexts C1) (#62) * feat(context): nan context ls|show|create|push|pull|edit|rm (agent contexts C1) The CLI side of agent contexts (helmcode/nan-devops AGENT-CONTEXT.md §6, C1), against the /v1/contexts API merged in cloud-api (#391, #395). - internal/contexts/path.go is a verbatim copy of cloud-api's validator, with its parity table (path_test.go) and a test of the shared pins. - push: walks DIR, NFC-normalises names (macOS gives NFD), skips dotfiles with a notice, prints every refused file with its reason and exits 65 before uploading unless --skip-invalid (CLAUDE.md gets a rename hint), checks the version limits, hashes, asks blobs/check, uploads only missing blobs (4 at a time; single PUT up to 16 MiB, multipart above, resumed within a push via the upload status and across pushes via a remembered upload id), publishes with base_version and an Idempotency-Key. Handles blob_missing (re-check, re-upload), version_conflict (exit 1, or --force shows the diff and rebases), context_secret_detected (--allow-secrets), per-file refusals and Retry-After (up to 2 minutes). --dry-run prints the add/change/remove list and the bytes to upload. - pull: refuses a non-empty DIR (--force overrides), validates every server path with the shared validator before writing anything, writes through os.OpenRoot, pins downloads to the version read and verifies sha256/size. - golang.org/x/text becomes a direct dependency at v0.39.0. CI gains a job (scripts/check-cloud-api-pins.sh, secret CLOUD_API_READ_TOKEN) comparing x/text, the Go toolchain's Unicode tables and path.go with cloud-api. Co-Authored-By: Claude Opus 5.5 * ci: move the cloud-api parity check to cloud-api's CI nan-cli is public and cloud-api private: cloud-api's CI reads this repository's go.mod and path.go without a token, so the check lives there and no secret is added here. The unit test keeps pinning golang.org/x/text v0.39.0 and the Unicode tables (15.0.0). Co-Authored-By: Claude Opus 5.5 * test(contexts): point the pins test at cloud-api's parity check Co-Authored-By: Claude Opus 5.5 * fix(context): address the QA and Security review of #62 Security: - A DIR that is a symlink is resolved once and its target walked; WalkDir alone saw one non-directory and push published an empty tree. - push refuses to publish no files over a version that has some unless --allow-empty (also after a --force rebase). - Files are hashed and uploaded through os.OpenRoot on DIR and refused if they stopped being regular files. - pull downloads into a temporary name, verifies, sets the mode both ways and renames into place: a failed download never deletes a member's file, and a link or folder at a file's path is never replaced. The total of a version is capped at 250 MiB; a partial pull says how many files were written and points at --force. - NAME|id is checked locally (context name or id) before any request. - Remembered multipart uploads are keyed by API host and credential hash. QA: - Tests for a symlinked folder inside DIR and a symlinked file on pull, the blob_missing re-check body and single PUT, the version limits (31 skills, 1001 files, 250 MiB, 100 MiB per file, SKILL.md 64 KiB), aborting an unusable remembered upload, forgetting an upload dropped mid-parts, exit mappings (entitlement_required and others), the inherited executable bit, pull --force modes, the total cap and local ref checks. - Unchanged files keep the remote executable bit where the file system has none (Windows). - Dead ContextVersions/ContextVersionInfo removed. - The fake follows cloud-api's publish check order, decodes strictly and answers 411 without Content-Length. - Help says API keys are not used here; pins comment wrapped. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: barckcode Co-authored-by: Claude Opus 5.5 * feat(run,automations): --context on runs and automations, checkout none (agent contexts C2) nan run --context NAME (repeatable, at most 5, order kept) sends "contexts" on POST /v1/runs. An ephemeral run may leave out --repo only with a context; --github-token-secret without --repo is refused. nan automations create|edit take --context NAME and --checkout none (an ephemeral automation without a repository, which needs a context); edit accepts the flags without -f, and --checkout none on edit sets github_repo to null unless the file says otherwise. A field given both in the file and as a flag is refused. Every mistake (a sixth context, a repeat, an invalid name, a contexts field that is not a list) exits 64 before any request. The platform's 404 context_not_found (naming contexts[i]), 422 context_skill_conflict and 413 context_too_large read as what to do, also on automation edit where a 404 is no longer taken for a missing automation. api.Run carries the pinned contexts so --json shows them. Request goldens under cmd/testdata/contexts, help text and README. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Pau Morillas Co-authored-by: Claude Opus 5.5 Co-authored-by: barckcode --- CONTRIBUTING.md | 2 +- README.md | 83 +- cmd/auth.go | 228 ++- cmd/auth_mfa_test.go | 439 +++++ cmd/automations_contexts_test.go | 209 +++ cmd/automations_write.go | 123 +- cmd/context.go | 1656 +++++++++++++++++ cmd/context_fake_test.go | 558 ++++++ cmd/context_hardening_test.go | 349 ++++ cmd/context_test.go | 689 +++++++ cmd/ephemeral.go | 60 +- cmd/ephemeral_test.go | 15 + cmd/hidden_unix_test.go | 82 + cmd/json_output_test.go | 17 + cmd/run.go | 128 +- cmd/run_contexts_test.go | 241 +++ .../automation_create_ephemeral_none.json | 12 + .../contexts/automation_create_workspace.json | 10 + .../automation_edit_checkout_none.json | 7 + cmd/testdata/contexts/ls.golden | 3 + cmd/testdata/contexts/pull.golden | 1 + cmd/testdata/contexts/push.golden | 1 + cmd/testdata/contexts/push_dry_run.golden | 6 + .../contexts/push_dry_run.json.golden | 20 + .../contexts/push_refused.stderr.golden | 10 + .../run_request_ephemeral_no_repo.json | 11 + .../contexts/run_request_ephemeral_repo.json | 12 + .../contexts/run_request_workspace.json | 10 + cmd/testdata/contexts/show.golden | 12 + go.mod | 8 +- go.sum | 1 + internal/api/automations.go | 4 +- internal/api/contexts.go | 442 +++++ internal/api/contexts_test.go | 161 ++ internal/api/runs.go | 17 + internal/auth/auth.go | 75 +- internal/auth/auth_test.go | 6 +- internal/auth/authtest/platform.go | 330 ++++ internal/auth/mfa.go | 321 ++++ internal/auth/mfa_test.go | 519 ++++++ internal/contexts/path.go | 290 +++ internal/contexts/path_test.go | 257 +++ internal/contexts/pins_test.go | 51 + internal/tui/config_test.go | 4 +- internal/tui/mfa_test.go | 233 +++ internal/tui/tui.go | 115 +- 46 files changed, 7765 insertions(+), 63 deletions(-) create mode 100644 cmd/auth_mfa_test.go create mode 100644 cmd/automations_contexts_test.go create mode 100644 cmd/context.go create mode 100644 cmd/context_fake_test.go create mode 100644 cmd/context_hardening_test.go create mode 100644 cmd/context_test.go create mode 100644 cmd/hidden_unix_test.go create mode 100644 cmd/run_contexts_test.go create mode 100644 cmd/testdata/contexts/automation_create_ephemeral_none.json create mode 100644 cmd/testdata/contexts/automation_create_workspace.json create mode 100644 cmd/testdata/contexts/automation_edit_checkout_none.json create mode 100644 cmd/testdata/contexts/ls.golden create mode 100644 cmd/testdata/contexts/pull.golden create mode 100644 cmd/testdata/contexts/push.golden create mode 100644 cmd/testdata/contexts/push_dry_run.golden create mode 100644 cmd/testdata/contexts/push_dry_run.json.golden create mode 100644 cmd/testdata/contexts/push_refused.stderr.golden create mode 100644 cmd/testdata/contexts/run_request_ephemeral_no_repo.json create mode 100644 cmd/testdata/contexts/run_request_ephemeral_repo.json create mode 100644 cmd/testdata/contexts/run_request_workspace.json create mode 100644 cmd/testdata/contexts/show.golden create mode 100644 internal/api/contexts.go create mode 100644 internal/api/contexts_test.go create mode 100644 internal/auth/authtest/platform.go create mode 100644 internal/auth/mfa.go create mode 100644 internal/auth/mfa_test.go create mode 100644 internal/contexts/path.go create mode 100644 internal/contexts/path_test.go create mode 100644 internal/contexts/pins_test.go create mode 100644 internal/tui/mfa_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 609f35e..25a4ef5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This document covers how to get the pr ## Prerequisites -- **Go 1.26.9+** — the version in `go.mod`, which is where the standard +- **Go 1.26.10+** — the version in `go.mod`, which is where the standard library carries the current security fixes. The project uses [mise](https://mise.jdx.dev/) to pin the version. Run `mise install` in the repo root and Go will be available automatically. - A [nan.builders](https://nan.builders) account with an API key (needed to test the TUI at runtime). diff --git a/README.md b/README.md index b12890b..3e066a8 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,10 @@ id=$(nan run --detach "upgrade the dependencies") | `--model M` | the agent's own | Model for the agent. Required with `--byok-secret` and `--ephemeral`. | | `--byok-secret NAME` | NaN inference | Run on your own provider key: the name of a provider key secret saved in the portal. See below. | | `--ephemeral` | | Run in a fresh, throwaway container instead of a workspace. See below. | -| `--repo OWNER/NAME` | | With `--ephemeral`: the GitHub repository the container clones. | +| `--repo OWNER/NAME` | | With `--ephemeral`: the GitHub repository the container clones. Can be left out with `--context`. | | `--github-token-secret NAME` | | With `--ephemeral` and `--repo`: the name of your secret holding a GitHub token, to clone a private repository. | | `--size s\|m\|l` | `s` | With `--ephemeral`: the container size. | +| `--context NAME` | | Load one of your [agent contexts](#agent-contexts), by name or id. Repeat it for more, at most 5, loaded in the order given. | | `--cwd PATH` | `/home/nan` | Folder in the workspace. | | `--worktree` / `--no-worktree` | auto | Work on a separate branch, or in the folder itself. See below. | | `--timeout D` | `30m` | Stop the run after this long, `1m` to `2h`. | @@ -147,7 +148,9 @@ the agent with the model you name, streams like any run, and is destroyed at the end. The container can only reach the internet over HTTP and HTTPS (no SSH, no SMTP). For a private repository, `--github-token-secret NAME` names one of your secrets holding a GitHub token that can read it (`nan secrets ls`): -it is used only to clone and is never given to the agent. Starting one needs your session or a platform token with the +it is used only to clone and is never given to the agent. With `--context`, +`--repo` can be left out: the agent then works in an empty folder with its +contexts and clones nothing. Starting one needs your session or a platform token with the `runs:ephemeral` scope; API keys cannot. Docs: . @@ -172,6 +175,7 @@ rather than the columns. ```bash nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review the open PRs" +nan run --ephemeral --context release-notes --model glm5.3 "write this week's release notes" # no repository nan ephemeral usage # this month's minutes, cost, free S-minutes left, prices nan ephemeral settings # your monthly spend cap and concurrent runs (change them in the browser) ``` @@ -218,8 +222,9 @@ and manage your secrets (values go in, only names and metadata come out): ```bash nan automations ls [--json] nan automations show [--json] -nan automations create -f automation.yaml # YAML or JSON, - for stdin +nan automations create -f automation.yaml [--context NAME ...] [--checkout none] # YAML or JSON, - for stdin nan automations edit -f changes.yaml # only the fields in the file change +nan automations edit --context NAME [--context NAME ...] [--checkout none] # no -f needed nan automations enable|disable nan automations rm [--yes] nan automations triggers ls [--json] @@ -303,6 +308,51 @@ A platform token needs `notifications:read` to read and notifications on your account, these commands exit 64 and point at the portal. Docs: . +### Agent contexts + +Keep instructions (`AGENTS.md`), skills (`skills//SKILL.md`) and +reference files (`files/...`) on NaN as a versioned context for your runs and +automations: + +```bash +nan context ls [--json] +nan context show [--json] +nan context create NAME [--description TEXT] [--from DIR] [--json] +nan context push DIR [--create] [--dry-run] [--force] [--allow-secrets] [--skip-invalid] [--allow-empty] [--json] +nan context pull DIR [--force] +nan context edit [--name NEW] [--description TEXT] [--json] +nan context rm [--yes] +``` + +`push` checks every file name before uploading anything (dotfiles are +skipped; other refused names are listed with their reason and the push exits +65 unless `--skip-invalid`), uploads only what NaN does not already hold +(large files in resumable 16 MiB parts) and publishes a new version. Text +that looks like a credential is refused unless `--allow-secrets`, and an +empty folder never empties a context unless `--allow-empty`. `pull` +writes into an empty folder only, after checking every path with the same +rules. Without contexts on your account, these commands exit 64 and point at +the portal. Docs: . + +Use them in runs and automations with `--context NAME` (repeatable, at most 5, +loaded in the order given, each at its latest version when the run starts): + +```bash +nan run --context acme-api --context style-guide "review PR 42" +nan run --ephemeral --context release-notes --model glm5.3 "write the release notes" # no --repo needed +nan automations create -f review.yaml --context acme-api +nan automations create -f notes.yaml --checkout none --context release-notes # ephemeral, no repository +nan automations edit "PR review api" --context acme-api --context style-guide # replaces its contexts +``` + +An ephemeral run or automation without a repository (`--checkout none`, the +file's `checkout: none`) needs at least one context. A file's `contexts:` +field and `--context` are the same thing: give them in one place. Every +mistake (a sixth context, the same one twice, a name that is not a context +name) exits 64 before anything is sent; so does a context the platform does +not find, two contexts that define the same skill, or contexts too large for +one run together (250 MiB, 5000 files, 64 KiB of instructions). + ### Exit codes `nan run`, `nan runs logs -f` and `nan automations run` exit with the run's outcome, so a script can @@ -316,7 +366,7 @@ branch on it without parsing text: | 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`) | +| 64 | usage error (also: more than one workspace and no `--ws`; a `--context` that does not exist, repeats a skill of another, or makes the run too large) | | 65 | not signed in, session expired, or not allowed (also: a token without `runs:ephemeral`, ephemeral runs not available to you) | | 69 | nan.builders unavailable, the stream was lost after retries (the run goes on; the id is printed), or an ephemeral run would pass your monthly spend cap | | 75 | `--detach`, or Ctrl-C: the run was accepted and is still going | @@ -369,9 +419,32 @@ nan auth login --api-token < token.txt # or pipe it in Never put a token on the command line itself: it would end up in your shell history and in `ps`. +**Two-factor authentication.** With it turned on for your account (Settings > +Account > Two-factor authentication at ), +`nan auth login` and the dashboard's sign-in ask for the code from your +authenticator app after the link, without echo. One of your recovery codes +works in its place. When nan cannot ask, pipe the code in. Never type the code +into the command itself, where it would stay in your shell history: read it +into a variable without echo, or redirect it from a file or a password +manager's command line. + +```bash +read -rs code # type or paste it; it is not shown +printf '%s\n' "$code" | nan auth login --link "" --totp - +nan auth login --link "" --totp - < code.txt +``` + +`--totp` never takes the code as an argument: its only value is `-`, which +reads one line of stdin. With `--totp -` the code gets one try, where the +prompt lets you try again; if stdin is a terminal it is still asked for there, +without echo. + +Versions before 0.1.35 cannot ask for the code: the platform refuses the link +with `mfa enabled update nan cli`. Update, then ask for a new link. + ## Build from source -Requires Go 1.26.9+ ([mise](https://mise.jdx.dev/) recommended) — the version +Requires Go 1.26.10+ ([mise](https://mise.jdx.dev/) recommended) — the version in `go.mod`, which is where the standard library carries the current security fixes: diff --git a/cmd/auth.go b/cmd/auth.go index a9f0e10..ef6ccee 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "os/signal" "strings" "github.com/charmbracelet/x/term" @@ -23,6 +24,7 @@ var ( linkFlag string keepToolsFlag bool apiTokenFlag bool + totpFlag string ) var authCmd = &cobra.Command{ @@ -35,6 +37,12 @@ var loginCmd = &cobra.Command{ Short: "Log in by email, or save a platform token with --api-token", Long: `Log in with a sign-in link sent to your email. +With two-factor authentication on, the link is followed by the code from +your authenticator app, or one of your recovery codes. It is asked for +without echo, again after a wrong one; with no terminal to ask on, pipe it +in with --totp -. With --totp - the code gets one try, and if stdin is a +terminal it is still asked for there without echo. + With --api-token, save a platform token instead, for a machine with nobody at the keyboard (a server, a CI runner). Create the token in Settings > Tokens at https://cloud.nan.builders. It is read from stdin only, so it @@ -45,7 +53,8 @@ need the email sign-in. An API key (sk-...) can be saved the same way; it replaces the one the Setup tab saved.`, Example: ` nan auth login --email you@example.com nan auth login --api-token # paste the token; it is not shown - nan auth login --api-token < token.txt`, + nan auth login --api-token < token.txt + read -rs code; printf '%s\n' "$code" | nan auth login --link "" --totp - # two-factor, no prompt from nan`, RunE: runLogin, } @@ -63,6 +72,7 @@ func init() { loginCmd.Flags().StringVar(&linkFlag, "link", "", `Finish the login with the link from the email ("-" reads it from stdin, keeping the token out of your shell history)`) loginCmd.Flags().StringVar(&tokenFlag, "token", "", `Save a nan_session token directly, skipping the email ("-" reads it from stdin, keeping it out of your shell history)`) loginCmd.Flags().BoolVar(&apiTokenFlag, "api-token", false, "Save a platform token (nan_pat_...) or API key (sk-...) for nan run and nan runs, read from stdin") + loginCmd.Flags().StringVar(&totpFlag, "totp", "", `With two-factor authentication on, read the code (or a recovery code) from stdin instead of asking: "-" is the only value`) logoutCmd.Flags().BoolVar(&keepToolsFlag, "keep-tools", false, "Leave the API key in the tools this CLI configured") } @@ -72,11 +82,14 @@ func init() { // and the command then asked for a cookie that no longer existed. func runLogin(cmd *cobra.Command, args []string) error { if apiTokenFlag { - if tokenFlag != "" || linkFlag != "" || emailFlag != "" { - return fmt.Errorf("--api-token cannot be combined with --email, --link or --token") + if tokenFlag != "" || linkFlag != "" || emailFlag != "" || totpFlag != "" { + return fmt.Errorf("--api-token cannot be combined with --email, --link, --token or --totp") } return loginWithAPIToken(cmd.Context(), newTokenInput(os.Stdin), os.Stdout, os.Stderr) } + if err := checkTOTPFlag(); err != nil { + return err + } if tokenFlag != "" { token, err := flagValue(tokenFlag) if err != nil { @@ -97,11 +110,11 @@ func runLogin(cmd *cobra.Command, args []string) error { if err != nil { return err } - sessionToken, err := auth.ExchangeToken(token) - if err != nil { - return err - } - return saveToken(sessionToken) + // A code is a line of a few characters; nothing past this is one. + stdin := bufio.NewReader(io.LimitReader(os.Stdin, 1024)) + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + return stdin.ReadString('\n') + }), os.Stdout, os.Stderr) } in := bufio.NewScanner(os.Stdin) @@ -169,11 +182,206 @@ func runLogin(cmd *cobra.Command, args []string) error { return err } + // The code comes from the same scanner as the two answers before it: a + // pipe that carries all three has already been read into its buffer. + return finishSignIn(token, newCodeInput(os.Stdin, func() (string, error) { + if in.Scan() { + return in.Text(), nil + } + if err := in.Err(); err != nil { + return "", err + } + return "", io.EOF + }), os.Stdout, os.Stderr) +} + +// checkTOTPFlag refuses the --totp spellings that cannot work. +// +// Only "-": --totp never takes the code as an argument. A recovery code is +// good until it is used, and one written on the command line is in the +// shell's history and in `ps` for as long as that lasts. The examples keep it +// off the command line too - read into a variable, or from a file - because +// `echo |` would put it in the history just the same. And not next to another flag that reads stdin, because the first one +// would take everything and leave the code nothing. +func checkTOTPFlag() error { + switch { + case totpFlag == "": + return nil + case totpFlag != "-": + return fmt.Errorf(`--totp only takes "-": it never takes the code as an argument, where it would stay in your shell history and in ps. Read it from stdin:%s`, totpHint) + case tokenFlag != "": + return fmt.Errorf("--totp cannot be combined with --token: a saved session token skips the sign-in, and its code") + case linkFlag == "-": + return fmt.Errorf("--link - and --totp - cannot both read stdin: pass the link as an argument (it works once, and is spent by this command):%s", totpHint) + } + return nil +} + +// totpHint is how to pipe the code in without typing it into the command: +// the variable is filled without echo and never appears in the history. +const totpHint = "\n\n read -rs code; printf '%s\\n' \"$code\" | nan auth login --link \"\" --totp -\n\n" + + "or redirect it from a file or a password manager: --totp - < code.txt" + +// finishSignIn spends the link, answers the two-factor challenge when the +// account has one, and saves the session. +func finishSignIn(token string, codes codeInput, out, prompt io.Writer) error { sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if !errors.As(err, &challenge) { + if err != nil { + return err + } + return saveToken(sessionToken) + } + + signIn, err := answerChallenge(challenge, codes, prompt) if err != nil { return err } - return saveToken(sessionToken) + if err := saveToken(signIn.Session); err != nil { + return err + } + if signIn.UsedRecoveryCode { + fmt.Fprintln(out, recoveryCodesLeft(signIn.RecoveryCodesLeft)) + } + return nil +} + +// answerChallenge asks for the code until the platform takes one or the +// sign-in ends. Asking again is for a member at a terminal: a code piped in +// with --totp - gets one try, and anything else ends with the refusal. +func answerChallenge(challenge *auth.MFARequiredError, codes codeInput, prompt io.Writer) (*auth.SignIn, error) { + if !codes.tty && !codes.fromStdin { + // The link is spent by now - the platform consumed it to open the + // challenge - so this cannot just say "run it again with the flag". + return nil, fmt.Errorf("this account uses two-factor authentication, and there is no terminal here to ask for the code. "+ + "The link is spent: ask for a new one, and pipe the code in with --totp -:%s", totpHint) + } + interactive := codes.tty && !codes.fromStdin + if interactive { + fmt.Fprintln(prompt, "This account uses two-factor authentication. Enter the 6-digit code from your") + fmt.Fprintln(prompt, "authenticator app, or one of your recovery codes.") + } + for { + input, err := codes.read(prompt) + if err != nil { + return nil, err + } + signIn, err := challenge.Complete(input) + if err == nil { + return signIn, nil + } + if !interactive || !auth.Retryable(err) { + return nil, err + } + fmt.Fprintln(prompt, "error: "+err.Error()) + } +} + +// recoveryCodesLeft is what the login says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesLeft(n int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + switch n { + case 0: + return "That was your last recovery code. Make new ones before you need one: " + where + "." + case 1: + return "You signed in with a recovery code: 1 left. Make new ones in the browser: " + where + "." + } + return fmt.Sprintf("You signed in with a recovery code: %d left. You can make new ones in the browser: %s.", n, where) +} + +// readHiddenLine reads a line from the terminal f without echo. +// +// term.ReadPassword turns echo off and only puts it back when it returns. A +// ctrl-c in between ends the process first, and the shell it hands back to is +// left without echo: whatever is typed next is invisible until `reset`. So for +// as long as the prompt is up, an interrupt puts the terminal back before the +// command exits. +func readHiddenLine(f *os.File) ([]byte, error) { + fd := f.Fd() + if state, err := termGetState(fd); err == nil { + release := onInterrupt(func() { + _ = termRestore(fd, state) + fmt.Fprintln(os.Stderr) + }) + defer release() + } + return termReadPassword(fd) +} + +// The terminal calls readHiddenLine makes, as variables so a test can stand +// in for a terminal it does not have and see what the interrupt handler puts +// back. +var ( + termGetState = term.GetState + termRestore = term.Restore + termReadPassword = term.ReadPassword +) + +// interrupted ends the command after ctrl-c, with the status a shell gives an +// interrupted one. A variable for the tests. +var interrupted = func() { os.Exit(130) } + +// onInterrupt runs undo and then interrupted if ctrl-c arrives before release +// is called. Until then the interrupt is this function's to handle, not the +// runtime's, which would exit without undoing anything. +func onInterrupt(undo func()) (release func()) { + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + done := make(chan struct{}) + go func() { + select { + case <-sig: + undo() + interrupted() + case <-done: + } + }() + return func() { + signal.Stop(sig) + close(done) + } +} + +// codeInput is where the second factor comes from: a hidden prompt on a +// terminal, or one line of stdin with --totp -. +type codeInput struct { + tty bool + fromStdin bool + readHidden func() ([]byte, error) + readLine func() (string, error) +} + +// newCodeInput asks f, hidden when it is a terminal; readLine is the next +// line of the same stdin, for --totp -. +func newCodeInput(f *os.File, readLine func() (string, error)) codeInput { + return codeInput{ + tty: term.IsTerminal(f.Fd()), + fromStdin: totpFlag == "-", + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, + readLine: readLine, + } +} + +func (in codeInput) read(prompt io.Writer) (string, error) { + if in.tty { + fmt.Fprint(prompt, "Authenticator code: ") + raw, err := in.readHidden() + fmt.Fprintln(prompt) + if err != nil { + return "", fmt.Errorf("could not read the code: %w", err) + } + return strings.TrimSpace(string(raw)), nil + } + line, err := in.readLine() + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("could not read the code from stdin: %w", err) + } + if line = strings.TrimSpace(line); line == "" { + return "", fmt.Errorf("no code arrived on stdin. The link is spent: ask for a new one, and pipe the code in:%s", totpHint) + } + return line, nil } // A flag whose value is a credential, with "-" meaning stdin. @@ -266,7 +474,7 @@ func newTokenInput(f *os.File) tokenInput { return tokenInput{ r: f, tty: term.IsTerminal(f.Fd()), - readHidden: func() ([]byte, error) { return term.ReadPassword(f.Fd()) }, + readHidden: func() ([]byte, error) { return readHiddenLine(f) }, } } diff --git a/cmd/auth_mfa_test.go b/cmd/auth_mfa_test.go new file mode 100644 index 0000000..953877b --- /dev/null +++ b/cmd/auth_mfa_test.go @@ -0,0 +1,439 @@ +package cmd + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// Two-factor sign-in from `nan auth login`, against a fake platform written +// from the MFA API contract (internal/auth/authtest). + +const testLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// mfaPlatform points the sign-in at the fake and gives the login flags back +// their defaults when the test ends: cobra writes them into package vars. +func mfaPlatform(t *testing.T, factor bool) *authtest.Platform { + t.Helper() + p := authtest.New(t) + p.Factor = factor + swap(t, &auth.SignInBaseURL, p.URL) + for _, f := range []*string{&linkFlag, &totpFlag, &tokenFlag, &emailFlag} { + swap(t, f, "") + } + swap(t, &apiTokenFlag, false) + return p +} + +// typed is a terminal on which the member types these, one per prompt. +func typed(answers ...string) (codeInput, *int) { + asked := new(int) + return codeInput{tty: true, readHidden: func() ([]byte, error) { + if *asked >= len(answers) { + return nil, io.EOF + } + *asked++ + return []byte(answers[*asked-1] + "\n"), nil + }}, asked +} + +// piped is stdin that is not a terminal: what --totp - reads. +func piped(stdin string, totp bool) codeInput { + r := strings.NewReader(stdin) + line := func() (string, error) { + var b strings.Builder + buf := make([]byte, 1) + for { + n, err := r.Read(buf) + if n == 1 { + if buf[0] == '\n' { + return b.String(), nil + } + b.WriteByte(buf[0]) + } + if err != nil { + return b.String(), err + } + } + } + return codeInput{fromStdin: totp, readLine: line} +} + +func TestLoginWithoutAFactorIsUnchanged(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, false) + var prompt bytes.Buffer + codes, asked := typed() + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code an account without a factor does not have: %d prompts, %q", *asked, prompt.String()) + } +} + +func TestLoginAsksForTheCodeOnATerminal(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + sess, _ := readSession(t, home) + if sess["token"] != authtest.Session || sess["apiKey"] != "sk-kept" { + t.Errorf("session.json = %v", sess) + } + if *asked != 1 || !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("%d prompts: %q", *asked, prompt.String()) + } + if !strings.Contains(prompt.String(), "recovery codes") { + t.Errorf("the prompt does not say a recovery code works too: %q", prompt.String()) + } +} + +// A wrong code, a replayed one and something that is not a code at all are +// all asked again, on the same sign-in, with what went wrong. +func TestLoginAsksAgainAfterAWrongCode(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + var prompt bytes.Buffer + codes, asked := typed("000000", "not a code", authtest.Code) + if err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if *asked != 3 { + t.Errorf("%d prompts, want 3", *asked) + } + for _, want := range []string{"not right (4 attempts left)", "neither a 6-digit code"} { + if !strings.Contains(prompt.String(), want) { + t.Errorf("%q does not say %q", prompt.String(), want) + } + } + // "not a code" never left the machine. + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs, want 2", n) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } +} + +func TestLoginWithARecoveryCodeSaysHowManyAreLeft(t *testing.T) { + for _, c := range []struct { + codes []string + want string + }{ + {[]string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd", "eeee-ffff-gggg-hhhh"}, "2 left"}, + {[]string{"q7wa-z2xd-pl4r-6wze"}, "last recovery code"}, + } { + t.Run(c.want, func(t *testing.T) { + fakeHome(t, "") + p := mfaPlatform(t, true) + p.RecoveryCodes = c.codes + var out bytes.Buffer + codes, _ := typed("Q7WA Z2XD PL4R 6WZE") + if err := finishSignIn(authtest.LinkToken, codes, &out, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), c.want) || !strings.Contains(out.String(), "Two-factor authentication") { + t.Errorf("%q does not say %q and where to make more", out.String(), c.want) + } + }) + } +} + +// Each way the sign-in ends saves nothing and says what to do next. +func TestLoginThatCannotFinishSavesNothing(t *testing.T) { + for _, c := range []struct { + name string + setup func(*authtest.Platform) + codes []string + want string + }{ + {"five wrong codes", nil, []string{"000000", "000001", "000002", "000003", "000004"}, "too many wrong codes"}, + {"expired", func(p *authtest.Platform) { p.Expire() }, []string{authtest.Code}, "timed out"}, + {"refused", func(p *authtest.Platform) { + p.Next = &authtest.Refusal{Status: 403, Body: `{"error":"login_refused","message":"x"}`} + }, []string{authtest.Code}, "refused the sign-in"}, + {"nothing typed at all", nil, nil, "could not read the code"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, _ := typed(c.codes...) + // The challenge opens on the verify POST, which finishSignIn + // makes; the fake is changed as soon as it has. + if c.setup != nil { + inner := codes.readHidden + first := true + codes.readHidden = func() ([]byte, error) { + if first { + first = false + c.setup(p) + } + return inner() + } + } + err := finishSignIn(authtest.LinkToken, codes, io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// --totp - reads one line of stdin, with no prompt and no second try: a +// script cannot answer one. +func TestTOTPFromStdin(t *testing.T) { + t.Run("right", func(t *testing.T) { + home := fakeHome(t, "") + mfaPlatform(t, true) + var prompt bytes.Buffer + if err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", true), io.Discard, &prompt); err != nil { + t.Fatal(err) + } + if prompt.Len() != 0 { + t.Errorf("prompted a pipe: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + }) + t.Run("wrong, then nothing", func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("000000\n"+authtest.Code+"\n", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if n := len(p.Challenges()); n != 1 { + t.Errorf("%d tries from a pipe, want 1", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + t.Run("empty", func(t *testing.T) { + fakeHome(t, "") + mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped("", true), io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "no code arrived") || !strings.Contains(err.Error(), "--totp -") { + t.Fatalf("err = %v", err) + } + }) +} + +// --totp - with a terminal on stdin: the code is still read without echo, +// and it still gets one try, as the help says. +func TestTOTPFromATerminalStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + codes, asked := typed("000000", authtest.Code) + codes.fromStdin = true + var prompt bytes.Buffer + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + if err == nil || !strings.Contains(err.Error(), "not right") { + t.Fatalf("err = %v", err) + } + if *asked != 1 || len(p.Challenges()) != 1 { + t.Errorf("%d prompts, %d POSTs: --totp - gets one try", *asked, len(p.Challenges())) + } + if !strings.Contains(prompt.String(), "Authenticator code: ") { + t.Errorf("not asked on the terminal: %q", prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// An account that has not finished signing up and has a factor finishes in +// the browser: nan auth login asks for no code and saves nothing, and says +// what to do next. +func TestLoginForAnUnfinishedSignUpSendsItToTheBrowser(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + p.Onboarding = true + var prompt bytes.Buffer + codes, asked := typed(authtest.Code) + err := finishSignIn(authtest.LinkToken, codes, io.Discard, &prompt) + const want = "your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if err == nil || err.Error() != want { + t.Fatalf("err = %v", err) + } + if *asked != 0 || prompt.Len() != 0 || len(p.Challenges()) != 0 { + t.Errorf("asked for a code with no challenge: %d prompts, %q", *asked, prompt.String()) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// No terminal and no --totp: the link is already spent, so the message says +// to ask for another and names the flag that would have worked. +func TestNoTerminalAndNoFlag(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + err := finishSignIn(authtest.LinkToken, piped(authtest.Code+"\n", false), io.Discard, io.Discard) + if err == nil { + t.Fatal("signed in with no way to ask for the code") + } + for _, want := range []string{"two-factor", "new one", "--totp -"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("read a code nobody asked to be read: %d POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } +} + +// The whole command, as a script runs it: link as an argument, code piped in. +func TestLoginCommandWithTOTPFromStdin(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + withStdin(t, authtest.Code+"\n") + rootCmd.SetArgs([]string{"auth", "login", "--link", testLink, "--totp", "-"}) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + if err := rootCmd.Execute(); err != nil { + t.Fatal(err) + } + if sess, _ := readSession(t, home); sess["token"] != authtest.Session { + t.Errorf("session.json = %v", sess) + } + if f := p.Verifies(); len(f) != 1 || f[0].Get("mfa") != "1" { + t.Errorf("verify forms = %v", f) + } + assertNoChallengeOnDisk(t, home) +} + +func TestTOTPFlagSpellings(t *testing.T) { + for _, c := range []struct { + name string + args []string + want string + }{ + {"a code on the command line", []string{"--link", testLink, "--totp", authtest.Code}, `only takes "-"`}, + {"two flags reading stdin", []string{"--link", "-", "--totp", "-"}, "cannot both read stdin"}, + {"with a saved token", []string{"--token", "x", "--totp", "-"}, "cannot be combined with --token"}, + {"with --api-token", []string{"--api-token", "--totp", "-"}, "cannot be combined"}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, "") + p := mfaPlatform(t, true) + rootCmd.SetArgs(append([]string{"auth", "login"}, c.args...)) + rootCmd.SetOut(io.Discard) + rootCmd.SetErr(io.Discard) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("err = %v, want %q", err, c.want) + } + if strings.Contains(err.Error(), authtest.Code) { + t.Errorf("the refusal repeats the code: %v", err) + } + if n := len(p.Verifies()); n != 0 { + t.Errorf("spent the link on flags that cannot work: %d verify POSTs", n) + } + if sess, _ := readSession(t, home); sess != nil { + t.Errorf("saved %v", sess) + } + }) + } +} + +// No example of --totp - shows the code typed into the command, which would +// put it in the shell history the flag exists to keep it out of: not the +// help, not the hint the errors print, not the README. +func TestTOTPExamplesKeepTheCodeOffTheCommandLine(t *testing.T) { + readme, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatal(err) + } + for name, text := range map[string]string{ + "nan auth login --help": loginCmd.Example + "\n" + loginCmd.Long, + "the errors' hint": totpHint, + "README.md": string(readme), + } { + var examples int + for _, line := range strings.Split(text, "\n") { + if !strings.Contains(line, "--totp -") { + continue + } + examples++ + if strings.Contains(line, "echo ") || strings.Contains(line, "") || strings.Contains(line, authtest.Code) { + t.Errorf("%s puts the code on the command line: %q", name, strings.TrimSpace(line)) + } + } + if examples == 0 { + t.Errorf("%s has no --totp - example left to check", name) + } + } +} + +// The challenge cookie is a credential for ten minutes and is kept in memory +// only. Whatever the sign-in wrote under HOME - on success and on failure - +// does not contain it, and neither does anything it printed. +func TestTheChallengeCookieNeverReachesDiskOrOutput(t *testing.T) { + for _, c := range []struct { + name string + codes []string + }{ + {"signed in", []string{"000000", authtest.Code}}, + {"gave up", []string{"000000"}}, + } { + t.Run(c.name, func(t *testing.T) { + home := fakeHome(t, `{"token":"","apiKey":"sk-kept"}`) + mfaPlatform(t, true) + var out, prompt bytes.Buffer + codes, _ := typed(c.codes...) + err := finishSignIn(authtest.LinkToken, codes, &out, &prompt) + printed := out.String() + prompt.String() + if err != nil { + printed += err.Error() + } + if strings.Contains(printed, authtest.Pending) { + t.Errorf("printed the challenge cookie: %q", printed) + } + assertNoChallengeOnDisk(t, home) + }) + } +} + +func assertNoChallengeOnDisk(t *testing.T, home string) { + t.Helper() + err := filepath.WalkDir(home, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + t.Fatal(err) + } +} diff --git a/cmd/automations_contexts_test.go b/cmd/automations_contexts_test.go new file mode 100644 index 0000000..959fe4d --- /dev/null +++ b/cmd/automations_contexts_test.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Agent contexts on automations (devops AGENT-CONTEXT.md §2.5, §6, C2): +// nan automations create|edit --context NAME (repeatable, at most 5) puts +// "contexts" in the body, and --checkout none makes an ephemeral +// automation without a repository, which needs at least one context. + +func automationFile(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "a.yaml") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +// bodyGolden compares a sent JSON body, indented, with a golden file. +func bodyGolden(t *testing.T, name, body string) { + t.Helper() + var out bytes.Buffer + if err := json.Indent(&out, []byte(body), "", " "); err != nil { + t.Fatalf("%s: %v (%q)", name, err, body) + } + checkGolden(t, "contexts/"+name, out.String()+"\n") +} + +const wsAutomationYAML = "name: PR review api\nworkspace: develop\nagent: pi\ninstructions: Review it.\n" + +const noRepoAutomationYAML = "name: weekly notes\nexecutor: ephemeral\nsize: s\nagent: pi\nmodel: glm5.3\ninstructions: Write the notes.\n" + +func TestAutomationsCreateWithContexts(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, wsAutomationYAML), + "--context", "acme-api", "--context", "style-guide") + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + posts := fake.sent("POST", "/v1/automations") + if len(posts) != 1 { + t.Fatalf("%v", fake.requests()) + } + bodyGolden(t, "automation_create_workspace.json", posts[0].Body) +} + +func TestAutomationsCreateWithoutRepository(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, noRepoAutomationYAML), + "--checkout", "none", "--context", "acme-api") + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + posts := fake.sent("POST", "/v1/automations") + if len(posts) != 1 { + t.Fatalf("%v", fake.requests()) + } + bodyGolden(t, "automation_create_ephemeral_none.json", posts[0].Body) + + // The same, written in the file. + fake = newFakeWrite() + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", + automationFile(t, noRepoAutomationYAML+"checkout: none\ncontexts: [acme-api]\n")) + if r.code != 0 || len(fake.sent("POST", "/v1/automations")) != 1 { + t.Fatalf("from the file: exit %d: %s", r.code, r.msg) + } +} + +// Without flags, the file still goes out exactly as written. +func TestAutomationsCreateWithoutContextFlagsIsUntouched(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession, stdin: `{"name":"x","approval_kinds":["github_push"]}`}, "automations", "create", "-f", "-") + if r.code != 0 || fake.sent("POST", "/v1/automations")[0].Body != `{"name":"x","approval_kinds":["github_push"]}` { + t.Fatalf("exit %d %s %v", r.code, r.msg, fake.requests()) + } +} + +func TestAutomationsEditContextsWithoutAFile(t *testing.T) { + path := "/v1/automations/" + testAutoID + for name, tc := range map[string]struct { + args []string + body string + golden string + }{ + "contexts only": {[]string{"--context", "acme-api", "--context", "style-guide"}, `{"contexts":["acme-api","style-guide"]}`, ""}, + "checkout none": {[]string{"--checkout", "none", "--context", "acme-api"}, "", "automation_edit_checkout_none.json"}, + "with a file": {[]string{"-f", automationFile(t, "size: l\n"), "--context", "acme-api"}, `{"contexts":["acme-api"],"size":"l"}`, ""}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, append([]string{"automations", "edit", testAutoID}, tc.args...)...) + if r.code != 0 { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + got := fake.sent("PATCH", path) + if len(got) != 1 { + t.Fatalf("%v", fake.requests()) + } + if tc.golden != "" { + bodyGolden(t, tc.golden, got[0].Body) + } else if got[0].Body != tc.body { + t.Errorf("body %s, want %s", got[0].Body, tc.body) + } + }) + } +} + +func TestAutomationsContextMistakesAreUsageErrors(t *testing.T) { + six := []string{} + for _, n := range []string{"a1", "a2", "a3", "a4", "a5", "a6"} { + six = append(six, "--context", n) + } + ws := automationFile(t, wsAutomationYAML) + noRepo := automationFile(t, noRepoAutomationYAML) + withRepo := automationFile(t, noRepoAutomationYAML+"github_repo: acme/api\n") + both := automationFile(t, wsAutomationYAML+"contexts: [acme-api]\n") + bothCheckout := automationFile(t, noRepoAutomationYAML+"checkout: fresh\n") + fileSix := automationFile(t, wsAutomationYAML+"contexts: [a1, a2, a3, a4, a5, a6]\n") + fileBad := automationFile(t, wsAutomationYAML+"contexts: [Acme API]\n") + fileNotList := automationFile(t, wsAutomationYAML+"contexts: acme-api\n") + for _, tc := range []struct { + name string + args []string + wants []string + }{ + {"six on create", append([]string{"automations", "create", "-f", ws}, six...), []string{"at most 5"}}, + {"six on edit", append([]string{"automations", "edit", testAutoID}, six...), []string{"at most 5"}}, + {"repeated", []string{"automations", "create", "-f", ws, "--context", "acme-api", "--context", "acme-api"}, []string{"twice"}}, + {"not a name", []string{"automations", "create", "-f", ws, "--context", "Acme API"}, []string{"context name"}}, + {"in the file and as a flag", []string{"automations", "create", "-f", both, "--context", "b"}, []string{"contexts", "--context", "once"}}, + {"checkout in the file and as a flag", []string{"automations", "create", "-f", bothCheckout, "--checkout", "none", "--context", "a"}, + []string{"checkout", "--checkout", "once"}}, + {"none without a context", []string{"automations", "create", "-f", noRepo, "--checkout", "none"}, []string{`checkout "none"`, "--context"}}, + {"none with a repository", []string{"automations", "create", "-f", withRepo, "--checkout", "none", "--context", "a"}, + []string{`checkout "none"`, "github_repo"}}, + {"none on a workspace automation", []string{"automations", "create", "-f", ws, "--checkout", "none", "--context", "a"}, + []string{"ephemeral"}}, + {"unknown checkout", []string{"automations", "create", "-f", ws, "--checkout", "clone"}, []string{"--checkout", "none"}}, + {"ephemeral with neither", []string{"automations", "create", "-f", noRepo}, []string{"github_repo", "checkout: none"}}, + {"edit with nothing", []string{"automations", "edit", testAutoID}, []string{"-f FILE"}}, + {"six in the file", []string{"automations", "create", "-f", fileSix}, []string{"at most 5"}}, + {"bad name in the file", []string{"automations", "create", "-f", fileBad}, []string{"context name"}}, + {"contexts not a list", []string{"automations", "create", "-f", fileNotList}, []string{"contexts", "list"}}, + {"none edit with a repository", []string{"automations", "edit", testAutoID, "-f", automationFile(t, "github_repo: acme/api\n"), "--checkout", "none"}, + []string{`checkout "none"`, "github_repo"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := newFakeWrite() + r := runWrite(t, fake, writeSetup{session: cliSession}, tc.args...) + if r.code != exitUsage { + t.Fatalf("exit %d: %s", r.code, r.msg) + } + wantAll(t, tc.name, r.msg, tc.wants...) + if len(fake.reqs) != 0 { + t.Errorf("sent %d requests", len(fake.reqs)) + } + }) + } +} + +func TestAutomationsContextRefusals(t *testing.T) { + notFound := `{"error":{"message":"context not found \u001b[2J","type":"invalid_request_error","param":"contexts[1]","code":"context_not_found"}}` + ws := automationFile(t, wsAutomationYAML) + + fake := newFakeWrite() + fake.failOn("POST /v1/automations", 404, notFound) + r := runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", ws, "--context", "acme-api", "--context", "style-guide") + if r.code != exitUsage || !strings.Contains(r.msg, `context "style-guide" not found`) || !strings.Contains(r.msg, "nan context ls") { + t.Errorf("create: exit %d: %s", r.code, r.msg) + } + assertNoControls(t, "create", r.msg) + + // On edit a 404 about a context is not "automation not found". + fake = newFakeWrite() + fake.failOn("PATCH /v1/automations/"+testAutoID, 404, notFound) + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "edit", testAutoID, "--context", "acme-api", "--context", "style-guide") + if r.code != exitUsage || !strings.Contains(r.msg, `context "style-guide" not found`) || strings.Contains(r.msg, "automation") { + t.Errorf("edit: exit %d: %s", r.code, r.msg) + } + + // A context listed in the file is named too. + fake = newFakeWrite() + fake.failOn("POST /v1/automations", 404, strings.Replace(notFound, "contexts[1]", "contexts[0]", 1)) + r = runWrite(t, fake, writeSetup{session: cliSession}, "automations", "create", "-f", automationFile(t, wsAutomationYAML+"contexts: [from-file]\n")) + if r.code != exitUsage || !strings.Contains(r.msg, `context "from-file" not found`) { + t.Errorf("file: exit %d: %s", r.code, r.msg) + } +} + +func TestAutomationsHelpExplainsContexts(t *testing.T) { + for _, c := range []struct { + name string + long string + }{{"create", automationsCreateCmd.Long}, {"edit", automationsEditCmd.Long}} { + wantAll(t, c.name, c.long, "--context NAME", "at most 5", "--checkout none") + for _, f := range []string{"context", "checkout"} { + if (map[string]bool{"create": automationsCreateCmd.Flags().Lookup(f) != nil, "edit": automationsEditCmd.Flags().Lookup(f) != nil})[c.name] == false { + t.Errorf("%s has no --%s", c.name, f) + } + } + } +} diff --git a/cmd/automations_write.go b/cmd/automations_write.go index 1d4af1f..59cf791 100644 --- a/cmd/automations_write.go +++ b/cmd/automations_write.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "text/tabwriter" @@ -29,6 +30,10 @@ var ( trigRotateHard bool trigRotateWhsecFile string trigRotateWhsecStdin bool + // --context (repeatable) and --checkout on create and edit, merged + // into the file's body. + autoWriteContexts []string + autoWriteCheckout string ) const automationFileHelp = `The file is YAML or JSON (- reads it from stdin) with the fields of the @@ -67,6 +72,16 @@ scope; see: nan ephemeral --help. The full list of fields is in the API reference: https://nan.builders/docs/runs` +const automationContextsHelp = `Agent contexts: + --context NAME (repeat it, at most 5) sets the agent contexts every run + of the automation loads, in that order, at their latest version; it is + the file's contexts: [NAME, ...] field, so give them in one place only. + NAME is a context name or id from: nan context ls. + --checkout none makes an ephemeral automation without a repository: no + github_repo, and its runs work in an empty folder with their contexts, + so it needs at least one --context. It is the file's checkout: none. + Docs: ` + contextsDocs + var automationsCreateCmd = &cobra.Command{ Use: "create -f FILE", Short: "Create an automation from a YAML or JSON file", @@ -78,11 +93,15 @@ An automation receives the secrets you assign it, and approval_kinds: [] lets every action it proposes apply without asking you. Then add a trigger with nan automations triggers add. +` + automationContextsHelp + ` + ` + writeCredentialHelp + ` Docs: ` + writeDocs, Example: ` nan automations create -f pr-review.yaml - nan automations create -f - < pr-review.json`, + nan automations create -f - < pr-review.json + nan automations create -f review.yaml --context acme-api --context style-guide + nan automations create -f notes.yaml --checkout none --context release-notes`, Args: cobra.NoArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -90,6 +109,9 @@ Docs: ` + writeDocs, if err != nil { return err } + if body, err = applyAutomationFlags(cmd, body, true); err != nil { + return err + } return withWriteEnv(func(env *runsEnv) error { return doAutomationsCreate(cmd.Context(), env, body) }) }, } @@ -107,13 +129,30 @@ or, for an ephemeral automation, to run it in a bigger container: size: l +With --context or --checkout, -f can be left out: the flags are the +change. --context replaces the automation's contexts with the ones given +(contexts: [] in a file removes them all); --checkout none also sets +github_repo to null unless the file says otherwise. + +` + automationContextsHelp + ` + ` + writeCredentialHelp + ` Docs: ` + writeDocs, + Example: ` nan automations edit "PR review api" -f patch.yaml + nan automations edit "PR review api" --context acme-api --context style-guide + nan automations edit "weekly notes" --checkout none --context release-notes`, Args: oneAutomation, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { - body, err := readBodyFile(autoWriteFile) + body := json.RawMessage(`{}`) + if autoWriteFile != "" || !automationFlagsSet(cmd) { + var err error + if body, err = readBodyFile(autoWriteFile); err != nil { + return err + } + } + body, err := applyAutomationFlags(cmd, body, false) if err != nil { return err } @@ -319,6 +358,10 @@ func init() { triggersAddCmd, triggersEditCmd, triggersRotateCmd} { c.Flags().BoolVar(&autoWriteJSON, "json", false, "Print the API's answer as JSON") } + for _, c := range []*cobra.Command{automationsCreateCmd, automationsEditCmd} { + c.Flags().StringArrayVar(&autoWriteContexts, "context", nil, "An agent context its runs load, by `NAME` or id (repeat for more, at most 5; replaces the file's contexts)") + c.Flags().StringVar(&autoWriteCheckout, "checkout", "", "The file's checkout field: none for an ephemeral automation without a repository (needs --context)") + } automationsRmCmd.Flags().BoolVar(&autoRmYes, "yes", false, "Delete without asking") triggersRmCmd.Flags().BoolVar(&trigRmYes, "yes", false, "Delete without asking") triggersLsCmd.Flags().BoolVar(&trigLsJSON, "json", false, "Print the triggers as JSON") @@ -367,6 +410,9 @@ func doAutomationsCreate(ctx context.Context, env *runsEnv, body json.RawMessage } a, raw, err := env.client.CreateAutomation(ctx, body) if err != nil { + if exit := automationContextExit(err, body); exit != nil { + return exit + } return writeExit(err, env.cred, "automations", scopeAutomationsWrite) } if autoWriteJSON { @@ -397,6 +443,9 @@ func doAutomationsUpdate(ctx context.Context, env *runsEnv, ref string, body jso } a, raw, err := env.client.UpdateAutomation(ctx, id, body) if err != nil { + if exit := automationContextExit(err, body); exit != nil { + return exit + } return automationWriteExit(err, ref, env.cred) } if autoWriteJSON { @@ -576,3 +625,73 @@ func triggerWriteExit(err error, ref, triggerID string, cred credential) *ExitEr } return writeExit(err, cred, "automations", scopeAutomationsWrite) } + +// automationFlagsSet reports whether --context or --checkout was given. +func automationFlagsSet(cmd *cobra.Command) bool { + return cmd.Flags().Changed("context") || cmd.Flags().Changed("checkout") +} + +// automationCheckouts are the checkout modes of an automation. +var automationCheckouts = []string{"workspace", "fresh", "none"} + +// applyAutomationFlags merges --context and --checkout into the body of +// create or edit. Without them the body goes out exactly as written. A +// field given both in the file and as a flag is a mistake: which one wins +// would be a guess. +func applyAutomationFlags(cmd *cobra.Command, body json.RawMessage, create bool) (json.RawMessage, error) { + if !automationFlagsSet(cmd) { + return body, nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil || m == nil { + return nil, usageErrorf("the file must hold one object (the automation's fields)") + } + if cmd.Flags().Changed("context") { + if _, ok := m["contexts"]; ok { + return nil, usageErrorf("contexts is in the file and given with --context: give them once, in one place") + } + if err := checkContextRefs(autoWriteContexts, "--context"); err != nil { + return nil, err + } + m["contexts"], _ = json.Marshal(autoWriteContexts) + } + if cmd.Flags().Changed("checkout") { + if !slices.Contains(automationCheckouts, autoWriteCheckout) { + return nil, usageErrorf("--checkout must be workspace, fresh or none (none: an ephemeral automation without a repository), not %q", + runs.Truncate(runs.SanitizeLine(autoWriteCheckout), 32)) + } + if _, ok := m["checkout"]; ok { + return nil, usageErrorf("checkout is in the file and given with --checkout: give it once, in one place") + } + m["checkout"], _ = json.Marshal(autoWriteCheckout) + if _, ok := m["github_repo"]; !ok && !create && autoWriteCheckout == "none" { + // A repository-less automation has no repository: an edit + // that turns one into it drops the one it had. + m["github_repo"] = json.RawMessage("null") + } + } + out, err := json.Marshal(m) + if err != nil { + return nil, usageErrorf("could not build the request: %v", err) + } + return out, nil +} + +// bodyContexts are the contexts a create or edit body names (nil when none +// or not a list of strings). +func bodyContexts(body json.RawMessage) []string { + var f struct { + Contexts []string `json:"contexts"` + } + _ = json.Unmarshal(body, &f) + return f.Contexts +} + +// automationContextExit reads a refusal of the contexts an automation names. +func automationContextExit(err error, body json.RawMessage) *ExitError { + var apiErr *api.APIError + if !errors.As(err, &apiErr) { + return nil + } + return contextRefusal(apiErr, bodyContexts(body), "--context") +} diff --git a/cmd/context.go b/cmd/context.go new file mode 100644 index 0000000..8f2d559 --- /dev/null +++ b/cmd/context.go @@ -0,0 +1,1656 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + "text/tabwriter" + "time" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" + "github.com/nxssie/nan-cli/internal/runs" + "github.com/spf13/cobra" + "golang.org/x/text/unicode/norm" +) + +// Agent contexts (helmcode/nan-devops AGENT-CONTEXT.md §6): a versioned file +// tree a member keeps on NaN and hands to their runs and automations: +// +// AGENTS.md instructions +// skills//... skills (SKILL.md and its files) +// files/... reference files +// +// push hashes a local folder, uploads only the blobs the platform lacks +// (one PUT up to 16 MiB, a resumable multipart upload above) and publishes +// the tree as a new version based on the one it read. pull writes a version +// into an empty folder, through os.OpenRoot and only after every path passed +// the same validator the platform uses (internal/contexts, a verbatim copy). + +const ( + contextsDocs = "https://nan.builders/docs/contexts" + + scopeContextsRead = "contexts:read" + scopeContextsWrite = "contexts:write" + + // exitDataErr is sysexits' EX_DATAERR: the content was refused (a file + // name, a type, a limit, secret-like content). The same number as + // exitAuth, which is EX_NOPERM's neighbour in this CLI's table. + exitDataErr = 65 + + // Limits of one version (AGENT-CONTEXT.md §1.4), checked before any + // upload so a push that cannot be published uploads nothing. + ctxMaxFiles = 1000 + ctxMaxSkills = 30 + ctxMaxTotalBytes = 250 << 20 + ctxMaxAgentsMD = 32 << 10 + ctxMaxSkillMD = 64 << 10 + + // ctxMaxWait is the longest Retry-After a push waits out by itself. + ctxMaxWait = 2 * time.Minute + // ctxRetries is how many times one request is retried after a + // Retry-After, a part after a failure, or a publish after blob_missing + // or (with --force) version_conflict. + ctxRetries = 3 +) + +// Test seams. +var ( + // ctxSleep waits out a Retry-After. + ctxSleep = func(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } + } + // ctxUploadStateDir is where an unfinished multipart upload is + // remembered, so the next push resumes it instead of starting over. + ctxUploadStateDir = func() (string, error) { + d, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(d, "nan", "context-uploads"), nil + } + // ctxConcurrency is how many blobs a push uploads at once. + ctxConcurrency = 4 + // ctxSingleMax is the largest blob sent as one PUT. + ctxSingleMax int64 = api.ContextPartSize + // ctxExecKnown is whether the file system carries an executable bit. + ctxExecKnown = runtime.GOOS != "windows" +) + +var ( + ctxJSON bool + + ctxCreateDescription string + ctxCreateFrom string + + ctxPushCreate bool + ctxPushForce bool + ctxPushAllowSecrets bool + ctxPushDryRun bool + ctxPushSkipInvalid bool + ctxPushAllowEmpty bool + + ctxPullForce bool + + ctxEditName string + ctxEditDescription string + + ctxRmYes bool +) + +var ctxVerbHints = map[string]string{ + "list": "ls", "get": "show", "new": "create", "add": "create", "upload": "push", "publish": "push", + "download": "pull", "clone": "pull", "update": "edit", "rename": "edit NAME --name NEW", "delete": "rm", "remove": "rm", +} + +const ctxCredentialHelp = `Authentication, first match wins: --token-file, your session from +nan auth login, NAN_TOKEN, a platform token saved with nan auth login +--api-token. A platform token needs contexts:read to list, show and pull, +and contexts:write to create, push, edit and delete. API keys (sk-) are +not used by these commands, and workspace keys never read or write +contexts.` + +var contextCmd = &cobra.Command{ + Use: "context", + Aliases: []string{"contexts"}, + Short: "Keep instructions, skills and files for your agent runs", + Long: `Manage your agent contexts: versioned folders of instructions, skills and +reference files that your runs and automations hand to the agent. + +A context has this layout: + AGENTS.md instructions for the agent (at most 32 KiB) + skills// a skill: SKILL.md (name and description in its + frontmatter) plus any files it uses + files/... reference files: docs, specs, CSV, PDF, images, + Office documents + +File names may use letters of any language, digits, spaces and +. _ - ( ) + , @. Dotfiles (.env, .git) are skipped; names with emoji, +symbols, control characters or Windows reserved names (CON, NUL, ...) +are refused, each with its reason. + +Every push publishes a new version; runs pin the version they start +with, automations follow the latest. Do not put credentials in a context: +keep them in nan secrets. A push that looks like it carries one is +refused unless you add --allow-secrets. + +Exit codes: 0 done, 1 failed (the context changed while pushing), 64 +usage, 65 content refused (file names, types, limits, secret-like +content, storage quota) or credential refused, 69 nan.builders +unavailable. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Annotations: sysexits, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + shown := runs.Truncate(runs.SanitizeLine(args[0]), 64) + if hint, ok := ctxVerbHints[args[0]]; ok { + return usageErrorf("there is no nan context %s; try: nan context %s", shown, hint) + } + return usageErrorf("unknown command %q for \"nan context\"; try: nan context --help", shown) + } + return cmd.Help() + }, +} + +var ctxLsCmd = &cobra.Command{ + Use: "ls", + Short: "List your contexts and the storage they use", + Args: cobra.NoArgs, + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextLs(cmd.Context(), env) }) + }, +} + +var ctxShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show a context and the files of its current version", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextShow(cmd.Context(), env, args[0]) }) + }, +} + +var ctxCreateCmd = &cobra.Command{ + Use: "create NAME [--description TEXT] [--from DIR]", + Short: "Create a context, empty or from a folder", + Long: `Create a context. NAME is lowercase letters, digits and dashes (at most +63). With --from DIR, the folder is pushed as its first content (see nan +context push --help for what a push checks). + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context create acme-api --description "Acme API conventions" + nan context create acme-api --from ./acme-context`, + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextCreate(cmd.Context(), env, args[0]) }) + }, +} + +var ctxPushCmd = &cobra.Command{ + Use: "push DIR", + Short: "Publish a folder as the context's next version", + Long: `Publish DIR as the next version of a context: the folder becomes the +context's whole content (files no longer in DIR are removed from the new +version; older versions are kept while a run uses them). + +Before anything is uploaded, push checks the layout and every file name: +dotfiles and dot-folders (.git, .env) are skipped with a notice; every +other refused file is printed with its reason (bad_top_level, +bad_character, case_collision, reserved_name, too_long, too_deep, +too_large, skill_invalid, symlink) and push exits 65 without uploading, +unless --skip-invalid leaves those files out. A CLAUDE.md is refused with +a hint to rename it to AGENTS.md. Names are normalised to NFC (macOS +hands out decomposed names). + +Then push hashes every file, asks the platform which ones it does not +already hold, uploads only those (16 MiB in one request, larger files in +16 MiB parts; an interrupted upload resumes on the next push) and +publishes the new version. --dry-run prints what would be added, changed +and removed, and how much would be uploaded, and changes nothing. + +If the context changed since push read it (another push, the portal), +push stops with exit 1. --force shows what changed and publishes on top +of it instead. Text that looks like a credential (API keys, tokens, +private keys) is refused with the file and line; keep credentials in nan +secrets, or publish anyway with --allow-secrets (the agent can read and +copy anything in a context). + +With --create, a context that does not exist yet is created first. A DIR +that is a symbolic link is followed once (links inside it are refused). A +folder with no files is refused over a version that has some, since it +would remove them all; --allow-empty publishes it anyway. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context push acme-api ./acme-context --dry-run + nan context push acme-api ./acme-context + nan context push acme-api ./acme-context --create --skip-invalid`, + Args: cobra.ExactArgs(2), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { + return doContextPush(cmd.Context(), env, args[0], args[1], pushOptionsFromFlags()) + }) + }, +} + +var ctxPullCmd = &cobra.Command{ + Use: "pull DIR", + Short: "Download a context's current version into an empty folder", + Long: `Write the current version of a context into DIR, which must be empty or +not exist yet (--force writes into a folder that has files, replacing +the ones the context has). + +Every path the platform sends is checked with the same rules a push +uses, and files are written through a handle rooted at DIR: a path +that is absolute, climbs out with .., differs from another only by case, +or is a Windows reserved name is refused before anything is written. +Every file is checked against its sha256 and size. + +` + ctxCredentialHelp + ` + +Docs: ` + contextsDocs, + Example: ` nan context pull acme-api ./acme-context`, + Args: cobra.ExactArgs(2), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextPull(cmd.Context(), env, args[0], args[1]) }) + }, +} + +var ctxEditCmd = &cobra.Command{ + Use: "edit [--name NEW] [--description TEXT]", + Short: "Rename a context or change its description (no new version)", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("name") && !cmd.Flags().Changed("description") { + return usageErrorf("nothing to change: give --name or --description") + } + var patch api.ContextPatch + if cmd.Flags().Changed("name") { + if !contexts.ValidContextName(ctxEditName) { + return usageErrorf("%s", contextNameRule(ctxEditName)) + } + patch.Name = &ctxEditName + } + if cmd.Flags().Changed("description") { + patch.Description = &ctxEditDescription + } + return withWriteEnv(func(env *runsEnv) error { return doContextEdit(cmd.Context(), env, args[0], patch) }) + }, +} + +var ctxRmCmd = &cobra.Command{ + Use: "rm [--yes]", + Short: "Delete a context and all its versions", + Args: cobra.ExactArgs(1), + Annotations: sysexits, + RunE: func(cmd *cobra.Command, args []string) error { + return withWriteEnv(func(env *runsEnv) error { return doContextRm(cmd.Context(), env, args[0]) }) + }, +} + +func init() { + rootCmd.AddCommand(contextCmd) + contextCmd.AddCommand(ctxLsCmd, ctxShowCmd, ctxCreateCmd, ctxPushCmd, ctxPullCmd, ctxEditCmd, ctxRmCmd) + contextCmd.PersistentFlags().StringVar(&tokenFileFlag, "token-file", "", tokenFileUsage) + + for _, c := range []*cobra.Command{ctxLsCmd, ctxShowCmd, ctxCreateCmd, ctxPushCmd, ctxEditCmd} { + c.Flags().BoolVar(&ctxJSON, "json", false, "Print the answer as JSON") + } + ctxCreateCmd.Flags().StringVar(&ctxCreateDescription, "description", "", "What the context is for") + ctxCreateCmd.Flags().StringVar(&ctxCreateFrom, "from", "", "Push this folder as the first content (`DIR`)") + for _, c := range []*cobra.Command{ctxCreateCmd, ctxPushCmd} { + c.Flags().BoolVar(&ctxPushAllowSecrets, "allow-secrets", false, "Publish even if a text file looks like it holds a credential") + c.Flags().BoolVar(&ctxPushSkipInvalid, "skip-invalid", false, "Leave refused files out instead of stopping") + } + ctxPushCmd.Flags().BoolVar(&ctxPushAllowEmpty, "allow-empty", false, "Publish an empty folder over a version that has files (removes them all)") + f := ctxPushCmd.Flags() + f.BoolVar(&ctxPushCreate, "create", false, "Create the context if it does not exist") + f.BoolVar(&ctxPushForce, "force", false, "Publish on top of a version someone else published meanwhile") + f.BoolVar(&ctxPushDryRun, "dry-run", false, "Print what would change and be uploaded; change nothing") + ctxPullCmd.Flags().BoolVar(&ctxPullForce, "force", false, "Write into a folder that is not empty") + ctxEditCmd.Flags().StringVar(&ctxEditName, "name", "", "The new name") + ctxEditCmd.Flags().StringVar(&ctxEditDescription, "description", "", "The new description") + ctxRmCmd.Flags().BoolVar(&ctxRmYes, "yes", false, "Do not ask for confirmation") +} + +type pushOptions struct { + create, force, allowSecrets, dryRun, skipInvalid, allowEmpty, json bool +} + +func pushOptionsFromFlags() pushOptions { + return pushOptions{create: ctxPushCreate, force: ctxPushForce, allowSecrets: ctxPushAllowSecrets, dryRun: ctxPushDryRun, + skipInvalid: ctxPushSkipInvalid, allowEmpty: ctxPushAllowEmpty, json: ctxJSON} +} + +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// checkContextRef refuses a NAME|id that is neither a context name nor an +// id before it becomes part of a request path. +func checkContextRef(ref string) error { + if contexts.ValidContextName(ref) || uuidPattern.MatchString(ref) { + return nil + } + return usageErrorf("%q is neither a context name (lowercase letters, digits and dashes) nor a context id (see: nan context ls)", + runs.Truncate(runs.SanitizeLine(ref), 80)) +} + +func contextNameRule(name string) string { + return fmt.Sprintf("%q is not a valid context name: use lowercase letters, digits and dashes, starting with a letter or digit, at most 63 characters (not a Windows reserved name like con or nul)", + runs.Truncate(runs.SanitizeLine(name), 80)) +} + +// ─── errors ──────────────────────────────────────────────────────────────── + +// ctxExit maps a refused contexts request onto an exit code and a message. +func ctxExit(err error, env *runsEnv, scope string) *ExitError { + var exit *ExitError + if errors.As(err, &exit) { + return exit + } + ce, ok := api.AsContextError(err) + if !ok { + return writeExit(err, env.cred, "contexts", scope) + } + msg := runs.SanitizeLine(ce.Message) + switch ce.Code { + case "not_found", "": + if ce.Status == 404 { + return apiOff("using agent contexts") + } + case "context_not_found": + return usageErrorf("no such context (see: nan context ls)") + case "context_name_taken": + return usageErrorf("you already have a context with that name") + case "too_many_contexts": + return exitf(exitDataErr, "%s", msg) + case "context_in_use": + return usageErrorf("automations use this context; remove it from them first (nan automations edit):\n%s", + joinNames(refLabels(ce.Automations))) + case "context_quota_exceeded": + return exitf(exitDataErr, "%s (%s of %s used). Delete contexts or versions you no longer need, or upgrade: %s/membership", + msg, humanBytes(ce.Used), humanBytes(ce.Quota), runs.PortalURL) + case "context_too_large", "unsupported_file_type", "invalid_path", "skill_invalid", "context_secret_detected": + return exitf(exitDataErr, "%s", msg) + case "storage_unavailable", "upload_in_progress", "blob_busy", "part_conflict", "upload_expired": + return exitf(exitUnavailable, "%s", msg) + case "rate_limited": + if ce.RetryAfter > 0 { + return exitf(exitUnavailable, "%s (try again in %s)", msg, ce.RetryAfter.Round(time.Second)) + } + return exitf(exitUnavailable, "%s", msg) + case "blob_hash_mismatch": + return exitf(exitUnavailable, "a file changed while it was being uploaded; push again (%s)", msg) + case "entitlement_required", "tier_restricted": + return exitf(exitAuth, "your membership does not include agent runs, which contexts are for: see %s/membership", runs.PortalURL) + } + return writeExit(ce.APIError, env.cred, "contexts", scope) +} + +// ctxRetry runs fn again after a Retry-After the platform asked for (rate +// limits, an upload of the same blob in progress, storage briefly away), at +// most ctxRetries times and never longer than ctxMaxWait each. +func ctxRetry(ctx context.Context, env *runsEnv, what string, fn func() error) error { + for attempt := 0; ; attempt++ { + err := fn() + ce, ok := api.AsContextError(err) + if !ok || attempt >= ctxRetries || !retryable(ce) { + return err + } + wait := ce.RetryAfter + if wait <= 0 { + wait = 5 * time.Second + } + if wait > ctxMaxWait { + return err + } + fmt.Fprintf(env.stderr, "%s: %s; retrying in %s\n", what, runs.SanitizeLine(ce.Message), wait.Round(time.Second)) + if serr := ctxSleep(ctx, wait); serr != nil { + return err + } + } +} + +func retryable(ce *api.ContextError) bool { + switch ce.Code { + case "rate_limited", "upload_in_progress", "blob_busy", "storage_unavailable": + return true + } + return ce.Status == 429 +} + +func ctxCode(err error) string { + if ce, ok := api.AsContextError(err); ok { + return ce.Code + } + return "" +} + +// ─── ls, show ────────────────────────────────────────────────────────────── + +func doContextLs(ctx context.Context, env *runsEnv) error { + list, err := env.client.ListContexts(ctx) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if ctxJSON { + return printJSON(env.stdout, list, true) + } + if len(list.Data) == 0 { + fmt.Fprintf(env.stderr, "no contexts yet; create one with: nan context create NAME --from DIR\n") + } else { + tw := tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tVERSION\tFILES\tSIZE\tUPDATED\tDESCRIPTION") + for _, c := range list.Data { + fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\t%s\n", runs.SanitizeLine(c.Name), c.Version, c.FileCount, humanBytes(c.TotalBytes), + localTime(c.UpdatedAt), runs.Truncate(runs.SanitizeLine(c.Description), 48)) + } + _ = tw.Flush() + } + fmt.Fprintf(env.stdout, "storage: %s of %s used\n", humanBytes(list.Usage.StoredBytes), humanBytes(list.Usage.QuotaBytes)) + return nil +} + +func doContextShow(ctx context.Context, env *runsEnv, ref string) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if ctxJSON { + return printJSON(env.stdout, d, true) + } + tw := tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + row := func(k, v string) { fmt.Fprintf(tw, "%s\t%s\n", k, v) } + row("name", runs.SanitizeLine(d.Name)) + row("id", runs.SanitizeLine(d.ID)) + if d.Description != "" { + row("description", runs.SanitizeLine(d.Description)) + } + row("version", fmt.Sprintf("%d (%s)", d.Version, localTime(d.UpdatedAt))) + row("files", fmt.Sprintf("%d, %s", d.FileCount, humanBytes(d.TotalBytes))) + row("instructions", yesNo(d.HasInstructions)) + skills := make([]string, len(d.Skills)) + for i, s := range d.Skills { + skills[i] = runs.SanitizeLine(s) + } + if len(skills) == 0 { + row("skills", "none") + } else { + row("skills", strings.Join(skills, ", ")) + } + _ = tw.Flush() + if len(d.Files) > 0 { + fmt.Fprintln(env.stdout) + tw = tabwriter.NewWriter(env.stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "PATH\tSIZE\tSHA256") + files := append([]api.ContextFile(nil), d.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + for _, f := range files { + p := runs.SanitizeLine(f.Path) + if f.Executable { + p += " (executable)" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", p, humanBytes(f.Size), runs.Truncate(runs.SanitizeLine(f.SHA256), 12)) + } + _ = tw.Flush() + } + return nil +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +// humanBytes is a size as people read it (1024-based, one decimal). +func humanBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit && exp < 3; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGT"[exp]) +} + +// ─── create, edit, rm ────────────────────────────────────────────────────── + +func doContextCreate(ctx context.Context, env *runsEnv, name string) error { + if !contexts.ValidContextName(name) { + return usageErrorf("%s", contextNameRule(name)) + } + opts := pushOptions{allowSecrets: ctxPushAllowSecrets, skipInvalid: ctxPushSkipInvalid, allowEmpty: ctxPushAllowEmpty, json: ctxJSON} + var local *localTree + if ctxCreateFrom != "" { + // The folder is checked before the context exists, so a folder that + // cannot be pushed does not leave an empty context behind. + var err error + if local, err = scanLocal(env, ctxCreateFrom, opts.skipInvalid); err != nil { + return err + } + defer local.close() + } + idem := newIdempotencyKey() + var c *api.Context + err := ctxRetry(ctx, env, "create", func() error { + var err error + c, err = env.client.CreateContext(ctx, name, ctxCreateDescription, idem) + return err + }) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + if local == nil { + if ctxJSON { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s created (empty, version %d)\n", runs.SanitizeLine(c.Name), c.Version) + fmt.Fprintf(env.stderr, "add content with: nan context push %s DIR\n", runs.SanitizeLine(c.Name)) + return nil + } + fmt.Fprintf(env.stderr, "context %s created\n", runs.SanitizeLine(c.Name)) + return pushTree(ctx, env, &api.ContextDetail{Context: *c, Files: []api.ContextFile{}}, local, opts) +} + +func doContextEdit(ctx context.Context, env *runsEnv, ref string, patch api.ContextPatch) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + c, err := env.client.UpdateContext(ctx, d.ID, patch) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + if ctxJSON { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s updated\n", runs.SanitizeLine(c.Name)) + return nil +} + +func doContextRm(ctx context.Context, env *runsEnv, ref string) error { + if err := checkContextRef(ref); err != nil { + return err + } + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + name := runs.SanitizeLine(d.Name) + if err := confirm(env, ctxRmYes, "delete context "+name+" and all its versions?"); err != nil { + return err + } + if err := env.client.DeleteContext(ctx, d.ID); err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + fmt.Fprintf(env.stdout, "context %s deleted\n", name) + return nil +} + +// ─── the local tree ──────────────────────────────────────────────────────── + +// localFile is one file of the folder being pushed. +type localFile struct { + path string // NFC, '/'-separated, relative to the folder + rel string // the name on disk, relative to root + root *os.Root + size int64 + exec bool + // execKnown is false where the file system has no executable bit + // (Windows): the bit then comes from the version being replaced. + execKnown bool + sha string +} + +// open reads the file through the folder's root: a name swapped for a +// symlink after the scan is refused instead of followed. +func (f localFile) open() (*os.File, error) { + if st, err := f.root.Lstat(f.rel); err != nil { + return nil, err + } else if !st.Mode().IsRegular() { + return nil, fmt.Errorf("it is no longer a regular file") + } + return f.root.Open(f.rel) +} + +// refusedFile is one file push will not publish, with its reason. +type refusedFile struct { + path, reason, detail string +} + +type localTree struct { + files []localFile + root *os.Root +} + +func (t *localTree) close() { + if t != nil && t.root != nil { + _ = t.root.Close() + } +} + +func (t *localTree) totalBytes() int64 { + var n int64 + for _, f := range t.files { + n += f.size + } + return n +} + +// scanLocal walks dir, checks every name and the layout, applies the +// per-file limits and hashes what is left. Refused files stop the push +// (exit 65) unless skipInvalid; dotfiles are always skipped with a notice. +func scanLocal(env *runsEnv, dir string, skipInvalid bool) (*localTree, error) { + shown := runs.SanitizeLine(dir) + lst, err := os.Lstat(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if lst.Mode()&fs.ModeSymlink != 0 { + // The folder the member named is behind a link (a dotfile manager, + // ~/work -> /Volumes/...): walk what it points at. WalkDir would + // otherwise see one non-directory and report an empty tree. + target, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + dir = target + } + st, err := os.Stat(dir) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if !st.IsDir() { + return nil, usageErrorf("%s is not a folder", shown) + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, usageErrorf("cannot open %s: %v", shown, unwrapPath(err)) + } + tree, err := scanRoot(env, dir, root, skipInvalid) + if err != nil { + _ = root.Close() + return nil, err + } + return tree, nil +} + +func scanRoot(env *runsEnv, dir string, root *os.Root, skipInvalid bool) (*localTree, error) { + shown := runs.SanitizeLine(dir) + var ( + files []localFile + refused []refusedFile + dotted []string + ) + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if p == dir { + return nil + } + rel, rerr := filepath.Rel(dir, p) + if rerr != nil { + return rerr + } + slash := filepath.ToSlash(rel) + name := norm.NFC.String(slash) + if strings.HasPrefix(d.Name(), ".") { + dotted = append(dotted, name) + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + switch { + case d.Type()&fs.ModeSymlink != 0: + refused = append(refused, refusedFile{name, "symlink", "links are not kept; copy the file instead"}) + return nil + case d.IsDir(): + return nil + case !d.Type().IsRegular(): + refused = append(refused, refusedFile{name, "not_a_file", ""}) + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + files = append(files, localFile{path: name, rel: rel, root: root, size: info.Size(), + execKnown: ctxExecKnown, exec: ctxExecKnown && info.Mode().Perm()&0o111 != 0}) + return nil + }) + if err != nil { + return nil, usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + for _, p := range dotted { + fmt.Fprintf(env.stderr, "skipped %s (dotfiles are not kept in a context)\n", runs.SanitizeLine(p)) + } + + refused = append(refused, checkTree(files)...) + bad := map[string]bool{} + for _, r := range refused { + bad[r.path] = true + } + // A skill folder that lost its SKILL.md loses the whole skill. + for _, r := range refused { + if r.reason == "skill_invalid" && strings.HasSuffix(r.path, "/"+contexts.SkillFileName) { + prefix := strings.TrimSuffix(r.path, contexts.SkillFileName) + for _, f := range files { + if strings.HasPrefix(f.path, prefix) { + bad[f.path] = true + } + } + } + } + if len(refused) > 0 { + sort.SliceStable(refused, func(i, j int) bool { return refused[i].path < refused[j].path }) + verb := "refused" + if skipInvalid { + verb = "skipped" + } + for _, r := range refused { + line := fmt.Sprintf("%s %s: %s", verb, runs.SanitizeLine(r.path), r.reason) + if r.detail != "" { + line += " (" + r.detail + ")" + } + fmt.Fprintln(env.stderr, line) + } + if !skipInvalid { + return nil, exitf(exitDataErr, "%d %s cannot be published; nothing was uploaded. Rename or remove %s, or leave %s out with --skip-invalid", + len(refused), pluralWord(len(refused), "file", "files"), pluralWord(len(refused), "it", "them"), pluralWord(len(refused), "it", "them")) + } + } + kept := files[:0] + for _, f := range files { + if !bad[f.path] { + kept = append(kept, f) + } + } + tree := &localTree{files: kept, root: root} + if err := checkTreeLimits(tree); err != nil { + return nil, err + } + for i := range tree.files { + f := &tree.files[i] + sum, size, err := hashFile(*f) + if err != nil { + return nil, exitf(exitFailed, "cannot read %s: %v", runs.SanitizeLine(f.path), unwrapPath(err)) + } + f.sha, f.size = sum, size + } + sort.Slice(tree.files, func(i, j int) bool { return tree.files[i].path < tree.files[j].path }) + return tree, nil +} + +func pluralWord(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +// checkTree is the platform's path validator over the whole tree (names, +// layout, case collisions), plus the per-file limits a publish enforces. +func checkTree(files []localFile) []refusedFile { + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.path + } + var out []refusedFile + for _, pe := range contexts.ValidateTree(paths) { + r := refusedFile{path: pe.Path, reason: string(pe.Reason)} + top := strings.SplitN(pe.Path, "/", 2)[0] + switch { + case pe.Path == "CLAUDE.md": + r.detail = "rename it to AGENTS.md: that is the file the agents read" + case pe.Reason == contexts.ReasonBadTopLevel && !strings.Contains(pe.Path, "/"): + r.detail = "only AGENTS.md lives at the top; put reference files under files/" + case pe.Reason == contexts.ReasonBadTopLevel && top != "skills" && top != "files": + r.detail = "put reference files under files/ and skills under skills//" + case pe.Reason == contexts.ReasonBadCharacter && top == "skills" && strings.Count(pe.Path, "/") >= 2: + if !contexts.ValidSkillName(strings.SplitN(pe.Path, "/", 3)[1]) { + r.detail = "a skill folder name is lowercase letters, digits and dashes" + } + } + out = append(out, r) + } + skillDirs, skillFiles := map[string]bool{}, map[string]bool{} + for _, f := range files { + switch { + case f.size > api.ContextMaxFileBytes: + out = append(out, refusedFile{f.path, "too_large", "a file is at most 100 MiB"}) + case f.path == contexts.InstructionsPath && f.size > ctxMaxAgentsMD: + out = append(out, refusedFile{f.path, "too_large", "AGENTS.md is at most 32 KiB"}) + } + parts := strings.SplitN(f.path, "/", 3) + if len(parts) == 3 && parts[0] == "skills" && contexts.ValidSkillName(parts[1]) { + skillDirs[parts[1]] = true + if parts[2] == contexts.SkillFileName { + skillFiles[parts[1]] = true + if f.size > ctxMaxSkillMD { + out = append(out, refusedFile{f.path, "skill_invalid", "SKILL.md is at most 64 KiB"}) + } + } + } + } + for dir := range skillDirs { + if !skillFiles[dir] { + out = append(out, refusedFile{"skills/" + dir + "/" + contexts.SkillFileName, "skill_invalid", "every skill folder needs a SKILL.md"}) + } + } + return out +} + +func checkTreeLimits(t *localTree) error { + if len(t.files) > ctxMaxFiles { + return exitf(exitDataErr, "%d files: a context version holds at most %d", len(t.files), ctxMaxFiles) + } + if total := t.totalBytes(); total > ctxMaxTotalBytes { + return exitf(exitDataErr, "%s in total: a context version holds at most 250 MiB", humanBytes(total)) + } + skills := map[string]bool{} + for _, f := range t.files { + if parts := strings.SplitN(f.path, "/", 3); len(parts) == 3 && parts[0] == "skills" { + skills[parts[1]] = true + } + } + if len(skills) > ctxMaxSkills { + return exitf(exitDataErr, "%d skills: a context holds at most %d", len(skills), ctxMaxSkills) + } + return nil +} + +func hashFile(lf localFile) (string, int64, error) { + f, err := lf.open() + if err != nil { + return "", 0, err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), n, nil +} + +// ─── push ────────────────────────────────────────────────────────────────── + +func doContextPush(ctx context.Context, env *runsEnv, ref, dir string, opts pushOptions) error { + if err := checkContextRef(ref); err != nil { + return err + } + local, err := scanLocal(env, dir, opts.skipInvalid) + if err != nil { + return err + } + defer local.close() + d, err := env.client.GetContext(ctx, ref) + if ctxCode(err) == "context_not_found" && opts.create { + if !contexts.ValidContextName(ref) { + return usageErrorf("%s", contextNameRule(ref)) + } + if opts.dryRun { + d = &api.ContextDetail{Context: api.Context{Name: ref, Version: 0}, Files: []api.ContextFile{}} + err = nil + } else { + var c *api.Context + idem := newIdempotencyKey() + err = ctxRetry(ctx, env, "create", func() error { + var cerr error + c, cerr = env.client.CreateContext(ctx, ref, "", idem) + return cerr + }) + if err == nil { + fmt.Fprintf(env.stderr, "context %s created\n", runs.SanitizeLine(c.Name)) + d = &api.ContextDetail{Context: *c, Files: []api.ContextFile{}} + } + } + } + if err != nil { + if ctxCode(err) == "context_not_found" { + return usageErrorf("no context %s: create it with nan context create %s, or add --create", + runs.SanitizeLine(ref), runs.SanitizeLine(ref)) + } + return ctxExit(err, env, scopeContextsRead) + } + return pushTree(ctx, env, d, local, opts) +} + +// treeDiff is what a push changes against the version it is based on. +type treeDiff struct { + Added []string `json:"added"` + Changed []string `json:"changed"` + Removed []string `json:"removed"` +} + +func (d treeDiff) empty() bool { return len(d.Added)+len(d.Changed)+len(d.Removed) == 0 } + +func diffTree(remote []api.ContextFile, local *localTree) treeDiff { + d := treeDiff{Added: []string{}, Changed: []string{}, Removed: []string{}} + have := make(map[string]api.ContextFile, len(remote)) + for _, f := range remote { + have[f.Path] = f + } + seen := map[string]bool{} + for _, f := range local.files { + seen[f.path] = true + r, ok := have[f.path] + switch { + case !ok: + d.Added = append(d.Added, f.path) + case r.SHA256 != f.sha || r.Executable != f.exec: + d.Changed = append(d.Changed, f.path) + } + } + for _, f := range remote { + if !seen[f.Path] { + d.Removed = append(d.Removed, f.Path) + } + } + sort.Strings(d.Added) + sort.Strings(d.Changed) + sort.Strings(d.Removed) + return d +} + +func printDiff(w io.Writer, d treeDiff, sizes map[string]int64) { + for _, p := range d.Added { + fmt.Fprintf(w, " + %s (%s)\n", runs.SanitizeLine(p), humanBytes(sizes[p])) + } + for _, p := range d.Changed { + fmt.Fprintf(w, " ~ %s (%s)\n", runs.SanitizeLine(p), humanBytes(sizes[p])) + } + for _, p := range d.Removed { + fmt.Fprintf(w, " - %s\n", runs.SanitizeLine(p)) + } +} + +// inheritExec takes the executable bit of a file whose content did not +// change from the version being replaced, where the file system cannot say +// (Windows): pushing an unchanged pulled tree must not strip +x. +func inheritExec(remote []api.ContextFile, local *localTree) { + have := make(map[string]api.ContextFile, len(remote)) + for _, f := range remote { + have[f.Path] = f + } + for i := range local.files { + f := &local.files[i] + if r, ok := have[f.path]; ok && !f.execKnown && r.SHA256 == f.sha { + f.exec = r.Executable + } + } +} + +// checkNotEmptied refuses to publish no files over a version that has some +// unless --allow-empty: an empty or wrong folder would otherwise remove the +// whole context, and automations follow the latest version. +func checkNotEmptied(d *api.ContextDetail, local *localTree, opts pushOptions) error { + if len(local.files) > 0 || len(d.Files) == 0 || opts.allowEmpty { + return nil + } + return usageErrorf("the folder has no files to publish: that would remove all %d %s of context %s (automations use the latest version). "+ + "Check the folder, or add --allow-empty to empty the context", len(d.Files), pluralWord(len(d.Files), "file", "files"), runs.SanitizeLine(d.Name)) +} + +// uploadPlan is the blobs a push sends: one local file per missing sha. +type uploadPlan struct { + files []localFile + bytes int64 +} + +func planUploads(local *localTree, missing []string) uploadPlan { + want := map[string]bool{} + for _, s := range missing { + want[s] = true + } + var p uploadPlan + for _, f := range local.files { + if want[f.sha] { + p.files = append(p.files, f) + p.bytes += f.size + delete(want, f.sha) + } + } + return p +} + +func distinctShas(local *localTree) []string { + seen := map[string]bool{} + var out []string + for _, f := range local.files { + if !seen[f.sha] { + seen[f.sha] = true + out = append(out, f.sha) + } + } + sort.Strings(out) + return out +} + +type dryRunReport struct { + Context string `json:"context"` + BaseVersion int `json:"base_version"` + Diff treeDiff `json:"changes"` + Upload struct { + Files int `json:"files"` + Bytes int64 `json:"bytes"` + } `json:"upload"` +} + +func pushTree(ctx context.Context, env *runsEnv, d *api.ContextDetail, local *localTree, opts pushOptions) error { + name := runs.SanitizeLine(d.Name) + sizes := map[string]int64{} + for _, f := range local.files { + sizes[f.path] = f.size + } + inheritExec(d.Files, local) + if err := checkNotEmptied(d, local, opts); err != nil { + return err + } + diff := diffTree(d.Files, local) + if diff.empty() && d.Version > 0 { + if opts.json { + return printJSON(env.stdout, d.Context, true) + } + fmt.Fprintf(env.stdout, "context %s is up to date (version %d)\n", name, d.Version) + return nil + } + var missing []string + if len(local.files) > 0 { + err := ctxRetry(ctx, env, "check", func() error { + var err error + missing, err = env.client.CheckBlobs(ctx, distinctShas(local)) + return err + }) + if err != nil { + return ctxExit(err, env, scopeContextsWrite) + } + } + plan := planUploads(local, missing) + if opts.dryRun { + if opts.json { + r := dryRunReport{Context: d.Name, BaseVersion: d.Version, Diff: diff} + r.Upload.Files, r.Upload.Bytes = len(plan.files), plan.bytes + return printJSON(env.stdout, r, true) + } + fmt.Fprintf(env.stdout, "context %s: version %d -> %d (dry run, nothing changed)\n", name, d.Version, d.Version+1) + printDiff(env.stdout, diff, sizes) + fmt.Fprintf(env.stdout, "%d added, %d changed, %d removed; %d %s to upload (%s)\n", len(diff.Added), len(diff.Changed), + len(diff.Removed), len(plan.files), pluralWord(len(plan.files), "file", "files"), humanBytes(plan.bytes)) + return nil + } + if err := uploadBlobs(ctx, env, plan); err != nil { + return err + } + + req := api.PublishRequest{BaseVersion: d.Version, AllowSecretLike: opts.allowSecrets} + for _, f := range local.files { + req.Files = append(req.Files, api.PublishFile{Path: f.path, SHA256: f.sha, Executable: f.exec}) + } + idem := newIdempotencyKey() + missingRounds, rebases := 0, 0 + for { + var c *api.Context + err := ctxRetry(ctx, env, "publish", func() error { + var err error + c, err = env.client.PublishContext(ctx, d.ID, req, idem) + return err + }) + if err == nil { + return reportPushed(env, c, diff, plan, opts) + } + ce, ok := api.AsContextError(err) + if !ok { + return ctxExit(err, env, scopeContextsWrite) + } + switch ce.Code { + case "blob_missing": + // The platform's cleanup removed a blob between the check and + // the publish: check again and upload what it lacks. + if missingRounds >= ctxRetries { + return ctxExit(err, env, scopeContextsWrite) + } + missingRounds++ + fmt.Fprintf(env.stderr, "%d %s no longer stored on nan.builders; uploading again\n", len(ce.MissingBlobs), + pluralWord(len(ce.MissingBlobs), "file is", "files are")) + again, cerr := env.client.CheckBlobs(ctx, ce.MissingBlobs) + if cerr != nil { + return ctxExit(cerr, env, scopeContextsWrite) + } + if err := uploadBlobs(ctx, env, planUploads(local, again)); err != nil { + return err + } + continue + case "version_conflict": + current := "a newer version" + if ce.CurrentVersion != nil { + current = fmt.Sprintf("version %d", *ce.CurrentVersion) + } + if !opts.force { + return exitf(exitFailed, "context %s changed while pushing (it is now at %s; this push was based on version %d). "+ + "Nothing was published. Push again to compare with it, or add --force to publish on top of it", name, current, req.BaseVersion) + } + if rebases >= ctxRetries { + return exitf(exitFailed, "context %s keeps changing; nothing was published, try again", name) + } + rebases++ + latest, gerr := env.client.GetContext(ctx, d.ID) + if gerr != nil { + return ctxExit(gerr, env, scopeContextsRead) + } + fmt.Fprintf(env.stderr, "context %s changed to version %d meanwhile; publishing on top of it (--force). Against it, this push:\n", + name, latest.Version) + inheritExec(latest.Files, local) + if err := checkNotEmptied(latest, local, opts); err != nil { + return err + } + diff = diffTree(latest.Files, local) + printDiff(env.stderr, diff, sizes) + req.BaseVersion = latest.Version + idem = newIdempotencyKey() + continue + case "context_secret_detected": + for _, h := range ce.Findings { + fmt.Fprintf(env.stderr, " %s:%d looks like %s\n", runs.SanitizeLine(h.Path), h.Line, runs.SanitizeLine(h.Pattern)) + } + return exitf(exitDataErr, "some files look like they hold credentials; nothing was published. Keep credentials in nan secrets "+ + "(the agent can read and copy anything in a context), or publish anyway with --allow-secrets") + case "invalid_path", "unsupported_file_type", "skill_invalid", "context_too_large": + for _, r := range ce.Files { + line := fmt.Sprintf(" %s: %s", runs.SanitizeLine(r.Path), runs.SanitizeLine(r.Reason)) + if r.Detail != "" { + line += " (" + runs.SanitizeLine(r.Detail) + ")" + } + fmt.Fprintln(env.stderr, line) + } + return exitf(exitDataErr, "%s Nothing was published", runs.SanitizeLine(ce.Message)) + } + return ctxExit(err, env, scopeContextsWrite) + } +} + +func reportPushed(env *runsEnv, c *api.Context, diff treeDiff, plan uploadPlan, opts pushOptions) error { + if opts.json { + return printJSON(env.stdout, c, true) + } + fmt.Fprintf(env.stdout, "context %s: version %d published (%d %s, %s; %d added, %d changed, %d removed; uploaded %d %s, %s)\n", + runs.SanitizeLine(c.Name), c.Version, c.FileCount, pluralWord(c.FileCount, "file", "files"), humanBytes(c.TotalBytes), + len(diff.Added), len(diff.Changed), len(diff.Removed), len(plan.files), pluralWord(len(plan.files), "file", "files"), humanBytes(plan.bytes)) + return nil +} + +// uploadBlobs sends every blob of plan, ctxConcurrency at a time. The first +// failure stops the push after the uploads in flight finish; a multipart +// upload that was cut is resumed by the next push. +func uploadBlobs(ctx context.Context, env *runsEnv, plan uploadPlan) error { + if len(plan.files) == 0 { + return nil + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + jobs := make(chan localFile) + var ( + mu sync.Mutex + firstErr error + wg sync.WaitGroup + ) + workers := max(1, min(ctxConcurrency, len(plan.files))) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for f := range jobs { + if err := uploadOne(ctx, env, f); err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + cancel() + } + mu.Unlock() + } + } + }() + } + for _, f := range plan.files { + select { + case jobs <- f: + case <-ctx.Done(): + } + } + close(jobs) + wg.Wait() + return firstErr +} + +func uploadOne(ctx context.Context, env *runsEnv, f localFile) error { + if ctx.Err() != nil { + return ctx.Err() + } + var err error + if f.size <= ctxSingleMax { + err = ctxRetry(ctx, env, "upload "+runs.SanitizeLine(f.path), func() error { return putSingle(ctx, env, f) }) + } else { + err = putMultipart(ctx, env, f) + } + if err != nil { + var exit *ExitError + if errors.As(err, &exit) { + return err + } + e := ctxExit(err, env, scopeContextsWrite) + return &ExitError{Code: e.Code, Err: fmt.Errorf("upload of %s failed: %w", runs.SanitizeLine(f.path), e.Err)} + } + return nil +} + +// openChecked opens a file being uploaded and makes sure it is still the +// size it was hashed at (the platform verifies the sha256 too). +func openChecked(f localFile) (*os.File, error) { + fh, err := f.open() + if err != nil { + return nil, exitf(exitFailed, "cannot read %s: %v", runs.SanitizeLine(f.path), unwrapPath(err)) + } + if st, err := fh.Stat(); err != nil || st.Size() != f.size { + fh.Close() + return nil, exitf(exitFailed, "%s changed while pushing; push again", runs.SanitizeLine(f.path)) + } + return fh, nil +} + +func putSingle(ctx context.Context, env *runsEnv, f localFile) error { + fh, err := openChecked(f) + if err != nil { + return err + } + defer fh.Close() + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + _, err = env.client.PutBlob(ctx, f.sha, io.NewSectionReader(fh, 0, f.size), f.size, path.Base(f.path)) + return err +} + +// uploadState is what a push remembers about a multipart upload it did not +// finish: enough to ask the platform where it stands. The id alone grants +// nothing without the member's credential. +type uploadState struct { + UploadID string `json:"upload_id"` + Size int64 `json:"size"` +} + +var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +// uploadAccount keys the remembered uploads by API host and credential (a +// hash, never the credential itself): an upload id is only ever sent back +// to the account and endpoint that issued it. +func uploadAccount(env *runsEnv) string { + h := sha256.New() + _, _ = io.WriteString(h, env.client.BaseURL()+"\x00"+env.cred.session+"\x00"+env.cred.bearer) + return hex.EncodeToString(h.Sum(nil))[:32] +} + +func uploadStatePath(env *runsEnv, sha string) (string, error) { + if !shaPattern.MatchString(sha) { + return "", fmt.Errorf("bad sha") + } + dir, err := ctxUploadStateDir() + if err != nil { + return "", err + } + return filepath.Join(dir, uploadAccount(env), sha+".json"), nil +} + +func loadUploadState(env *runsEnv, sha string, size int64) *uploadState { + p, err := uploadStatePath(env, sha) + if err != nil { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + var s uploadState + if json.Unmarshal(b, &s) != nil || s.Size != size || s.UploadID == "" { + return nil + } + return &s +} + +func saveUploadState(env *runsEnv, sha string, s uploadState) { + p, err := uploadStatePath(env, sha) + if err != nil { + return + } + if os.MkdirAll(filepath.Dir(p), 0o700) != nil { + return + } + b, _ := json.Marshal(s) + _ = os.WriteFile(p, b, 0o600) +} + +func clearUploadState(env *runsEnv, sha string) { + if p, err := uploadStatePath(env, sha); err == nil { + _ = os.Remove(p) + } +} + +// validUpload checks the platform's description of an upload against the +// file: a part size this client can send and the part count it implies. +func validUpload(u *api.ContextUpload, size int64) bool { + if u == nil || u.PartSize <= 0 || u.PartSize > api.ContextPartSize || u.NextPart < 1 { + return false + } + parts := (size + int64(u.PartSize) - 1) / int64(u.PartSize) + return int64(u.Parts) == parts && u.NextPart <= u.Parts+1 +} + +func putMultipart(ctx context.Context, env *runsEnv, f localFile) error { + fh, err := openChecked(f) + if err != nil { + return err + } + defer fh.Close() + shown := runs.SanitizeLine(f.path) + + var up *api.ContextUpload + if s := loadUploadState(env, f.sha, f.size); s != nil { + st, err := env.client.UploadStatus(ctx, f.sha, s.UploadID) + switch { + case err == nil && validUpload(st, f.size): + up = st + up.UploadID = s.UploadID + fmt.Fprintf(env.stderr, "resuming the upload of %s at part %d of %d\n", shown, up.NextPart, up.Parts) + case err == nil: + // An upload this client cannot continue: drop it, or its marker + // would refuse the new one (upload_in_progress). + _ = env.client.AbortUpload(ctx, f.sha, s.UploadID) + clearUploadState(env, f.sha) + case ctxCode(err) == "upload_not_found", ctxCode(err) == "upload_expired": + clearUploadState(env, f.sha) + default: + return err + } + } + if up == nil { + var blob *api.ContextBlob + err := ctxRetry(ctx, env, "upload "+shown, func() error { + var err error + up, blob, err = env.client.StartUpload(ctx, f.sha, f.size, path.Base(f.path)) + return err + }) + if err != nil { + return err + } + if blob != nil { + return nil // the platform already holds it + } + if !validUpload(up, f.size) { + return exitf(exitUnavailable, "nan.builders answered an upload of %s this CLI cannot send; update nan (nan --version)", shown) + } + saveUploadState(env, f.sha, uploadState{UploadID: up.UploadID, Size: f.size}) + } + + failures := 0 + for n := up.NextPart; n <= up.Parts; { + off := int64(n-1) * int64(up.PartSize) + size := min(int64(up.PartSize), f.size-off) + pctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + next, err := env.client.PutPart(pctx, f.sha, up.UploadID, n, io.NewSectionReader(fh, off, size), size) + cancel() + if err == nil && next > n { + n = next + continue + } + if ctx.Err() != nil { + return ctx.Err() + } + if code := ctxCode(err); code == "upload_not_found" || code == "upload_expired" { + clearUploadState(env, f.sha) + return err + } + failures++ + if failures > ctxRetries { + if err == nil { + err = fmt.Errorf("the platform did not take part %d", n) + } + return err + } + if ce, ok := api.AsContextError(err); ok && retryable(ce) && ce.RetryAfter > 0 && ce.RetryAfter <= ctxMaxWait { + if serr := ctxSleep(ctx, ce.RetryAfter); serr != nil { + return err + } + } + // Ask where the upload stands: the part may have been stored + // before the answer was lost, or another attempt holds it. + st, serr := env.client.UploadStatus(ctx, f.sha, up.UploadID) + if serr != nil { + return err + } + if !validUpload(st, f.size) { + return exitf(exitUnavailable, "nan.builders answered an upload of %s this CLI cannot send; update nan (nan --version)", shown) + } + fmt.Fprintf(env.stderr, "part %d of %s did not go through; resuming at part %d\n", n, shown, st.NextPart) + n = st.NextPart + } + err = ctxRetry(ctx, env, "upload "+shown, func() error { + cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + _, err := env.client.CompleteUpload(cctx, f.sha, up.UploadID) + return err + }) + if err != nil { + if code := ctxCode(err); code == "upload_not_found" || code == "upload_expired" || code == "blob_hash_mismatch" { + clearUploadState(env, f.sha) + } + return err + } + clearUploadState(env, f.sha) + return nil +} + +// ─── pull ────────────────────────────────────────────────────────────────── + +func doContextPull(ctx context.Context, env *runsEnv, ref, dir string) error { + if err := checkContextRef(ref); err != nil { + return err + } + shown := runs.SanitizeLine(dir) + if st, err := os.Stat(dir); err == nil { + if !st.IsDir() { + return usageErrorf("%s is not a folder", shown) + } + entries, err := os.ReadDir(dir) + if err != nil { + return usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + if len(entries) > 0 && !ctxPullForce { + return usageErrorf("%s is not empty: pull into an empty or new folder, or add --force to write into it", shown) + } + } else if !errors.Is(err, fs.ErrNotExist) { + return usageErrorf("cannot read %s: %v", shown, unwrapPath(err)) + } + + d, err := env.client.GetContext(ctx, ref) + if err != nil { + return ctxExit(err, env, scopeContextsRead) + } + if err := checkServerTree(env, d.Files); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return usageErrorf("cannot create %s: %v", shown, unwrapPath(err)) + } + root, err := os.OpenRoot(dir) + if err != nil { + return usageErrorf("cannot open %s: %v", shown, unwrapPath(err)) + } + defer root.Close() + + files := append([]api.ContextFile(nil), d.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + var total int64 + for i, f := range files { + if err := pullOne(ctx, env, root, d, f); err != nil { + if i == 0 { + return err + } + var exit *ExitError + errors.As(err, &exit) + return &ExitError{Code: exit.Code, Err: fmt.Errorf("%v. %d of %d files were already written to %s; run the pull again with --force to finish it", + exit.Err, i, len(files), shown)} + } + total += f.Size + } + fmt.Fprintf(env.stdout, "context %s version %d written to %s (%d %s, %s)\n", runs.SanitizeLine(d.Name), d.Version, shown, + len(files), pluralWord(len(files), "file", "files"), humanBytes(total)) + return nil +} + +// checkServerTree refuses a file list a well-behaved platform never sends: +// paths the shared validator refuses (absolute, .., case collisions, +// reserved names, ...), bad hashes or sizes, more than a version holds. +// Nothing is written then. +func checkServerTree(env *runsEnv, files []api.ContextFile) error { + if len(files) > ctxMaxFiles { + return exitf(exitDataErr, "nan.builders sent %d files, more than a context holds; nothing was written", len(files)) + } + paths := make([]string, len(files)) + var bad []string + var total int64 + for i, f := range files { + paths[i] = f.Path + if !shaPattern.MatchString(f.SHA256) || f.Size < 0 || f.Size > api.ContextMaxFileBytes { + bad = append(bad, fmt.Sprintf("%s: bad hash or size", runs.SanitizeLine(f.Path))) + continue + } + total += f.Size + } + if total > ctxMaxTotalBytes { + return exitf(exitDataErr, "nan.builders sent %s of files, more than a context version holds (250 MiB); nothing was written", humanBytes(total)) + } + for _, pe := range contexts.ValidateTree(paths) { + bad = append(bad, fmt.Sprintf("%s: %s", runs.SanitizeLine(pe.Path), pe.Reason)) + } + if len(bad) == 0 { + return nil + } + sort.Strings(bad) + for _, b := range bad { + fmt.Fprintf(env.stderr, "refused %s\n", b) + } + return exitf(exitDataErr, "nan.builders sent %d %s this CLI will not write; nothing was written", len(bad), pluralWord(len(bad), "path", "paths")) +} + +// pullOne downloads one file into a temporary name next to it, checks it, +// sets its mode and renames it into place: a failed download never leaves +// a half file nor removes one the member had. An existing link or folder at +// the path is never replaced. +func pullOne(ctx context.Context, env *runsEnv, root *os.Root, d *api.ContextDetail, f api.ContextFile) error { + shown := runs.SanitizeLine(f.Path) + local := filepath.FromSlash(f.Path) + parent := filepath.Dir(local) + if parent != "." { + if err := root.MkdirAll(parent, 0o755); err != nil { + return exitf(exitFailed, "cannot create the folder of %s: %v", shown, unwrapPath(err)) + } + } + switch st, err := root.Lstat(local); { + case err == nil && (st.Mode()&fs.ModeSymlink != 0 || !st.Mode().IsRegular()): + return exitf(exitFailed, "%s is a link or a folder in the target folder; it was left as it is and nothing was written there", shown) + case err == nil && !ctxPullForce: + return exitf(exitFailed, "%s already exists (use --force to replace files)", shown) + case err != nil && !errors.Is(err, fs.ErrNotExist): + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + tmp := filepath.Join(parent, ".nan-pull-"+newIdempotencyKey()[len("nan-cli-"):]) + out, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + fail := func(e *ExitError) error { + out.Close() + _ = root.Remove(tmp) + return e + } + var body io.ReadCloser + err = ctxRetry(ctx, env, "download "+shown, func() error { + var err error + body, err = env.client.OpenContextFile(ctx, d.ID, f.Path, d.Version) + return err + }) + if err != nil { + return fail(ctxExit(err, env, scopeContextsRead)) + } + defer body.Close() + h := sha256.New() + n, err := io.Copy(io.MultiWriter(out, h), io.LimitReader(body, f.Size+1)) + if err != nil { + return fail(exitf(exitUnavailable, "the download of %s was cut: %v", shown, err)) + } + if n != f.Size || hex.EncodeToString(h.Sum(nil)) != f.SHA256 { + return fail(exitf(exitUnavailable, "the download of %s does not match its sha256 and size; it was not kept", shown)) + } + if err := out.Close(); err != nil { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, err) + } + mode := os.FileMode(0o644) + if f.Executable { + mode = 0o755 + } + // Both ways: a replaced file that was executable and no longer is + // loses the bit. + if err := root.Chmod(tmp, mode); err != nil && runtime.GOOS != "windows" { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, err) + } + if err := root.Rename(tmp, local); err != nil { + _ = root.Remove(tmp) + return exitf(exitFailed, "cannot write %s: %v", shown, unwrapPath(err)) + } + return nil +} diff --git a/cmd/context_fake_test.go b/cmd/context_fake_test.go new file mode 100644 index 0000000..e25d0fe --- /dev/null +++ b/cmd/context_fake_test.go @@ -0,0 +1,558 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" +) + +// fakeCtx is an in-memory /v1/contexts with the shapes, codes and status +// codes of cloud-api's handlers (internal/handlers/contexts.go, +// contexts_crud.go and internal/contexts/errors.go): the OpenAI error +// envelope with the extra fields at the top level, 201 for a new blob or +// version, 200 for a blob the member already has, Retry-After on 429/409. + +const ( + fakeCtxID = "c7000000-0000-4000-8000-000000000001" + fakeCtxStamp = "2026-10-21T10:00:00Z" + fakeSecretPat = "ghp_" +) + +type fakeContext struct { + ID, Name, Description string + Version int + Files []api.ContextFile +} + +type fakeUpload struct { + sha string + size int64 + partSize int + next int + data []byte +} + +type ctxReq struct { + Method, Path, Query, Body, Idem, Filename string + Size int64 +} + +type fakeResp struct { + status int + body map[string]any + header map[string]string +} + +type fakeCtx struct { + mu sync.Mutex + off bool + contexts []*fakeContext + blobs map[string][]byte + uploads map[string]*fakeUpload + partSize int + reqs []ctxReq + // queued answers "METHOD /path" before the normal handling. + queued map[string][]fakeResp + // onPublish runs before a publish is handled (f.mu held). + onPublish func(f *fakeCtx, n int) + publishes int + // corrupt serves wrong bytes for these paths on download. + corrupt map[string]bool + // failPart answers 500 to the first PUT of these part numbers. + failPart map[int]bool + srv *httptest.Server +} + +// server starts the fake once; every run against it sees the same URL, so +// the remembered uploads (keyed by API host and account) carry over. +func (f *fakeCtx) server(t *testing.T) *httptest.Server { + if f.srv == nil { + f.srv = httptest.NewServer(f) + t.Cleanup(f.srv.Close) + } + return f.srv +} + +// stateFile is where a push run against f remembers an upload of sha. +func (f *fakeCtx) stateFile(t *testing.T, stateDir, sha string) string { + h := sha256.Sum256([]byte(f.server(t).URL + "\x00" + "sess-cli" + "\x00")) + return filepath.Join(stateDir, hex.EncodeToString(h[:])[:32], sha+".json") +} + +// strict decodes a JSON body like cloud-api: unknown fields are a 400. +func strict(w http.ResponseWriter, body []byte, v any) bool { + dec := json.NewDecoder(bytes.NewReader(body)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + ctxFail(w, 400, "invalid_request", "body has an unknown or malformed field", nil, 0) + return false + } + return true +} + +func newFakeCtx() *fakeCtx { + return &fakeCtx{ + blobs: map[string][]byte{}, + uploads: map[string]*fakeUpload{}, + partSize: api.ContextPartSize, + queued: map[string][]fakeResp{}, + corrupt: map[string]bool{}, + failPart: map[int]bool{}, + } +} + +func shaOf(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +// seed adds a context whose files hold the given contents. +func (f *fakeCtx) seed(name string, version int, files map[string]string) *fakeContext { + c := &fakeContext{ID: fakeCtxID, Name: name, Description: "Acme API conventions", Version: version} + if len(f.contexts) > 0 { + c.ID = fmt.Sprintf("c7000000-0000-4000-8000-%012d", len(f.contexts)+1) + } + for p, body := range files { + sha := shaOf([]byte(body)) + f.blobs[sha] = []byte(body) + c.Files = append(c.Files, api.ContextFile{Path: p, SHA256: sha, Size: int64(len(body)), Executable: strings.HasSuffix(p, ".sh")}) + } + sort.Slice(c.Files, func(i, j int) bool { return c.Files[i].Path < c.Files[j].Path }) + f.contexts = append(f.contexts, c) + return c +} + +func (f *fakeCtx) queue(route string, r fakeResp) { + f.mu.Lock() + defer f.mu.Unlock() + f.queued[route] = append(f.queued[route], r) +} + +func (f *fakeCtx) requests() []ctxReq { + f.mu.Lock() + defer f.mu.Unlock() + return append([]ctxReq(nil), f.reqs...) +} + +func (f *fakeCtx) sent(method, prefix string) []ctxReq { + var out []ctxReq + for _, r := range f.requests() { + if r.Method == method && strings.HasPrefix(r.Path, prefix) { + out = append(out, r) + } + } + return out +} + +func (c *fakeContext) view() map[string]any { + var total int64 + agents := false + skills := []string{} + seen := map[string]bool{} + for _, f := range c.Files { + total += f.Size + agents = agents || f.Path == "AGENTS.md" + if parts := strings.SplitN(f.Path, "/", 3); len(parts) == 3 && parts[0] == "skills" && !seen[parts[1]] { + seen[parts[1]] = true + skills = append(skills, parts[1]) + } + } + sort.Strings(skills) + return map[string]any{"id": c.ID, "object": "context", "name": c.Name, "description": c.Description, "version": c.Version, + "tree_sha256": strings.Repeat("ab", 32), "file_count": len(c.Files), "total_bytes": total, "has_instructions": agents, + "skills": skills, "created_at": fakeCtxStamp, "updated_at": fakeCtxStamp} +} + +func ctxFail(w http.ResponseWriter, status int, code, msg string, extra map[string]any, retryAfter int) { + if retryAfter > 0 { + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + } + typ := "invalid_request_error" + if status == 429 { + typ = "rate_limit_exceeded" + } else if status >= 500 { + typ = "server_error" + } + body := map[string]any{"error": map[string]any{"message": msg, "type": typ, "param": nil, "code": code}} + for k, v := range extra { + body[k] = v + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func ctxJSONResp(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func (f *fakeCtx) find(ref string) *fakeContext { + for _, c := range f.contexts { + if c.ID == ref || c.Name == ref { + return c + } + } + return nil +} + +func (f *fakeCtx) blobView(sha string) map[string]any { + b := f.blobs[sha] + findings := []any{} + if bytes.Contains(b, []byte(fakeSecretPat)) { + findings = append(findings, map[string]any{"line": 1, "pattern": "github_token"}) + } + return map[string]any{"sha256": sha, "size": len(b), "format": "text", "secret_findings": findings} +} + +func (f *fakeCtx) ServeHTTP(w http.ResponseWriter, r *http.Request) { + isRaw := r.Header.Get("Content-Type") == "application/octet-stream" + if r.Method == http.MethodPut && r.ContentLength < 0 { + ctxFail(w, 411, "invalid_request", "Content-Length is required", nil, 0) + return + } + body, _ := io.ReadAll(r.Body) + f.mu.Lock() + defer f.mu.Unlock() + rec := ctxReq{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Idem: r.Header.Get("Idempotency-Key"), + Filename: r.Header.Get("X-Nan-Filename"), Size: int64(len(body))} + if !isRaw { + rec.Body = string(body) + } + f.reqs = append(f.reqs, rec) + if f.off { + ctxFail(w, 404, "not_found", "Not found.", nil, 0) + return + } + route := r.Method + " " + r.URL.Path + if q := f.queued[route]; len(q) > 0 { + f.queued[route] = q[1:] + for k, v := range q[0].header { + w.Header().Set(k, v) + } + if q[0].body == nil { + w.WriteHeader(q[0].status) + return + } + ctxJSONResp(w, q[0].status, q[0].body) + return + } + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/contexts"), "/") + parts := strings.Split(rest, "/") + switch { + case rest == "" && r.Method == http.MethodGet: + data := []any{} + for _, c := range f.contexts { + data = append(data, c.view()) + } + ctxJSONResp(w, 200, map[string]any{"data": data, "usage": map[string]any{"stored_bytes": 1536, "quota_bytes": 500 << 20}}) + case rest == "" && r.Method == http.MethodPost: + var in struct { + Name string `json:"name"` + Description string `json:"description"` + } + if !strict(w, body, &in) { + return + } + if f.find(in.Name) != nil { + ctxFail(w, 409, "context_name_taken", "You already have a context with this name.", nil, 0) + return + } + c := &fakeContext{ID: fmt.Sprintf("c7000000-0000-4000-8000-%012d", len(f.contexts)+1), Name: in.Name, Description: in.Description, Version: 1} + f.contexts = append(f.contexts, c) + ctxJSONResp(w, 201, c.view()) + case parts[0] == "blobs": + f.serveBlobs(w, r, parts[1:], body) + default: + c := f.find(parts[0]) + if c == nil { + ctxFail(w, 404, "context_not_found", "context not found", nil, 0) + return + } + sub := strings.Join(parts[1:], "/") + switch r.Method + " " + sub { + case "GET ": + v := c.view() + files := []any{} + for _, fl := range c.Files { + files = append(files, map[string]any{"path": fl.Path, "size": fl.Size, "sha256": fl.SHA256, "executable": fl.Executable}) + } + v["files"] = files + ctxJSONResp(w, 200, v) + case "PATCH ": + var in struct { + Name *string `json:"name"` + Description *string `json:"description"` + } + if !strict(w, body, &in) { + return + } + if in.Name != nil { + c.Name = *in.Name + } + if in.Description != nil { + c.Description = *in.Description + } + ctxJSONResp(w, 200, c.view()) + case "DELETE ": + for i, x := range f.contexts { + if x == c { + f.contexts = append(f.contexts[:i], f.contexts[i+1:]...) + break + } + } + w.WriteHeader(204) + case "GET file": + p := r.URL.Query().Get("path") + if v := r.URL.Query().Get("version"); v != strconv.Itoa(c.Version) { + ctxFail(w, 404, "version_not_found", "version not found", nil, 0) + return + } + for _, fl := range c.Files { + if fl.Path == p { + b := f.blobs[fl.SHA256] + if f.corrupt[p] { + b = append([]byte("X"), b[1:]...) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(b) + return + } + } + ctxFail(w, 404, "file_not_found", "file not found", nil, 0) + case "POST versions": + f.publishes++ + if f.onPublish != nil { + f.onPublish(f, f.publishes) + } + var in api.PublishRequest + if !strict(w, body, &in) { + return + } + // cloud-api's order (service.Publish): the tree (paths, then + // blobs), then base_version. + paths := make([]string, len(in.Files)) + for i, fl := range in.Files { + paths[i] = fl.Path + } + if bad := contexts.ValidateTree(paths); len(bad) > 0 { + refused := []any{} + for _, b := range bad { + refused = append(refused, map[string]any{"path": b.Path, "reason": string(b.Reason)}) + } + ctxFail(w, 400, "invalid_path", "Some file names are not allowed.", map[string]any{"files": refused}, 0) + return + } + var missing []string + for _, fl := range in.Files { + if _, ok := f.blobs[fl.SHA256]; !ok { + missing = append(missing, fl.SHA256) + } + } + if len(missing) > 0 { + ctxFail(w, 409, "blob_missing", "Some files are no longer stored; upload them again and publish.", map[string]any{"sha256": missing}, 0) + return + } + if in.BaseVersion != c.Version { + ctxFail(w, 409, "version_conflict", "The context changed since you loaded it; reload and publish again.", + map[string]any{"current_version": c.Version}, 0) + return + } + if !in.AllowSecretLike { + var hits []any + for _, fl := range in.Files { + if bytes.Contains(f.blobs[fl.SHA256], []byte(fakeSecretPat)) { + hits = append(hits, map[string]any{"path": fl.Path, "line": 1, "pattern": "github_token"}) + } + } + if len(hits) > 0 { + ctxFail(w, 422, "context_secret_detected", "Some files look like they contain secrets. Keep secrets in Secrets, or publish anyway.", + map[string]any{"findings": hits}, 0) + return + } + } + c.Version++ + c.Files = nil + for _, fl := range in.Files { + c.Files = append(c.Files, api.ContextFile{Path: fl.Path, SHA256: fl.SHA256, Size: int64(len(f.blobs[fl.SHA256])), Executable: fl.Executable}) + } + ctxJSONResp(w, 201, c.view()) + default: + ctxFail(w, 404, "not_found", "no route "+route, nil, 0) + } + } +} + +func (f *fakeCtx) serveBlobs(w http.ResponseWriter, r *http.Request, parts []string, body []byte) { + if len(parts) == 1 && parts[0] == "check" { + var in struct { + SHA256 []string `json:"sha256"` + } + if !strict(w, body, &in) { + return + } + missing := []string{} + for _, s := range in.SHA256 { + if _, ok := f.blobs[s]; !ok { + missing = append(missing, s) + } + } + ctxJSONResp(w, 200, map[string]any{"missing": missing}) + return + } + sha := parts[0] + switch { + case len(parts) == 1 && r.Method == http.MethodPut: + if int64(len(body)) > api.ContextPartSize { + ctxFail(w, 413, "invalid_request", "a single upload is at most 16 MiB", nil, 0) + return + } + if _, ok := f.blobs[sha]; ok { + ctxJSONResp(w, 200, f.blobView(sha)) + return + } + if shaOf(body) != sha { + ctxFail(w, 422, "blob_hash_mismatch", "The uploaded bytes do not match the declared sha256 and size; nothing was stored.", nil, 0) + return + } + f.blobs[sha] = body + ctxJSONResp(w, 201, f.blobView(sha)) + case len(parts) == 2 && r.Method == http.MethodPost: + if _, ok := f.blobs[sha]; ok { + ctxJSONResp(w, 200, f.blobView(sha)) + return + } + var in struct { + Size int64 `json:"size"` + Filename string `json:"filename"` + } + if !strict(w, body, &in) { + return + } + id := fmt.Sprintf("u0000000-0000-4000-8000-%012d", len(f.uploads)+1) + u := &fakeUpload{sha: sha, size: in.Size, partSize: f.partSize, next: 1} + f.uploads[id] = u + ctxJSONResp(w, 201, f.uploadView(id, u)) + case len(parts) >= 3: + id := parts[2] + u := f.uploads[id] + if u == nil || u.sha != sha { + ctxFail(w, 404, "upload_not_found", "upload not found", nil, 0) + return + } + sub := strings.Join(parts[3:], "/") + switch { + case r.Method == http.MethodGet && sub == "": + ctxJSONResp(w, 200, f.uploadView(id, u)) + case r.Method == http.MethodPut && strings.HasPrefix(sub, "parts/"): + n, _ := strconv.Atoi(strings.TrimPrefix(sub, "parts/")) + if f.failPart[n] { + delete(f.failPart, n) + ctxFail(w, 500, "internal_error", "Something went wrong. Try again.", nil, 0) + return + } + if n < u.next { + ctxJSONResp(w, 200, map[string]int{"next_part": u.next}) + return + } + if n != u.next { + ctxFail(w, 409, "part_conflict", "out of order", nil, 0) + return + } + u.data = append(u.data, body...) + u.next++ + ctxJSONResp(w, 200, map[string]int{"next_part": u.next}) + case r.Method == http.MethodPost && sub == "complete": + if int64(len(u.data)) != u.size || shaOf(u.data) != sha { + ctxFail(w, 422, "blob_hash_mismatch", "mismatch", nil, 0) + return + } + f.blobs[sha] = u.data + delete(f.uploads, id) + ctxJSONResp(w, 201, f.blobView(sha)) + case r.Method == http.MethodDelete && sub == "": + delete(f.uploads, id) + w.WriteHeader(204) + default: + ctxFail(w, 404, "not_found", "no route", nil, 0) + } + default: + ctxFail(w, 404, "not_found", "no route", nil, 0) + } +} + +func (f *fakeCtx) uploadView(id string, u *fakeUpload) map[string]any { + parts := int((u.size + int64(u.partSize) - 1) / int64(u.partSize)) + return map[string]any{"upload_id": id, "part_size": u.partSize, "parts": parts, "next_part": u.next} +} + +// ctxRun is one nan invocation against a fakeCtx. +type ctxRun struct { + code int + msg string + stdout, stderr string + sleeps []time.Duration + stateDir string +} + +type ctxSetup struct { + stdin string + tty bool + stateDir string // "" = a fresh temp dir +} + +func runCtx(t *testing.T, fake *fakeCtx, setup ctxSetup, args ...string) ctxRun { + t.Helper() + srv := fake.server(t) + fakeHome(t, cliSession) + t.Setenv(tokenEnvVar, "") + swap(t, &runsBaseURL, srv.URL) + swap(t, &writeStdin, io.Reader(strings.NewReader(setup.stdin))) + tty := setup.tty + swap(t, &writeStdinTTY, func() bool { return tty }) + var out, errOut bytes.Buffer + swap(t, &writeStdout, io.Writer(&out)) + swap(t, &writeStderr, io.Writer(&errOut)) + state := setup.stateDir + if state == "" { + state = t.TempDir() + } + swap(t, &ctxUploadStateDir, func() (string, error) { return state, nil }) + var mu sync.Mutex + var sleeps []time.Duration + swap(t, &ctxSleep, func(_ context.Context, d time.Duration) error { + mu.Lock() + defer mu.Unlock() + sleeps = append(sleeps, d) + return nil + }) + code, msg := execute(t, args...) + mu.Lock() + defer mu.Unlock() + return ctxRun{code: code, msg: msg, stdout: out.String(), stderr: errOut.String(), sleeps: sleeps, stateDir: state} +} + +func mustCtxExit(t *testing.T, r ctxRun, code int, wants ...string) { + t.Helper() + if r.code != code { + t.Fatalf("exit %d (want %d): %s\nstdout: %s\nstderr: %s", r.code, code, r.msg, r.stdout, r.stderr) + } + wantAll(t, "message", r.msg, wants...) +} diff --git a/cmd/context_hardening_test.go b/cmd/context_hardening_test.go new file mode 100644 index 0000000..e9ace57 --- /dev/null +++ b/cmd/context_hardening_test.go @@ -0,0 +1,349 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// The review findings of #62 (QA and Security), each as a test first. + +func skipOnWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("needs symlinks and Unix modes") + } +} + +// A DIR that is itself a symlink is the folder the member named: push walks +// what it points at instead of publishing an empty tree. +func TestContextPushThroughASymlinkedFolder(t *testing.T) { + skipOnWindows(t) + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + real := writeTree(t, map[string]string{"files/a.md": "a\n", "files/b.md": "b\n"}) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", link) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 || strings.Join(publishPaths(publishBody(t, pubs[0])), ",") != "files/a.md,files/b.md" { + t.Errorf("published %+v", pubs) + } +} + +// An empty folder never silently empties a context. +func TestContextPushRefusesEmptyingAContext(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + empty := t.TempDir() + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty) + mustCtxExit(t, r, exitUsage, "no files to publish", "remove all 1 file", "--allow-empty") + if len(fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions")) != 0 { + t.Fatal("published an empty tree") + } + // Only dotfiles left is the same thing. + dotted := writeTree(t, map[string]string{".env": "S=1\n"}) + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dotted), exitUsage, "--allow-empty") + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty, "--allow-empty") + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 || len(publishBody(t, pubs[0]).Files) != 0 || fake.contexts[0].Version != 3 { + t.Errorf("publishes %+v", pubs) + } + // An empty context takes an empty folder without the flag. + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", empty) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date") +} + +// Writes go through os.OpenRoot: a link already inside DIR never leads a +// pull outside it, and a link at a file's path is never replaced. +func TestContextPullNeverWritesThroughALink(t *testing.T) { + skipOnWindows(t) + t.Run("files is a link", func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "a\n"}) + outside := t.TempDir() + dir := t.TempDir() + if err := os.Symlink(outside, filepath.Join(dir, "files")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitFailed) + if entries, _ := os.ReadDir(outside); len(entries) != 0 { + t.Errorf("wrote outside DIR: %v", entries) + } + }) + t.Run("files/a.md is a link", func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "a\n"}) + outside := filepath.Join(t.TempDir(), "victim") + if err := os.WriteFile(outside, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(dir, "files", "a.md")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitFailed, "link") + if b, _ := os.ReadFile(outside); string(b) != "keep\n" { + t.Errorf("wrote through the link: %q", b) + } + if st, err := os.Lstat(filepath.Join(dir, "files", "a.md")); err != nil || st.Mode()&os.ModeSymlink == 0 { + t.Errorf("replaced the link: %v", err) + } + }) +} + +// The limits of one version, checked before anything is hashed or sent. +func TestContextPushTreeLimits(t *testing.T) { + sparse := func(t *testing.T, dir, rel string, size int64) { + t.Helper() + p := filepath.Join(dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(size); err != nil { + t.Fatal(err) + } + f.Close() + } + for name, tc := range map[string]struct { + build func(t *testing.T, dir string) + want string + }{ + "31 skills": {func(t *testing.T, dir string) { + for i := 0; i < 31; i++ { + sparse(t, dir, fmt.Sprintf("skills/s%d/SKILL.md", i), 10) + } + }, "31 skills: a context holds at most 30"}, + "1001 files": {func(t *testing.T, dir string) { + for i := 0; i < 1001; i++ { + sparse(t, dir, fmt.Sprintf("files/f%d.md", i), 0) + } + }, "1001 files: a context version holds at most 1000"}, + "250 MiB": {func(t *testing.T, dir string) { + for i := 0; i < 3; i++ { + sparse(t, dir, fmt.Sprintf("files/big%d.bin", i), 90<<20) + } + }, "270.0 MiB in total: a context version holds at most 250 MiB"}, + "100 MiB file": {func(t *testing.T, dir string) { + sparse(t, dir, "files/huge.bin", 100<<20+1) + }, ""}, + "64 KiB SKILL.md": {func(t *testing.T, dir string) { + sparse(t, dir, "skills/x/SKILL.md", 64<<10+1) + }, ""}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := t.TempDir() + tc.build(t, dir) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, tc.want) + switch name { + case "100 MiB file": + wantAll(t, "stderr", r.stderr, "refused files/huge.bin: too_large (a file is at most 100 MiB)") + case "64 KiB SKILL.md": + wantAll(t, "stderr", r.stderr, "refused skills/x/SKILL.md: skill_invalid (SKILL.md is at most 64 KiB)") + } + if len(fake.requests()) != 0 { + t.Errorf("sent %d requests", len(fake.requests())) + } + }) + } + // At the limits exactly, nothing is refused locally. + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := t.TempDir() + for i := 0; i < 30; i++ { + sparse(t, dir, fmt.Sprintf("skills/s%d/SKILL.md", i), 0) + } + sparse(t, dir, "skills/s0/SKILL.md", 64<<10) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run") + mustCtxExit(t, r, 0) +} + +// A remembered upload the platform describes in a way this CLI cannot +// continue is aborted (its marker would refuse a new one), and a new one +// is started. +func TestContextPushAbortsAnUnusableRememberedUpload(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000042" + fake.uploads[id] = &fakeUpload{sha: sha, size: 10, partSize: 32 << 20, next: 1} + state := t.TempDir() + writeState(t, fake.stateFile(t, state, sha), id, 10) + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if dels := fake.sent("DELETE", "/v1/contexts/blobs/"+sha+"/uploads/"+id); len(dels) != 1 { + t.Errorf("abort not sent: %v", fake.requests()) + } + if _, err := os.Stat(fake.stateFile(t, state, sha)); !os.IsNotExist(err) { + t.Errorf("state file left: %v", err) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } +} + +// An upload the platform dropped mid-way (expired, or gone) is forgotten: +// the next push starts a new one instead of asking for it again. +func TestContextPushForgetsAnUploadThePlatformDropped(t *testing.T) { + for code, status := range map[string]int{"upload_not_found": 404, "upload_expired": 409} { + t.Run(code, func(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000001" // the fake's first upload id + fake.queue("PUT /v1/contexts/blobs/"+sha+"/uploads/"+id+"/parts/2", fakeResp{status: status, body: map[string]any{ + "error": map[string]any{"message": "The upload expired before it finished; upload the file again.", "type": "invalid_request_error", "param": nil, "code": code}}}) + state := t.TempDir() + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + if r.code == 0 { + t.Fatalf("push succeeded: %s", r.stdout) + } + wantAll(t, "message", r.msg, "upload of files/big.csv failed") + if _, err := os.Stat(fake.stateFile(t, state, sha)); !os.IsNotExist(err) { + t.Errorf("state file left: %v", err) + } + }) + } +} + +func TestContextExitMapping(t *testing.T) { + for _, tc := range []struct { + status int + code string + exit int + want string + }{ + {403, "entitlement_required", exitAuth, "membership does not include agent runs"}, + {503, "storage_unavailable", exitUnavailable, "unavailable"}, + {409, "context_name_taken", exitUsage, "already have a context"}, + {409, "too_many_contexts", exitDataErr, "at most 20"}, + } { + fake := newFakeCtx() + msg := "Context storage is unavailable right now; try again later." + if tc.code == "too_many_contexts" { + msg = "You can keep at most 20 contexts." + } + // Retryable refusals are retried (3 times): queue enough of them. + for i := 0; i < 4; i++ { + fake.queue("POST /v1/contexts", fakeResp{status: tc.status, body: map[string]any{ + "error": map[string]any{"message": msg, "type": "invalid_request_error", "param": nil, "code": tc.code}}}) + } + r := runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api") + mustCtxExit(t, r, tc.exit, tc.want) + } +} + +// Where the file system has no executable bit, an unchanged file keeps the +// bit of the version it replaces. +func TestContextPushKeepsTheRemoteExecutableBit(t *testing.T) { + swap(t, &ctxExecKnown, false) + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\n"}) + dir := writeTree(t, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 2)") + + // A changed file has no remote bit to inherit. + dir = writeTree(t, map[string]string{"skills/x/SKILL.md": skillMD, "skills/x/run.sh": "#!/bin/sh\necho 2\n"}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + for _, f := range publishBody(t, pubs[len(pubs)-1]).Files { + if f.Executable { + t.Errorf("%s published executable", f.Path) + } + } +} + +// pull --force replaces files whole: the mode follows the remote file both +// ways, and a failed download leaves the member's file as it was. +func TestContextPullForceReplacesFilesWhole(t *testing.T) { + skipOnWindows(t) + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "new\n", "files/b.md": "fine\n"}) + dir := writeTree(t, map[string]string{"files/a.md": "mine\n", "files/b.md": "keep me\n"}) + if err := os.Chmod(filepath.Join(dir, "files", "a.md"), 0o755); err != nil { + t.Fatal(err) + } + fake.corrupt["files/b.md"] = true + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, exitUnavailable, "does not match", "1 of 2 files were already written", "--force") + if b, _ := os.ReadFile(filepath.Join(dir, "files", "b.md")); string(b) != "keep me\n" { + t.Errorf("a failed download changed the member's file: %q", b) + } + st, _ := os.Stat(filepath.Join(dir, "files", "a.md")) + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "new\n" || st.Mode().Perm() != 0o644 { + t.Errorf("a.md %q %v", b, st.Mode()) + } + entries, _ := os.ReadDir(filepath.Join(dir, "files")) + if len(entries) != 2 { + t.Errorf("left temporary files: %v", entries) + } +} + +func TestContextPullCapsTheTotal(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 1, nil) + for i := 0; i < 3; i++ { + c.Files = append(c.Files, api.ContextFile{Path: fmt.Sprintf("files/big%d.bin", i), SHA256: shaOf([]byte{byte(i)}), Size: 90 << 20}) + } + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "more than a context version holds", "nothing was written") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("created %s", dir) + } +} + +func TestContextRefIsCheckedLocally(t *testing.T) { + for _, ref := range []string{"..", ".", "a/b", "Acme", "%2e%2e"} { + for _, args := range [][]string{{"show", ref}, {"pull", ref, t.TempDir()}, {"rm", ref, "--yes"}, {"edit", ref, "--name", "x"}} { + fake := newFakeCtx() + r := runCtx(t, fake, ctxSetup{}, append([]string{"context"}, args...)...) + mustCtxExit(t, r, exitUsage, "neither a context name") + if len(fake.requests()) != 0 { + t.Errorf("%v sent %v", args, fake.requests()) + } + } + } + // A context id is accepted as is. + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "show", fakeCtxID), 0) + if got := fake.requests(); len(got) != 1 || got[0].Method != http.MethodGet { + t.Errorf("%v", got) + } +} diff --git a/cmd/context_test.go b/cmd/context_test.go new file mode 100644 index 0000000..6014e37 --- /dev/null +++ b/cmd/context_test.go @@ -0,0 +1,689 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/api" +) + +// nan context (helmcode/nan-devops AGENT-CONTEXT.md §6, C1 test list), +// against fakeCtx: the shapes and codes of cloud-api's /v1/contexts. + +// writeTree creates files (path -> content) under a new temp folder. A path +// ending in ".sh" is made executable. +func writeTree(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for p, body := range files { + full := filepath.Join(dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + mode := os.FileMode(0o644) + if strings.HasSuffix(p, ".sh") { + mode = 0o755 + } + if err := os.WriteFile(full, []byte(body), mode); err != nil { + t.Fatal(err) + } + } + return dir +} + +const skillMD = "---\nname: pr-review\ndescription: Review a pull request.\n---\nSteps.\n" + +func goodTree() map[string]string { + return map[string]string{ + "AGENTS.md": "Use tabs.\n", + "skills/pr-review/SKILL.md": skillMD, + "skills/pr-review/scripts/run.sh": "#!/bin/sh\necho ok\n", + "files/especificación técnica.md": "Spec.\n", + } +} + +func publishBody(t *testing.T, r ctxReq) api.PublishRequest { + t.Helper() + var in api.PublishRequest + if err := json.Unmarshal([]byte(r.Body), &in); err != nil { + t.Fatalf("publish body %q: %v", r.Body, err) + } + return in +} + +func publishPaths(in api.PublishRequest) []string { + var out []string + for _, f := range in.Files { + out = append(out, f.Path) + } + return out +} + +// ─── ls, show, create, edit, rm ───────────────────────────────────────────── + +func TestContextLsAndShowGoldens(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 3, map[string]string{"AGENTS.md": "Use tabs.\n", "skills/pr-review/SKILL.md": skillMD, "files/spec.md": "Spec.\n"}) + + r := runCtx(t, fake, ctxSetup{}, "context", "ls") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/ls.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "acme-api") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/show.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "acme-api", "--json") + mustCtxExit(t, r, 0) + var d api.ContextDetail + if err := json.Unmarshal([]byte(r.stdout), &d); err != nil || d.Version != 3 || len(d.Files) != 3 { + t.Errorf("%v %s", err, r.stdout) + } + + r = runCtx(t, fake, ctxSetup{}, "context", "show", "nope") + mustCtxExit(t, r, exitUsage, "no such context") +} + +func TestContextSurfaceOff(t *testing.T) { + fake := newFakeCtx() + fake.off = true + r := runCtx(t, fake, ctxSetup{}, "context", "ls") + mustCtxExit(t, r, exitUsage, "not available for your account yet", "portal") +} + +func TestContextCreateValidatesTheName(t *testing.T) { + for _, name := range []string{"Acme", "con", "blobs", "a_b", "123e4567-e89b-12d3-a456-426614174000"} { + fake := newFakeCtx() + r := runCtx(t, fake, ctxSetup{}, "context", "create", name) + mustCtxExit(t, r, exitUsage, "not a valid context name") + if len(fake.requests()) != 0 { + t.Errorf("%s: sent %v", name, fake.requests()) + } + } +} + +func TestContextCreateFromAFolder(t *testing.T) { + fake := newFakeCtx() + dir := writeTree(t, goodTree()) + r := runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api", "--description", "Acme", "--from", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "version 2 published", "4 files") + creates := fake.sent("POST", "/v1/contexts") + if len(creates) < 1 || !idemKeyPattern.MatchString(creates[0].Idem) || !strings.Contains(creates[0].Body, `"description":"Acme"`) { + t.Errorf("create %+v", creates) + } + pubs := fake.sent("POST", "/v1/contexts/"+fake.contexts[0].ID+"/versions") + if len(pubs) != 1 || publishBody(t, pubs[0]).BaseVersion != 1 { + t.Fatalf("publishes %+v", pubs) + } + + // A folder that cannot be pushed leaves no empty context behind. + fake = newFakeCtx() + bad := writeTree(t, map[string]string{"CLAUDE.md": "x"}) + r = runCtx(t, fake, ctxSetup{}, "context", "create", "acme-api", "--from", bad) + mustCtxExit(t, r, exitDataErr) + if len(fake.requests()) != 0 { + t.Errorf("sent %v", fake.requests()) + } +} + +func TestContextEditAndRm(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 2, nil) + r := runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-api", "--name", "acme-web", "--description", "") + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "context acme-web updated") + p := fake.sent("PATCH", "/v1/contexts/"+fakeCtxID) + if len(p) != 1 || p[0].Body != `{"name":"acme-web","description":""}` { + t.Errorf("patch %+v", p) + } + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-web"), exitUsage, "nothing to change") + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "edit", "acme-web", "--name", "Bad Name"), exitUsage, "not a valid context name") + + // rm asks, and without a terminal needs --yes. + mustCtxExit(t, runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web"), exitUsage, "--yes") + fake.queue("DELETE /v1/contexts/"+fakeCtxID, fakeResp{status: 409, body: map[string]any{ + "error": map[string]any{"message": "Automations use this context; remove it from them first.", "type": "invalid_request_error", "param": nil, "code": "context_in_use"}, + "automations": []any{map[string]any{"id": testAutoID, "name": "PR review api"}}}}) + r = runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web", "--yes") + mustCtxExit(t, r, exitUsage, "automations use this context", "PR review api") + r = runCtx(t, fake, ctxSetup{}, "context", "rm", "acme-web", "--yes") + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "context acme-web deleted") + if len(fake.contexts) != 0 { + t.Error("not deleted") + } +} + +// ─── push: local checks ───────────────────────────────────────────────────── + +func TestContextPushLayoutChecks(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{ + "CLAUDE.md": "Use tabs.\n", + "README.md": "top level\n", + "notes/a.md": "a\n", + "skills/no-skill-md/x.md": "x\n", + "skills/Bad Skill/SKILL.md": skillMD, + "files/rocket\U0001F680.md": "emoji\n", + "files/con.txt": "reserved\n", + "files/ok.md": "ok\n", + "files/.env": "SECRET=1\n", + ".git/config": "[core]\n", + "files/sub/.hidden/inner.md": "hidden\n", + }) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "cannot be published", "nothing was uploaded", "--skip-invalid") + checkGolden(t, "contexts/push_refused.stderr.golden", r.stderr) + if n := len(fake.requests()); n != 0 { + t.Errorf("a refused push sent %d requests: %v", n, fake.requests()) + } +} + +func TestContextPushSkipInvalid(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"CLAUDE.md": "x\n", "files/ok.md": "ok\n", "files/a:b.md": "colon\n", + "skills/no-skill-md/x.md": "x\n", "files/.env": "S=1\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--skip-invalid") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "skipped CLAUDE.md: bad_top_level (rename it to AGENTS.md", "skipped files/a:b.md: bad_character", + "skipped skills/no-skill-md/SKILL.md: skill_invalid", "skipped files/.env (dotfiles") + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 { + t.Fatalf("publishes %v", fake.requests()) + } + if got := publishPaths(publishBody(t, pubs[0])); strings.Join(got, ",") != "files/ok.md" { + t.Errorf("published %v", got) + } +} + +// macOS hands out decomposed (NFD) names; the platform refuses non-NFC +// paths, so push normalises before hashing and sending. +func TestContextPushNormalisesNFD(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + nfd := "files/especificación.md" + dir := writeTree(t, map[string]string{nfd: "spec\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 1 { + t.Fatalf("%v", fake.requests()) + } + if got := publishPaths(publishBody(t, pubs[0])); len(got) != 1 || got[0] != "files/especificación.md" { + t.Errorf("published %q, want the NFC form", got) + } + + // Two names that are the same once normalised collide (a file system + // that is not normalisation-insensitive can hold both). + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + return + } + dir = writeTree(t, map[string]string{nfd: "a\n", "files/especificación.md": "b\n"}) + r = runCtx(t, newFakeCtx(), ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "case_collision") +} + +func TestContextPushRefusesSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + if err := os.Symlink("/etc/passwd", filepath.Join(dir, "files", "passwd")); err != nil { + t.Fatal(err) + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "refused files/passwd: symlink") +} + +func TestContextPushLimits(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"AGENTS.md": strings.Repeat("a", 32<<10+1)}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr) + wantAll(t, "stderr", r.stderr, "refused AGENTS.md: too_large (AGENTS.md is at most 32 KiB)") +} + +// ─── push: uploads and publish ────────────────────────────────────────────── + +func TestContextPushDryRun(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 3, map[string]string{"AGENTS.md": "Old rules.\n", "files/old.md": "old\n", "files/same.md": "same\n"}) + dir := writeTree(t, map[string]string{"AGENTS.md": "Use tabs.\n", "files/same.md": "same\n", "files/new.md": "new file\n", + "skills/pr-review/SKILL.md": skillMD}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push_dry_run.golden", r.stdout) + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--dry-run", "--json") + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push_dry_run.json.golden", r.stdout) + + for _, req := range fake.requests() { + if req.Method != http.MethodGet && !(req.Method == http.MethodPost && req.Path == "/v1/contexts/blobs/check") { + t.Errorf("a dry run sent %s %s", req.Method, req.Path) + } + } +} + +func TestContextPushUploadsOnlyMissingBlobs(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 2, map[string]string{"files/kept.md": "kept\n"}) + tree := goodTree() + tree["files/kept.md"] = "kept\n" + tree["files/copy-of-agents.md"] = tree["AGENTS.md"] // same blob, two paths + dir := writeTree(t, tree) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/push.golden", r.stdout) + + puts := fake.sent("PUT", "/v1/contexts/blobs/") + if len(puts) != 4 { // AGENTS.md (once for two paths), SKILL.md, run.sh, the spec + t.Errorf("uploaded %d blobs, want 4: %+v", len(puts), puts) + } + for _, p := range puts { + if p.Path == "/v1/contexts/blobs/"+shaOf([]byte("kept\n")) { + t.Error("uploaded a blob the platform already had") + } + if p.Filename == "" || strings.Contains(p.Filename, "/") { + t.Errorf("X-Nan-Filename %q: the base name is the type hint", p.Filename) + } + } + pubs := fake.sent("POST", "/v1/contexts/"+c.ID+"/versions") + if len(pubs) != 1 { + t.Fatalf("publishes %d", len(pubs)) + } + in := publishBody(t, pubs[0]) + if in.BaseVersion != 2 || in.AllowSecretLike || len(in.Files) != 6 || !idemKeyPattern.MatchString(pubs[0].Idem) { + t.Errorf("publish %+v idem %q", in, pubs[0].Idem) + } + for _, f := range in.Files { + if f.Executable != (f.Path == "skills/pr-review/scripts/run.sh") && runtime.GOOS != "windows" { + t.Errorf("%s executable=%v", f.Path, f.Executable) + } + } + + // Pushing the same folder again changes nothing. + n := len(fake.requests()) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 3)") + if extra := fake.requests()[n:]; len(extra) != 1 || extra[0].Method != http.MethodGet { + t.Errorf("an up-to-date push sent %v", extra) + } +} + +func TestContextPushCreate(t *testing.T) { + fake := newFakeCtx() + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitUsage, "nan context create acme-api", "--create") + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--create") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "context acme-api created") + if len(fake.contexts) != 1 || fake.contexts[0].Version != 2 { + t.Errorf("%+v", fake.contexts) + } +} + +// A blob over the single-PUT limit goes up in parts; a part that fails is +// resumed from where the platform says the upload stands. +func TestContextPushMultipartResumesAfterAFailedPart(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.failPart[2] = true + fake.seed("acme-api", 1, nil) + big := "0123456789" // 3 parts of 4, 4, 2 bytes + dir := writeTree(t, map[string]string{"files/big.csv": big, "files/small.md": "small\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "part 2 of files/big.csv did not go through; resuming at part 2") + sha := shaOf([]byte(big)) + var parts []string + for _, q := range fake.sent("PUT", "/v1/contexts/blobs/"+sha+"/uploads/") { + parts = append(parts, q.Path[strings.LastIndex(q.Path, "/")+1:]) + } + if strings.Join(parts, ",") != "1,2,2,3" { + t.Errorf("parts sent %v", parts) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } + if starts := fake.sent("POST", "/v1/contexts/blobs/"+sha+"/uploads"); len(starts) != 2 { // start + complete + t.Errorf("start/complete %v", starts) + } + if _, err := os.Stat(fake.stateFile(t, r.stateDir, sha)); !os.IsNotExist(err) { + t.Errorf("upload state left behind: %v", err) + } +} + +// An upload a previous push left unfinished is resumed: no new upload, the +// stored parts are not sent again. +func TestContextPushMultipartResumesAcrossRuns(t *testing.T) { + swap(t, &ctxSingleMax, int64(8)) + fake := newFakeCtx() + fake.partSize = 4 + fake.seed("acme-api", 1, nil) + big := "0123456789" + sha := shaOf([]byte(big)) + const id = "u0000000-0000-4000-8000-000000000042" + fake.uploads[id] = &fakeUpload{sha: sha, size: int64(len(big)), partSize: 4, next: 2, data: []byte("0123")} + state := t.TempDir() + writeState(t, fake.stateFile(t, state, sha), id, 10) + dir := writeTree(t, map[string]string{"files/big.csv": big}) + r := runCtx(t, fake, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "resuming the upload of files/big.csv at part 2 of 3") + if starts := fake.sent("POST", "/v1/contexts/blobs/"+sha+"/uploads"); len(starts) != 1 || !strings.HasSuffix(starts[0].Path, "/complete") { + t.Errorf("a new upload was started: %v", starts) + } + var parts []string + for _, q := range fake.sent("PUT", "/v1/contexts/blobs/"+sha+"/uploads/"+id+"/parts/") { + parts = append(parts, q.Path[strings.LastIndex(q.Path, "/")+1:]) + } + if strings.Join(parts, ",") != "2,3" { + t.Errorf("parts sent %v", parts) + } + if string(fake.blobs[sha]) != big { + t.Errorf("stored %q", fake.blobs[sha]) + } + + // A remembered upload the platform no longer has starts over. + fake2 := newFakeCtx() + fake2.partSize = 4 + fake2.seed("acme-api", 1, nil) + writeState(t, fake2.stateFile(t, state, sha), id, 10) + r = runCtx(t, fake2, ctxSetup{stateDir: state}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if string(fake2.blobs[sha]) != big { + t.Errorf("stored %q", fake2.blobs[sha]) + } +} + +func writeState(t *testing.T, path, id string, size int) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(fmt.Sprintf(`{"upload_id":%q,"size":%d}`, id, size)), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestContextPushVersionConflict(t *testing.T) { + newFake := func() *fakeCtx { + fake := newFakeCtx() + fake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) + // Someone publishes version 3 between this push's read and its + // publish. + fake.onPublish = func(f *fakeCtx, n int) { + if n == 1 { + c := f.contexts[0] + c.Version = 3 + c.Files = append(c.Files, api.ContextFile{Path: "files/theirs.md", SHA256: shaOf([]byte("a\n")), Size: 2}) + } + } + return fake + } + dir := writeTree(t, map[string]string{"files/a.md": "a\n", "files/mine.md": "mine\n"}) + + fake := newFake() + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitFailed, "changed while pushing", "now at version 3", "based on version 2", "--force") + if fake.contexts[0].Version != 3 { + t.Error("published anyway") + } + + fake = newFake() + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--force") + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "changed to version 3 meanwhile", "- files/theirs.md") + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 2 || publishBody(t, pubs[1]).BaseVersion != 3 || pubs[0].Idem == pubs[1].Idem { + t.Errorf("publishes %+v", pubs) + } + if fake.contexts[0].Version != 4 { + t.Errorf("version %d", fake.contexts[0].Version) + } +} + +func TestContextPushSecretLikeContent(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/setup.md": "token: " + fakeSecretPat + "abcdef\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "look like they hold credentials", "nan secrets", "--allow-secrets") + wantAll(t, "stderr", r.stderr, "files/setup.md:1 looks like github_token") + if strings.Contains(r.stderr+r.stdout+r.msg, "abcdef") { + t.Error("the secret was printed") + } + + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir, "--allow-secrets") + mustCtxExit(t, r, 0) + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if !publishBody(t, pubs[len(pubs)-1]).AllowSecretLike { + t.Error("allow_secret_like not sent") + } +} + +// The platform's cleanup can remove an unreferenced blob between the check +// and the publish: push checks again, uploads it again and publishes. +func TestContextPushBlobMissingReuploads(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + dir := writeTree(t, map[string]string{"files/a.md": "a\n", "files/b.md": "b\n"}) + gone := shaOf([]byte("b\n")) + fake.onPublish = func(f *fakeCtx, n int) { + if n == 1 { + delete(f.blobs, gone) + } + } + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stderr", r.stderr, "1 file is no longer stored on nan.builders; uploading again") + if puts := fake.sent("PUT", "/v1/contexts/blobs/"+gone); len(puts) != 2 { + t.Errorf("uploads of the missing blob: %d", len(puts)) + } + // It asks again, for exactly the blobs the publish named, and sends + // only those: the other file goes up once. + checks := fake.sent("POST", "/v1/contexts/blobs/check") + if len(checks) != 2 || checks[1].Body != `{"sha256":["`+gone+`"]}` { + t.Errorf("checks %+v", checks) + } + if puts := fake.sent("PUT", "/v1/contexts/blobs/"+shaOf([]byte("a\n"))); len(puts) != 1 { + t.Errorf("uploads of the file still stored: %d", len(puts)) + } + if pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions"); len(pubs) != 2 { + t.Errorf("publishes %d", len(pubs)) + } +} + +func TestContextPushWaitsOutRetryAfter(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 429, header: map[string]string{"Retry-After": "7"}, + body: map[string]any{"error": map[string]any{"message": "Publish limit reached (120 attempts per hour); try again later.", + "type": "rate_limit_exceeded", "param": nil, "code": "rate_limited"}}}) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + if len(r.sleeps) != 1 || r.sleeps[0] != 7*time.Second { + t.Errorf("sleeps %v", r.sleeps) + } + pubs := fake.sent("POST", "/v1/contexts/"+fakeCtxID+"/versions") + if len(pubs) != 2 || pubs[0].Idem != pubs[1].Idem { + t.Errorf("a retried publish keeps its Idempotency-Key: %+v", pubs) + } + + // A wait longer than a push waits by itself is reported, not slept. + fake = newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 429, header: map[string]string{"Retry-After": "3600"}, + body: map[string]any{"error": map[string]any{"message": "Publish limit reached.", "type": "rate_limit_exceeded", "param": nil, "code": "rate_limited"}}}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitUnavailable, "try again in 1h0m0s") + if len(r.sleeps) != 0 { + t.Errorf("slept %v", r.sleeps) + } +} + +func TestContextPushServerRefusalsArePerFile(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/"+fakeCtxID+"/versions", fakeResp{status: 415, body: map[string]any{ + "error": map[string]any{"message": "Some files are not allowed at their path.", "type": "invalid_request_error", "param": nil, "code": "unsupported_file_type"}, + "files": []any{map[string]any{"path": "files/a.md", "reason": "unsupported_file_type"}}}}) + dir := writeTree(t, map[string]string{"files/a.md": "a\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "not allowed at their path") + wantAll(t, "stderr", r.stderr, "files/a.md: unsupported_file_type") + + fake = newFakeCtx() + fake.seed("acme-api", 1, nil) + fake.queue("POST /v1/contexts/blobs/check", fakeResp{status: 413, body: map[string]any{ + "error": map[string]any{"message": "This upload does not fit in your plan's context storage.", "type": "invalid_request_error", "param": nil, "code": "context_quota_exceeded"}, + "used": 500 << 20, "quota": 500 << 20}}) + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "500.0 MiB of 500.0 MiB used") +} + +// ─── pull ─────────────────────────────────────────────────────────────────── + +func TestContextPullWritesVerifiedFiles(t *testing.T) { + fake := newFakeCtx() + files := goodTree() + fake.seed("acme-api", 3, files) + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, 0) + checkGolden(t, "contexts/pull.golden", strings.ReplaceAll(r.stdout, dir, "DIR")) + for p, want := range files { + got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p))) + if err != nil || string(got) != want { + t.Errorf("%s: %q %v", p, got, err) + } + } + if runtime.GOOS != "windows" { + st, _ := os.Stat(filepath.Join(dir, "skills", "pr-review", "scripts", "run.sh")) + if st == nil || st.Mode().Perm()&0o100 == 0 { + t.Errorf("run.sh is not executable: %v", st) + } + } + for _, q := range fake.sent("GET", "/v1/contexts/"+fakeCtxID+"/file") { + if !strings.Contains(q.Query, "version=3") { + t.Errorf("a download is not pinned to the version read: %s", q.Query) + } + } + + // Round trip: what was pulled pushes as no change. + r = runCtx(t, fake, ctxSetup{}, "context", "push", "acme-api", dir) + mustCtxExit(t, r, 0) + wantAll(t, "stdout", r.stdout, "is up to date (version 3)") +} + +func TestContextPullRefusesANonEmptyFolder(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "new\n"}) + dir := writeTree(t, map[string]string{"files/a.md": "mine\n"}) + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitUsage, "is not empty", "--force") + if len(fake.requests()) != 0 { + t.Errorf("sent %v", fake.requests()) + } + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "mine\n" { + t.Error("overwrote a file") + } + r = runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir, "--force") + mustCtxExit(t, r, 0) + if b, _ := os.ReadFile(filepath.Join(dir, "files", "a.md")); string(b) != "new\n" { + t.Errorf("--force did not write: %q", b) + } +} + +// A platform (or anything answering in its place) that sends a path the +// validator refuses gets nothing written, inside or outside DIR. +func TestContextPullRefusesHostilePaths(t *testing.T) { + for name, paths := range map[string][]string{ + "dotdot": {"files/../../evil.md"}, + "absolute": {"/tmp/evil.md"}, + "top dotdot": {"../evil.md"}, + "backslash": {"files\\..\\..\\evil.md"}, + "case collision": {"files/Readme.md", "files/README.md"}, + "file vs folder": {"files/docs", "files/Docs/a.md"}, + "reserved": {"files/CON"}, + "reserved ext": {"files/nul.txt"}, + "dotfile": {"files/.bashrc"}, + "not nfc": {"files/especificación.md"}, + "bad top": {"evil.md"}, + } { + t.Run(name, func(t *testing.T) { + fake := newFakeCtx() + c := fake.seed("acme-api", 1, nil) + for _, p := range paths { + fake.blobs[shaOf([]byte("x"))] = []byte("x") + c.Files = append(c.Files, api.ContextFile{Path: p, SHA256: shaOf([]byte("x")), Size: 1}) + } + parent := t.TempDir() + dir := filepath.Join(parent, "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitDataErr, "nothing was written") + if len(fake.sent("GET", "/v1/contexts/"+fakeCtxID+"/file")) != 0 { + t.Error("downloaded a file") + } + entries, _ := os.ReadDir(parent) + if len(entries) != 0 { + t.Errorf("wrote %v", entries) + } + }) + } +} + +func TestContextPullRefusesBadBytes(t *testing.T) { + fake := newFakeCtx() + fake.seed("acme-api", 1, map[string]string{"files/a.md": "good\n", "files/b.md": "fine\n"}) + fake.corrupt["files/b.md"] = true + dir := filepath.Join(t.TempDir(), "out") + r := runCtx(t, fake, ctxSetup{}, "context", "pull", "acme-api", dir) + mustCtxExit(t, r, exitUnavailable, "does not match its sha256") + if _, err := os.Stat(filepath.Join(dir, "files", "b.md")); !os.IsNotExist(err) { + t.Errorf("kept a bad file: %v", err) + } +} + +func TestContextHelp(t *testing.T) { + var all strings.Builder + all.WriteString(contextCmd.Long) + for _, c := range contextCmd.Commands() { + all.WriteString(c.Long + c.Short + c.Example) + if c.InheritedFlags().Lookup("token-file") == nil { + t.Errorf("nan context %s has no --token-file", c.Name()) + } + } + for _, want := range []string{"contexts:read", "contexts:write", contextsDocs, "AGENTS.md", "--allow-secrets", "--skip-invalid", "65"} { + if !strings.Contains(all.String(), want) { + t.Errorf("nan context help lacks %q", want) + } + } + if strings.Contains(all.String(), "—") { + t.Error("an em-dash in the context help") + } + for verb, want := range map[string]string{"list": "ls", "upload": "push", "delete": "rm", "frobnicate": "unknown command"} { + r := runCtx(t, newFakeCtx(), ctxSetup{}, "context", verb) + mustCtxExit(t, r, exitUsage, want) + } +} diff --git a/cmd/ephemeral.go b/cmd/ephemeral.go index e0bbd64..a8d6ee6 100644 --- a/cmd/ephemeral.go +++ b/cmd/ephemeral.go @@ -244,9 +244,11 @@ func ephemeralScopeHint(cred credential) string { // before it is sent, so a mistake costs a clear message instead of a round // trip: executor is workspace or ephemeral, size is s, m or l and only for // ephemeral, and (on create, where the file is the whole automation) an -// ephemeral automation has no workspace, a fresh checkout of a github_repo -// and a model. An edit holds only the fields that change: the platform -// checks the result. +// ephemeral automation has no workspace, a model, and either a fresh +// checkout of a github_repo or checkout "none" with at least one agent +// context. contexts, when present, is at most 5 context names or ids. +// An edit holds only the fields that change: the platform checks the +// result. func checkAutomationExecutor(body json.RawMessage, create bool) error { var f map[string]json.RawMessage if err := json.Unmarshal(body, &f); err != nil { @@ -276,17 +278,37 @@ func checkAutomationExecutor(body json.RawMessage, create bool) error { if hasSize && size != "" && hasExecutor && executor == api.ExecutorWorkspace { return usageErrorf(`size is for ephemeral automations only: set executor: ephemeral, or remove size`) } + ctxCount, err := checkBodyContexts(f["contexts"]) + if err != nil { + return err + } + checkout, _, _ := str("checkout") + repo, _, _ := str("github_repo") + if checkout == "none" { + switch { + case repo != "": + return usageErrorf(`checkout "none" is an automation without a repository: remove github_repo (or set it to null), or use checkout: fresh`) + case executor == api.ExecutorWorkspace || (create && executor == ""): + return usageErrorf(`checkout "none" is only for ephemeral automations (executor: ephemeral): a workspace automation works in its workspace`) + case create && ctxCount == 0: + return usageErrorf(`checkout "none" (no repository) needs at least one agent context: add --context NAME or contexts: [NAME]`) + } + } if !create || executor != api.ExecutorEphemeral { return nil } if ws, has, _ := str("workspace"); has && ws != "" { return usageErrorf("an ephemeral automation has no workspace: remove workspace (it runs in a fresh container)") } - if checkout, _, _ := str("checkout"); checkout != "fresh" { - return usageErrorf(`an ephemeral automation works on a fresh checkout: set checkout: fresh and github_repo: OWNER/NAME`) - } - if repo, _, _ := str("github_repo"); repo == "" { - return usageErrorf(`an ephemeral automation needs github_repo: OWNER/NAME, the repository each run clones`) + switch checkout { + case "none": + case "fresh": + if repo == "" { + return usageErrorf(`an ephemeral automation needs github_repo: OWNER/NAME, the repository each run clones`) + } + default: + return usageErrorf(`an ephemeral automation works on a fresh checkout: set checkout: fresh and github_repo: OWNER/NAME; ` + + `or, without a repository, on its agent contexts: checkout: none and --context NAME`) } if cwd, has, _ := str("cwd"); has && cwd != "" { return usageErrorf("an ephemeral automation works in its fresh checkout: remove cwd") @@ -305,6 +327,19 @@ func checkAutomationExecutor(body json.RawMessage, create bool) error { return nil } +// checkBodyContexts checks an automation file's contexts field: a list of +// at most 5 context names or ids. It returns how many there are. +func checkBodyContexts(raw json.RawMessage) (int, error) { + if raw == nil || string(raw) == "null" { + return 0, nil + } + var refs []string + if err := json.Unmarshal(raw, &refs); err != nil { + return 0, usageErrorf("contexts must be a list of context names or ids, like contexts: [acme-api]") + } + return len(refs), checkContextRefs(refs, "contexts:") +} + func sizeList() string { parts := make([]string, len(ephemeralSizes)) for i, s := range ephemeralSizes { @@ -593,10 +628,13 @@ func checkEphemeralFlags(opts *runOptions) error { return usageErrorf("an ephemeral run works in its fresh clone of --repo: drop --cwd") case opts.worktree || opts.noWorktree: return usageErrorf("an ephemeral run works on a fresh clone, never a git worktree: drop --worktree/--no-worktree") - case opts.repo == "": - return usageErrorf("--ephemeral needs --repo OWNER/NAME: the GitHub repository the container clones") + case opts.repo == "" && len(opts.contexts) == 0: + return usageErrorf("--ephemeral needs --repo OWNER/NAME, the GitHub repository the container clones, " + + "or --context NAME, an agent context to work on without a repository (see: nan context ls)") + case opts.repo == "" && (opts.githubTokenSecret != "" || opts.githubTokenSecretSet): + return usageErrorf("--github-token-secret clones a private --repo: a run without --repo clones nothing, so drop it or add --repo OWNER/NAME") } - if !validGitHubRepo(opts.repo) { + if opts.repo != "" && !validGitHubRepo(opts.repo) { if repo, ok := repoFromURL(opts.repo); ok { return usageErrorf("--repo takes OWNER/NAME, not a URL: did you mean --repo %s?", repo) } diff --git a/cmd/ephemeral_test.go b/cmd/ephemeral_test.go index 64d8629..f575ac3 100644 --- a/cmd/ephemeral_test.go +++ b/cmd/ephemeral_test.go @@ -583,6 +583,21 @@ func TestCheckAutomationExecutor(t *testing.T) { {"no repo", `{"executor":"ephemeral","checkout":"fresh","model":"m"}`, true, "github_repo: OWNER/NAME"}, {"cwd", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b","model":"m","cwd":"/home/nan/x"}`, true, "remove cwd"}, {"no model", `{"executor":"ephemeral","checkout":"fresh","github_repo":"a/b"}`, true, "must name its model"}, + // Agent contexts (C2): checkout "none" is an ephemeral automation + // without a repository, with at least one context. + {"none with a context", `{"executor":"ephemeral","checkout":"none","contexts":["acme-api"],"model":"m"}`, true, ""}, + {"none without a context", `{"executor":"ephemeral","checkout":"none","model":"m"}`, true, "at least one agent context"}, + {"none with an empty list", `{"executor":"ephemeral","checkout":"none","contexts":[],"model":"m"}`, true, "at least one agent context"}, + {"none with a repo", `{"executor":"ephemeral","checkout":"none","github_repo":"a/b","contexts":["c"],"model":"m"}`, true, "remove github_repo"}, + {"none, repo null", `{"executor":"ephemeral","checkout":"none","github_repo":null,"contexts":["c"],"model":"m"}`, true, ""}, + {"none on a workspace automation", `{"workspace":"develop","checkout":"none","contexts":["c"]}`, true, "only for ephemeral"}, + {"edit: none alone", `{"checkout":"none","github_repo":null}`, false, ""}, + {"edit: none to workspace", `{"executor":"workspace","checkout":"none"}`, false, "only for ephemeral"}, + {"none still needs a model", `{"executor":"ephemeral","checkout":"none","contexts":["c"]}`, true, "must name its model"}, + {"contexts on a workspace automation", `{"workspace":"develop","contexts":["a","b"]}`, true, ""}, + {"edit: contexts cleared", `{"contexts":[]}`, false, ""}, + {"six contexts", `{"contexts":["a1","a2","a3","a4","a5","a6"]}`, false, "at most 5"}, + {"contexts not a list", `{"contexts":"a"}`, false, "list of context names"}, } { err := checkAutomationExecutor(json.RawMessage(tc.body), tc.create) switch { diff --git a/cmd/hidden_unix_test.go b/cmd/hidden_unix_test.go new file mode 100644 index 0000000..35ee927 --- /dev/null +++ b/cmd/hidden_unix_test.go @@ -0,0 +1,82 @@ +//go:build unix + +package cmd + +import ( + "os" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/charmbracelet/x/term" +) + +// Ctrl-c at a hidden prompt used to end the process while echo was off, and +// the shell stayed that way. While the prompt is up, the interrupt is caught: +// the terminal is put back first, and only then does the command exit. +func TestCtrlCAtAHiddenPromptPutsTheTerminalBackFirst(t *testing.T) { + var restored atomic.Bool + exited := make(chan bool, 1) + swap(t, &interrupted, func() { exited <- restored.Load() }) + + release := onInterrupt(func() { restored.Store(true) }) + defer release() + + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + select { + case restoredFirst := <-exited: + if !restoredFirst { + t.Error("exited before putting the terminal back") + } + case <-time.After(5 * time.Second): + t.Fatal("ctrl-c at the prompt was not handled") + } +} + +// readHiddenLine hands the interrupt handler the terminal's own state: a +// ctrl-c while the password is being read restores exactly what GetState +// saved, on the same descriptor, before the command exits. Without a real +// terminal, the three calls are stood in for. +func TestCtrlCWhileReadingRestoresTheSavedState(t *testing.T) { + saved := &term.State{} + f, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + type restore struct { + fd uintptr + state *term.State + } + restored := make(chan restore, 1) + exited := make(chan struct{}) + swap(t, &termGetState, func(uintptr) (*term.State, error) { return saved, nil }) + swap(t, &termRestore, func(fd uintptr, s *term.State) error { restored <- restore{fd, s}; return nil }) + swap(t, &interrupted, func() { close(exited) }) + // The member presses ctrl-c while the prompt is waiting for them. + swap(t, &termReadPassword, func(uintptr) ([]byte, error) { + if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { + t.Error(err) + } + select { + case <-exited: + case <-time.After(5 * time.Second): + t.Error("ctrl-c while reading was not handled") + } + return nil, os.ErrClosed + }) + + _, _ = readHiddenLine(f) + select { + case got := <-restored: + if got.fd != f.Fd() || got.state != saved { + t.Errorf("restored fd %d state %p, want fd %d state %p", got.fd, got.state, f.Fd(), saved) + } + default: + t.Error("ctrl-c exited without restoring the terminal") + } +} diff --git a/cmd/json_output_test.go b/cmd/json_output_test.go index 1632bcd..dac7396 100644 --- a/cmd/json_output_test.go +++ b/cmd/json_output_test.go @@ -86,6 +86,9 @@ func jsonCases(t *testing.T) map[string][]jsonCase { if err := os.WriteFile(hook, []byte(testHookURL+"\n"), 0o600); err != nil { t.Fatal(err) } + // A folder to push: skipped and refused files print their notes on + // stderr only. + ctxDir := writeTree(t, map[string]string{"AGENTS.md": "Use tabs.\n", "files/.env": "S=1\n", "CLAUDE.md": "x\n"}) return map[string][]jsonCase{ "nan approvals ls": {{args: []string{"approvals", "ls", "--json"}}}, "nan approvals show": {{args: []string{"approvals", "show", testRunID, "--json"}}}, @@ -122,6 +125,14 @@ func jsonCases(t *testing.T) map[string][]jsonCase { // goes to stderr only. {args: []string{"notifications", "add", "ci", "--kind", "https", "--url-stdin", "--event", "run.failed", "--json"}, stdin: testHookHTTPS}, }, + "nan context create": {{args: []string{"context", "create", "new-ctx", "--json"}}}, + "nan context edit": {{args: []string{"context", "edit", "acme-api", "--description", "new", "--json"}}}, + "nan context ls": {{args: []string{"context", "ls", "--json"}}}, + "nan context push": { + {args: []string{"context", "push", "acme-api", ctxDir, "--skip-invalid", "--json"}}, + {args: []string{"context", "push", "acme-api", ctxDir, "--skip-invalid", "--dry-run", "--json"}}, + }, + "nan context show": {{args: []string{"context", "show", "acme-api", "--json"}}}, "nan notifications deliveries": {{args: []string{"notifications", "deliveries", "ci receiver", "--limit", "3", "--json"}}}, "nan notifications edit": { {args: []string{"notifications", "edit", "team alerts", "--disable", "--json"}}, @@ -293,6 +304,8 @@ func runJSONCase(t *testing.T, c jsonCase) (code int, msg, stdout, stderr string phase2.runGet = awaitingRunJSON() } writeHandler := write.handler(t) + ctxFake := newFakeCtx() + ctxFake.seed("acme-api", 2, map[string]string{"files/a.md": "a\n"}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if name, ok := strings.CutPrefix(p, "/api/ephemeral/"); ok { @@ -304,6 +317,10 @@ func runJSONCase(t *testing.T, c jsonCase) (code int, msg, stdout, stderr string _, _ = w.Write(b) return } + if strings.HasPrefix(p, "/v1/contexts") { + ctxFake.ServeHTTP(w, r) + return + } toWrite := p == "/api/auth/me" || p == "/api/account/secrets" || strings.HasPrefix(p, "/v1/secrets") || strings.HasPrefix(p, "/v1/notification-channels") || strings.HasSuffix(p, "/triggers") || strings.Contains(p, "/triggers/") || diff --git a/cmd/run.go b/cmd/run.go index d8cbe76..b63ff9e 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -5,15 +5,18 @@ import ( "errors" "fmt" "io" + "net/http" "os" "os/signal" "regexp" "slices" + "strconv" "strings" "time" "unicode/utf8" "github.com/nxssie/nan-cli/internal/api" + "github.com/nxssie/nan-cli/internal/contexts" "github.com/nxssie/nan-cli/internal/runs" "github.com/spf13/cobra" ) @@ -56,6 +59,9 @@ type runOptions struct { // holding the GitHub token that clones a private --repo. githubTokenSecret string githubTokenSecretSet bool + // contexts: the agent contexts the run loads (--context, repeatable), + // by name or id, in order. + contexts []string } var runOpts runOptions @@ -95,6 +101,15 @@ Your own provider key (BYOK): it holds. A token can trigger an automation that uses your key instead. Hermes cannot use an Anthropic key yet: use pi or pi_gentle with it. +Agent contexts: + --context NAME loads one of your agent contexts into the run: its + instructions, skills and files, at its latest version. Repeat it to load + more, at most 5; they load in the order given. NAME is a context name or + id from: nan context ls. Two contexts cannot define the same skill, and + together they must fit a run (250 MiB, 5000 files, 64 KiB of + instructions). Make one with: nan context create. + Docs: ` + contextsDocs + ` + Ephemeral runs (no workspace): --ephemeral --repo OWNER/NAME --model ID runs the agent in a fresh, throwaway container instead of a workspace: it clones the GitHub @@ -105,6 +120,8 @@ Ephemeral runs (no workspace): secrets holding a GitHub token that can read it (nan secrets ls): it is used only to clone, never given to the agent. --ws, --cwd, --worktree and --no-worktree do not apply. + With --context, --repo can be left out: without --repo the agent works + in an empty folder with its contexts and clones nothing. --size picks the container (default s), billed per started minute (a 14 s run is 1 minute): ` + sizeTable(" ") + ` Free minutes every month: Community 100, Inference 300, Premium 1000. @@ -152,7 +169,8 @@ Exit codes: key, bad configuration, an agent that cannot use your provider key) 10 awaiting approval: an automation run whose proposed actions wait for you (nan runs proposal ; approve in the portal) - 64 usage error + 64 usage error, or a --context that does not exist or does not fit + with the others (same skill twice, too large together) 65 not signed in, or not allowed (a token for a run with your own key, a token without runs:ephemeral, ephemeral runs not available to you) 69 nan.builders unavailable, the stream was lost (the run goes on), or @@ -167,7 +185,9 @@ Docs: https://nan.builders/docs/runs`, nan run --ws ci -f task.md # in CI, with NAN_TOKEN set from a secret nan run --byok-secret ANTHROPIC_KEY --model claude-sonnet-4-5 "fix the flaky test" nan run --ephemeral --repo acme/api --model glm5.3 --size m "find and fix the flaky test" - nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review it"`, + nan run --ephemeral --repo acme/private --github-token-secret GITHUB_TOKEN --model glm5.3 "review it" + nan run --context acme-api --context style-guide "review PR 42" + nan run --ephemeral --context release-notes --model glm5.3 "write this week's release notes"`, Args: cobra.ArbitraryArgs, Annotations: sysexits, RunE: func(cmd *cobra.Command, args []string) error { @@ -197,9 +217,10 @@ func init() { rootCmd.AddCommand(runCmd) f := runCmd.Flags() f.StringVar(&runOpts.workspace, "ws", "", "Workspace to run in (default: your only workspace)") - f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --repo and --model)") + f.BoolVar(&runOpts.ephemeral, "ephemeral", false, "Run in a fresh, throwaway container instead of a workspace (needs --model, and --repo or --context)") f.StringVar(&runOpts.repo, "repo", "", "With --ephemeral: the GitHub repository to clone, `OWNER/NAME`") f.StringVar(&runOpts.githubTokenSecret, "github-token-secret", "", "With --ephemeral and --repo: the `NAME` of your secret holding a GitHub token, to clone a private repository (never the token itself)") + f.StringArrayVar(&runOpts.contexts, "context", nil, "Load one of your agent contexts, by `NAME` or id (repeat for more, at most 5; see: nan context ls)") f.StringVar(&runOpts.size, "size", "", "With --ephemeral: the container, s, m or l (default s; see Ephemeral runs above)") f.StringVar(&runOpts.agent, "agent", "pi", "Agent to run: pi, pi_gentle (Pi + Gentle Shell) or hermes, if the workspace has it installed") f.StringVar(&runOpts.model, "model", "", "Model for the agent (default: the agent's own; required with --byok-secret and --ephemeral)") @@ -268,6 +289,9 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat if err != nil { var apiErr *api.APIError if errors.As(err, &apiErr) { + if exit := contextRefusal(apiErr, req.Contexts, "--context"); exit != nil { + return exit + } if exit := byokRefusal(apiErr, req); exit != nil { return exit } @@ -319,6 +343,13 @@ func startRun(ctx context.Context, env *runsEnv, opts runOptions, req *api.Creat case ephemeral && req.Model != nil: agent += ", model " + runs.SanitizeLine(*req.Model) } + if len(req.Contexts) > 0 { + names := make([]string, len(req.Contexts)) + for i, c := range req.Contexts { + names[i] = runs.SanitizeLine(c) + } + agent += ", contexts " + strings.Join(names, ", ") + } fmt.Fprintf(env.stderr, "run %s queued %s (%s), Ctrl-C to detach\n", id, runPlace(run, req), agent) } return follow(ctx, env, run.ID, 0, opts.json, true) @@ -507,6 +538,9 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat return nil, err } } + if err := checkContextRefs(opts.contexts, "--context"); err != nil { + return nil, err + } if err := checkEphemeralFlags(&opts); err != nil { return nil, err } @@ -545,10 +579,16 @@ func buildRunRequest(in promptInput, opts runOptions, args []string) (*api.Creat req.GitIsolation = &iso } if opts.ephemeral { - repo := opts.repo - req.Executor, req.Size, req.GitHubRepo = api.ExecutorEphemeral, opts.size, &repo + req.Executor, req.Size = api.ExecutorEphemeral, opts.size + if opts.repo != "" { + repo := opts.repo + req.GitHubRepo = &repo + } req.GitHubTokenSecret = opts.githubTokenSecret } + if len(opts.contexts) > 0 { + req.Contexts = slices.Clone(opts.contexts) + } if opts.timeoutSet { if opts.timeout < minTimeout || opts.timeout > maxTimeout { return nil, usageErrorf("--timeout must be between 1m and 2h, not %s", opts.timeout) @@ -636,3 +676,81 @@ func defaultWorkspace(ctx context.Context, env *runsEnv) (string, error) { } return "", usageErrorf("you have %d workspaces; pick one with --ws:\n%s", len(list), joinNames(names)) } + +// maxRunContexts is how many agent contexts a run or an automation loads +// (cloud-api ctxpath.MaxContextsPerRun). +const maxRunContexts = 5 + +// checkContextRefs checks the contexts a run or an automation names (with +// the flag or file field they came from, for the message): at most 5, each +// a context name or id, none twice. Whether they exist is the platform's +// to say. +func checkContextRefs(refs []string, from string) error { + if len(refs) > maxRunContexts { + return usageErrorf("%s: at most %d contexts, not %d", from, maxRunContexts, len(refs)) + } + seen := make(map[string]bool, len(refs)) + for _, ref := range refs { + if ref == "" { + return usageErrorf("%s is empty: give a context name or id (see: nan context ls)", from) + } + key := ref + switch { + case runIDPattern.MatchString(ref): + key = strings.ToLower(ref) + case !contexts.ValidContextName(ref): + return usageErrorf("%s %q is not a context name (lowercase letters, digits and dashes) or a context id; see: nan context ls", + from, runs.Truncate(runs.SanitizeLine(ref), 80)) + } + if seen[key] { + return usageErrorf("%s %q is given twice: each context loads once", from, runs.Truncate(runs.SanitizeLine(ref), 80)) + } + seen[key] = true + } + return nil +} + +// contextRefusal reads the platform's refusal of the contexts a run or an +// automation named (refs, in the order sent). nil when it is about +// something else. +func contextRefusal(apiErr *api.APIError, refs []string, from string) *ExitError { + msg := runs.Truncate(runs.SanitizeLine(apiErr.Message), 200) + ref, named := contextAt(apiErr.Param, refs) + switch apiErr.Code { + case "context_not_found": + if named { + return usageErrorf("context %q not found: see your contexts with nan context ls", ref) + } + return usageErrorf("one of the contexts was not found: see your contexts with nan context ls") + case "context_skill_conflict": + return usageErrorf("two of the contexts define the same skill (%s): load only one of them with %s, "+ + "or rename the skill in one and push it again (nan context push)", msg, from) + case "context_too_large": + return usageErrorf("the contexts are too large for one run together (%s): a run takes up to 250 MiB, "+ + "5000 files and 64 KiB of instructions; load fewer contexts", msg) + } + if apiErr.Status == http.StatusBadRequest && (apiErr.Param == "contexts" || strings.HasPrefix(apiErr.Param, "contexts[")) { + if named { + return usageErrorf("%s %q refused: %s", from, ref, msg) + } + return usageErrorf("%s refused: %s", from, msg) + } + return nil +} + +// contextAt is the context a "contexts[i]" param points at, sanitised. +func contextAt(param string, refs []string) (string, bool) { + idx, ok := strings.CutPrefix(param, "contexts[") + if !ok { + return "", false + } + idx, ok = strings.CutSuffix(idx, "]") + if !ok { + return "", false + } + i, err := strconv.Atoi(idx) + if err != nil || i < 0 || i >= len(refs) { + return "", false + } + return runs.Truncate(runs.SanitizeLine(refs[i]), 80), true +} diff --git a/cmd/run_contexts_test.go b/cmd/run_contexts_test.go new file mode 100644 index 0000000..9fd654c --- /dev/null +++ b/cmd/run_contexts_test.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/nxssie/nan-cli/internal/api" +) + +// Agent contexts on runs (devops AGENT-CONTEXT.md §2.5, §6, C2): nan run +// --context NAME (repeatable, at most 5, order kept) sends "contexts" on +// POST /v1/runs; an ephemeral run may leave out --repo only when it loads +// at least one context. Every mistake is a usage error (64) before any +// request; the platform's context refusals read as what to do. + +// requestGolden is the request body as the CLI marshals it. +func requestGolden(t *testing.T, name string, v any) { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + checkGolden(t, "contexts/"+name, string(b)+"\n") +} + +func TestRunContextsAreSentInOrder(t *testing.T) { + h := newHarness(t, &fakePlatform{workspaces: `[{"workspace_uuid":"w1","name":"develop","status":"running"}]`}) + opts := defaultOpts() + opts.contexts = []string{"acme-api", "style-guide"} + if err := doRun(context.Background(), h.env, opts, []string{"review it"}); err != nil { + t.Fatal(err) + } + got, _ := h.fake.created["contexts"].([]any) + if fmt.Sprint(got) != "[acme-api style-guide]" { + t.Errorf("contexts sent: %v (%v)", h.fake.created["contexts"], h.fake.created) + } + wantAll(t, "stderr", h.stderr.String(), "contexts acme-api, style-guide") +} + +func TestRunWithoutContextsSendsNone(t *testing.T) { + req, err := buildRunRequest(promptInput{}, defaultOpts(), []string{"x"}) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(req) + if strings.Contains(string(b), "contexts") { + t.Errorf("a run without --context sent contexts: %s", b) + } +} + +func TestRunContextRequestGoldens(t *testing.T) { + ws := defaultOpts() + ws.workspace, ws.contexts = "develop", []string{"acme-api", "style-guide"} + req, err := buildRunRequest(promptInput{}, ws, []string{"review the open pull requests"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_workspace.json", req) + + eph := ephemeralOpts() + eph.repo, eph.size, eph.contexts = "", "m", []string{"acme-api"} + req, err = buildRunRequest(promptInput{}, eph, []string{"write the release notes"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_ephemeral_no_repo.json", req) + + both := ephemeralOpts() + both.contexts = []string{"style-guide", "acme-api"} + req, err = buildRunRequest(promptInput{}, both, []string{"fix the flaky test"}) + if err != nil { + t.Fatal(err) + } + requestGolden(t, "run_request_ephemeral_repo.json", req) +} + +// An ephemeral run with a context and no repository: no github_repo is +// sent, and the announcement names no repository. +func TestEphemeralRunWithOnlyAContext(t *testing.T) { + h := newHarness(t, &fakePlatform{workspaces: "not json", ephemeral: true}) + opts := ephemeralOpts() + opts.repo, opts.contexts = "", []string{"acme-api"} + if err := doRun(context.Background(), h.env, opts, []string{"write the notes"}); err != nil { + t.Fatal(err) + } + c := h.fake.created + if _, ok := c["github_repo"]; ok || c["executor"] != "ephemeral" || fmt.Sprint(c["contexts"]) != "[acme-api]" { + t.Errorf("created with %v", c) + } +} + +func TestRunContextFlagMistakesAreUsageErrors(t *testing.T) { + six := []string{"a1", "a2", "a3", "a4", "a5", "a6"} + for _, tc := range []struct { + name string + edit func(*runOptions) + wants []string + }{ + {"six contexts", func(o *runOptions) { o.contexts = six }, []string{"at most 5", "--context"}}, + {"repeated", func(o *runOptions) { o.contexts = []string{"acme-api", "acme-api"} }, []string{"acme-api", "twice"}}, + {"repeated id, other case", func(o *runOptions) { + o.contexts = []string{"c0000000-0000-4000-8000-0000000000ab", "C0000000-0000-4000-8000-0000000000AB"} + }, []string{"twice"}}, + {"not a name", func(o *runOptions) { o.contexts = []string{"Acme API"} }, []string{"Acme API", "context name", "nan context ls"}}, + {"empty", func(o *runOptions) { o.contexts = []string{""} }, []string{"empty", "--context"}}, + {"control bytes", func(o *runOptions) { o.contexts = []string{"a\x1b[2Jb"} }, []string{"context name"}}, + {"ephemeral with neither repo nor context", func(o *runOptions) { o.ephemeral, o.model = true, "glm5.3" }, + []string{"--repo OWNER/NAME", "--context NAME"}}, + {"token secret without repo", func(o *runOptions) { + o.ephemeral, o.model, o.contexts = true, "glm5.3", []string{"acme-api"} + o.githubTokenSecret, o.githubTokenSecretSet = "GITHUB_TOKEN", true + }, []string{"--github-token-secret", "--repo"}}, + {"ephemeral context without model", func(o *runOptions) { o.ephemeral, o.contexts = true, []string{"acme-api"} }, + []string{"--model"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t, &fakePlatform{}) + opts := defaultOpts() + tc.edit(&opts) + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + assertNoControls(t, tc.name, err.Error()) + if h.fake.created != nil { + t.Error("sent the run anyway") + } + }) + } +} + +// A context id is accepted in any case, as the platform does. +func TestRunContextAcceptsIDs(t *testing.T) { + opts := defaultOpts() + opts.contexts = []string{"C0000000-0000-4000-8000-0000000000AB", "acme-api"} + req, err := buildRunRequest(promptInput{}, opts, []string{"x"}) + if err != nil || len(req.Contexts) != 2 || req.Contexts[0] != "C0000000-0000-4000-8000-0000000000AB" { + t.Fatalf("%+v %v", req, err) + } +} + +// Through cobra: the flag repeats, and a sixth one stops the command +// before it even looks for a credential. +func TestRunContextFlagThroughTheCommand(t *testing.T) { + fakeHome(t, "") + t.Setenv(tokenEnvVar, "") + args := []string{"run"} + for _, n := range []string{"a1", "a2", "a3", "a4", "a5", "a6"} { + args = append(args, "--context", n) + } + code, msg := execute(t, append(args, "x")...) + if code != exitUsage || !strings.Contains(msg, "at most 5") { + t.Fatalf("exit %d: %s", code, msg) + } + code, msg = execute(t, "run", "--ephemeral", "--model", "glm5.3", "x") + if code != exitUsage || !strings.Contains(msg, "--context NAME") { + t.Fatalf("ephemeral without repo: exit %d: %s", code, msg) + } + // A comma is not a separator: one flag is one context. + code, msg = execute(t, "run", "--context", "a,b", "x") + if code != exitUsage || !strings.Contains(msg, `"a,b"`) { + t.Fatalf("comma: exit %d: %s", code, msg) + } +} + +func TestRunContextRefusalsSayWhatToDo(t *testing.T) { + for _, tc := range []struct { + name string + ephemeral bool + status int + code, param string + wants, avoids []string + }{ + {"unknown context", false, 404, "context_not_found", "contexts[1]", + []string{`context "style-guide" not found`, "nan context ls"}, []string{"workspace", "not enabled"}}, + {"unknown context, ephemeral", true, 404, "context_not_found", "contexts[0]", + []string{`context "acme-api" not found`, "nan context ls"}, []string{"not enabled"}}, + {"unknown context, no index", false, 404, "context_not_found", "contexts", + []string{"not found", "nan context ls"}, []string{"workspace"}}, + {"skill conflict", false, 422, "context_skill_conflict", "contexts", + []string{"same skill", "--context"}, nil}, + {"too large", true, 413, "context_too_large", "contexts", + []string{"too large for one run", "250 MiB", "fewer contexts"}, nil}, + {"bad reference", false, 400, "invalid_request", "contexts[0]", + []string{"--context", "acme-api"}, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + param := "null" + if tc.param != "" { + param = fmt.Sprintf("%q", tc.param) + } + h := newHarness(t, &fakePlatform{ephemeral: tc.ephemeral, createStatus: tc.status, + createBody: fmt.Sprintf(`{"error":{"message":"refused \u001b[2J","type":"invalid_request_error","param":%s,"code":%q}}`, param, tc.code)}) + opts := defaultOpts() + opts.workspace = "develop" + if tc.ephemeral { + opts = ephemeralOpts() + } + opts.contexts = []string{"acme-api", "style-guide"} + err := doRun(context.Background(), h.env, opts, []string{"x"}) + if exitCode(err) != exitUsage { + t.Fatalf("exit %d (%v)", exitCode(err), err) + } + wantAll(t, tc.name, err.Error(), tc.wants...) + for _, a := range tc.avoids { + if strings.Contains(err.Error(), a) { + t.Errorf("%q mentions %q", err.Error(), a) + } + } + assertNoControls(t, tc.name, err.Error()) + }) + } +} + +// The run object's pinned contexts are part of the client model, so +// nan run --json shows what the run loaded. +func TestRunJSONCarriesPinnedContexts(t *testing.T) { + var r api.Run + if err := json.Unmarshal([]byte(`{"id":"x","contexts":[{"id":"c1","name":"acme-api","version":3,"tree_sha256":"ab"}]}`), &r); err != nil { + t.Fatal(err) + } + if len(r.Contexts) != 1 || r.Contexts[0].Name != "acme-api" || r.Contexts[0].Version != 3 || r.Contexts[0].TreeSHA256 != "ab" { + t.Errorf("%+v", r.Contexts) + } + b, _ := json.Marshal(api.Run{}) + if strings.Contains(string(b), "contexts") { + t.Errorf("a run without contexts serialises them: %s", b) + } +} + +func TestRunHelpExplainsContexts(t *testing.T) { + wantAll(t, "nan run --help", runCmd.Long, "--context NAME", "at most 5", "nan context ls", "without --repo") + wantAll(t, "nan run examples", runCmd.Example, "--context") + if f := runCmd.Flags().Lookup("context"); f == nil || f.Value.Type() != "stringArray" { + t.Fatalf("--context flag: %+v", f) + } +} diff --git a/cmd/testdata/contexts/automation_create_ephemeral_none.json b/cmd/testdata/contexts/automation_create_ephemeral_none.json new file mode 100644 index 0000000..03bbc31 --- /dev/null +++ b/cmd/testdata/contexts/automation_create_ephemeral_none.json @@ -0,0 +1,12 @@ +{ + "agent": "pi", + "checkout": "none", + "contexts": [ + "acme-api" + ], + "executor": "ephemeral", + "instructions": "Write the notes.", + "model": "glm5.3", + "name": "weekly notes", + "size": "s" +} diff --git a/cmd/testdata/contexts/automation_create_workspace.json b/cmd/testdata/contexts/automation_create_workspace.json new file mode 100644 index 0000000..4be9fce --- /dev/null +++ b/cmd/testdata/contexts/automation_create_workspace.json @@ -0,0 +1,10 @@ +{ + "agent": "pi", + "contexts": [ + "acme-api", + "style-guide" + ], + "instructions": "Review it.", + "name": "PR review api", + "workspace": "develop" +} diff --git a/cmd/testdata/contexts/automation_edit_checkout_none.json b/cmd/testdata/contexts/automation_edit_checkout_none.json new file mode 100644 index 0000000..24ae764 --- /dev/null +++ b/cmd/testdata/contexts/automation_edit_checkout_none.json @@ -0,0 +1,7 @@ +{ + "checkout": "none", + "contexts": [ + "acme-api" + ], + "github_repo": null +} diff --git a/cmd/testdata/contexts/ls.golden b/cmd/testdata/contexts/ls.golden new file mode 100644 index 0000000..b462f08 --- /dev/null +++ b/cmd/testdata/contexts/ls.golden @@ -0,0 +1,3 @@ +NAME VERSION FILES SIZE UPDATED DESCRIPTION +acme-api 3 3 83 B 2026-10-21 10:00 Acme API conventions +storage: 1.5 KiB of 500.0 MiB used diff --git a/cmd/testdata/contexts/pull.golden b/cmd/testdata/contexts/pull.golden new file mode 100644 index 0000000..955ac81 --- /dev/null +++ b/cmd/testdata/contexts/pull.golden @@ -0,0 +1 @@ +context acme-api version 3 written to DIR (4 files, 101 B) diff --git a/cmd/testdata/contexts/push.golden b/cmd/testdata/contexts/push.golden new file mode 100644 index 0000000..716ad67 --- /dev/null +++ b/cmd/testdata/contexts/push.golden @@ -0,0 +1 @@ +context acme-api: version 3 published (6 files, 116 B; 5 added, 0 changed, 0 removed; uploaded 4 files, 101 B) diff --git a/cmd/testdata/contexts/push_dry_run.golden b/cmd/testdata/contexts/push_dry_run.golden new file mode 100644 index 0000000..d1c84e7 --- /dev/null +++ b/cmd/testdata/contexts/push_dry_run.golden @@ -0,0 +1,6 @@ +context acme-api: version 3 -> 4 (dry run, nothing changed) + + files/new.md (9 B) + + skills/pr-review/SKILL.md (67 B) + ~ AGENTS.md (10 B) + - files/old.md +2 added, 1 changed, 1 removed; 3 files to upload (86 B) diff --git a/cmd/testdata/contexts/push_dry_run.json.golden b/cmd/testdata/contexts/push_dry_run.json.golden new file mode 100644 index 0000000..8031c09 --- /dev/null +++ b/cmd/testdata/contexts/push_dry_run.json.golden @@ -0,0 +1,20 @@ +{ + "context": "acme-api", + "base_version": 3, + "changes": { + "added": [ + "files/new.md", + "skills/pr-review/SKILL.md" + ], + "changed": [ + "AGENTS.md" + ], + "removed": [ + "files/old.md" + ] + }, + "upload": { + "files": 3, + "bytes": 86 + } +} diff --git a/cmd/testdata/contexts/push_refused.stderr.golden b/cmd/testdata/contexts/push_refused.stderr.golden new file mode 100644 index 0000000..0d2dec0 --- /dev/null +++ b/cmd/testdata/contexts/push_refused.stderr.golden @@ -0,0 +1,10 @@ +skipped .git (dotfiles are not kept in a context) +skipped files/.env (dotfiles are not kept in a context) +skipped files/sub/.hidden (dotfiles are not kept in a context) +refused CLAUDE.md: bad_top_level (rename it to AGENTS.md: that is the file the agents read) +refused README.md: bad_top_level (only AGENTS.md lives at the top; put reference files under files/) +refused files/con.txt: reserved_name +refused files/rocket🚀.md: bad_character +refused notes/a.md: bad_top_level (put reference files under files/ and skills under skills//) +refused skills/Bad Skill/SKILL.md: bad_character (a skill folder name is lowercase letters, digits and dashes) +refused skills/no-skill-md/SKILL.md: skill_invalid (every skill folder needs a SKILL.md) diff --git a/cmd/testdata/contexts/run_request_ephemeral_no_repo.json b/cmd/testdata/contexts/run_request_ephemeral_no_repo.json new file mode 100644 index 0000000..85649ed --- /dev/null +++ b/cmd/testdata/contexts/run_request_ephemeral_no_repo.json @@ -0,0 +1,11 @@ +{ + "agent": "pi", + "prompt": "write the release notes", + "model": "glm5.3", + "trigger": "cli", + "executor": "ephemeral", + "size": "m", + "contexts": [ + "acme-api" + ] +} diff --git a/cmd/testdata/contexts/run_request_ephemeral_repo.json b/cmd/testdata/contexts/run_request_ephemeral_repo.json new file mode 100644 index 0000000..42befd9 --- /dev/null +++ b/cmd/testdata/contexts/run_request_ephemeral_repo.json @@ -0,0 +1,12 @@ +{ + "agent": "pi", + "prompt": "fix the flaky test", + "model": "glm5.3", + "trigger": "cli", + "executor": "ephemeral", + "github_repo": "acme/api", + "contexts": [ + "style-guide", + "acme-api" + ] +} diff --git a/cmd/testdata/contexts/run_request_workspace.json b/cmd/testdata/contexts/run_request_workspace.json new file mode 100644 index 0000000..869ba83 --- /dev/null +++ b/cmd/testdata/contexts/run_request_workspace.json @@ -0,0 +1,10 @@ +{ + "workspace": "develop", + "agent": "pi", + "prompt": "review the open pull requests", + "trigger": "cli", + "contexts": [ + "acme-api", + "style-guide" + ] +} diff --git a/cmd/testdata/contexts/show.golden b/cmd/testdata/contexts/show.golden new file mode 100644 index 0000000..b053ea2 --- /dev/null +++ b/cmd/testdata/contexts/show.golden @@ -0,0 +1,12 @@ +name acme-api +id c7000000-0000-4000-8000-000000000001 +description Acme API conventions +version 3 (2026-10-21 10:00) +files 3, 83 B +instructions yes +skills pr-review + +PATH SIZE SHA256 +AGENTS.md 10 B 2d812d6fdf8… +files/spec.md 6 B 6b04df15eab… +skills/pr-review/SKILL.md 67 B da1195fa204… diff --git a/go.mod b/go.mod index 66f3047..7ef4447 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nxssie/nan-cli -go 1.26.9 +go 1.26.10 require ( github.com/charmbracelet/bubbles v1.0.0 @@ -8,6 +8,9 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/term v0.2.2 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 + go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/text v0.39.0 ) require ( @@ -29,9 +32,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index a2f04d3..092b071 100644 --- a/go.sum +++ b/go.sum @@ -60,4 +60,5 @@ golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/automations.go b/internal/api/automations.go index 836a3c3..94773d7 100644 --- a/internal/api/automations.go +++ b/internal/api/automations.go @@ -64,7 +64,9 @@ type Automation struct { Enabled bool `json:"enabled"` DisabledReason *string `json:"disabled_reason"` UpdatedBy string `json:"updated_by,omitempty"` - // Checkout is "workspace" or "fresh" (a new clone of github_repo per run). + // Checkout is "workspace", "fresh" (a new clone of github_repo per run) + // or "none" (an ephemeral automation without a repository, working on + // its agent contexts). Checkout string `json:"checkout,omitempty"` // Executor is where its runs execute ("workspace" or "ephemeral"), Size // the container size of an ephemeral automation (nil otherwise). diff --git a/internal/api/contexts.go b/internal/api/contexts.go new file mode 100644 index 0000000..4e159ac --- /dev/null +++ b/internal/api/contexts.go @@ -0,0 +1,442 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// Agent contexts (helmcode/nan-devops AGENT-CONTEXT.md §2): versioned file +// trees a member keeps for their runs and automations, under /v1/contexts. +// Reads take a CLI session, the member's sk- key or a platform token with +// contexts:read; writes take a CLI session or a token with contexts:write. +// +// Content goes up as content-addressed blobs (sha256): POST blobs/check says +// which ones the platform lacks, a blob of at most PartSize bytes is one PUT, +// a larger one a multipart upload of PartSize parts that can be resumed. A +// version is then published with the full file list and the version it was +// based on. This client only speaks the protocol; the push and pull logic +// lives with the command. + +// ContextPartSize is the platform's single-upload limit and multipart part +// size (16 MiB). +const ContextPartSize = 16 << 20 + +// ContextMaxFileBytes is the largest file a context holds (100 MiB). +const ContextMaxFileBytes = 100 << 20 + +// maxCheckShas is the platform's cap on one blobs/check body. +const maxCheckShas = 1000 + +// Context is a context's metadata at its current version (or, for a +// publish replay, at the version that publish created). +type Context struct { + ID string `json:"id"` + Object string `json:"object"` + Name string `json:"name"` + Description string `json:"description"` + Version int `json:"version"` + TreeSHA256 string `json:"tree_sha256"` + FileCount int `json:"file_count"` + TotalBytes int64 `json:"total_bytes"` + HasInstructions bool `json:"has_instructions"` + Skills []string `json:"skills"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ContextFile is one file of a version: what a pull writes. +type ContextFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Executable bool `json:"executable"` +} + +// ContextDetail is GET /v1/contexts/{id_or_name}: the context and the files +// of its current version. +type ContextDetail struct { + Context + Files []ContextFile `json:"files"` +} + +type ContextUsage struct { + StoredBytes int64 `json:"stored_bytes"` + QuotaBytes int64 `json:"quota_bytes"` +} + +type ContextList struct { + Data []Context `json:"data"` + Usage ContextUsage `json:"usage"` +} + +// SecretFinding is where a text blob looks like it holds a credential. The +// platform never returns the match itself. +type SecretFinding struct { + Line int `json:"line"` + Pattern string `json:"pattern"` +} + +// ContextBlob is a stored blob. +type ContextBlob struct { + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + Format string `json:"format"` + SecretFindings []SecretFinding `json:"secret_findings"` +} + +// ContextUpload is a multipart upload in progress. +type ContextUpload struct { + UploadID string `json:"upload_id"` + PartSize int `json:"part_size"` + Parts int `json:"parts"` + NextPart int `json:"next_part"` +} + +// PublishFile is one entry of a publish. +type PublishFile struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Executable bool `json:"executable"` +} + +// PublishRequest is the body of POST /v1/contexts/{id}/versions. +type PublishRequest struct { + BaseVersion int `json:"base_version"` + Files []PublishFile `json:"files"` + AllowSecretLike bool `json:"allow_secret_like"` +} + +// ContextPatch is the body of PATCH /v1/contexts/{id}: nil fields stay. +type ContextPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` +} + +// FileRefusal is one refused file of a publish: invalid_path, +// unsupported_file_type, skill_invalid (reason per file, detail optional). +type FileRefusal struct { + Path string `json:"path"` + Reason string `json:"reason"` + Detail string `json:"detail,omitempty"` +} + +// SecretHit is one finding of 422 context_secret_detected. +type SecretHit struct { + Path string `json:"path"` + Line int `json:"line"` + Pattern string `json:"pattern"` +} + +// ContextError is a refusal of the contexts API with the fields the +// platform adds next to the error envelope. It unwraps to the *APIError, so +// the generic exit mapping still sees the code. +type ContextError struct { + *APIError + // RetryAfter is the Retry-After header (0 when absent). + RetryAfter time.Duration + // CurrentVersion is set on 409 version_conflict. + CurrentVersion *int + // MissingBlobs is 409 blob_missing's sha256 list. + MissingBlobs []string + // Files is the per-file refusal list of invalid_path, + // unsupported_file_type and skill_invalid. + Files []FileRefusal + // Findings is 422 context_secret_detected's list. + Findings []SecretHit + // Used and Quota are 413 context_quota_exceeded's figures. + Used, Quota int64 +} + +func (e *ContextError) Error() string { return e.APIError.Error() } +func (e *ContextError) Unwrap() error { return e.APIError } + +// AsContextError finds a *ContextError in err. +func AsContextError(err error) (*ContextError, bool) { + var ce *ContextError + if errors.As(err, &ce) { + return ce, true + } + return nil, false +} + +func (c *RunsClient) contextError(status int, header http.Header, body []byte) error { + err := c.statusError(status, body) + apiErr, ok := err.(*APIError) + if !ok { + return err + } + ce := &ContextError{APIError: apiErr} + if s := header.Get("Retry-After"); s != "" { + if n, perr := strconv.Atoi(s); perr == nil && n >= 0 { + ce.RetryAfter = time.Duration(n) * time.Second + } + } + var extra struct { + CurrentVersion *int `json:"current_version"` + SHA256 []string `json:"sha256"` + Files []FileRefusal `json:"files"` + Findings []SecretHit `json:"findings"` + Used int64 `json:"used"` + Quota int64 `json:"quota"` + Automations []Ref `json:"automations"` + } + if json.Unmarshal(body, &extra) == nil { + ce.CurrentVersion = extra.CurrentVersion + ce.MissingBlobs = extra.SHA256 + ce.Files = extra.Files + ce.Findings = extra.Findings + ce.Used, ce.Quota = extra.Used, extra.Quota + if len(apiErr.Automations) == 0 { + apiErr.Automations = extra.Automations + } + } + return ce +} + +// ctxDo sends req with client and decodes a 2xx JSON answer into out. +func (c *RunsClient) ctxDo(client *http.Client, req *http.Request, out any) (int, error) { + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse)) + if err != nil { + return resp.StatusCode, fmt.Errorf("could not read the answer from nan.builders: %w", err) + } + if resp.StatusCode >= 400 { + return resp.StatusCode, c.contextError(resp.StatusCode, resp.Header, body) + } + if out != nil && len(bytes.TrimSpace(body)) > 0 { + if err := json.Unmarshal(body, out); err != nil { + return resp.StatusCode, fmt.Errorf("unexpected answer from nan.builders: %w", err) + } + } + return resp.StatusCode, nil +} + +func (c *RunsClient) ctxJSON(ctx context.Context, method, path string, body, out any, idemKey string) (int, error) { + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return 0, err + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + return c.ctxDo(c.http, req, out) +} + +// rawRequest is a request whose body is streamed bytes of a known length. +func (c *RunsClient) rawRequest(ctx context.Context, method, path string, body io.Reader, size int64) (*http.Request, error) { + if body == nil || size == 0 { + body = http.NoBody + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + req.ContentLength = size + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "nan-cli") + c.authorize(req) + return req, nil +} + +// BaseURL is where this client sends its requests. +func (c *RunsClient) BaseURL() string { return c.baseURL } + +func contextPath(id string) string { return "/v1/contexts/" + url.PathEscape(id) } + +func blobPath(sha string) string { return "/v1/contexts/blobs/" + url.PathEscape(sha) } + +func uploadPath(sha, uploadID string) string { + return blobPath(sha) + "/uploads/" + url.PathEscape(uploadID) +} + +// ListContexts is GET /v1/contexts. +func (c *RunsClient) ListContexts(ctx context.Context) (*ContextList, error) { + out := &ContextList{} + _, err := c.ctxJSON(ctx, http.MethodGet, "/v1/contexts", nil, out, "") + return out, err +} + +// GetContext is GET /v1/contexts/{id_or_name}. +func (c *RunsClient) GetContext(ctx context.Context, idOrName string) (*ContextDetail, error) { + out := &ContextDetail{} + _, err := c.ctxJSON(ctx, http.MethodGet, contextPath(idOrName), nil, out, "") + return out, err +} + +// CreateContext is POST /v1/contexts: the context with an empty version 1. +func (c *RunsClient) CreateContext(ctx context.Context, name, description, idemKey string) (*Context, error) { + body := map[string]string{"name": name} + if description != "" { + body["description"] = description + } + out := &Context{} + _, err := c.ctxJSON(ctx, http.MethodPost, "/v1/contexts", body, out, idemKey) + return out, err +} + +// UpdateContext is PATCH /v1/contexts/{id} (name and description only; no +// new version). +func (c *RunsClient) UpdateContext(ctx context.Context, id string, p ContextPatch) (*Context, error) { + out := &Context{} + _, err := c.ctxJSON(ctx, http.MethodPatch, contextPath(id), p, out, "") + return out, err +} + +// DeleteContext is DELETE /v1/contexts/{id}; 409 context_in_use names the +// automations that use it. +func (c *RunsClient) DeleteContext(ctx context.Context, id string) error { + _, err := c.ctxJSON(ctx, http.MethodDelete, contextPath(id), nil, nil, "") + return err +} + +// CheckBlobs is POST /v1/contexts/blobs/check: which of shas the platform +// does not hold for this member (asked in batches of the platform's cap). +func (c *RunsClient) CheckBlobs(ctx context.Context, shas []string) ([]string, error) { + missing := []string{} + for start := 0; start < len(shas); start += maxCheckShas { + end := min(start+maxCheckShas, len(shas)) + var out struct { + Missing []string `json:"missing"` + } + if _, err := c.ctxJSON(ctx, http.MethodPost, "/v1/contexts/blobs/check", map[string][]string{"sha256": shas[start:end]}, &out, ""); err != nil { + return nil, err + } + missing = append(missing, out.Missing...) + } + return missing, nil +} + +// PutBlob is PUT /v1/contexts/blobs/{sha256}: one upload of at most +// ContextPartSize bytes. filename is a type hint for the platform only +// (X-Nan-Filename); it is never stored. The request has no client timeout: +// ctx bounds it. +func (c *RunsClient) PutBlob(ctx context.Context, sha string, body io.Reader, size int64, filename string) (*ContextBlob, error) { + req, err := c.rawRequest(ctx, http.MethodPut, blobPath(sha), body, size) + if err != nil { + return nil, err + } + if filename != "" { + req.Header.Set("X-Nan-Filename", filename) + } + out := &ContextBlob{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// StartUpload is POST /v1/contexts/blobs/{sha256}/uploads for a blob larger +// than ContextPartSize. When the platform already holds the blob it answers +// it instead of an upload (blob non-nil, upload nil). +func (c *RunsClient) StartUpload(ctx context.Context, sha string, size int64, filename string) (*ContextUpload, *ContextBlob, error) { + req, err := c.newRequest(ctx, http.MethodPost, blobPath(sha)+"/uploads", map[string]any{"size": size, "filename": filename}) + if err != nil { + return nil, nil, err + } + var raw json.RawMessage + status, err := c.ctxDo(c.http, req, &raw) + if err != nil { + return nil, nil, err + } + if status == http.StatusOK { + b := &ContextBlob{} + return nil, b, json.Unmarshal(raw, b) + } + u := &ContextUpload{} + return u, nil, json.Unmarshal(raw, u) +} + +// UploadStatus is GET …/uploads/{upload_id}: where a multipart upload +// stands, to resume it. +func (c *RunsClient) UploadStatus(ctx context.Context, sha, uploadID string) (*ContextUpload, error) { + out := &ContextUpload{} + _, err := c.ctxJSON(ctx, http.MethodGet, uploadPath(sha, uploadID), nil, out, "") + return out, err +} + +// PutPart is PUT …/uploads/{upload_id}/parts/{n}; it answers the next part +// the platform expects. +func (c *RunsClient) PutPart(ctx context.Context, sha, uploadID string, n int, body io.Reader, size int64) (int, error) { + req, err := c.rawRequest(ctx, http.MethodPut, uploadPath(sha, uploadID)+"/parts/"+strconv.Itoa(n), body, size) + if err != nil { + return 0, err + } + var out struct { + NextPart int `json:"next_part"` + } + _, err = c.ctxDo(c.stream, req, &out) + return out.NextPart, err +} + +// CompleteUpload is POST …/uploads/{upload_id}/complete. +func (c *RunsClient) CompleteUpload(ctx context.Context, sha, uploadID string) (*ContextBlob, error) { + req, err := c.newRequest(ctx, http.MethodPost, uploadPath(sha, uploadID)+"/complete", nil) + if err != nil { + return nil, err + } + out := &ContextBlob{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// AbortUpload is DELETE …/uploads/{upload_id}. +func (c *RunsClient) AbortUpload(ctx context.Context, sha, uploadID string) error { + _, err := c.ctxJSON(ctx, http.MethodDelete, uploadPath(sha, uploadID), nil, nil, "") + return err +} + +// PublishContext is POST /v1/contexts/{id}/versions. The answer is the +// context at the new version. It can take a while (the platform rescans and +// reads every SKILL.md), so ctx bounds it instead of the client timeout. +func (c *RunsClient) PublishContext(ctx context.Context, id string, in PublishRequest, idemKey string) (*Context, error) { + if in.Files == nil { + in.Files = []PublishFile{} + } + req, err := c.newRequest(ctx, http.MethodPost, contextPath(id)+"/versions", in) + if err != nil { + return nil, err + } + if idemKey != "" { + req.Header.Set("Idempotency-Key", idemKey) + } + out := &Context{} + _, err = c.ctxDo(c.stream, req, out) + return out, err +} + +// OpenContextFile is GET /v1/contexts/{id}/file?path=…&version=N: the +// file's bytes as a stream the caller closes. The caller verifies them +// against the manifest; nothing here trusts the length or the content. +func (c *RunsClient) OpenContextFile(ctx context.Context, id, path string, version int) (io.ReadCloser, error) { + q := url.Values{} + q.Set("path", path) + if version > 0 { + q.Set("version", strconv.Itoa(version)) + } + req, err := c.newRequest(ctx, http.MethodGet, contextPath(id)+"/file?"+q.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/octet-stream") + resp, err := c.stream.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach nan.builders: %w", err) + } + if resp.StatusCode >= 400 { + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return nil, c.contextError(resp.StatusCode, resp.Header, body) + } + return resp.Body, nil +} diff --git a/internal/api/contexts_test.go b/internal/api/contexts_test.go new file mode 100644 index 0000000..e74a425 --- /dev/null +++ b/internal/api/contexts_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func ctxServer(t *testing.T, h http.HandlerFunc) *RunsClient { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return NewRunsClient("sess", "").WithBaseURL(srv.URL) +} + +// The contexts API puts its extra fields next to the error envelope (cloud-api +// ContextsHandler.fail); every one of them reaches the caller. +func TestContextErrorCarriesTheExtraFields(t *testing.T) { + for _, tc := range []struct { + body string + check func(*ContextError) bool + }{ + {`{"error":{"message":"changed","type":"invalid_request_error","param":"base_version","code":"version_conflict"},"current_version":7}`, + func(e *ContextError) bool { + return e.CurrentVersion != nil && *e.CurrentVersion == 7 && e.Param == "base_version" + }}, + {`{"error":{"message":"gone","type":"invalid_request_error","param":null,"code":"blob_missing"},"sha256":["aa","bb"]}`, + func(e *ContextError) bool { return strings.Join(e.MissingBlobs, ",") == "aa,bb" }}, + {`{"error":{"message":"names","type":"invalid_request_error","param":null,"code":"invalid_path"},"files":[{"path":"files/.env","reason":"dotfile"}]}`, + func(e *ContextError) bool { return len(e.Files) == 1 && e.Files[0].Reason == "dotfile" }}, + {`{"error":{"message":"secrets","type":"invalid_request_error","param":null,"code":"context_secret_detected"},"findings":[{"path":"AGENTS.md","line":3,"pattern":"github_token"}]}`, + func(e *ContextError) bool { return len(e.Findings) == 1 && e.Findings[0].Line == 3 }}, + {`{"error":{"message":"quota","type":"invalid_request_error","param":null,"code":"context_quota_exceeded"},"used":10,"quota":5,"growing":true}`, + func(e *ContextError) bool { return e.Used == 10 && e.Quota == 5 }}, + {`{"error":{"message":"in use","type":"invalid_request_error","param":null,"code":"context_in_use"},"automations":[{"id":"a1","name":"PR review"}]}`, + func(e *ContextError) bool { return len(e.Automations) == 1 && e.Automations[0].Name == "PR review" }}, + } { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "5") + w.WriteHeader(409) + _, _ = io.WriteString(w, tc.body) + }) + _, err := c.GetContext(context.Background(), "acme") + ce, ok := AsContextError(err) + if !ok || !tc.check(ce) || ce.RetryAfter != 5*time.Second { + t.Errorf("%s: %+v", tc.body, ce) + continue + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code == "" { + t.Errorf("does not unwrap to the APIError: %v", err) + } + } +} + +func TestContextSessionExpired(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(401) }) + if _, err := c.ListContexts(context.Background()); !errors.Is(err, ErrSessionExpired) { + t.Errorf("%v", err) + } +} + +func TestCheckBlobsBatchesAtTheCap(t *testing.T) { + var sizes []int + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + var in struct { + SHA256 []string `json:"sha256"` + } + _ = json.NewDecoder(r.Body).Decode(&in) + sizes = append(sizes, len(in.SHA256)) + _ = json.NewEncoder(w).Encode(map[string]any{"missing": in.SHA256[:1]}) + }) + shas := make([]string, 2001) + for i := range shas { + shas[i] = fmt.Sprintf("%064x", i) + } + missing, err := c.CheckBlobs(context.Background(), shas) + if err != nil || len(missing) != 3 || fmt.Sprint(sizes) != "[1000 1000 1]" { + t.Errorf("%v %v %v", err, missing, sizes) + } +} + +func TestPutBlobStreamsWithItsLengthAndHint(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if r.Method != http.MethodPut || r.URL.Path != "/v1/contexts/blobs/abc" || r.ContentLength != 5 || string(b) != "hello" || + r.Header.Get("X-Nan-Filename") != "a.md" || r.Header.Get("Content-Type") != "application/octet-stream" || + r.Header.Get("Cookie") != "nan_session=sess" { + t.Errorf("%s %s len=%d %q %v", r.Method, r.URL.Path, r.ContentLength, b, r.Header) + } + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"sha256":"abc","size":5,"format":"text","secret_findings":[{"line":1,"pattern":"github_token"}]}`) + }) + b, err := c.PutBlob(context.Background(), "abc", strings.NewReader("hello"), 5, "a.md") + if err != nil || b.Size != 5 || len(b.SecretFindings) != 1 { + t.Errorf("%+v %v", b, err) + } +} + +// A blob the member already has answers 200 with it instead of an upload. +func TestStartUploadTellsAStoredBlobFromAnUpload(t *testing.T) { + status := 200 + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + var in map[string]any + _ = json.NewDecoder(r.Body).Decode(&in) + if in["size"] != float64(20<<20) || in["filename"] != "big.pdf" { + t.Errorf("body %v", in) + } + w.WriteHeader(status) + if status == 200 { + _, _ = io.WriteString(w, `{"sha256":"abc","size":20971520,"format":"pdf","secret_findings":[]}`) + return + } + _, _ = io.WriteString(w, `{"upload_id":"u1","part_size":16777216,"parts":2,"next_part":1}`) + }) + up, blob, err := c.StartUpload(context.Background(), "abc", 20<<20, "big.pdf") + if err != nil || up != nil || blob == nil || blob.Format != "pdf" { + t.Errorf("%+v %+v %v", up, blob, err) + } + status = 201 + up, blob, err = c.StartUpload(context.Background(), "abc", 20<<20, "big.pdf") + if err != nil || blob != nil || up == nil || up.UploadID != "u1" || up.Parts != 2 { + t.Errorf("%+v %+v %v", up, blob, err) + } +} + +func TestOpenContextFileRefusalIsAContextError(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("path") != "files/a b.md" || r.URL.Query().Get("version") != "3" { + t.Errorf("query %q", r.URL.RawQuery) + } + w.WriteHeader(404) + _, _ = io.WriteString(w, `{"error":{"message":"file not found","type":"invalid_request_error","param":"path","code":"file_not_found"}}`) + }) + _, err := c.OpenContextFile(context.Background(), "id", "files/a b.md", 3) + if ce, ok := AsContextError(err); !ok || ce.Code != "file_not_found" { + t.Errorf("%v", err) + } +} + +func TestPublishSendsAnEmptyListNotNull(t *testing.T) { + c := ctxServer(t, func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + if string(b) != `{"base_version":1,"files":[],"allow_secret_like":false}` || r.Header.Get("Idempotency-Key") != "k" { + t.Errorf("%s %v", b, r.Header) + } + w.WriteHeader(201) + _, _ = io.WriteString(w, `{"id":"id","name":"acme","version":2}`) + }) + out, err := c.PublishContext(context.Background(), "id", PublishRequest{BaseVersion: 1}, "k") + if err != nil || out.Version != 2 { + t.Errorf("%+v %v", out, err) + } +} diff --git a/internal/api/runs.go b/internal/api/runs.go index 3bc2006..536646f 100644 --- a/internal/api/runs.go +++ b/internal/api/runs.go @@ -130,6 +130,19 @@ type Run struct { Executor string `json:"executor,omitempty"` Size *string `json:"size,omitempty"` Cost *RunCost `json:"cost,omitempty"` + + // Contexts are the agent contexts the run pinned at admission, in run + // order (absent on an older platform). + Contexts []RunContext `json:"contexts,omitempty"` +} + +// RunContext is one agent context a run loaded: the version pinned when +// the run was admitted, and that version's tree hash. +type RunContext struct { + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + TreeSHA256 string `json:"tree_sha256"` } // Executors, as the platform names them. @@ -205,6 +218,10 @@ type CreateRunRequest struct { // member's secret holding a GitHub token, used only to clone a private // repository and never given to the agent. Left out: anonymous clone. GitHubTokenSecret string `json:"github_token_secret,omitempty"` + + // Contexts: 0..5 of the member's agent contexts by name or id, in + // order. An ephemeral run without GitHubRepo needs at least one. + Contexts []string `json:"contexts,omitempty"` } // RunInference is the inference choice of POST /v1/runs: {"source":"byok"} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b4b61e7..57930cc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -20,8 +20,7 @@ import ( ) const ( - loginRequestURL = "https://cloud-api.nan.builders/api/auth/login/request" - sessionCookie = "nan_session" + 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. linkDomain = "nan.builders" @@ -34,9 +33,30 @@ 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" +// SignInBaseURL is where the three calls of the sign-in go: the link +// request, the link exchange and the two-factor challenge. Nothing else: the +// rest of the CLI (internal/api - nan me, logout, runs, the panel's tabs) +// keeps its own production URLs. +// +// A variable and not a constant for two readers only: the tests, which point +// the sign-in at a server of their own, and a build for QA, which points it at +// the dev platform without a flag or an environment variable that a released +// binary would carry: +// +// go build -ldflags "-X github.com/nxssie/nan-cli/internal/auth.SignInBaseURL=https://cloud-api-dev.nan.builders" -o nan-qa . +// HOME=$(mktemp -d) ./nan-qa auth login --email you@example.com +// +// Run a QA build with a HOME of its own. It saves the dev session in +// ~/.config/nan/session.json, over the member's production one, and every +// command after the sign-in sends that dev session to production: the panel +// takes the refusal for an expired session and clears it, and logout asks +// production, not dev, to end it. +// +// Nothing else sets it. +var SignInBaseURL = "https://cloud-api.nan.builders" + +func loginRequestURL() string { return SignInBaseURL + "/api/auth/login/request" } +func loginVerifyURL() string { return SignInBaseURL + "/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 @@ -61,7 +81,7 @@ func RequestSignInLink(email string) error { return err } client := &http.Client{Timeout: requestTimeout} - resp, err := client.Post(loginRequestURL, "application/json", bytes.NewReader(body)) + resp, err := client.Post(loginRequestURL(), "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("could not reach nan.builders: %w", err) } @@ -119,6 +139,10 @@ func isLinkHost(host string) bool { // cookie back. This does the same POST and keeps the cookie instead of // following the redirect, which is the whole reason the old flow had to send // people into DevTools. +// +// An account with two-factor authentication gets no session from the link +// alone: the answer is a challenge, returned here as *MFARequiredError, and +// the session comes from answering it (MFARequiredError.Complete). func ExchangeToken(token string) (string, error) { client := &http.Client{ Timeout: requestTimeout, @@ -126,23 +150,39 @@ func ExchangeToken(token string) (string, error) { return http.ErrUseLastResponse }, } - form := url.Values{"token": {token}, "client": {clientKind}} - resp, err := client.PostForm(loginVerifyURL, form) + // mfa=1 says this build can answer a two-factor challenge. Without it the + // platform refuses an account that has a factor outright, with a reason + // that tells the member to update - which is what every build before this + // one gets, since none of them could ask for the code. + form := url.Values{"token": {token}, "client": {clientKind}, "mfa": {"1"}} + resp, err := client.PostForm(loginVerifyURL(), form) if err != nil { return "", fmt.Errorf("could not reach nan.builders: %w", err) } defer resp.Body.Close() + var pending string for _, c := range resp.Cookies() { if c.Name == sessionCookie && c.Value != "" { return c.Value, nil } + if c.Name == mfaPendingCookie && c.Value != "" { + pending = c.Value + } } // A spent or expired link redirects to the platform's access-denied page // with the reason in the query, which is more useful than the status code. + // A link that worked for an account with a second factor redirects to the + // page that asks for the code, with the challenge in a cookie. if location, err := resp.Location(); err == nil { - if reason := location.Query().Get("reason"); reason != "" { + reason := location.Query().Get("reason") + switch { + case reason == mfaRequiredReason && pending != "": + return "", &MFARequiredError{pending: pending} + case reason == mfaRequiredReason: + return "", errors.New("nan.builders asked for a two-factor code but sent no sign-in to answer it — ask for a new link and sign in again") + case reason != "": return "", errors.New(linkReasonMessage(reason)) } } @@ -159,11 +199,28 @@ var linkReasons = map[string]string{ "invalid_link": "that link expired or was already used — a link works once and " + "expires " + LinkValidity + " after it is sent, so ask for another one", "missing_token": "that link is incomplete — copy the whole link out of the most recent email", + // What a build from before two-factor sign-in gets for an account that + // has it. This build says it can answer the challenge, so it should never + // be told this; if it is, the sentence is still the right one. + "mfa_enabled_update_nan_cli": "this account uses two-factor authentication, and this nan-cli cannot ask for the code — " + + "update it with the installer (https://github.com/helmcode/nan-cli#install) and ask for a new link", + // An account that has not finished signing up and has two-factor + // authentication on: the setup, behind the code, happens in the browser + // only. The link this was is spent. + "finish_onboarding_in_the_browser": "your NaN account is not set up yet — finish it in the browser: " + + "ask for a new link at https://cloud.nan.builders (this one is spent), complete the setup there, " + + "then sign in here again", } func linkReasonMessage(reason string) string { if message, ok := linkReasons[reason]; ok { return message } + // The reason comes out of a redirect's query, decoded, so it can carry + // anything - an escape sequence, a paragraph. Only something shaped like + // the platform's reasons is put on the terminal. + if !isErrorCode(reason) { + return "the link did not work, and nan.builders did not say why in a way this version can show — ask for a new link" + } return "the link did not work: " + strings.ReplaceAll(reason, "_", " ") } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 3edb67b..b057b2e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -109,9 +109,9 @@ func TestExchangeTokenDeclaresTheCLI(t *testing.T) { http.Redirect(w, r, "/dashboard", http.StatusSeeOther) })) defer srv.Close() - old := loginVerifyURL - loginVerifyURL = srv.URL - defer func() { loginVerifyURL = old }() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() got, err := ExchangeToken("tok") if err != nil || got != "sess" { diff --git a/internal/auth/authtest/platform.go b/internal/auth/authtest/platform.go new file mode 100644 index 0000000..c747cb2 --- /dev/null +++ b/internal/auth/authtest/platform.go @@ -0,0 +1,330 @@ +// Package authtest is a fake nan.builders sign-in for tests: the link +// exchange and the two-factor challenge, answering the way the platform's API +// contract says they answer (nan-cloud-api openspec/changes/mfa/api-contract.md, +// "POST /api/auth/login/verify", "POST /api/auth/mfa/challenge" and "CLI +// contract"). +// +// It is imported by tests only, so it never reaches the binary. +package authtest + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" +) + +const ( + // FrontendURL is the UI the platform redirects to, and the only origin + // the challenge route accepts. + FrontendURL = "https://cloud.nan.builders" + + // PendingCookie is the challenge cookie's name. + PendingCookie = "__Host-nan_mfa_pending" + + // Values a test can look for: in what was sent, and in what must never + // be written anywhere. + LinkToken = "link-token-for-a-test" + Pending = "fake-pending-challenge-0123456789abcdefghij" // 43 base64url characters, like the real one + Session = "fake-session-from-the-platform" + Code = "246810" +) + +// Platform is the fake. Set the exported fields before the first request. +type Platform struct { + *httptest.Server + + // Factor: the account has a confirmed authenticator. Without it the link + // alone signs in, exactly as before two-factor sign-in existed. + Factor bool + // Onboarding: the member is invited or registered and has not finished + // signing up, so a link gets them the onboarding token, never a session. + Onboarding bool + // RecoveryCodes the account has not used yet, in canonical form + // (xxxx-xxxx-xxxx-xxxx). + RecoveryCodes []string + // Next, when set, answers the next challenge POST instead of checking + // it, and is then cleared: a refusal the test wants to see handled. + Next *Refusal + + mu sync.Mutex + // What came in, for the test to look at. + verifies []url.Values + challenges []Request + + spent bool + open bool // a challenge is waiting for its code + ended string // why the last challenge ended: used, exhausted, expired + attempts int // left on the open challenge + codeUsed bool // Code's step has been spent (anti-replay) + lockedUntil time.Time +} + +// Refusal is an answer the test forces on the challenge route. +type Refusal struct { + Status int + Body string + Header map[string]string +} + +// Request is a challenge POST as it arrived. +type Request struct { + Header http.Header + Body string + // Cookie is the challenge cookie it carried, "" without one. + Cookie string +} + +// New starts the fake. It stops when the test ends. +func New(t testing.TB) *Platform { + t.Helper() + p := &Platform{} + mux := http.NewServeMux() + mux.HandleFunc("POST /api/auth/login/request", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) + }) + mux.HandleFunc("POST /api/auth/login/verify", p.verify) + mux.HandleFunc("POST /api/auth/mfa/challenge", p.challenge) + p.Server = httptest.NewServer(mux) + t.Cleanup(p.Close) + return p +} + +// Verifies are the forms the verify route received. +func (p *Platform) Verifies() []url.Values { + p.mu.Lock() + defer p.mu.Unlock() + return append([]url.Values(nil), p.verifies...) +} + +// Challenges are the challenge POSTs the platform received. +func (p *Platform) Challenges() []Request { + p.mu.Lock() + defer p.mu.Unlock() + return append([]Request(nil), p.challenges...) +} + +// NewLink lets one more link through, as a new email would. +func (p *Platform) NewLink() { + p.mu.Lock() + defer p.mu.Unlock() + p.spent = false +} + +// Expire ends the open challenge the way ten minutes would. +func (p *Platform) Expire() { + p.mu.Lock() + defer p.mu.Unlock() + if p.open { + p.open, p.ended = false, "expired" + } +} + +// Lock pauses the account's two-factor sign-in until the given time. +func (p *Platform) Lock(until time.Time) { + p.mu.Lock() + defer p.mu.Unlock() + p.lockedUntil = until +} + +func (p *Platform) verify(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + p.mu.Lock() + defer p.mu.Unlock() + p.verifies = append(p.verifies, r.PostForm) + + // The link is consumed in every row of the contract's table. + if r.PostForm.Get("token") != LinkToken || p.spent { + http.Redirect(w, r, FrontendURL+"/access-denied?reason=invalid_link", http.StatusFound) + return + } + p.spent = true + + cli := r.PostForm.Get("client") == "cli" + switch { + case p.Onboarding && !p.Factor: + // Unchanged by two-factor sign-in: the onboarding token, which is not + // a session, and the page that uses it. + http.SetCookie(w, &http.Cookie{Name: "nan_onboarding", Value: "fake-onboarding-token", Path: "/", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/onboarding", http.StatusFound) + case p.Onboarding && cli: + // "CLI contract" step 8: with a factor, onboarding is finished in the + // browser, whatever mfa says. No cookie of any kind. + http.Redirect(w, r, FrontendURL+"/access-denied?reason=finish_onboarding_in_the_browser", http.StatusFound) + case !p.Factor: + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + http.Redirect(w, r, FrontendURL+"/", http.StatusFound) + case cli && r.PostForm.Get("mfa") != "1": + http.Redirect(w, r, FrontendURL+"/access-denied?reason=mfa_enabled_update_nan_cli", http.StatusFound) + default: + // A new challenge replaces any earlier one (D8). + p.open, p.ended, p.attempts = true, "", 5 + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: Pending, Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) + http.Redirect(w, r, FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + } +} + +func (p *Platform) challenge(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16)) + cookie := "" + if c, err := r.Cookie(PendingCookie); err == nil { + cookie = c.Value + } + p.mu.Lock() + defer p.mu.Unlock() + p.challenges = append(p.challenges, Request{Header: r.Header.Clone(), Body: string(body), Cookie: cookie}) + w.Header().Set("Cache-Control", "no-store") + + // The FRONTEND_URL origin only; without an Origin, refused when the + // browser says it came from another site, through otherwise (the CLI). + if o := r.Header.Get("Origin"); o != "" && o != FrontendURL { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if s := r.Header.Get("Sec-Fetch-Site"); r.Header.Get("Origin") == "" && (s == "cross-site" || s == "same-site") { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden_origin"}) + return + } + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if p.Next != nil { + next := p.Next + p.Next = nil + for k, v := range next.Header { + w.Header().Set(k, v) + } + w.WriteHeader(next.Status) + _, _ = io.WriteString(w, next.Body) + return + } + + if cookie != Pending { + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_missing")) + return + } + if !p.open { + expire(w) + code := "mfa_challenge_" + p.ended + if p.ended == "" { + code = "mfa_challenge_missing" + } + writeJSON(w, http.StatusForbidden, refusal(code)) + return + } + if time.Now().Before(p.lockedUntil) { + p.locked(w) + return + } + + var answer map[string]string + dec := json.NewDecoder(strings.NewReader(string(body))) + dec.DisallowUnknownFields() + if err := dec.Decode(&answer); err != nil || len(answer) != 1 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + + if code, ok := answer["code"]; ok { + code = strings.ReplaceAll(code, " ", "") + if len(code) != 6 { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + switch { + case code == Code && !p.codeUsed: + p.codeUsed = true + p.pass(w, nil) + case code == Code: + p.fail(w, "mfa_code_already_used") + default: + p.fail(w, "mfa_code_invalid") + } + return + } + if rc, ok := answer["recovery_code"]; ok { + canonical, ok := canonicalRecovery(rc) + if !ok { + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) + return + } + for i, have := range p.RecoveryCodes { + if have == canonical { + p.RecoveryCodes = append(p.RecoveryCodes[:i:i], p.RecoveryCodes[i+1:]...) + left := len(p.RecoveryCodes) + p.pass(w, &left) + return + } + } + p.fail(w, "mfa_recovery_code_invalid") + return + } + writeJSON(w, http.StatusBadRequest, refusal("invalid_request")) +} + +func (p *Platform) pass(w http.ResponseWriter, recoveryLeft *int) { + p.open, p.ended = false, "used" + expire(w) + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: Session, Path: "/", Domain: ".nan.builders", Secure: true, HttpOnly: true}) + writeJSON(w, http.StatusOK, map[string]any{"redirect": "/", "recovery_codes_remaining": recoveryLeft}) +} + +func (p *Platform) fail(w http.ResponseWriter, code string) { + p.attempts-- + if p.attempts <= 0 { + p.open, p.ended = false, "exhausted" + expire(w) + writeJSON(w, http.StatusForbidden, refusal("mfa_challenge_exhausted")) + return + } + body := refusal(code) + body["attempts_remaining"] = p.attempts + writeJSON(w, http.StatusBadRequest, body) +} + +func (p *Platform) locked(w http.ResponseWriter) { + secs := int(time.Until(p.lockedUntil).Seconds()) + 1 + w.Header().Set("Retry-After", fmt.Sprint(secs)) + body := refusal("mfa_locked") + body["locked_until"] = p.lockedUntil.UTC().Format(time.RFC3339) + body["retry_after"] = secs + writeJSON(w, http.StatusTooManyRequests, body) +} + +// canonicalRecovery is the platform's reading of a recovery code: sixteen +// base32 characters in any case, with or without hyphens and spaces. +func canonicalRecovery(s string) (string, bool) { + s = strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(s)) + if len(s) != 16 { + return "", false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= '2' && r <= '7') { + return "", false + } + } + return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16], true +} + +func refusal(code string) map[string]any { + return map[string]any{"error": code, "message": "A sentence the client must not branch on."} +} + +func expire(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: PendingCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/auth/mfa.go b/internal/auth/mfa.go new file mode 100644 index 0000000..e0c8f5a --- /dev/null +++ b/internal/auth/mfa.go @@ -0,0 +1,321 @@ +package auth + +// Two-factor sign-in. +// +// A member who has turned on two-factor authentication does not get a session +// from the link alone. The verify POST answers with a challenge instead - a +// cookie that names it, and a redirect whose reason says so - and the session +// comes back only once a code from the member's authenticator app, or one of +// their recovery codes, is posted against that challenge. +// +// The challenge cookie is a credential for the next ten minutes, so it lives +// in memory and nowhere else: not in session.json, not in an error message, +// not in a log. A process that exits halfway leaves it to expire on the +// platform, and the member asks for a new link. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + mfaPendingCookie = "__Host-nan_mfa_pending" + mfaRequiredReason = "mfa_required" + + // What ends every message about a sign-in that cannot go on. The link was + // spent when the challenge was opened, so a new one is the only way back. + newLinkHint = "ask for a new link and sign in again" +) + +func mfaChallengeURL() string { return SignInBaseURL + "/api/auth/mfa/challenge" } + +// MFARequiredError is what ExchangeToken returns when the link was good and +// the account asks for a second factor. Answer it with Complete. +type MFARequiredError struct { + pending string +} + +func (e *MFARequiredError) Error() string { + return "this account uses two-factor authentication: enter the 6-digit code from your authenticator app, or one of your recovery codes" +} + +// Format prints the message whatever the verb. Without it %#v - or %d, or +// anything else fmt does not route to Error - prints the struct, and the +// struct is the challenge. +func (e *MFARequiredError) Format(f fmt.State, _ rune) { _, _ = io.WriteString(f, e.Error()) } + +// SignIn is a challenge answered. +type SignIn struct { + Session string + // UsedRecoveryCode is set when the answer was a recovery code, and + // RecoveryCodesLeft is then how many the member still has. + UsedRecoveryCode bool + RecoveryCodesLeft int +} + +// ErrNotACode is input that is neither shape the platform takes, refused here +// without spending one of the sign-in's attempts on it. +var ErrNotACode = errors.New("that is neither a 6-digit code nor a recovery code (xxxx-xxxx-xxxx-xxxx) — " + + "type the code your authenticator app shows for nan.builders") + +// MFAError is the platform refusing a code, or the challenge it was posted to. +type MFAError struct { + // The platform's code for it, e.g. "mfa_code_invalid". Empty when the + // answer carried none. + Code string + Status int + Attempts int // attempts left on this sign-in, for a wrong code + LockedTill time.Time + RetryAfter time.Duration +} + +// Retryable reports whether the same challenge can take another answer: the +// code was wrong, already used or not a code at all, and the sign-in is still +// open. Anything else ends it, and the member needs a new link. +func Retryable(err error) bool { + if errors.Is(err, ErrNotACode) { + return true + } + var e *MFAError + if !errors.As(err, &e) { + return false + } + switch e.Code { + case "mfa_code_invalid", "mfa_recovery_code_invalid", "mfa_code_already_used", + // Nothing was spent: the platform could not read what was sent. + "invalid_request": + return true + } + return false +} + +func (e *MFAError) Error() string { + switch e.Code { + case "mfa_code_invalid": + return "that code is not right" + attemptsLeft(e.Attempts) + case "mfa_recovery_code_invalid": + return "that recovery code is not right, or it was already used" + attemptsLeft(e.Attempts) + case "mfa_code_already_used": + return "that code was already used — wait for the next one in your authenticator app" + attemptsLeft(e.Attempts) + case "invalid_request": + return "nan.builders could not read that — type the 6-digit code from your authenticator app, or a recovery code (xxxx-xxxx-xxxx-xxxx)" + case "mfa_challenge_exhausted": + return "too many wrong codes for this sign-in — " + newLinkHint + case "mfa_challenge_expired": + return "this sign-in timed out: the code has to be entered within 10 minutes of using the link — " + newLinkHint + case "mfa_challenge_used": + return "this sign-in is already over (it was finished, or a newer sign-in replaced it) — " + newLinkHint + case "mfa_challenge_missing": + return "nan.builders has no record of this sign-in — " + newLinkHint + case "login_refused": + return "nan.builders refused the sign-in for this account — " + newLinkHint + case "mfa_locked": + return "too many wrong codes on this account: two-factor sign-in is paused " + e.pausedFor() + " — then " + newLinkHint + case "rate_limited": + return "too many sign-in attempts from this network — wait " + e.wait() + " and " + newLinkHint + } + if e.Code == "" || e.Status >= 500 { + return fmt.Sprintf("nan.builders could not check the code (HTTP %d) — %s", e.Status, newLinkHint) + } + // A code from a newer platform than this build still gets said, rather + // than swallowed. + return fmt.Sprintf("nan.builders refused the code: %s (HTTP %d) — %s", + strings.ReplaceAll(e.Code, "_", " "), e.Status, newLinkHint) +} + +// attemptsLeft is the tail of a wrong-code message. The platform always says +// how many are left on those; a body without the number says nothing rather +// than "0 attempts left", which would read as the end of the sign-in. +func attemptsLeft(n int) string { + switch { + case n <= 0: + return "" + case n == 1: + return " (1 attempt left)" + } + return fmt.Sprintf(" (%d attempts left)", n) +} + +// now is the clock the lock message is read against. A variable for the tests. +var now = time.Now + +// pausedFor says when the lock ends. locked_until is the platform's clock and +// retry_after the seconds it counts from now; the second is what the +// countdown uses, so a machine whose clock is off still waits the right time. +func (e *MFAError) pausedFor() string { + if e.LockedTill.IsZero() { + return "for " + e.wait() + } + until, today := e.LockedTill.Local(), now().Local() + layout := "15:04" + if until.YearDay() != today.YearDay() || until.Year() != today.Year() { + layout = "Mon 2 Jan 15:04" + } + if e.RetryAfter <= 0 { + return "until " + until.Format(layout) + } + return "until " + until.Format(layout) + " (" + e.wait() + " from now)" +} + +func (e *MFAError) wait() string { + d := e.RetryAfter + if d <= 0 { + return "a few minutes" + } + if d < time.Minute { + return fmt.Sprintf("%d seconds", int(d.Seconds())) + } + mins := int((d + time.Minute - 1) / time.Minute) + if mins == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", mins) +} + +// normalizeAnswer decides what the member typed, the way the platform does: six +// digits once the spaces are out is an authenticator code, and sixteen +// characters of the RFC 4648 base32 alphabet (A-Z and 2-7) a recovery code - in +// any case, with or without its hyphens. That is the alphabet the platform +// makes them in, so a 0, 1, 8 or 9 is a typo, refused before it costs one of +// the sign-in's attempts. +func normalizeAnswer(input string) (field, value string, err error) { + compact := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '-': + return -1 + } + return r + }, strings.TrimSpace(input)) + + if len(compact) == 6 && allASCII(compact, isDigit) { + return "code", compact, nil + } + if len(compact) == 16 && allASCII(compact, isBase32) { + c := strings.ToLower(compact) + return "recovery_code", c[0:4] + "-" + c[4:8] + "-" + c[8:12] + "-" + c[12:16], nil + } + return "", "", ErrNotACode +} + +// isErrorCode is the shape of the platform's codes, ^[a-z0-9_]{1,64}$: the +// error codes of the challenge and the reasons of a refused link alike. Only +// those are echoed to the terminal. +func isErrorCode(s string) bool { + return s != "" && len(s) <= 64 && allASCII(s, func(b byte) bool { + return isDigit(b) || (b >= 'a' && b <= 'z') || b == '_' + }) +} + +func allASCII(s string, ok func(byte) bool) bool { + for i := 0; i < len(s); i++ { + if !ok(s[i]) { + return false + } + } + return true +} + +func isDigit(b byte) bool { return b >= '0' && b <= '9' } + +// isBase32 is the RFC 4648 alphabet, in either case. +func isBase32(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '2' && b <= '7') +} + +// Complete answers the challenge with what the member typed: the code their +// authenticator app shows, or one of their recovery codes. It returns the +// session on success, and an *MFAError (or ErrNotACode) otherwise; Retryable +// says whether this same challenge can take another try. +func (e *MFARequiredError) Complete(input string) (*SignIn, error) { + field, value, err := normalizeAnswer(input) + if err != nil { + return nil, err + } + body, err := json.Marshal(map[string]string{field: value}) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, mfaChallengeURL(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + // JSON is what the platform requires here: a text/plain POST would skip a + // browser's preflight, so anything else is refused before the code is + // looked at. No Origin and no Sec-Fetch-Site - this is not a browser, and + // the platform lets a request through without them only because the + // challenge cookie can be held by nothing but the client that opened it. + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.AddCookie(&http.Cookie{Name: mfaPendingCookie, Value: e.pending}) + + // Never a redirect. Go forwards the Cookie header to a redirect on the + // same host, whatever the port, and a 307 or 308 replays the body with the + // code in it: following one would hand the challenge and its answer to + // wherever it points. The contract has no redirect here, so one is an + // answer like any other refusal. + client := &http.Client{ + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("could not reach nan.builders: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + + if resp.StatusCode == http.StatusOK { + var ok struct { + RecoveryCodesRemaining *int `json:"recovery_codes_remaining"` + } + _ = json.Unmarshal(raw, &ok) + for _, c := range resp.Cookies() { + if c.Name == sessionCookie && c.Value != "" { + in := &SignIn{Session: c.Value} + if ok.RecoveryCodesRemaining != nil { + in.UsedRecoveryCode = true + in.RecoveryCodesLeft = *ok.RecoveryCodesRemaining + } + return in, nil + } + } + return nil, errors.New("nan.builders accepted the code but sent no session back — " + newLinkHint) + } + + var refusal struct { + Error string `json:"error"` + AttemptsRemaining int `json:"attempts_remaining"` + LockedUntil string `json:"locked_until"` + RetryAfter int `json:"retry_after"` + } + _ = json.Unmarshal(raw, &refusal) + out := &MFAError{Status: resp.StatusCode, Attempts: refusal.AttemptsRemaining} + // Only something shaped like the platform's codes is taken as one: an + // unknown code is printed, and a body that is not ours - a proxy's error + // page, a 404 from a platform with two-factor sign-in turned off - should + // not put whatever it carries on the member's terminal. + if isErrorCode(refusal.Error) { + out.Code = refusal.Error + } + if t, err := time.Parse(time.RFC3339, refusal.LockedUntil); err == nil { + out.LockedTill = t + } + switch { + case refusal.RetryAfter > 0: + out.RetryAfter = time.Duration(refusal.RetryAfter) * time.Second + default: + if s, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && s > 0 { + out.RetryAfter = time.Duration(s) * time.Second + } + } + return nil, out +} diff --git a/internal/auth/mfa_test.go b/internal/auth/mfa_test.go new file mode 100644 index 0000000..14392cd --- /dev/null +++ b/internal/auth/mfa_test.go @@ -0,0 +1,519 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/nxssie/nan-cli/internal/auth/authtest" +) + +// fakePlatform starts the contract's fake and points the sign-in at it. +func fakePlatform(t *testing.T) *authtest.Platform { + t.Helper() + p := authtest.New(t) + old := SignInBaseURL + SignInBaseURL = p.URL + t.Cleanup(func() { SignInBaseURL = old }) + return p +} + +// challengeFor runs the link exchange against an account with a factor and +// returns the challenge it comes back with. +func challengeFor(t *testing.T, p *authtest.Platform) *MFARequiredError { + t.Helper() + p.Factor = true + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if !errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %v, want a two-factor challenge", err) + } + return ch +} + +// An account without a factor signs in exactly as before: one POST, the +// session from its cookie. The POST now says this build can answer a +// challenge, and that is the only thing that changed. +func TestNoFactorSignsInAsBefore(t *testing.T) { + p := fakePlatform(t) + got, err := ExchangeToken(authtest.LinkToken) + if err != nil || got != authtest.Session { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + forms := p.Verifies() + if len(forms) != 1 { + t.Fatalf("%d verify POSTs", len(forms)) + } + f := forms[0] + if f.Get("token") != authtest.LinkToken || f.Get("client") != "cli" || f.Get("mfa") != "1" || f.Has("flow") { + t.Errorf("verify form = %v", f) + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs for an account without a factor", n) + } +} + +// The new CLI announces it can answer, so a platform that follows the +// contract never sends it the "update nan-cli" refusal that older builds get. +func TestTheNewCLINeverMeetsTheUpdateRefusal(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if ch == nil || p.Verifies()[0].Get("mfa") != "1" { + t.Fatal("no challenge") + } + // And should it meet it anyway, it says what to do in a sentence. + msg := linkReasonMessage("mfa_enabled_update_nan_cli") + for _, want := range []string{"two-factor", "update", "new link"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not mention %q", msg, want) + } + } +} + +func TestRightCodeSignsIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + in, err := ch.Complete(" 246 810 ") + if err != nil { + t.Fatal(err) + } + if in.Session != authtest.Session || in.UsedRecoveryCode { + t.Errorf("SignIn = %+v", in) + } + + reqs := p.Challenges() + if len(reqs) != 1 { + t.Fatalf("%d challenge POSTs", len(reqs)) + } + r := reqs[0] + if r.Cookie != authtest.Pending { + t.Errorf("challenge cookie = %q", r.Cookie) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + // Not a browser, and must not pass for one: the platform lets an + // Origin-less request through only because the cookie proves who opened + // the challenge. + for _, h := range []string{"Origin", "Sec-Fetch-Site", "Referer"} { + if v := r.Header.Get(h); v != "" { + t.Errorf("sent %s: %q", h, v) + } + } + var body map[string]string + if err := json.Unmarshal([]byte(r.Body), &body); err != nil || len(body) != 1 || body["code"] != "246810" { + t.Errorf("body = %s", r.Body) + } +} + +func TestWrongCodeThenRight(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + _, err := ch.Complete("111111") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_invalid" { + t.Fatalf("err = %v", err) + } + if !Retryable(err) { + t.Error("a wrong code ends the sign-in") + } + if !strings.Contains(err.Error(), "4 attempts left") { + t.Errorf("%q does not say how many tries are left", err) + } + + in, err := ch.Complete(authtest.Code) + if err != nil || in.Session != authtest.Session { + t.Fatalf("second try: %+v %v", in, err) + } +} + +// A recovery code is read the way the platform reads it - any case, with or +// without hyphens and spaces - and sent as one, and the answer says how many +// are left. +func TestRecoveryCodeSignsInAndCountsWhatIsLeft(t *testing.T) { + for _, typed := range []string{"q7wa-z2xd-pl4r-6wze", "Q7WA Z2XD PL4R 6WZE", "q7waz2xdpl4r6wze"} { + t.Run(typed, func(t *testing.T) { + p := fakePlatform(t) + p.RecoveryCodes = []string{"aaaa-bbbb-cccc-dddd", "q7wa-z2xd-pl4r-6wze"} + ch := challengeFor(t, p) + + in, err := ch.Complete(typed) + if err != nil { + t.Fatal(err) + } + if !in.UsedRecoveryCode || in.RecoveryCodesLeft != 1 || in.Session != authtest.Session { + t.Errorf("SignIn = %+v", in) + } + var body map[string]string + _ = json.Unmarshal([]byte(p.Challenges()[0].Body), &body) + if len(body) != 1 || body["recovery_code"] != "q7wa-z2xd-pl4r-6wze" { + t.Errorf("body = %s", p.Challenges()[0].Body) + } + }) + } +} + +func TestUsedRecoveryCodeIsRefused(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + _, err := ch.Complete("zzzz-zzzz-zzzz-zzzz") + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_recovery_code_invalid" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "already used") { + t.Errorf("%q", err) + } +} + +// The same 30-second code twice: the platform keeps one use per step, and the +// member is told to wait for the next. +func TestCodeAlreadyUsed(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + if _, err := ch.Complete(authtest.Code); err != nil { + t.Fatal(err) + } + p.NewLink() + ch = challengeFor(t, p) + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_code_already_used" || !Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "wait for the next one") { + t.Errorf("%q", err) + } +} + +// Five wrong codes end the sign-in, and the last answer says so. +func TestFifthWrongCodeEndsTheSignIn(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + var err error + for i := 0; i < 5; i++ { + _, err = ch.Complete("000000") + } + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_exhausted" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +func TestExpiredChallenge(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Expire() + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_challenge_expired" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "10 minutes") || !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// A lock is said with its end, by the platform's clock and the seconds it +// counts, and it ends this sign-in. +func TestLockedAccount(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + until := time.Now().Add(14*time.Minute + 30*time.Second) + p.Lock(until) + + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) || refused.Code != "mfa_locked" || Retryable(err) { + t.Fatalf("err = %v", err) + } + if refused.Status != http.StatusTooManyRequests || refused.RetryAfter < 14*time.Minute { + t.Errorf("refusal = %+v", refused) + } + msg := err.Error() + for _, want := range []string{"paused", until.Local().Format("15:04"), "15 minutes from now"} { + if !strings.Contains(msg, want) { + t.Errorf("%q does not say %q", msg, want) + } + } +} + +// Every refusal in the contract, as the platform sends it: whether the same +// sign-in can take another answer, and what the member is told. +func TestEveryRefusalInTheContract(t *testing.T) { + for _, c := range []struct { + name string + refusal authtest.Refusal + code string + retryable bool + want []string + }{ + {"wrong code", r(400, `{"error":"mfa_code_invalid","message":"x","attempts_remaining":1}`), "mfa_code_invalid", true, []string{"not right", "1 attempt left"}}, + {"wrong recovery code", r(400, `{"error":"mfa_recovery_code_invalid","message":"x","attempts_remaining":3}`), "mfa_recovery_code_invalid", true, []string{"recovery code", "3 attempts left"}}, + {"replayed code", r(400, `{"error":"mfa_code_already_used","message":"x","attempts_remaining":2}`), "mfa_code_already_used", true, []string{"next one", "2 attempts left"}}, + {"unreadable", r(400, `{"error":"invalid_request","message":"x"}`), "invalid_request", true, []string{"6-digit"}}, + {"exhausted", r(403, `{"error":"mfa_challenge_exhausted","message":"x"}`), "mfa_challenge_exhausted", false, []string{"too many wrong codes", "new link"}}, + {"expired", r(403, `{"error":"mfa_challenge_expired","message":"x"}`), "mfa_challenge_expired", false, []string{"timed out", "new link"}}, + {"used", r(403, `{"error":"mfa_challenge_used","message":"x"}`), "mfa_challenge_used", false, []string{"already over", "new link"}}, + {"missing", r(403, `{"error":"mfa_challenge_missing","message":"x"}`), "mfa_challenge_missing", false, []string{"no record", "new link"}}, + {"refused account", r(403, `{"error":"login_refused","message":"x"}`), "login_refused", false, []string{"refused the sign-in", "new link"}}, + {"locked, body only", r(429, `{"error":"mfa_locked","message":"x","retry_after":600}`), "mfa_locked", false, []string{"paused for 10 minutes"}}, + {"rate limited", rh(429, `{"error":"rate_limited","message":"x","retry_after":60}`, "Retry-After", "60"), "rate_limited", false, []string{"wait 1 minute", "new link"}}, + {"rate limited, header only", rh(429, `{"error":"rate_limited","message":"x"}`, "Retry-After", "45"), "rate_limited", false, []string{"wait 45 seconds"}}, + {"another origin", r(403, `{"error":"forbidden_origin"}`), "forbidden_origin", false, []string{"forbidden origin", "HTTP 403"}}, + {"server error", r(500, `{"error":"internal_error","message":"x"}`), "internal_error", false, []string{"could not check the code", "HTTP 500"}}, + {"two-factor turned off", r(404, "404 page not found\n"), "", false, []string{"HTTP 404", "new link"}}, + {"not our body", r(502, ""+authtest.Pending+"\x1b[31m"), "", false, []string{"HTTP 502"}}, + {"a code that is not one", r(403, `{"error":"Bad \u001b[2J`+authtest.Pending+`"}`), "", false, []string{"HTTP 403"}}, + {"a code from tomorrow", r(403, `{"error":"mfa_something_new","message":"x"}`), "mfa_something_new", false, []string{"mfa something new"}}, + } { + t.Run(c.name, func(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &c.refusal + _, err := ch.Complete(authtest.Code) + var refused *MFAError + if !errors.As(err, &refused) { + t.Fatalf("err = %v (%T)", err, err) + } + if refused.Code != c.code { + t.Errorf("code = %q, want %q", refused.Code, c.code) + } + if Retryable(err) != c.retryable { + t.Errorf("Retryable = %v, want %v", !c.retryable, c.retryable) + } + for _, want := range c.want { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if strings.Contains(err.Error(), authtest.Pending) || strings.ContainsRune(err.Error(), '\x1b') { + t.Errorf("the message carries what the body did: %q", err) + } + }) + } +} + +func r(status int, body string) authtest.Refusal { + return authtest.Refusal{Status: status, Body: body, Header: map[string]string{"Content-Type": "application/json"}} +} + +func rh(status int, body, k, v string) authtest.Refusal { + out := r(status, body) + out.Header[k] = v + return out +} + +// Something that is neither a code nor a recovery code is refused here, and +// spends nothing: no request, no attempt. +func TestNotACodeIsNeverSent(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + for _, typed := range []string{"", "12345", "1234567", "abcdef", "q7wa-z2xd-pl4r", "q7wa-z2xd-pl4r-6wze-xxxx", "246810\x00", "246810", + // Sixteen characters, but not the alphabet recovery codes are made + // in: RFC 4648 base32 has no 0, 1, 8 or 9. + "q7wa-z2xd-pl4r-6wz0", "q7wa-z2xd-pl4r-6wz1", "Q7WA Z2XD PL4R 6WZ8", "q7waz2xdpl4r6wz9"} { + if _, err := ch.Complete(typed); !errors.Is(err, ErrNotACode) || !Retryable(err) { + t.Errorf("Complete(%q) = %v", typed, err) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d requests for input that is not a code", n) + } +} + +func TestNormalizeAnswer(t *testing.T) { + for _, c := range []struct{ in, field, value string }{ + {"123456", "code", "123456"}, + {" 123 456 ", "code", "123456"}, + {"123-456", "code", "123456"}, + {"q7wa-z2xd-pl4r-6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"Q7WAZ2XDPL4R6WZE", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + {"q7wa z2xd\tpl4r 6wze", "recovery_code", "q7wa-z2xd-pl4r-6wze"}, + } { + field, value, err := normalizeAnswer(c.in) + if err != nil || field != c.field || value != c.value { + t.Errorf("normalizeAnswer(%q) = %q %q %v", c.in, field, value, err) + } + } +} + +// A redirect from the challenge route is not followed. Go would forward the +// Cookie header to it - same host, other port - and a 307 or 308 would replay +// the body with the code, so the server it points at answers like a +// successful challenge: following it would also look like a sign-in. +func TestTheChallengeDoesNotFollowRedirects(t *testing.T) { + for _, status := range []int{http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(http.StatusText(status), func(t *testing.T) { + var reached []string + var mu sync.Mutex + elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + reached = append(reached, fmt.Sprintf("%s cookie=%q body=%q", r.Method, r.Header.Get("Cookie"), body)) + mu.Unlock() + http.SetCookie(w, &http.Cookie{Name: "nan_session", Value: "a-session-from-elsewhere"}) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"redirect":"/","recovery_codes_remaining":null}`) + })) + defer elsewhere.Close() + + p := fakePlatform(t) + ch := challengeFor(t, p) + p.Next = &authtest.Refusal{Status: status, Header: map[string]string{"Location": elsewhere.URL + "/api/auth/mfa/challenge"}} + + in, err := ch.Complete(authtest.Code) + if err == nil { + t.Errorf("signed in through a %d: %+v", status, in) + } else if Retryable(err) || !strings.Contains(err.Error(), fmt.Sprintf("HTTP %d", status)) { + t.Errorf("err = %v", err) + } + mu.Lock() + defer mu.Unlock() + if len(reached) != 0 { + t.Errorf("followed the %d: the server it pointed at got %v", status, reached) + } + }) + } +} + +// A redirect that asks for the code without the challenge to answer it with +// is a dead end, and says so instead of prompting for nothing. +func TestMFAReasonWithoutTheCookie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/mfa?reason=mfa_required", http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || errors.As(err, &ch) { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "new link") { + t.Errorf("%q", err) + } +} + +// The challenge cookie is a credential for ten minutes. However the +// challenge is printed or logged, the value is not in it. +func TestTheChallengeNeverPrintsItsCookie(t *testing.T) { + p := fakePlatform(t) + ch := challengeFor(t, p) + + var out []string + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%d", "%x"} { + out = append(out, fmt.Sprintf(verb, ch), fmt.Sprintf(verb, error(ch))) + } + out = append(out, ch.Error(), fmt.Errorf("wrapped: %w", ch).Error()) + if b, err := json.Marshal(ch); err == nil { + out = append(out, string(b)) + } + var logged strings.Builder + slog.New(slog.NewTextHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + slog.New(slog.NewJSONHandler(&logged, nil)).Info("sign-in", "err", ch, "challenge", ch) + out = append(out, logged.String()) + + for _, s := range out { + if strings.Contains(s, authtest.Pending) { + t.Errorf("the challenge cookie is in %q", s) + } + } +} + +// An account that has not finished signing up and has a factor is sent to the +// browser, with or without mfa=1 ("CLI contract" step 8): no challenge, no +// cookie, and a sentence that says what to do with a link that is spent. +func TestUnfinishedSignUpWithAFactorIsSentToTheBrowser(t *testing.T) { + p := fakePlatform(t) + p.Factor, p.Onboarding = true, true + got, err := ExchangeToken(authtest.LinkToken) + var ch *MFARequiredError + if err == nil || got != "" || errors.As(err, &ch) { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } + if err.Error() != linkReasons["finish_onboarding_in_the_browser"] { + t.Errorf("message = %q", err) + } + for _, want := range []string{"browser", "new link", "cloud.nan.builders", "spent", "sign in here again"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } + if n := len(p.Challenges()); n != 0 { + t.Errorf("%d challenge POSTs with no challenge to answer", n) + } +} + +// Without a factor nothing changed: the onboarding token is not a session, +// and the CLI does not take it for one. +func TestUnfinishedSignUpWithoutAFactorGetsNoSession(t *testing.T) { + p := fakePlatform(t) + p.Onboarding = true + got, err := ExchangeToken(authtest.LinkToken) + if err == nil || got != "" || !strings.Contains(err.Error(), "no session came back") { + t.Fatalf("ExchangeToken = %q, %v", got, err) + } +} + +// The reason of a refused link is read out of a redirect's query, decoded, so +// it can be anything. Only the platform's shape of reason is put on the +// terminal; anything else gets a sentence of our own. +func TestOddReasonsNeverReachTheTerminal(t *testing.T) { + for _, c := range []struct { + name, reason string + echoed bool + }{ + {"one of ours, unknown to this build", "a_reason_from_tomorrow", true}, + {"the longest that is still one", strings.Repeat("a", 64), true}, + {"an escape sequence", "\x1b[2J\x1b]0;owned\x07", false}, + {"a new line", "invalid\nlink", false}, + {"capitals", "Invalid_Link", false}, + {"spaces", "please paste your token here", false}, + {"too long", strings.Repeat("a", 65), false}, + {"not ASCII", "enlace_inválido", false}, + } { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, authtest.FrontendURL+"/access-denied?reason="+url.QueryEscape(c.reason), http.StatusFound) + })) + defer srv.Close() + old := SignInBaseURL + SignInBaseURL = srv.URL + defer func() { SignInBaseURL = old }() + + _, err := ExchangeToken(authtest.LinkToken) + if err == nil { + t.Fatal("no error") + } + msg := err.Error() + shown := strings.ReplaceAll(c.reason, "_", " ") + if c.echoed != strings.Contains(msg, shown) { + t.Errorf("echoed = %v, want %v: %q", !c.echoed, c.echoed, msg) + } + if strings.ContainsAny(msg, "\x1b\x07\n") { + t.Errorf("control characters reached the message: %q", msg) + } + if !c.echoed && !strings.Contains(msg, "ask for a new link") { + t.Errorf("%q does not say what to do", msg) + } + }) + } +} diff --git a/internal/contexts/path.go b/internal/contexts/path.go new file mode 100644 index 0000000..8362424 --- /dev/null +++ b/internal/contexts/path.go @@ -0,0 +1,290 @@ +// Package contexts holds the agent-context domain of devops AGENT-CONTEXT.md: +// path validators, content typing, the Office and secret checks, the strict +// SKILL.md decoder and the quota rules. Everything in this file set is pure +// (no database, no R2): the API, the manifest builder, nan-run and the CLI +// share it. +package contexts + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" +) + +// Reason is the per-file refusal code the UI and CLI show next to a file +// (AGENT-CONTEXT.md §1.5). The wire values are part of the API contract. +type Reason string + +const ( + ReasonDotfile Reason = "dotfile" + ReasonBadCharacter Reason = "bad_character" + ReasonNotNFC Reason = "not_nfc" + ReasonTooLong Reason = "too_long" + ReasonTooDeep Reason = "too_deep" + ReasonReservedName Reason = "reserved_name" + ReasonCaseCollision Reason = "case_collision" + ReasonBadTopLevel Reason = "bad_top_level" + ReasonNotSupportedYet Reason = "not_supported_yet" // mcp/ (reserved for v2) +) + +// Stored-path limits (§1.5). +const ( + MaxStoredPathBytes = 512 + MaxStoredPathDepth = 8 + MaxComponentBytes = 128 + InstructionsPath = "AGENTS.md" + SkillFileName = "SKILL.md" + topSkills = "skills" + topFiles = "files" + topMCP = "mcp" + contextNameMaxBytes = 63 +) + +// PathError is one refused path with its reason. +type PathError struct { + Path string + Reason Reason +} + +func (e *PathError) Error() string { return fmt.Sprintf("contexts: invalid path (%s)", e.Reason) } + +// ErrInvalidPath matches every *PathError with errors.Is. +var ErrInvalidPath = errors.New("contexts: invalid path") + +// Is makes errors.Is(err, ErrInvalidPath) hold for a *PathError. +func (e *PathError) Is(target error) bool { return target == ErrInvalidPath } + +func pathErr(p string, r Reason) *PathError { return &PathError{Path: p, Reason: r} } + +var ( + reContextName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + reSkillName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) +) + +// ValidContextName reports whether name is an acceptable context name: the +// contexts_name_shape CHECK of migration 126, and not a Windows reserved +// basename (`con`, `nul`, `com1`, ...). The second rule is not in the SQL +// CHECK: a context's name becomes a directory of the run's manifest +// (files//...), and the manifest validator refuses reserved names, so +// a context named `con` could never deliver its files. +// +// Two more names are refused because the API could never reach them by +// name: a UUID-shaped name (GET /v1/contexts/{x} always reads it as an id) +// and "blobs" (the /v1/contexts/blobs/... upload routes). +func ValidContextName(name string) bool { + return reContextName.MatchString(name) && !isWindowsReserved(name) && name != reservedBlobs && !uuidShaped(name) +} + +const reservedBlobs = "blobs" + +// uuidShaped reports the 8-4-4-4-12 lowercase hex form. +func uuidShaped(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') { + return false + } + } + } + return true +} + +// ValidSkillName reports whether name is an acceptable skill directory name. +func ValidSkillName(name string) bool { return reSkillName.MatchString(name) } + +// ValidateStoredPath checks one path a member publishes (§1.5 "Stored +// paths"). It returns nil or a *PathError carrying the reason. Uniqueness +// (case_collision) is a property of the whole tree: see ValidateTree. +func ValidateStoredPath(p string) error { + comps, err := checkPathShape(p, MaxStoredPathBytes, MaxStoredPathDepth) + if err != nil { + return err + } + switch top := comps[0]; { + case len(comps) == 1 && top == InstructionsPath: + return nil + case top == topSkills: + // skills// + if len(comps) < 3 { + return pathErr(p, ReasonBadTopLevel) + } + if !ValidSkillName(comps[1]) { + return pathErr(p, ReasonBadCharacter) + } + return nil + case top == topFiles: + if len(comps) < 2 { + return pathErr(p, ReasonBadTopLevel) + } + return nil + case top == topMCP: + return pathErr(p, ReasonNotSupportedYet) + default: + return pathErr(p, ReasonBadTopLevel) + } +} + +// checkPathShape applies the rules shared by stored and manifest paths: +// UTF-8, NFC, byte and depth limits, and the component rules. +func checkPathShape(p string, maxBytes, maxDepth int) ([]string, error) { + if !utf8.ValidString(p) { + return nil, pathErr(p, ReasonBadCharacter) + } + if !norm.NFC.IsNormalString(p) { + return nil, pathErr(p, ReasonNotNFC) + } + if len(p) == 0 { + return nil, pathErr(p, ReasonBadCharacter) + } + if len(p) > maxBytes { + return nil, pathErr(p, ReasonTooLong) + } + comps := strings.Split(p, "/") + if len(comps) > maxDepth { + return nil, pathErr(p, ReasonTooDeep) + } + for _, c := range comps { + if r := componentReason(c); r != "" { + return nil, pathErr(p, r) + } + } + return comps, nil +} + +// componentReason returns the reason one path component is refused, or "". +func componentReason(c string) Reason { + if c == "" { + return ReasonBadCharacter // empty component: leading, trailing or doubled '/' + } + if len(c) > MaxComponentBytes { + return ReasonTooLong + } + if c[0] == '.' { + return ReasonDotfile // .git, .env.example, ., .. + } + if c[0] == ' ' || c[len(c)-1] == ' ' || c[len(c)-1] == '.' { + return ReasonBadCharacter + } + for _, r := range c { + if !allowedRune(r) { + return ReasonBadCharacter + } + } + if isWindowsReserved(c) { + return ReasonReservedName + } + return "" +} + +// allowedRune: Unicode letters (L*), combining marks (Mn, Mc), decimal +// digits (Nd), the ASCII space and `. _ - ( ) + , @`, minus every +// Default_Ignorable_Code_Point. Everything else (Cc, Cf, other separators, +// symbols, emoji, private use, unassigned, shell/Windows-hostile ASCII) is +// refused. +func allowedRune(r rune) bool { + switch r { + case ' ', '.', '_', '-', '(', ')', '+', ',', '@': + return true + } + if !(unicode.IsLetter(r) || unicode.In(r, unicode.Mn, unicode.Mc, unicode.Nd)) { + return false + } + return !defaultIgnorable(r) +} + +// defaultIgnorable covers Default_Ignorable_Code_Point among the categories +// allowedRune admits: the derived property is Other_Default_Ignorable_Code_Point +// + Cf + Variation_Selector (minus a few code points that are Cf, refused +// anyway). So Hangul fillers (Lo), U+034F and the Khmer inherent vowels (Mn) +// and every variation selector (Mn) are refused. +func defaultIgnorable(r rune) bool { + return unicode.In(r, unicode.Other_Default_Ignorable_Code_Point, unicode.Variation_Selector, unicode.Cf) +} + +var windowsReserved = map[string]bool{"CON": true, "PRN": true, "AUX": true, "NUL": true} + +func init() { + for i := 1; i <= 9; i++ { + windowsReserved[fmt.Sprintf("COM%d", i)] = true + windowsReserved[fmt.Sprintf("LPT%d", i)] = true + } +} + +// isWindowsReserved: the basename before the first '.' (any extension, any +// case) is CON, PRN, AUX, NUL, COM1..COM9 or LPT1..LPT9. +func isWindowsReserved(c string) bool { + base := c + if i := strings.IndexByte(c, '.'); i >= 0 { + base = c[:i] + } + return windowsReserved[strings.ToUpper(base)] +} + +var folder = cases.Fold() + +// FoldKey is the case-insensitive identity of a path: Unicode full case +// folding of the NFC form, re-normalised (folding İ yields a combining dot). +// Two paths with the same key collide on a case-insensitive file system. +func FoldKey(p string) string { + return norm.NFC.String(folder.String(norm.NFC.String(p))) +} + +// ValidateTree validates every path of one version and their uniqueness: no +// two paths with the same FoldKey, and no path whose folded form is also a +// folder of another path (a file and a folder cannot share a name on any +// file system). It returns every refusal (sorted by path), not only the +// first, so the UI and CLI can mark each file. +func ValidateTree(paths []string) []PathError { + var out []PathError + seen := make(map[string]string, len(paths)) // fold key -> first path + valid := make([]string, 0, len(paths)) + for _, p := range paths { + if err := ValidateStoredPath(p); err != nil { + var pe *PathError + errors.As(err, &pe) + out = append(out, *pe) + continue + } + k := FoldKey(p) + if _, dup := seen[k]; dup { + out = append(out, PathError{Path: p, Reason: ReasonCaseCollision}) + continue + } + seen[k] = p + valid = append(valid, p) + } + // File/folder clash: a valid path whose folded form is a folder prefix + // of another valid path. + dirs := make(map[string]bool) + for _, p := range valid { + k := FoldKey(p) + for i := 0; i < len(k); i++ { + if k[i] == '/' { + dirs[k[:i]] = true + } + } + } + for _, p := range valid { + if dirs[FoldKey(p)] { + out = append(out, PathError{Path: p, Reason: ReasonCaseCollision}) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} diff --git a/internal/contexts/path_test.go b/internal/contexts/path_test.go new file mode 100644 index 0000000..b9c4d82 --- /dev/null +++ b/internal/contexts/path_test.go @@ -0,0 +1,257 @@ +package contexts + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +// Parity table: copied from cloud-api internal/contexts/path_test.go (the +// middleware name-shape test is left out: nan-cli has no route table). The +// copy must stay in step with the server; cloud-api's CI checks that it does. +// +// Stored paths (devops AGENT-CONTEXT.md section 1.5): every refusal carries its +// per-file reason; the table covers the Unicode rules and the exact maxima. + +func reasonOf(err error) Reason { + var pe *PathError + if errors.As(err, &pe) { + return pe.Reason + } + return "" +} + +// rep builds a component of n bytes from an ASCII letter. +func rep(n int) string { return strings.Repeat("a", n) } + +// pathOf builds a files/ path of exactly total bytes with depth components +// (files + depth-1 components), every component at most 128 bytes. +func pathOf(t *testing.T, total, depth int) string { + t.Helper() + comps := []string{"files"} + left := total - len("files") + n := depth - 1 + for i := 0; i < n; i++ { + remainingComps := n - i + // bytes for this component: its share of what is left after the separators + avail := left - remainingComps // one '/' per remaining component + share := avail / remainingComps + if i == n-1 { + share = avail + } + if share > MaxComponentBytes { + share = MaxComponentBytes + } + comps = append(comps, rep(share)) + left -= share + 1 + } + p := strings.Join(comps, "/") + if len(p) != total || len(comps) != depth { + t.Fatalf("pathOf(%d,%d) built %d bytes, %d comps", total, depth, len(p), len(comps)) + } + return p +} + +func TestStoredPathTable(t *testing.T) { + for _, tc := range []struct { + name string + path string + want Reason // "" = accepted + }{ + // Layout. + {"instructions", "AGENTS.md", ""}, + {"skill file", "skills/pr-review/SKILL.md", ""}, + {"skill aux", "skills/pr-review/scripts/run.sh", ""}, + {"file", "files/spec.pdf", ""}, + {"nested file", "files/docs/api/v1.md", ""}, + {"agents not top", "files/AGENTS.md", ""}, + {"agents lowercase", "agents.md", ReasonBadTopLevel}, + {"agents as dir", "AGENTS.md/x", ReasonBadTopLevel}, + {"unknown top", "notes/a.md", ReasonBadTopLevel}, + {"bare files", "files", ReasonBadTopLevel}, + {"skill without file", "skills/pr-review", ReasonBadTopLevel}, + {"file directly in skills", "skills/readme.md", ReasonBadTopLevel}, + {"skill uppercase", "skills/PR/SKILL.md", ReasonBadCharacter}, + {"skill accent", "skills/revisión/SKILL.md", ReasonBadCharacter}, + {"skill 64 bytes", "skills/" + rep(64) + "/SKILL.md", ""}, + {"skill 65 bytes", "skills/" + rep(65) + "/SKILL.md", ReasonBadCharacter}, + {"mcp reserved", "mcp/server.json", ReasonNotSupportedYet}, + // Spaces and Unicode letters are accepted (Lead decision 2026-10-21). + {"spaces", "files/Meeting notes 2026.md", ""}, + {"accents", "files/especificación técnica.md", ""}, + {"cjk", "files/仕様書.md", ""}, + {"cyrillic", "files/документ.txt", ""}, + {"arabic digits", "files/ملف١٢.txt", ""}, + {"devanagari marks", "files/ह\u093Fन\u094Dद\u0940.txt", ""}, + {"punctuation set", "files/a_b-c(d)+e,f@g.txt", ""}, + {"dotless i", "files/ılık.md", ""}, + {"eszett", "files/Straße.md", ""}, + // NFC is required, never rewritten. + {"decomposed e acute", "files/especificacio\u0301n.md", ReasonNotNFC}, + {"composed e acute", "files/especificación.md", ""}, + // Format and ignorable characters. + {"rtl override", "files/invoice\u202Etxt.exe", ReasonBadCharacter}, + {"zero width joiner", "files/a\u200Db.md", ReasonBadCharacter}, + {"zero width space", "files/a\u200Bb.md", ReasonBadCharacter}, + {"bom", "files/\uFEFFa.md", ReasonBadCharacter}, + {"soft hyphen", "files/a\u00ADb.md", ReasonBadCharacter}, + {"nbsp", "files/a\u00A0b.md", ReasonBadCharacter}, + {"ideographic space", "files/a\u3000b.md", ReasonBadCharacter}, + {"tab", "files/a\tb.md", ReasonBadCharacter}, + {"newline", "files/a\nb.md", ReasonBadCharacter}, + {"nul", "files/a\x00b.md", ReasonBadCharacter}, + {"variation selector 16", "files/a\uFE0Fb.md", ReasonBadCharacter}, + {"variation selector supplement", "files/a\U000E0100b.md", ReasonBadCharacter}, + {"mongolian fvs", "files/a\u180Bb.md", ReasonBadCharacter}, + {"combining grapheme joiner", "files/a\u034Fb.md", ReasonBadCharacter}, + {"hangul filler", "files/a\u3164b.md", ReasonBadCharacter}, + {"hangul choseong filler", "files/\u115Fa.md", ReasonBadCharacter}, + {"hangul jungseong filler", "files/\u1160a.md", ReasonBadCharacter}, + {"halfwidth hangul filler", "files/a\uFFA0b.md", ReasonBadCharacter}, + {"khmer inherent vowel", "files/a\u17B4b.md", ReasonBadCharacter}, + {"emoji", "files/rocket\U0001F680.md", ReasonBadCharacter}, + {"symbol", "files/a\u00A9b.md", ReasonBadCharacter}, + {"private use", "files/a\uE000b.md", ReasonBadCharacter}, + {"unassigned", "files/a\U000E0080b.md", ReasonBadCharacter}, + {"invalid utf8", "files/a\xffb.md", ReasonBadCharacter}, + // Shell and Windows hostile ASCII. + {"backslash", "files/a\\b.md", ReasonBadCharacter}, + {"colon", "files/a:b.md", ReasonBadCharacter}, + {"star", "files/a*.md", ReasonBadCharacter}, + {"question", "files/a?.md", ReasonBadCharacter}, + {"quote", "files/a\"b.md", ReasonBadCharacter}, + {"lt", "files/ab.md", ReasonBadCharacter}, + {"pipe", "files/a|b.md", ReasonBadCharacter}, + {"apostrophe", "files/don't.md", ReasonBadCharacter}, + {"backtick", "files/a`b.md", ReasonBadCharacter}, + {"dollar", "files/$HOME.md", ReasonBadCharacter}, + // Dotfiles, leading and trailing space or dot. + {"dotfile", "files/.env", ReasonDotfile}, + {"dot env example", "files/.env.example", ReasonDotfile}, + {"git dir", "files/.git/config", ReasonDotfile}, + {"pi dir", "skills/x/.pi/a.md", ReasonDotfile}, + {"dot", "files/./a.md", ReasonDotfile}, + {"dotdot", "files/../AGENTS.md", ReasonDotfile}, + {"leading space", "files/ a.md", ReasonBadCharacter}, + {"trailing space", "files/a.md ", ReasonBadCharacter}, + {"trailing dot", "files/a.", ReasonBadCharacter}, + {"trailing dot dir", "files/dir./a.md", ReasonBadCharacter}, + {"inner dots ok", "files/v1.2.3.tar.md", ""}, + // Empty components and absolute paths. + {"empty", "", ReasonBadCharacter}, + {"absolute", "/files/a.md", ReasonBadCharacter}, + {"double slash", "files//a.md", ReasonBadCharacter}, + {"trailing slash", "files/a/", ReasonBadCharacter}, + // Windows reserved basenames, any extension, any case. + {"con", "files/CON", ReasonReservedName}, + {"con lower ext", "files/con.txt", ReasonReservedName}, + {"nul double ext", "files/Nul.tar.md", ReasonReservedName}, + {"com1", "files/com1.md", ReasonReservedName}, + {"lpt9 dir", "files/LPT9/a.md", ReasonReservedName}, + {"aux", "files/aux", ReasonReservedName}, + {"prn", "files/prn.md", ReasonReservedName}, + {"com0 ok", "files/com0.md", ""}, + {"console ok", "files/console.md", ""}, + {"con- ok", "files/con-1.md", ""}, + // Exact maxima (512 bytes, 8 components, 128 bytes per component). + {"512 bytes", pathOf(t, 512, 6), ""}, + {"513 bytes", pathOf(t, 513, 6), ReasonTooLong}, + {"8 components", "files/a/b/c/d/e/f/g.md", ""}, + {"9 components", "files/a/b/c/d/e/f/g/h.md", ReasonTooDeep}, + {"component 128", "files/" + rep(128), ""}, + {"component 129", "files/" + rep(129), ReasonTooLong}, + {"component 128 multibyte", "files/" + strings.Repeat("é", 64), ""}, + {"component 129 multibyte", "files/" + strings.Repeat("é", 64) + "a", ReasonTooLong}, + } { + err := ValidateStoredPath(tc.path) + if got := reasonOf(err); got != tc.want { + t.Errorf("%s: %q: reason %q, want %q (err %v)", tc.name, tc.path, got, tc.want, err) + } + if err != nil && !errors.Is(err, ErrInvalidPath) { + t.Errorf("%s: error does not match ErrInvalidPath", tc.name) + } + } +} + +func TestValidContextName(t *testing.T) { + for name, want := range map[string]bool{ + "acme": true, "a": true, "0": true, "pr-review": true, rep(63): true, + rep(64): false, "": false, "-a": false, "Acme": false, "a_b": false, "a.b": false, "a b": false, "ñ": false, + // Windows reserved: the name becomes files// in the manifest. + "con": false, "nul": false, "aux": false, "prn": false, "com1": false, "lpt9": false, + "com0": true, "console": true, "con-1": true, + } { + if got := ValidContextName(name); got != want { + t.Errorf("ValidContextName(%q) = %v", name, got) + } + } +} + +// Uniqueness is case-insensitive with Unicode full case folding of the NFC +// form: ß folds to ss, final sigma to sigma; the Turkish dotless ı has no +// default folding (only the Turkic one), so ı and I do not collide, and İ +// folds to i + U+0307, so it collides with neither i nor I. +func TestValidateTreeCaseCollisions(t *testing.T) { + for _, tc := range []struct { + name string + paths []string + want []PathError + }{ + {"distinct", []string{"AGENTS.md", "files/a.md", "files/b.md", "skills/x/SKILL.md"}, nil}, + {"ascii case", []string{"files/Readme.md", "files/README.md"}, []PathError{{"files/README.md", ReasonCaseCollision}}}, + {"eszett", []string{"files/Straße.md", "files/STRASSE.md"}, []PathError{{"files/STRASSE.md", ReasonCaseCollision}}}, + {"capital eszett", []string{"files/ẞ.md", "files/ss.md"}, []PathError{{"files/ss.md", ReasonCaseCollision}}}, + {"sigma", []string{"files/ΟΔΟΣ.md", "files/οδος.md"}, []PathError{{"files/οδος.md", ReasonCaseCollision}}}, + {"final sigma", []string{"files/οδος.md", "files/οδοσ.md"}, []PathError{{"files/οδοσ.md", ReasonCaseCollision}}}, + {"accented case", []string{"files/Ésta.md", "files/ésta.md"}, []PathError{{"files/ésta.md", ReasonCaseCollision}}}, + {"dotless i is distinct", []string{"files/ı.md", "files/I.md", "files/i.md"}, []PathError{{"files/i.md", ReasonCaseCollision}}}, + {"dotted capital I is distinct", []string{"files/İ.md", "files/i.md"}, nil}, + {"folder case", []string{"files/Docs/a.md", "files/docs/b.md"}, nil}, + {"file vs folder", []string{"files/docs", "files/Docs/a.md"}, []PathError{{"files/docs", ReasonCaseCollision}}}, + {"invalid reported too", []string{"files/.env", "files/a.md", "files/A.md"}, + []PathError{{"files/.env", ReasonDotfile}, {"files/A.md", ReasonCaseCollision}}}, + } { + got := ValidateTree(tc.paths) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%s: %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestFoldKeyIsStable(t *testing.T) { + // Folding İ yields a combining sequence; the key is re-normalised so a + // second fold is a no-op. + for _, p := range []string{"files/İstanbul.md", "files/Straße.md", "files/ΟΔΟΣ.md"} { + k := FoldKey(p) + if FoldKey(k) != k { + t.Errorf("FoldKey(%q) not idempotent: %q vs %q", p, k, FoldKey(k)) + } + } +} + +// Names the API could never reach by name are refused: UUID-shaped names +// (always read as ids) and "blobs" (the upload routes). +func TestContextNameReachable(t *testing.T) { + for _, n := range []string{"123e4567-e89b-12d3-a456-426614174000", "blobs"} { + if ValidContextName(n) { + t.Errorf("%q accepted", n) + } + } + for _, n := range []string{"blobs-2", "my-blobs", "123e4567-e89b-12d3-a456-42661417400", "123e4567e89b12d3a456426614174000"} { + if !ValidContextName(n) { + t.Errorf("%q refused", n) + } + } +} + +// File names never carry a quote, a backslash or a percent sign (they would +// need escaping in Content-Disposition). +func TestStoredPathRefusesHeaderCharacters(t *testing.T) { + for _, p := range []string{`files/a"b.txt`, `files/a\b.txt`, "files/a%b.txt"} { + if ValidateStoredPath(p) == nil { + t.Errorf("%q accepted", p) + } + } +} diff --git a/internal/contexts/pins_test.go b/internal/contexts/pins_test.go new file mode 100644 index 0000000..5938c29 --- /dev/null +++ b/internal/contexts/pins_test.go @@ -0,0 +1,51 @@ +package contexts + +import ( + "os" + "path/filepath" + "regexp" + "testing" + "unicode" +) + +// The path rules read Unicode tables from two places: the standard library +// (unicode.IsLetter, the categories) and golang.org/x/text (NFC, case +// folding). cloud-api accepts a name only when its own tables say so, so +// this CLI has to read the same tables or `nan context pull` could refuse a +// name the server stored (devops AGENT-CONTEXT.md §1.5). +// +// These are the pins both repositories must share. x/text is v0.39.0, +// the first release without GO-2026-5970 (an infinite loop in +// unicode/norm on invalid UTF-8, reached by these rules). +// +// The cross-repo check lives in cloud-api's CI +// (scripts/check-nan-cli-text-parity.sh, helmcode/nan-cloud-api#397): it +// reads this repository's public go.mod and compares the x/text version +// and unicode.Version under the go version declared here with its own. +// This repository is public and cannot read cloud-api. +const ( + cloudAPIXText = "v0.39.0" + cloudAPIUnicode = "15.0.0" +) + +var reXText = regexp.MustCompile(`(?m)^\s*golang\.org/x/text\s+(v\S+)`) + +func TestXTextPinnedToCloudAPI(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + t.Fatal(err) + } + m := reXText.FindSubmatch(b) + if m == nil { + t.Fatal("go.mod does not require golang.org/x/text") + } + if got := string(m[1]); got != cloudAPIXText { + t.Errorf("go.mod pins golang.org/x/text %s, cloud-api builds with %s: the path rules would read other Unicode tables", got, cloudAPIXText) + } +} + +func TestUnicodeTablesMatchCloudAPI(t *testing.T) { + if unicode.Version != cloudAPIUnicode { + t.Errorf("this toolchain's unicode tables are %s, cloud-api's are %s", unicode.Version, cloudAPIUnicode) + } +} diff --git a/internal/tui/config_test.go b/internal/tui/config_test.go index 0f56b83..581bbbe 100644 --- a/internal/tui/config_test.go +++ b/internal/tui/config_test.go @@ -1980,7 +1980,7 @@ func TestSigningInGivesTheClientTheNewToken(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if after.sess.Token != "a-fresh-session-token" { @@ -2247,7 +2247,7 @@ func TestSigningInLeadsStraightToTheKey(t *testing.T) { t.Fatal(err) } - updated, _ := m.Update(signedInMsg{nil}) + updated, _ := m.Update(signedInMsg{}) after := updated.(model) if !after.editingKey { t.Error("after signing in the member is left to work out that `e` is next") diff --git a/internal/tui/mfa_test.go b/internal/tui/mfa_test.go new file mode 100644 index 0000000..1422161 --- /dev/null +++ b/internal/tui/mfa_test.go @@ -0,0 +1,233 @@ +package tui + +import ( + "bytes" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/nxssie/nan-cli/internal/auth" + "github.com/nxssie/nan-cli/internal/auth/authtest" + "github.com/nxssie/nan-cli/internal/session" +) + +// Two-factor sign-in from the panel: the same flow as `nan auth login`, +// through internal/auth, with the code asked for as a third question. + +const mfaTestLink = "https://cloud-api.nan.builders/api/auth/login/verify?token=" + authtest.LinkToken + +// atTheCode drives the panel up to the code question against a fake platform +// whose account has a factor: the address, the link, and the exchange that +// comes back with a challenge. +func atTheCode(t *testing.T) (model, *authtest.Platform) { + t.Helper() + p := authtest.New(t) + p.Factor = true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + m.loginEmail = "member@example.com" + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + + msg := signIn(mfaTestLink)() + if _, ok := msg.(mfaChallengeMsg); !ok { + t.Fatalf("the link came back as %#v, not a challenge", msg) + } + mod, _ = m.Update(msg) + return mod.(model), p +} + +// enter types value into the field and presses enter, running what that +// starts and handing its answer back to the panel. +func enter(t *testing.T, m model, value string) model { + t.Helper() + m.loginInput.SetValue(value) + mod, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter started nothing") + } + mod, _ = mod.(model).Update(cmd()) + return mod.(model) +} + +func TestThePanelAsksForTheCode(t *testing.T) { + m, _ := atTheCode(t) + if m.loginStage != loginAskCode || m.wizard != wizardLink { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginInput.EchoMode == 0 { + t.Error("the code field echoes: a recovery code works until it is used") + } + flat := strings.Join(strings.Fields(m.View()), " ") + for _, want := range []string{"Authenticator code", "two-factor", "recovery codes"} { + if !strings.Contains(flat, want) { + t.Errorf("the screen does not say %q:\n%s", want, m.View()) + } + } + + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("still signing in after the right code: stage %v", m.loginStage) + } + sess, err := session.Load() + if err != nil || sess.Token != authtest.Session { + t.Fatalf("session = %+v, %v", sess, err) + } + if m.client.Token() != authtest.Session { + t.Error("the panel's client does not carry the new session") + } + assertNoChallengeUnderHome(t) +} + +// A wrong code keeps the same question, empty, with what went wrong and how +// many tries are left. +func TestThePanelAsksAgainAfterAWrongCode(t *testing.T) { + m, p := atTheCode(t) + m = enter(t, m, "000000") + if m.loginStage != loginAskCode { + t.Fatalf("a wrong code left the question: stage %v", m.loginStage) + } + if m.loginInput.Value() != "" { + t.Error("the wrong code is still in the field") + } + if !strings.Contains(m.loginMsg, "4 attempts left") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + m = enter(t, m, authtest.Code) + if m.loginStage != loginOff { + t.Errorf("the right code after a wrong one did not sign in: stage %v", m.loginStage) + } + if n := len(p.Challenges()); n != 2 { + t.Errorf("%d challenge POSTs", n) + } +} + +// A sign-in that cannot go on - timed out, too many wrong codes, refused - +// spent its link, so the panel goes back to the address, filled in: enter +// asks for a new one. +func TestThePanelStartsAgainWhenTheSignInEnds(t *testing.T) { + m, p := atTheCode(t) + p.Expire() + m = enter(t, m, authtest.Code) + if m.loginStage != loginAskEmail || m.wizard != wizardEmail { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + if m.loginChallenge != nil { + t.Error("the panel keeps a challenge that is over") + } + if m.loginInput.Value() != "member@example.com" || m.loginInput.Prompt != "Email: " { + t.Errorf("field = %q %q", m.loginInput.Prompt, m.loginInput.Value()) + } + if !strings.HasPrefix(m.loginMsg, "error: ") || !strings.Contains(m.loginMsg, "new link") { + t.Errorf("loginMsg = %q", m.loginMsg) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} + +func TestThePanelSaysHowManyRecoveryCodesAreLeft(t *testing.T) { + m, p := atTheCode(t) + p.RecoveryCodes = []string{"q7wa-z2xd-pl4r-6wze", "aaaa-bbbb-cccc-dddd"} + m = enter(t, m, "Q7WA-Z2XD-PL4R-6WZE") + if m.loginStage != loginOff || m.wizard != wizardKey { + t.Fatalf("stage %v, wizard %v", m.loginStage, m.wizard) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "1 left") || !strings.Contains(flat, "Two-factor authentication") { + t.Errorf("the next step does not say how many are left:\n%s", m.View()) + } +} + +// Esc leaves the code question and forgets the challenge; an exchange that +// answers after the member left does not bring the question back. +func TestEscapeForgetsTheChallenge(t *testing.T) { + m, _ := atTheCode(t) + mod, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = mod.(model) + if m.loginStage != loginOff || m.loginChallenge != nil { + t.Errorf("stage %v, challenge kept: %v", m.loginStage, m.loginChallenge != nil) + } + + m2, _ := atTheCode(t) + challenge := m2.loginChallenge + m2.cancelLogin() + mod, _ = m2.Update(mfaChallengeMsg{challenge}) + if got := mod.(model); got.loginStage != loginOff || got.loginChallenge != nil { + t.Errorf("a late challenge reopened the sign-in: stage %v", got.loginStage) + } +} + +// The screen never shows the challenge cookie, and nothing under HOME holds +// it. +func TestThePanelKeepsTheChallengeToItself(t *testing.T) { + m, _ := atTheCode(t) + m = enter(t, m, "000000") + if strings.Contains(m.View(), authtest.Pending) || strings.Contains(m.loginMsg, authtest.Pending) { + t.Error("the challenge cookie is on screen") + } + assertNoChallengeUnderHome(t) +} + +func assertNoChallengeUnderHome(t *testing.T) { + t.Helper() + err := filepath.WalkDir(os.Getenv("HOME"), func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if bytes.Contains(data, []byte(authtest.Pending)) { + t.Errorf("%s holds the challenge cookie", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// An account that has not finished signing up and has a factor: the panel +// stays on the link question with the sentence that sends it to the browser, +// and asks for no code. +func TestThePanelSendsAnUnfinishedSignUpToTheBrowser(t *testing.T) { + p := authtest.New(t) + p.Factor, p.Onboarding = true, true + old := auth.SignInBaseURL + auth.SignInBaseURL = p.URL + t.Cleanup(func() { auth.SignInBaseURL = old }) + + m := setupModel(t, &session.Session{}) + m.lay = newLayout(100, 40) + m.startLogin() + mod, _ := m.Update(linkSentMsg{}) + m = mod.(model) + m = enter(t, m, mfaTestLink) + + if m.loginStage != loginAskLink || m.loginChallenge != nil { + t.Fatalf("stage %v, challenge %v", m.loginStage, m.loginChallenge != nil) + } + const want = "error: your NaN account is not set up yet — finish it in the browser: ask for a new link at " + + "https://cloud.nan.builders (this one is spent), complete the setup there, then sign in here again" + if m.loginMsg != want { + t.Errorf("loginMsg = %q", m.loginMsg) + } + flat := strings.Join(strings.Fields(m.View()), " ") + if !strings.Contains(flat, "finish it in the browser") { + t.Errorf("the screen does not say it:\n%s", m.View()) + } + if s, err := session.Load(); err == nil && s.Token != "" { + t.Errorf("saved a session: %+v", s) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6ad5386..561325d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -153,7 +153,17 @@ type configuredMsg struct { type firstRunMsg struct{} type linkSentMsg struct{ err error } -type signedInMsg struct{ err error } + +// signedInMsg ends the sign-in: err when it failed, and notice for something +// the member should read once it worked (a recovery code spent). +type signedInMsg struct { + err error + notice string +} + +// mfaChallengeMsg is a link that worked for an account with two-factor +// authentication: the session comes once the challenge is answered. +type mfaChallengeMsg struct{ challenge *auth.MFARequiredError } // signedOutMsg is the platform's answer to ending the session (err nil when it // confirmed). @@ -205,6 +215,11 @@ type model struct { loginEmail string loginMsg string loginBusy bool + // The two-factor challenge the link opened, while the code is asked + // for. In memory only, like everything else about it. + loginChallenge *auth.MFARequiredError + // Said on the steps after the sign-in, once: a recovery code was spent. + loginNotice string } // Where a member is in the guided setup. The inner inputs still drive @@ -262,13 +277,15 @@ func (w wizardStep) mood() mascotMood { } // Where a member is in the sign-in flow. It is two questions - an address and -// the link that arrives at it. +// the link that arrives at it - and a third for an account with two-factor +// authentication: the code from the authenticator app. type loginStage int const ( loginOff loginStage = iota loginAskEmail loginAskLink + loginAskCode ) func newModel(client *api.Client, sess *session.Session) model { @@ -425,10 +442,44 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginInput.EchoCharacter = '•' return m, m.loginInput.Focus() + case mfaChallengeMsg: + m.loginBusy = false + // Left with esc while the link was being exchanged: nothing to ask. + if m.loginStage != loginAskLink { + return m, nil + } + m.loginChallenge = msg.challenge + m.loginStage = loginAskCode + m.loginMsg = "this account uses two-factor authentication — enter the 6-digit code from your" + + " authenticator app, or one of your recovery codes" + m.loginInput.SetValue("") + m.loginInput.Placeholder = "123456" + m.loginInput.Prompt = "Authenticator code: " + // A recovery code works until it is used, so it is not echoed either. + m.loginInput.EchoMode = textinput.EchoPassword + m.loginInput.EchoCharacter = '•' + return m, m.loginInput.Focus() + case signedInMsg: m.loginBusy = false if msg.err != nil { m.loginMsg = "error: " + msg.err.Error() + if m.loginStage == loginAskCode { + if auth.Retryable(msg.err) { + m.loginInput.SetValue("") + } else { + // The challenge is over, and the link that opened it was + // spent opening it: back to the address, filled in, so + // enter asks for a new one. + m.loginChallenge = nil + m.wizard = wizardEmail + m.loginStage = loginAskEmail + m.loginInput.SetValue(m.loginEmail) + m.loginInput.Placeholder = "you@example.com" + m.loginInput.Prompt = "Email: " + m.loginInput.EchoMode = textinput.EchoNormal + } + } return m, m.loginInput.Focus() } // Reload the session so the token is the one just written, and drop @@ -444,6 +495,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.client = api.New(m.sess.Token) wasGuided := m.wizard != wizardOff m.cancelLogin() + m.loginNotice = msg.notice if wasGuided { m.wizard = wizardKey } @@ -507,6 +559,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loginMsg = "sending a link to " + value + "…" return m, sendLink(value) } + if m.loginStage == loginAskCode { + m.loginMsg = "checking the code…" + return m, answerChallenge(m.loginChallenge, value) + } m.loginMsg = "signing in…" return m, signIn(value) case "esc": @@ -771,6 +827,8 @@ func (m *model) startLogin() tea.Cmd { m.wizard = wizardEmail m.loginStage = loginAskEmail m.loginMsg = "" + m.loginNotice = "" + m.loginChallenge = nil // A machine set up with a platform token looks signed in to whoever set // it up. The dashboard cannot use one, and asking for an email with no // word about it reads as the token having been lost. @@ -832,6 +890,7 @@ func (m *model) resumeSetup() tea.Cmd { func (m *model) cancelLogin() { m.wizard = wizardOff m.loginStage = loginOff + m.loginChallenge = nil m.loginBusy = false m.loginInput.Blur() m.loginInput.SetValue("") @@ -846,24 +905,57 @@ func signIn(pasted string) tea.Cmd { return func() tea.Msg { token, err := auth.TokenFromLink(strings.TrimSpace(pasted)) if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } sessionToken, err := auth.ExchangeToken(token) + var challenge *auth.MFARequiredError + if errors.As(err, &challenge) { + return mfaChallengeMsg{challenge} + } if err != nil { - return signedInMsg{err} + return signedInMsg{err: err} } - current, err := session.Load() + return signedInMsg{err: saveSessionToken(sessionToken)} + } +} + +// answerChallenge posts the code the member typed against the challenge the +// link opened. +func answerChallenge(challenge *auth.MFARequiredError, code string) tea.Cmd { + return func() tea.Msg { + signIn, err := challenge.Complete(code) if err != nil { - current = &session.Session{} + return signedInMsg{err: err} } - current.Token = sessionToken - if err := session.Save(current); err != nil { - return signedInMsg{err} + if err := saveSessionToken(signIn.Session); err != nil { + return signedInMsg{err: err} } - return signedInMsg{nil} + if !signIn.UsedRecoveryCode { + return signedInMsg{} + } + return signedInMsg{notice: recoveryCodesNotice(signIn.RecoveryCodesLeft)} } } +// recoveryCodesNotice is what the panel says after a recovery code got the +// member in: each works once, and running out is the way to be locked out. +func recoveryCodesNotice(left int) string { + const where = "cloud.nan.builders, Settings > Account > Two-factor authentication" + if left == 0 { + return "that was your last recovery code — make new ones before you need one: " + where + } + return fmt.Sprintf("signed in with a recovery code, %d left — you can make new ones at %s", left, where) +} + +func saveSessionToken(token string) error { + current, err := session.Load() + if err != nil { + current = &session.Session{} + } + current.Token = token + return session.Save(current) +} + func (m model) fetchKeyStatus() tea.Cmd { client := m.client return func() tea.Msg { @@ -3380,6 +3472,9 @@ func (m model) renderWizard(l layout) string { b.WriteString("\n" + m.wrapped(l, m.loginMsg) + "\n") } case wizardKey: + if m.loginNotice != "" { + b.WriteString(m.wrapped(l, m.loginNotice) + "\n\n") + } if m.editingKey { b.WriteString(l.indent + m.keyInput.View() + "\n") }