From 9d8cc539eae0c1d50a8d6fa95cca91384d3d3610 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 20:56:21 -0400 Subject: [PATCH 01/11] fix: honor free-route privacy choice during recovery --- cmd/codeaf/chatv3_credits_test.go | 9 ++ internal/config/credits.go | 4 +- internal/config/credits_test.go | 12 +++ internal/config/crewhealth.go | 59 ++++++------ internal/config/crewhealth_test.go | 19 +++- internal/config/freecrew.go | 4 +- internal/config/freeprivacy_test.go | 100 +++++++++++++++++++++ internal/manual/chat/models-and-cost.md | 35 +++++--- internal/manual/chat/openrouter-credits.md | 17 +++- internal/session/crewladder_test.go | 32 +++++-- internal/session/crewprivacy_test.go | 88 ++++++++++++++++++ internal/session/taskcrew.go | 6 +- internal/session/taskcrewprivacy.go | 17 ++++ 13 files changed, 343 insertions(+), 59 deletions(-) create mode 100644 internal/config/freeprivacy_test.go create mode 100644 internal/session/crewprivacy_test.go create mode 100644 internal/session/taskcrewprivacy.go diff --git a/cmd/codeaf/chatv3_credits_test.go b/cmd/codeaf/chatv3_credits_test.go index 315bfd2d7f..d334b1f112 100644 --- a/cmd/codeaf/chatv3_credits_test.go +++ b/cmd/codeaf/chatv3_credits_test.go @@ -22,6 +22,9 @@ import ( func TestStartedConversationResolvesCurrentCreditDefault(t *testing.T) { t.Setenv(config.ModelEnv, "") dir := t.TempDir() + if err := config.SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } if err := config.WriteAPIKey(dir, "key"); err != nil { t.Fatal(err) } @@ -219,6 +222,9 @@ func TestProcessWatcherDropsAReadSupersededByAChangedKey(t *testing.T) { func TestEngineStartUsesTheCurrentDefaultAndItsWindow(t *testing.T) { proc := v3TestProcess(t) + if err := config.SetCrewFreeRoutes(proc.ProfileDir, true); err != nil { + t.Fatal(err) + } opts := v3Options{Workspace: t.TempDir(), Interactive: true} boot, err := openV3Launch(proc, opts) if err != nil { @@ -307,6 +313,9 @@ func TestLocalCreditDoorReadsOnlyDefaultServiceAndStoresNoBalance(t *testing.T) func TestAFreshConversationOpensOnTheDefaultTheBalanceAnswersNow(t *testing.T) { t.Setenv(config.ModelEnv, "") dir := t.TempDir() + if err := config.SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } if err := config.WriteAPIKey(dir, "key"); err != nil { t.Fatal(err) } diff --git a/internal/config/credits.go b/internal/config/credits.go index 7f837a26bb..e655025a5c 100644 --- a/internal/config/credits.go +++ b/internal/config/credits.go @@ -119,7 +119,9 @@ func ChatDefaultAt(profileDir string) string { // useFreeDefaultsAt is the single balance check for implicit chat and crew // bottom rungs. Both callers resolve explicit choices before asking it. -func useFreeDefaultsAt(profileDir string) bool { return CreditsLowAt(profileDir) } +func useFreeDefaultsAt(profileDir string) bool { + return CrewFreeRoutesAt(profileDir) && CreditsLowAt(profileDir) +} // IsFreeModel accepts either the published free suffix or a catalog row whose // three charges are known zeros. An unknown price never claims to be free. diff --git a/internal/config/credits_test.go b/internal/config/credits_test.go index c9aaaef18a..e00366771a 100644 --- a/internal/config/credits_test.go +++ b/internal/config/credits_test.go @@ -24,6 +24,9 @@ func creditsProfile(t *testing.T) string { func TestCreditsRecordChangesImplicitDefaultsWithoutSavingModels(t *testing.T) { dir := creditsProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } if err := WriteAPIKey(dir, "secret-key"); err != nil { t.Fatal(err) } @@ -190,6 +193,9 @@ func TestFreeModelNeedsASuffixOrAKnownZeroTariff(t *testing.T) { // paid OpenRouter route is tried first. A healthy reading routes as usual. func TestALowBalanceRoutesTheCrewToFreePools(t *testing.T) { dir := crewProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } rows := CrewCatalog() rows = append(rows, catalog.Model{ID: "z-ai/glm-5.3-flash:free", OpenWeights: true, IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ArenaElo: 1348, ContextLength: 1310720, Parameters: []string{"tools"}}) @@ -211,6 +217,9 @@ func TestALowBalanceRoutesTheCrewToFreePools(t *testing.T) { if strings.Count(d.Note, "free routes in use (may log prompts)") != 1 { t.Errorf("the decision note is %q, want the free-routes notice once", d.Note) } + if err := SetCrewFreeRoutes(dir, false); err != nil { + t.Fatal(err) + } if err := WriteCreditsReading(dir, APIKeyAt(dir), credits.Reading{Known: true}); err != nil { t.Fatal(err) } @@ -238,6 +247,9 @@ func TestLowCreditsFollowTheEffectiveEnvironmentKey(t *testing.T) { t.Run(row.name, func(t *testing.T) { t.Setenv("CODEAF_BASE_URL", "") dir := creditsProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } if err := WriteAPIKey(dir, "sk-or-v1-test-profile"); err != nil { t.Fatal(err) } diff --git a/internal/config/crewhealth.go b/internal/config/crewhealth.go index a237b0e896..195edaec65 100644 --- a/internal/config/crewhealth.go +++ b/internal/config/crewhealth.go @@ -113,9 +113,9 @@ func crewHealthAt(profileDir string) crewHealth { history = CrewRouteHistory(profileDir) } h := crewHealthOf(history, time.Now()) - // AN OPENROUTER BALANCE KNOWN TO BE LOW ([CreditsLowAt]) is an account out - // of credit before any call is made: the seats reach for free routes and - // the crew line says so, instead of a first paid call being refused. + // With free-route opt-in, a low OpenRouter balance steers automatic seats + // toward free routes. Without it, a small positive balance remains usable + // until a real payment refusal or known-zero account says otherwise. if useFreeDefaultsAt(profileDir) { h.unaffordable[modelsource.DefaultID] = true h.lowBalance = map[string]bool{modelsource.DefaultID: true} @@ -306,30 +306,18 @@ func (h crewHealth) usable(routes []crewroute.Route) []crewroute.Route { return out } -// crewCandidatesNoticed is [CrewCandidatesAt] with the one notice a decision -// carries when it had to reach for free routes. -// -// WHEN NOTHING PAID CAN BE REACHED, THE FREE POOLS ARE USED — whatever the -// free-routes row says — because a task on a free pool is better than a task -// that cannot start, and the line says so: a free pool may log what it is -// sent. A paid route coming back (a paid call answering, a provider reporting -// credit) puts the next task back on normal routing on its own. +// crewCandidatesNoticed keeps the automatic candidate set within the person's +// free-route choice, even when every paid account is out of credit. func crewCandidatesNoticed(profileDir string, health crewHealth) ([]crewroute.Candidate, string) { rule, providers, off := CrewAllowedAt(profileDir), CrewProvidersAt(profileDir), CrewProvidersOffAt(profileDir) facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: health} candidates := off.Candidates(crewCandidatesWith(rule, providers, facts)) - if facts.free || len(health.unaffordable) == 0 || anyPaidRoute(candidates) { - return candidates, "" - } - facts.free = true - withFree := off.Candidates(crewCandidatesWith(rule, providers, facts)) - if len(withFree) == len(candidates) { - return candidates, "" + if facts.free && len(health.unaffordable) > 0 && !anyPaidRoute(candidates) { + return candidates, "free routes in use (may log prompts) · credit unavailable on " + strings.Join(crewSet(health.unaffordable), ", ") } - return withFree, "free routes in use (may log prompts) · credit unavailable on " + strings.Join(crewSet(health.unaffordable), ", ") + return candidates, "" } -// anyPaidRoute is whether any candidate is reachable on a route that bills. func anyPaidRoute(candidates []crewroute.Candidate) bool { for _, c := range candidates { for _, r := range c.Routes { @@ -341,6 +329,22 @@ func anyPaidRoute(candidates []crewroute.Candidate) bool { return false } +// AutomaticCrewRungs excludes free pools from automatic substitution while the +// switch is off. An explicit pin is resolved separately and is never rewritten. +// Check again when selecting a saved ladder: a task can outlive a settings edit. +func AutomaticCrewRungs(profileDir string, rungs []crewroute.Pick) []crewroute.Pick { + if CrewFreeRoutesAt(profileDir) { + return rungs + } + out := make([]crewroute.Pick, 0, len(rungs)) + for _, rung := range rungs { + if rung.Kind != crewroute.Free && !crewroute.IsFree(rung.Send) { + out = append(out, rung) + } + } + return out +} + // crewSet is a set's members in order. func crewSet(set map[string]bool) []string { out := make([]string, 0, len(set)) @@ -429,6 +433,9 @@ func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, c // NOT EVEN THE PERSON'S OWN MODEL rides an account that just said it // is out of credit, or a key that was just refused: the conversation // running on it proves the model, not the account. + if pin.Kind == crewroute.Free && !CrewFreeRoutesAt(profileDir) { + return + } if health.disconnected[pin.Provider] || (pin.Kind == crewroute.Metered && health.unaffordable[pin.Provider]) { return } @@ -445,17 +452,13 @@ func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, c return out } -// crewFreeRescue is the seat on a free pool when EVERY PAID ROUTE IS OUT OF -// REACH — the free-routes switch off or on. With the switch on the pools are -// already routes on the seat's ladder; with it off they are used only here, -// and the crew line says so ([crewCandidatesNoticed]'s notice). The best few -// free picks for the seat, each a different model, so a pool at its limit -// leaves the next. +// crewFreeRescue offers the best few allowed free pools only after explicit +// free-route opt-in. An empty paid balance never grants that permission. func crewFreeRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, health crewHealth) []crewroute.Pick { - candidates, notice := crewCandidatesNoticed(profileDir, health) - if notice == "" { + if !CrewFreeRoutesAt(profileDir) { return nil } + candidates, _ := crewCandidatesNoticed(profileDir, health) var free []crewroute.Candidate for _, c := range candidates { var routes []crewroute.Route diff --git a/internal/config/crewhealth_test.go b/internal/config/crewhealth_test.go index 6fc772f9c8..fee7c02aff 100644 --- a/internal/config/crewhealth_test.go +++ b/internal/config/crewhealth_test.go @@ -171,6 +171,9 @@ func TestAZeroCreditAccountWalksTheLadder(t *testing.T) { if got := probe.Seat(crewroute.Worker); got.Kind != crewroute.Metered { t.Fatalf("the next task's worker is %+v, want the paid route, probed", got) } + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } rescue := CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "somelab/the-chat-model") if len(rescue) == 0 || rescue[0].Kind != crewroute.Free { t.Fatalf("the rescue is %+v, want the free pool", rescue) @@ -186,6 +189,9 @@ func TestAZeroCreditAccountWalksTheLadder(t *testing.T) { t.Errorf("the one action is %q", got) } + if err := SetCrewFreeRoutes(dir, false); err != nil { + t.Fatal(err) + } history = append(history, router.CrewRouteOutcome{At: time.Now(), Seat: "worker", Send: w.Send, Provider: w.Provider, Paid: true}) for _, r := range CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "somelab/the-chat-model") { if r.Kind == crewroute.Free { @@ -341,14 +347,17 @@ func TestCrewOffersNoSpeechOrImageModels(t *testing.T) { // person's own model on the account that said no is not. func TestTheRescueFallsToAFreePoolWhenNothingPaidIsReachable(t *testing.T) { dir := crewProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } rows := CrewCatalog() rows = append(rows, catalog.Model{ID: "z-ai/glm-5.3-flash:free", OpenWeights: true, IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ContextLength: 1310720, Parameters: []string{"tools"}}) CrewCatalog = func() []catalog.Model { return rows } history := []router.CrewRouteOutcome{{At: time.Now(), Seat: "worker", Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "payment"}} withRouteHistory(t, &history) - if CrewFreeRoutesAt(dir) { - t.Fatal("the switch should be off") + if !CrewFreeRoutesAt(dir) { + t.Fatal("the switch should be on") } rescue := CrewRescue(dir, crewroute.Bugfix, crewroute.Worker, "moonshotai/kimi-k3") if len(rescue) == 0 || rescue[0].Kind != crewroute.Free { @@ -467,6 +476,9 @@ func TestADecisionRowNamesOnlySeatableCandidatesAndItsTopThree(t *testing.T) { // says free routes once. func TestTheFreeRescuePrefersGeneralModels(t *testing.T) { dir := crewProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } rows := CrewCatalog() rows = append(rows, catalog.Model{ID: "inclusionai/ling-3.0-flash-fin:free", ContextLength: 262144, IntelligenceIndex: 60, CodingIndex: 80, AgenticIndex: 60, Parameters: []string{"tools"}}, @@ -573,6 +585,9 @@ func TestTheSpendLinesASeatCallIsHeldTo(t *testing.T) { // rescue — while an explicit-zero `:free` pool still is. func TestAnUnpricedModelIsNeverPicked(t *testing.T) { dir := crewProfile(t) + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } rows := CrewCatalog() rows = append(rows, catalog.Model{ID: "stealth/space-bunny-alpha", ContextLength: 1048576, diff --git a/internal/config/freecrew.go b/internal/config/freecrew.go index 7d18f08604..1013ad2d39 100644 --- a/internal/config/freecrew.go +++ b/internal/config/freecrew.go @@ -1,6 +1,6 @@ package config -// FreeChatModel is the build's chat bottom rung while a known account is low. +// FreeChatModel is the implicit chat rung when free routes are enabled and credit is low. const FreeChatModel = "qwen/qwen3.8-27b:free" // freeHelperModels are the free models the two rows that are not crew seats — @@ -11,7 +11,7 @@ const FreeChatModel = "qwen/qwen3.8-27b:free" // The three crew seats are not here: they are routed per task, and a known-low // account reaches the router as an OpenRouter account out of credit // ([crewHealthAt]), which moves the seats onto free routes with the router's -// own notice. +// own notice, but only with explicit free-route opt-in. var freeHelperModels = map[string]string{ ModelTierReflex: "nvidia/nemotron-3.5-lightning:free", ModelTierLow: "thinkingmachines/inkling-small:free", diff --git a/internal/config/freeprivacy_test.go b/internal/config/freeprivacy_test.go new file mode 100644 index 0000000000..9380111636 --- /dev/null +++ b/internal/config/freeprivacy_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/router" +) + +func TestLowBalanceDoesNotGrantFreeRouteConsent(t *testing.T) { + dir := creditsProfile(t) + if err := WriteAPIKey(dir, "fixture-key"); err != nil { + t.Fatal(err) + } + if err := WriteCreditsReading(dir, "fixture-key", credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + if CrewFreeRoutesAt(dir) || ChatDefaultAt(dir) != DefaultModel { + t.Fatal("low balance changed the free-route choice") + } + for tier := range freeHelperModels { + if got := TierModelAt(dir, tier); got != builtinTierModel(tier) { + t.Fatalf("%s automatically changed to %s", tier, got) + } + } + if crewHealthAt(dir).unaffordable[modelsource.DefaultID] { + t.Fatal("a low positive balance was incorrectly treated as empty") + } + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } + if ChatDefaultAt(dir) != FreeChatModel { + t.Fatal("explicit opt-in did not enable free defaults") + } + for tier, want := range freeHelperModels { + if got := TierModelAt(dir, tier); got != want { + t.Fatalf("opted-in %s=%s want%s", tier, got, want) + } + } + if err := SetCrewFreeRoutes(dir, false); err != nil { + t.Fatal(err) + } + if ChatDefaultAt(dir) != DefaultModel { + t.Fatal("opting out left the implicit free default active") + } +} + +func TestRememberedPaymentFailureDoesNotGrantFreeRouteConsent(t *testing.T) { + dir := crewProfile(t) + history := []router.CrewRouteOutcome{{At: time.Now(), Seat: "worker", Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "payment"}} + withRouteHistory(t, &history) + for _, seat := range crewroute.Seats { + for _, rung := range CrewRescue(dir, crewroute.Bugfix, seat, "vendor/chat:free") { + if rung.Kind == crewroute.Free || crewroute.IsFree(rung.Send) { + t.Fatalf("%s rescued onto free route without consent: %+v", seat, rung) + } + } + } + candidates, note := crewCandidatesNoticed(dir, crewHealthAt(dir)) + if strings.Contains(note, "free routes in use") { + t.Fatal(note) + } + for _, candidate := range candidates { + for _, route := range candidate.Routes { + if route.Kind == crewroute.Free { + t.Fatalf("free candidate despite off: %+v", route) + } + } + } + if CrewFreeRoutesAt(dir) { + t.Fatal("recovery changed saved preference") + } +} + +func TestSavedAutomaticRungsHonorCurrentChoiceWithoutBlockingConnectedPlans(t *testing.T) { + dir := creditsProfile(t) + rungs := []crewroute.Pick{ + {Send: "vendor/free:free", Kind: crewroute.Free}, + {Send: "vendor/paid", Kind: crewroute.Metered}, + {Send: "plan/chat", Kind: crewroute.Plan}, + {Send: "local/chat", Kind: crewroute.Local}, + } + got := AutomaticCrewRungs(dir, rungs) + if len(got) != 3 || got[0].Send != "vendor/paid" || got[1].Kind != crewroute.Plan || got[2].Kind != crewroute.Local { + t.Fatalf("eligible connected alternatives lost: %+v", got) + } + if len(rungs) != 4 || rungs[0].Kind != crewroute.Free { + t.Fatal("saved ladder was mutated") + } + if err := SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } + if got := AutomaticCrewRungs(dir, rungs); len(got) != 4 { + t.Fatal(got) + } +} diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index d4958b97cf..a564d6158e 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -883,8 +883,11 @@ the task stops on the one thing to do, which leads its line in place of `/redo s continue`, `reconnect openrouter with /connect`, or when the limit resets. What each route did is kept: a route that refused a model is left out for a week, one at its limit rests until its reset, an account out of credit is skipped until a paid call on it answers again. Free routes are -off unless you turn them on; when every paid route is out of reach they are used anyway, -and the line says `free routes in use (may log prompts)`. A rescue never takes a model +off unless you turn them on, including after a payment refusal or a low-balance reading. +With free routes off, the task tries eligible connected alternatives or stops with +`add credit on openrouter to continue`; your work stays saved. You can add credit, +connect another provider, or explicitly enable free routes in `/crew`. When enabled, +the line says `free routes in use (may log prompts)`. A rescue never takes a model whose name says it was tuned for one domain (finance, medicine, law) or is too small for a seat's work, and a seat tries at most three free pools in a task; a pool at its limit is not waited on — the seat moves on at once, and with nothing left the task stops on @@ -913,9 +916,10 @@ logged with how it ended, and routes are weighed by it: that rarely answers can lose to a dearer one that does; - an **account out of credit** or a **key refused** is skipped by helpers, and the next task's first seat call asks it again, once: an answer puts it back at once; -- an **OpenRouter balance read as low** (*OpenRouter credits and free models*) counts as out - of credit before any call is made and is not asked again by a task: with no other paid - provider connected, the crew goes to free routes and the line says +- an **OpenRouter balance read as low** (*OpenRouter credits and free models*) steers + automatic seats toward free routes only if you enabled them. With free routes off, + a small positive balance remains usable until a payment refusal or a known-zero + account says otherwise. With free routes enabled, the crew may use them and its line says `free routes in use (may log prompts) · credit unavailable on openrouter`. The balance is read again at every launch while it is low, so a top-up puts routing back to ordinary. @@ -1067,14 +1071,19 @@ content (`docs/TELEMETRY.md` in the repository lists every field they carry). **Free routes may log prompts.** A provider's free pool of a model (an OpenRouter `…:free` id) may log or train on what it is sent. The crew uses free routes only when you turn -them on — the `free routes` switch at the end of `/crew`'s providers list — with one -exception: when every paid route a seat could use is out of reach, a seat moves onto a -free pool rather than leave the task unable to run. "Out of reach" is one of: your -OpenRouter balance is known to be low, a paid call on that provider was refused for -payment, or the provider reports its credit at zero. A seat tries at most three free pools -in one task; your allowed models (`/crew models`) still limit which ones; and the task's -crew line then says `free routes in use (may log prompts)`. A paid call answering again -puts the next task back on paid routes. +them on — the `free routes` switch at the end of `/crew`'s providers list. A low balance, +remembered payment refusal, or live payment refusal never turns that switch on or +sends a task to a free pool behind your back. Existing paid, subscription and local +connections remain eligible. If none can serve the task, its work stays saved and +its line explains how to continue: add credit or reconnect the provider. You can +also explicitly enable free routes in `/crew` if their privacy tradeoff is acceptable. + +An explicit choice of a named `:free` model — in `/model`, a helper row, or a crew pin — +permits that named route even with the automatic switch off. It does not permit +other free fallback models. Turning the switch off also excludes free routes from +saved automatic fallback ladders. When free routes are enabled, a seat tries at +most three pools per task, within your allowed models and spend limits, and the +crew line says `free routes in use (may log prompts)`. ## Why does my OpenRouter activity say AgentField AI or codeaf dev — which app a request names diff --git a/internal/manual/chat/openrouter-credits.md b/internal/manual/chat/openrouter-credits.md index 52a2363812..05a7213ae6 100644 --- a/internal/manual/chat/openrouter-credits.md +++ b/internal/manual/chat/openrouter-credits.md @@ -1,10 +1,17 @@ # OpenRouter credits and free models: it worked and then stopped -## Out of credits — why codeaf picks only free models +## Out of credits — free models need your choice For the default OpenRouter service, codeaf reads the account's credit balance and the key's own spending cap, whichever is smaller. A known remaining balance of $0.50 -or less is low. While it is low, a new conversation on a profile where you never +or less is low. A low balance never grants permission to use prompt-logging free routes. +With automatic free routes off (the default), chat and helpers keep their normal +models, and a small positive balance can still serve paid tasks. If a paid request +is refused, tasks try eligible connected alternatives or stop with the add-credit action. +Your work stays saved. Add credit, connect another provider, or explicitly turn on +`free routes` in `/crew` if you accept that tradeoff. + +When free routes are enabled and the balance is low, a new conversation on a profile where you never chose a model opens on `qwen/qwen3.8-27b:free`, and the two helper rows nobody set use `nvidia/nemotron-3.5-lightning:free` for reflex and `thinkingmachines/inkling-small:free` for small work. The crew's three seats — worker, @@ -50,7 +57,8 @@ on the vendor's whole sentence, top-up link included, for example Any payment refusal from OpenRouter — on your turn, or on a background call such as the reflex, a title or a task — also starts a fresh balance read, unless one finished in the last 30 seconds. If the account reads low, the free defaults and -the warning below take effect without a relaunch. +the warning below take effect without a relaunch; free defaults apply only if you +enabled free routes. ## Why is my model a free one — when the balance is read @@ -69,7 +77,8 @@ The read decides whether the key expired; a merely invalid key is a failed read changes nothing. A conversation that has sent nothing and whose model nobody chose follows the -default both ways: onto the free model when the account reads low, and back to +default both ways: onto the free model when the account reads low and free routes +are enabled, and back to the usual default when a later read finds more than $0.50. A conversation that has sent a message keeps its model either way. After a top-up, the next new conversation opens on the usual default, the helper rows return to their usual models, diff --git a/internal/session/crewladder_test.go b/internal/session/crewladder_test.go index 51e08bbae1..02d8093e6b 100644 --- a/internal/session/crewladder_test.go +++ b/internal/session/crewladder_test.go @@ -128,12 +128,25 @@ func crewStubAgent(t *testing.T, stub *crewStub) (*Agent, string) { // askWorker routes one task and makes its worker's first call through the // seat ladder, as the run engine does. -func askWorker(t *testing.T, agent *Agent, row uint64) (*beltRun, error) { +func askWorker(t *testing.T, agent *Agent, row uint64, freeRecovery ...bool) (*beltRun, error) { t.Helper() crew, err := agent.routeTaskCrew(t.Context(), row, "fix: the parser crashes on empty input", "Traceback: ValueError") if err != nil { return nil, err } + // These controls opt into free recovery after the paid crew was selected, + // so the first 402 still exercises the fallback boundary. Other tests retain + // the profile's original choice. + if len(freeRecovery) > 0 && freeRecovery[0] { + if err := config.SetCrewFreeRoutes(agent.config.ProfileDir, true); err != nil { + t.Fatal(err) + } + defer func() { + if err := config.SetCrewFreeRoutes(agent.config.ProfileDir, false); err != nil { + t.Fatal(err) + } + }() + } run := &beltRun{row: row, crew: crew} worker := crew.current().Seat(crewroute.Worker).Send _, err = crewSeatCompleter{agent: agent, run: run}.CompleteWithMessages(t.Context(), @@ -152,7 +165,7 @@ func TestTheSeatLadderReachesTheFreePoolsOnAnAccountOutOfCredit(t *testing.T) { stub := newCrewStub(t, http.StatusPaymentRequired, free) agent, _ := crewStubAgent(t, stub) - run, err := askWorker(t, agent, 1) + run, err := askWorker(t, agent, 1, true) if err != nil { t.Fatalf("task 1: %v (asked %v)", err, stub.models()) } @@ -167,7 +180,7 @@ func TestTheSeatLadderReachesTheFreePoolsOnAnAccountOutOfCredit(t *testing.T) { stub.mu.Lock() stub.asked, stub.free["z-ai/glm-5.3-flash:free"] = nil, http.StatusTooManyRequests stub.mu.Unlock() - if _, err := askWorker(t, agent, 2); err != nil { + if _, err := askWorker(t, agent, 2, true); err != nil { t.Fatalf("task 2: %v (asked %v)", err, stub.models()) } if got := stub.models(); len(got) < 2 || got[0] != "z-ai/glm-5.3-flash" || got[len(got)-1] != "vendor/stranger:free" { @@ -177,7 +190,7 @@ func TestTheSeatLadderReachesTheFreePoolsOnAnAccountOutOfCredit(t *testing.T) { stub.mu.Lock() stub.free["vendor/stranger:free"] = http.StatusTooManyRequests stub.mu.Unlock() - run, err = askWorker(t, agent, 3) + run, err = askWorker(t, agent, 3, true) var stopped crewStopped if !errors.As(err, &stopped) || stopped.action != "add credit on openrouter to continue" { t.Fatalf("task 3 ended %v, want the one action", err) @@ -227,13 +240,13 @@ func TestNoCallReachesAQuarantinedRoute(t *testing.T) { IntelligenceIndex: 24.2, CodingIndex: 56.2, AgenticIndex: 22.2, ContextLength: 1048576, Parameters: []string{"tools"}}) config.CrewCatalog = func() []catalog.Model { return rows } - if _, err := askWorker(t, agent, 1); err != nil { + if _, err := askWorker(t, agent, 1, true); err != nil { t.Fatalf("task 1: %v (asked %v)", err, stub.models()) } stub.mu.Lock() stub.asked = nil stub.mu.Unlock() - run, err := askWorker(t, agent, 2) + run, err := askWorker(t, agent, 2, true) if err != nil { t.Fatalf("task 2: %v (asked %v)", err, stub.models()) } @@ -288,6 +301,9 @@ func TestEveryRouteDownStopsWithinThreeFreePools(t *testing.T) { if err != nil { t.Fatal(err) } + if err := config.SetCrewFreeRoutes(agent.config.ProfileDir, true); err != nil { + t.Fatal(err) + } run := &beltRun{row: 1, crew: crew} began := time.Now() // EVERY SEAT, AND THE WORKER AGAIN: the engine asks each seat, and asks a @@ -337,7 +353,7 @@ func TestNoRoadReachesARouteThatRefusedThisTask(t *testing.T) { if !config.CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") { t.Fatal("the route was resting before anything refused it") } - if _, err := askWorker(t, agent, 1); err != nil { + if _, err := askWorker(t, agent, 1, true); err != nil { t.Fatalf("task 1: %v (asked %v)", err, stub.models()) } glm := func() int { @@ -447,7 +463,7 @@ func TestTheDefaultProfileKeepsHelpersOffARefusedRoute(t *testing.T) { stub := newCrewStub(t, http.StatusOK, map[string]int{"z-ai/glm-5.3-flash:free": http.StatusOK}) stub.only = map[string]int{"z-ai/glm-5.3-flash": http.StatusForbidden} agent := crewStubAgentOnDefault(t, stub, nil) - if _, err := askWorker(t, agent, 1); err != nil { + if _, err := askWorker(t, agent, 1, true); err != nil { t.Fatalf("task 1: %v (asked %v)", err, stub.models()) } _, _, _ = agent.callRole(t.Context(), roles.RoleWorker, "z-ai/glm-5.3-flash", diff --git a/internal/session/crewprivacy_test.go b/internal/session/crewprivacy_test.go new file mode 100644 index 0000000000..d5c8e582c4 --- /dev/null +++ b/internal/session/crewprivacy_test.go @@ -0,0 +1,88 @@ +package session + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +func TestPaymentFailureKeepsTaskOffFreeRoutesWithoutConsent(t *testing.T) { + stub := newCrewStub(t, http.StatusPaymentRequired, map[string]int{"z-ai/glm-5.3-flash:free": http.StatusOK}) + agent, dir := crewStubAgent(t, stub) + run, err := askWorker(t, agent, 1) + var stopped crewStopped + if !errors.As(err, &stopped) || !strings.Contains(stopped.action, "add credit on openrouter") { + t.Fatalf("run=%+v err=%v", run, err) + } + if got := stub.models(); len(got) != 1 || crewroute.IsFree(got[0]) { + t.Fatalf("task leaked to automatic free route: %v", got) + } + if config.CrewFreeRoutesAt(dir) { + t.Fatal("task changed free-route choice") + } + if run.crew.current().Stopped == "" { + t.Fatal("stopped work did not retain its actionable state") + } +} + +func TestOptingOutBlocksSavedAutomaticFreeSeatButKeepsExplicitPin(t *testing.T) { + for _, pinned := range []bool{false, true} { + t.Run(map[bool]string{false: "automatic", true: "explicit pin"}[pinned], func(t *testing.T) { + stub := newCrewStub(t, http.StatusPaymentRequired, map[string]int{"z-ai/glm-5.3-flash:free": http.StatusOK}) + agent, _ := crewStubAgent(t, stub) + pick := crewroute.Pick{Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash:free", Provider: "openrouter", Kind: crewroute.Free, Pinned: pinned} + crew := &taskCrew{decision: crewroute.Decision{Crew: []crewroute.Pick{pick}}, original: map[crewroute.Seat]string{crewroute.Worker: pick.Send}} + run := &beltRun{row: 1, crew: crew} + _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private fixture task")}, ai.WithModel(pick.Send)) + if pinned { + if err != nil || len(stub.models()) != 1 { + t.Fatalf("explicit named consent refused: %v %v", stub.models(), err) + } + } else { + if err == nil || len(stub.models()) != 0 || !strings.Contains(err.Error(), "free routes are off") { + t.Fatalf("saved automatic free seat sent: %v %v", stub.models(), err) + } + } + }) + } +} + +func TestSmallPositiveBalanceStillUsesPaidRouteWhenFreeRoutesAreOff(t *testing.T) { + stub := newCrewStub(t, http.StatusOK, map[string]int{"z-ai/glm-5.3-flash:free": http.StatusOK}) + agent, dir := crewStubAgent(t, stub) + if err := config.WriteCreditsReading(dir, config.APIKeyAt(dir), credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + if _, err := askWorker(t, agent, 1); err != nil { + t.Fatalf("low positive balance was refused prematurely: %v", err) + } + if got := stub.models(); len(got) != 1 || crewroute.IsFree(got[0]) { + t.Fatalf("low balance changed paid model without consent: %v", got) + } +} + +func TestSavedFreeFallbackIsSkippedAfterPaymentRefusalWhileOff(t *testing.T) { + stub := newCrewStub(t, http.StatusPaymentRequired, map[string]int{"z-ai/glm-5.3-flash:free": http.StatusOK}) + agent, _ := crewStubAgent(t, stub) + crew, err := agent.routeTaskCrew(t.Context(), 1, "fix: parser crashes on empty input", "Traceback") + if err != nil { + t.Fatal(err) + } + crew.ladders = map[crewroute.Seat][]crewroute.Pick{} + crew.ladders[crewroute.Worker] = []crewroute.Pick{{Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Send: "z-ai/glm-5.3-flash:free", Provider: "openrouter", Kind: crewroute.Free}} + run := &beltRun{row: 1, crew: crew} + worker := crew.current().Seat(crewroute.Worker).Send + _, err = (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private fixture task")}, ai.WithModel(worker)) + if err == nil || !strings.Contains(err.Error(), "add credit") { + t.Fatalf("no actionable payment stop: %v", err) + } + if got := stub.models(); len(got) != 1 || crewroute.IsFree(got[0]) { + t.Fatalf("saved free fallback received task: %v", got) + } +} diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index a7fa5eb51b..0f0182b82a 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -393,6 +393,9 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message retried := false for { current := crew.sendFor(key) + if c.freeRouteBlocked(current) { + return nil, crewSeatEnd("free routes are off · choose a paid model or enable free routes in /crew", nil) + } if failed, ok := crew.failedHere(current); ok { // A SEND THAT FAILED ON THIS TASK IS NOT ASKED AGAIN, by this seat // or any other: a pool that answered 429 with an hour's @@ -817,7 +820,8 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro if run.recoveredCrew != nil { rescue = crew.rescue[seat] } - rung, ok := crew.takeRungLocked(seat, append(append([]crewroute.Pick(nil), crew.ladders[seat]...), rescue...)) + rungs := append(append([]crewroute.Pick(nil), crew.ladders[seat]...), rescue...) + rung, ok := crew.takeRungLocked(seat, config.AutomaticCrewRungs(a.config.ProfileDir, rungs)) if !ok { continue } diff --git a/internal/session/taskcrewprivacy.go b/internal/session/taskcrewprivacy.go new file mode 100644 index 0000000000..fc0ed6dfe8 --- /dev/null +++ b/internal/session/taskcrewprivacy.go @@ -0,0 +1,17 @@ +package session + +import ( + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// A saved or already-routed task may predate the current free-route setting. +// Explicit pins retain consent to their named model; automatic seats do not. +func (c crewSeatCompleter) freeRouteBlocked(current string) bool { + for _, pick := range c.run.crew.current().Crew { + if pick.Send == current && !pick.Pinned && (pick.Kind == crewroute.Free || crewroute.IsFree(pick.Send)) { + return !config.CrewFreeRoutesAt(c.agent.config.ProfileDir) + } + } + return false +} From 07bba8fd8dbea158502dd0f0bf0be9385f7de342 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 20:57:00 -0400 Subject: [PATCH 02/11] Verify free-route consent on startup and automatic task recovery --- internal/e2e/free_route_consent_e2e_test.go | 257 ++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 internal/e2e/free_route_consent_e2e_test.go diff --git a/internal/e2e/free_route_consent_e2e_test.go b/internal/e2e/free_route_consent_e2e_test.go new file mode 100644 index 0000000000..958e0d682d --- /dev/null +++ b/internal/e2e/free_route_consent_e2e_test.go @@ -0,0 +1,257 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" +) + +// This scripted-provider test uses the real command and a known-low account +// record. No real credentials or model calls enter either fixture endpoint. +func TestLowBalanceFreeRoutesRequireConsentOnTheRealBinary(t *testing.T) { + for _, allowed := range []bool{false, true} { + name := "off_keeps_context_off_free_routes" + if allowed { + name = "opt_in_allows_free_default" + } + t.Run(name, func(t *testing.T) { + const key = "fixture-free-consent-key-not-a-secret" + var mu sync.Mutex + var sent []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + var rows []map[string]any + for _, model := range []string{strings.TrimPrefix(config.DefaultModel, "~"), config.DefaultLowModel, config.DefaultReflexModel, config.FreeChatModel, "nvidia/nemotron-3.5-lightning:free", "thinkingmachines/inkling-small:free"} { + price := "0.000001" + if strings.HasSuffix(model, ":free") { + price = "0" + } + rows = append(rows, map[string]any{"id": model, "canonical_slug": model, "name": model, "context_length": 200000, "architecture": map[string]any{"input_modalities": []string{"text"}, "output_modalities": []string{"text"}}, "pricing": map[string]string{"prompt": price, "completion": price}, "supported_parameters": []string{"tools", "tool_choice"}}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": rows}) + case "/key", "/auth/key": + fmt.Fprint(w, `{"data":{"limit":1,"usage":0.75,"limit_remaining":0.25}}`) + case "/credits": + fmt.Fprint(w, `{"data":{"total_credits":1,"total_usage":0.75}}`) + case "/chat/completions": + var body struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "fixture decode", 400) + return + } + mu.Lock() + sent = append(sent, body.Model) + mu.Unlock() + + _ = json.NewEncoder(w).Encode(map[string]any{"id": "free-consent-fixture", "model": body.Model, "choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": "FREE_CONSENT_CONTROL_OK"}, "finish_reason": "stop"}}, "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + home := emptyHome(t) + profile := map[string]any{"api_key": key, "memory.enabled": false, "tools.approvalMode": "allow", "models.crew.free_routes": allowed, "setup_seen_at": time.Now().UTC().Format(time.RFC3339)} + raw, _ := json.Marshal(profile) + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + if err := config.WriteCreditsReading(home, key, credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + env := append(os.Environ(), config.APIKeyEnv+"="+key, "CODEAF_BASE_URL="+server.URL, "CODEAF_HOME="+home, "CODEAF_PROFILE_DIR=") + cmd := guardedCommand(t, ctx, home, env, binary(t), "chat", "--no-host", "--once", "Reply with FREE_CONSENT_CONTROL_OK") + out, err := cmd.CombinedOutput() + mu.Lock() + defer mu.Unlock() + free := 0 + for _, model := range sent { + if strings.HasSuffix(model, ":free") { + free++ + } + } + if !allowed { + if free != 0 { + t.Fatalf("off sent context to free models: %v", sent) + } + if err != nil || len(sent) == 0 || !strings.Contains(string(out), "FREE_CONSENT_CONTROL_OK") { + t.Fatalf("known low positive balance should retain usable paid route: err=%v models=%v output=%s", err, sent, out) + } + } else if err != nil || free == 0 || !strings.Contains(string(out), "FREE_CONSENT_CONTROL_OK") { + t.Fatalf("opt-in positive control failed: err=%v models=%v output=%s", err, sent, out) + } + t.Logf("PASS scripted real binary: automatic_free_consent=%t free_requests=%d models=%v output=%s", allowed, free, sent, out) + }) + } +} + +// Consent is changed only inside the paid request handler in the positive +// control: routing must first choose a paid worker, then receive a 402, before +// any free request can be admitted. Explicit seat pins are deliberately absent. +func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing.T) { + for _, enableOn402 := range []bool{false, true} { + name := "off_refuses_free_fallback" + if enableOn402 { + name = "consent_before_402_allows_fallback" + } + t.Run(name, func(t *testing.T) { + const key = "fixture-task-free-key-not-a-secret" + const manager = "stub/free-consent-manager" + const paid = "z-ai/glm-5.3-flash" + const free = paid + ":free" + home := filepath.Join(shortRoot(t), "h") + if err := os.Mkdir(home, 0o700); err != nil { + t.Fatal(err) + } + profile := map[string]any{"api_key": key, "model.talk": manager, "models.tiers.low": manager, "models.tiers.reflex": manager, "models.crew.free_routes": false, "memory.enabled": false, "tools.approvalMode": "allow", "task.audit": "off", "task.max_load": 0, "task.parallel": 1, "setup_seen_at": time.Now().UTC().Format(time.RFC3339), config.KeyIcons: config.IconsPlain} + saveProfile := func() error { + raw, err := json.Marshal(profile) + if err != nil { + return err + } + path := filepath.Join(home, "config.json") + if err := os.WriteFile(path+".fixture", raw, 0o600); err != nil { + return err + } + return os.Rename(path+".fixture", path) + } + if err := saveProfile(); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + paidRequests, freeRequests := 0, 0 + wrote := false + var models []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + var rows []map[string]any + for _, model := range []string{manager, paid, free} { + price := "0.00000015" + if model == free { + price = "0" + } + rows = append(rows, map[string]any{"id": model, "canonical_slug": model, "name": model, "context_length": 1310720, "architecture": map[string]any{"input_modalities": []string{"text"}, "output_modalities": []string{"text"}, "instruct_type": "chat", "modality": "text->text"}, "pricing": map[string]string{"prompt": price, "completion": price}, "supported_parameters": []string{"tools", "tool_choice", "max_tokens"}}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": rows}) + case "/key", "/auth/key": + fmt.Fprint(w, `{"data":{"limit":100,"usage":0,"limit_remaining":100}}`) + case "/credits": + fmt.Fprint(w, `{"data":{"total_credits":100,"total_usage":0}}`) + case "/chat/completions": + var body struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "fixturedecode", 400) + return + } + mu.Lock() + models = append(models, body.Model) + if body.Model == paid { + paidRequests++ + if enableOn402 && paidRequests == 1 { + profile["models.crew.free_routes"] = true + if err := saveProfile(); err != nil { + t.Error(err) + } + } + mu.Unlock() + w.WriteHeader(http.StatusPaymentRequired) + fmt.Fprint(w, `{"error":{"message":"Insufficient credits","code":402}}`) + return + } + if strings.HasSuffix(body.Model, ":free") { + freeRequests++ + if !wrote { + wrote = true + mu.Unlock() + args := `{"command":"printf FREE_TASK_FALLBACK_OK > free-consent-proof.txt"}` + freeConsentResponse(w, body.Model, "", map[string]any{"id": "free-task-write", "type": "function", "function": map[string]string{"name": "bash", "arguments": args}}) + return + } + } + mu.Unlock() + freeConsentResponse(w, body.Model, "The scripted task completed.", nil) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + ws := newWorkspace(t, "free-consent-task", false) + env := []string{config.APIKeyEnv + "=" + key, "CODEAF_BASE_URL=" + server.URL, "CODEAF_PROFILE_DIR="} + stopRecoveryEngineOnCleanup(t, env, home, ws) + rig := startWithEnv(t, env, "free_402_"+name, home, ws, 170, 48, "chat", "--model", manager) + rig.skipSetup(t) + rig.lit("/task solo fix: the parser crashes on empty input; write free-consent-proof.txt containing FREE_TASK_FALLBACK_OK") + rig.keys("Enter") + deadline := time.Now().Add(45 * time.Second) + for { + mu.Lock() + paidN, freeN := paidRequests, freeRequests + mu.Unlock() + if !enableOn402 && paidN > 0 && strings.Contains(strings.ToLower(rig.capture()), "credit") { + break + } + if enableOn402 && paidN > 0 && freeN > 0 && (freeConsentFileExists(home) || freeConsentFileExists(ws)) { + break + } + if time.Now().After(deadline) { + t.Fatalf("automatic402 acceptance incomplete: on=%t paid=%d free=%d screen=%s", enableOn402, paidN, freeN, rig.capture()) + } + time.Sleep(pollEvery) + } + mu.Lock() + defer mu.Unlock() + if paidRequests == 0 || (!enableOn402 && freeRequests != 0) || (enableOn402 && freeRequests == 0) { + t.Fatalf("wrong consent behavior: on=%t paid=%d free=%d models=%v", enableOn402, paidRequests, freeRequests, models) + } + t.Logf("PASS scripted real terminal: consent_at_402=%t paid_requests=%d free_requests=%d models=%v\n%s", enableOn402, paidRequests, freeRequests, models, rig.capture()) + }) + } +} +func freeConsentResponse(w http.ResponseWriter, model, text string, call any) { + message := map[string]any{"role": "assistant", "content": text} + finish := "stop" + if call != nil { + message["tool_calls"] = []any{call} + finish = "tool_calls" + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": "free-task-fixture", "model": model, "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}, "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) +} +func freeConsentFileExists(root string) bool { + found := false + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err == nil && !entry.IsDir() && entry.Name() == "free-consent-proof.txt" { + raw, _ := os.ReadFile(path) + found = string(raw) == "FREE_TASK_FALLBACK_OK" + if found { + return filepath.SkipAll + } + } + return nil + }) + return found +} From 6f9353b403c8542781d1bb3a417bd5c20778c43e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:05:47 -0400 Subject: [PATCH 03/11] fix: retain seat consent and block stale free helper choices --- internal/config/crewhealth.go | 14 ++++-- internal/config/freeprivacy_test.go | 11 +++++ internal/session/crewprivacy_test.go | 65 +++++++++++++++++++++++++ internal/session/taskcrew.go | 7 ++- internal/session/taskcrewprivacy.go | 72 +++++++++++++++++++++++++--- 5 files changed, 156 insertions(+), 13 deletions(-) diff --git a/internal/config/crewhealth.go b/internal/config/crewhealth.go index 195edaec65..38d1929403 100644 --- a/internal/config/crewhealth.go +++ b/internal/config/crewhealth.go @@ -312,21 +312,25 @@ func crewCandidatesNoticed(profileDir string, health crewHealth) ([]crewroute.Ca rule, providers, off := CrewAllowedAt(profileDir), CrewProvidersAt(profileDir), CrewProvidersOffAt(profileDir) facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: health} candidates := off.Candidates(crewCandidatesWith(rule, providers, facts)) - if facts.free && len(health.unaffordable) > 0 && !anyPaidRoute(candidates) { + if facts.free && len(health.unaffordable) > 0 && onlyFreeRoutes(candidates) { return candidates, "free routes in use (may log prompts) · credit unavailable on " + strings.Join(crewSet(health.unaffordable), ", ") } return candidates, "" } -func anyPaidRoute(candidates []crewroute.Candidate) bool { +// A notice names free routes only when every available route is free; an +// empty candidate set or a subscription/local alternative is not free use. +func onlyFreeRoutes(candidates []crewroute.Candidate) bool { + found := false for _, c := range candidates { for _, r := range c.Routes { - if r.Kind == crewroute.Metered { - return true + if r.Kind != crewroute.Free { + return false } + found = true } } - return false + return found } // AutomaticCrewRungs excludes free pools from automatic substitution while the diff --git a/internal/config/freeprivacy_test.go b/internal/config/freeprivacy_test.go index 9380111636..e584ce0534 100644 --- a/internal/config/freeprivacy_test.go +++ b/internal/config/freeprivacy_test.go @@ -98,3 +98,14 @@ func TestSavedAutomaticRungsHonorCurrentChoiceWithoutBlockingConnectedPlans(t *t t.Fatal(got) } } + +func TestFreeNoticeDoesNotInventFreeUseWithoutFreeRoutes(t *testing.T) { + for _, routes := range [][]crewroute.Route{nil, {{Kind: crewroute.Plan}}, {{Kind: crewroute.Local}}, {{Kind: crewroute.Plan}, {Kind: crewroute.Free}}} { + if onlyFreeRoutes([]crewroute.Candidate{{Routes: routes}}) { + t.Fatalf("claimed free use with routes %+v", routes) + } + } + if !onlyFreeRoutes([]crewroute.Candidate{{Routes: []crewroute.Route{{Kind: crewroute.Free}}}}) { + t.Fatal("free-only available routes not recognized") + } +} diff --git a/internal/session/crewprivacy_test.go b/internal/session/crewprivacy_test.go index d5c8e582c4..2b5b7349a0 100644 --- a/internal/session/crewprivacy_test.go +++ b/internal/session/crewprivacy_test.go @@ -10,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/credits" "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/roles" ) func TestPaymentFailureKeepsTaskOffFreeRoutesWithoutConsent(t *testing.T) { @@ -86,3 +87,67 @@ func TestSavedFreeFallbackIsSkippedAfterPaymentRefusalWhileOff(t *testing.T) { t.Fatalf("saved free fallback received task: %v", got) } } + +func TestSharedFreeModelKeepsEachSeatsOwnPinConsent(t *testing.T) { + for _, seat := range []crewroute.Seat{crewroute.Worker, crewroute.Checker} { + t.Run(string(seat), func(t *testing.T) { + model := "z-ai/glm-5.3-flash:free" + stub := newCrewStub(t, http.StatusPaymentRequired, map[string]int{model: http.StatusOK}) + agent, _ := crewStubAgent(t, stub) + crew := &taskCrew{decision: crewroute.Decision{Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: model, Send: model, Provider: "openrouter", Kind: crewroute.Free, Pinned: true}, + {Seat: crewroute.Checker, Model: model, Send: model, Provider: "openrouter", Kind: crewroute.Free}, + }}, original: map[crewroute.Seat]string{crewroute.Worker: model, crewroute.Checker: model}} + run := &beltRun{row: 1, crew: crew} + _, err := SeatCompleter(seat, crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private task")}, ai.WithModel(model)) + if seat == crewroute.Worker { + if err != nil || len(stub.models()) != 1 { + t.Fatalf("explicit worker pin refused by automatic checker: %v calls=%v", err, stub.models()) + } + } else { + if err == nil || len(stub.models()) != 0 { + t.Fatalf("automatic checker borrowed worker's consent: %v calls=%v", err, stub.models()) + } + } + }) + } +} + +// CrewFactory resolves a probe's low-tier model when it constructs BashWorker. +// Its cached key can outlive a settings edit, unlike v3's ordinary live helpers. +func TestAlreadyCreatedHelperHonorsOptOutUnlessItsCurrentRowExplicitlyNamesFreeModel(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(map[bool]string{false: "old automatic choice", true: "current explicit helper row"}[explicit], func(t *testing.T) { + stub := newCrewStub(t, http.StatusOK, map[string]int{"thinkingmachines/inkling-small:free": http.StatusOK}) + agent, dir := crewStubAgent(t, stub) + if err := config.SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } + if err := config.WriteCreditsReading(dir, config.APIKeyAt(dir), credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + frozen := config.TierModelAt(dir, config.ModelTierLow) + if !crewroute.IsFree(frozen) { + t.Fatal("fixture did not construct a free probe") + } + crew := &taskCrew{original: map[crewroute.Seat]string{crewroute.Worker: "z-ai/glm-5.3-flash"}} + run := &beltRun{row: 1, crew: crew} + if err := config.SetCrewFreeRoutes(dir, false); err != nil { + t.Fatal(err) + } + if explicit { + agent.config.RolesSource = func(key string) (string, bool) { return frozen, key == roles.TierKey(roles.TierLow) } + } + _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private probe task")}, ai.WithModel(frozen)) + if explicit { + if err != nil || len(stub.models()) != 1 { + t.Fatalf("explicit helper choice refused: err=%v calls=%v", err, stub.models()) + } + } else { + if err == nil || len(stub.models()) != 0 || !strings.Contains(err.Error(), "free routes are off") { + t.Fatalf("stale helper sent task: err=%v calls=%v", err, stub.models()) + } + } + }) + } +} diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 0f0182b82a..5e1d50849c 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -376,6 +376,9 @@ func (c crewSeatCompleter) CompleteWithMessages(ctx context.Context, messages [] return nil, err } if !crew.seats(key) { + if !c.agent.config.freeHelperAllowed(key) { + return nil, crewSeatEnd(freeRoutesOffAction, nil) + } // NOT A SEAT OF THIS CREW: a tier the run asks for that the crew does // not name (a probe, a small errand). It is an auxiliary call, and it // goes through the route-health guard like every other one — never @@ -393,8 +396,8 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message retried := false for { current := crew.sendFor(key) - if c.freeRouteBlocked(current) { - return nil, crewSeatEnd("free routes are off · choose a paid model or enable free routes in /crew", nil) + if c.freeRouteBlocked(ctx, current) { + return nil, crewSeatEnd(freeRoutesOffAction, nil) } if failed, ok := crew.failedHere(current); ok { // A SEND THAT FAILED ON THIS TASK IS NOT ASKED AGAIN, by this seat diff --git a/internal/session/taskcrewprivacy.go b/internal/session/taskcrewprivacy.go index fc0ed6dfe8..adb48fdae5 100644 --- a/internal/session/taskcrewprivacy.go +++ b/internal/session/taskcrewprivacy.go @@ -1,17 +1,77 @@ package session import ( + "context" + "strings" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/roles" ) -// A saved or already-routed task may predate the current free-route setting. -// Explicit pins retain consent to their named model; automatic seats do not. -func (c crewSeatCompleter) freeRouteBlocked(current string) bool { - for _, pick := range c.run.crew.current().Crew { - if pick.Send == current && !pick.Pinned && (pick.Kind == crewroute.Free || crewroute.IsFree(pick.Send)) { - return !config.CrewFreeRoutesAt(c.agent.config.ProfileDir) +const freeRoutesOffAction = "free routes are off · choose a paid model or enable free routes in /crew" + +// The run marks the actual seat on each call. A checker sharing a worker's +// model does not acquire that worker's pin, nor revoke its explicit consent. +func (c crewSeatCompleter) freeRouteBlocked(ctx context.Context, current string) bool { + if config.CrewFreeRoutesAt(c.agent.config.ProfileDir) { + return false + } + decision := c.run.crew.current() + if seat := crewSeatOf(ctx); seat != "" { + pick := decision.Seat(seat) + if pick.Send != current { + return crewroute.IsFree(current) + } + return pick.Send == current && !pick.Pinned && isFreeCrewPick(pick) + } + // Unmarked callers have no seat provenance; do not infer consent from a + // different seat. Production crew calls are marked by SeatCompleter. + for _, pick := range decision.Crew { + if pick.Send == current && !pick.Pinned && isFreeCrewPick(pick) { + return true + } + } + return false +} + +func isFreeCrewPick(pick crewroute.Pick) bool { + return pick.Kind == crewroute.Free || crewroute.IsFree(pick.Send) +} + +// Helpers have no crew seat. A stale automatic free choice is allowed only if +// a current explicit helper row or role pin still names that same route. +func (c Config) freeHelperAllowed(model string) bool { + if !crewroute.IsFree(model) || config.CrewFreeRoutesAt(c.ProfileDir) { + return true + } + if c.RolesSource != nil && c.sourceNamesFreeHelper(model) { + return true + } + for _, tier := range []string{config.ModelTierReflex, config.ModelTierLow} { + seat := config.TierSeatAt(c.ProfileDir, tier) + if seat.Source == config.SeatPinned && sameFreeModel(seat.Model, model) { + return true } } return false } + +func (c Config) sourceNamesFreeHelper(model string) bool { + keys := []string{roles.TierKey(roles.TierReflex), roles.TierKey(roles.TierLow)} + for _, role := range roles.Vocabulary() { + keys = append(keys, roles.PinKey(role)) + } + for _, key := range keys { + if named, ok := c.RolesSource(key); ok && sameFreeModel(named, model) { + return true + } + } + return false +} + +func sameFreeModel(left, right string) bool { + left, _ = roles.SplitEffort(strings.TrimSpace(left)) + right, _ = roles.SplitEffort(strings.TrimSpace(right)) + return strings.EqualFold(left, right) +} From 5b775d658863f1a86ac753bd0d1e6615dba19400 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:07:21 -0400 Subject: [PATCH 04/11] Match streaming and automatic crew selection in privacy acceptance fixtures --- internal/e2e/free_route_consent_e2e_test.go | 31 +++++++++++++++------ 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/internal/e2e/free_route_consent_e2e_test.go b/internal/e2e/free_route_consent_e2e_test.go index 958e0d682d..d6cef768f0 100644 --- a/internal/e2e/free_route_consent_e2e_test.go +++ b/internal/e2e/free_route_consent_e2e_test.go @@ -50,7 +50,8 @@ func TestLowBalanceFreeRoutesRequireConsentOnTheRealBinary(t *testing.T) { fmt.Fprint(w, `{"data":{"total_credits":1,"total_usage":0.75}}`) case "/chat/completions": var body struct { - Model string `json:"model"` + Stream bool `json:"stream"` + Model string `json:"model"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "fixture decode", 400) @@ -60,7 +61,7 @@ func TestLowBalanceFreeRoutesRequireConsentOnTheRealBinary(t *testing.T) { sent = append(sent, body.Model) mu.Unlock() - _ = json.NewEncoder(w).Encode(map[string]any{"id": "free-consent-fixture", "model": body.Model, "choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": "FREE_CONSENT_CONTROL_OK"}, "finish_reason": "stop"}}, "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) + freeConsentResponse(w, body.Model, "FREE_CONSENT_CONTROL_OK", nil, body.Stream) default: http.NotFound(w, r) } @@ -150,7 +151,11 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing if model == free { price = "0" } - rows = append(rows, map[string]any{"id": model, "canonical_slug": model, "name": model, "context_length": 1310720, "architecture": map[string]any{"input_modalities": []string{"text"}, "output_modalities": []string{"text"}, "instruct_type": "chat", "modality": "text->text"}, "pricing": map[string]string{"prompt": price, "completion": price}, "supported_parameters": []string{"tools", "tool_choice", "max_tokens"}}) + rows = append(rows, map[string]any{"id": model, "canonical_slug": model, "name": model, "hugging_face_id": "fixture/glm", "benchmarks": map[string]any{"artificial_analysis": map[string]float64{"intelligence_index": 41.8, "coding_index": 71.5, "agentic_index": 50.9}}, "context_length": 1310720, "architecture": map[string]any{"input_modalities": []string{"text"}, "output_modalities": []string{"text"}, "instruct_type": "chat", "modality": "text->text"}, "pricing": map[string]string{"prompt": price, "completion": price}, "supported_parameters": []string{"tools", "tool_choice", "max_tokens"}}) + if model == manager { + delete(rows[len(rows)-1], "benchmarks") + delete(rows[len(rows)-1], "hugging_face_id") + } } _ = json.NewEncoder(w).Encode(map[string]any{"data": rows}) case "/key", "/auth/key": @@ -159,6 +164,7 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing fmt.Fprint(w, `{"data":{"total_credits":100,"total_usage":0}}`) case "/chat/completions": var body struct { + Stream bool `json:"stream"` Model string `json:"model"` Messages []struct { Role string `json:"role"` @@ -189,12 +195,12 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing wrote = true mu.Unlock() args := `{"command":"printf FREE_TASK_FALLBACK_OK > free-consent-proof.txt"}` - freeConsentResponse(w, body.Model, "", map[string]any{"id": "free-task-write", "type": "function", "function": map[string]string{"name": "bash", "arguments": args}}) + freeConsentResponse(w, body.Model, "", map[string]any{"index": 0, "id": "free-task-write", "type": "function", "function": map[string]string{"name": "bash", "arguments": args}}, body.Stream) return } } mu.Unlock() - freeConsentResponse(w, body.Model, "The scripted task completed.", nil) + freeConsentResponse(w, body.Model, "The scripted task completed.", nil, body.Stream) default: http.NotFound(w, r) } @@ -205,7 +211,7 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing stopRecoveryEngineOnCleanup(t, env, home, ws) rig := startWithEnv(t, env, "free_402_"+name, home, ws, 170, 48, "chat", "--model", manager) rig.skipSetup(t) - rig.lit("/task solo fix: the parser crashes on empty input; write free-consent-proof.txt containing FREE_TASK_FALLBACK_OK") + rig.lit("/task fix: the parser crashes on empty input; write free-consent-proof.txt containing FREE_TASK_FALLBACK_OK") rig.keys("Enter") deadline := time.Now().Add(45 * time.Second) for { @@ -215,7 +221,7 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing if !enableOn402 && paidN > 0 && strings.Contains(strings.ToLower(rig.capture()), "credit") { break } - if enableOn402 && paidN > 0 && freeN > 0 && (freeConsentFileExists(home) || freeConsentFileExists(ws)) { + if enableOn402 && paidN > 0 && freeN > 0 && (freeConsentFileExists(home) || freeConsentFileExists(ws)) && strings.Contains(rig.capture(), say(t, "taskDoneWord")) { break } if time.Now().After(deadline) { @@ -232,13 +238,22 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing }) } } -func freeConsentResponse(w http.ResponseWriter, model, text string, call any) { +func freeConsentResponse(w http.ResponseWriter, model, text string, call any, stream bool) { message := map[string]any{"role": "assistant", "content": text} finish := "stop" if call != nil { message["tool_calls"] = []any{call} finish = "tool_calls" } + if stream { + w.Header().Set("Content-Type", "text/event-stream") + for _, choice := range []map[string]any{{"index": 0, "delta": message, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": finish}} { + raw, _ := json.Marshal(map[string]any{"id": "free-task-fixture", "model": model, "choices": []any{choice}, "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) + fmt.Fprintf(w, "data: %s\n\n", raw) + } + fmt.Fprint(w, "data: [DONE]\n\n") + return + } _ = json.NewEncoder(w).Encode(map[string]any{"id": "free-task-fixture", "model": model, "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}, "usage": map[string]int{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}) } func freeConsentFileExists(root string) bool { From 09ce3597de7b76bf84cfad2b79462db3caf8f6e3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:14:45 -0400 Subject: [PATCH 05/11] Finish scripted consent tasks through their actual plan assignments --- internal/e2e/free_route_consent_e2e_test.go | 25 ++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/e2e/free_route_consent_e2e_test.go b/internal/e2e/free_route_consent_e2e_test.go index d6cef768f0..675c72a7b2 100644 --- a/internal/e2e/free_route_consent_e2e_test.go +++ b/internal/e2e/free_route_consent_e2e_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -167,7 +168,8 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing Stream bool `json:"stream"` Model string `json:"model"` Messages []struct { - Role string `json:"role"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` } `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { @@ -191,11 +193,22 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing } if strings.HasSuffix(body.Model, ":free") { freeRequests++ - if !wrote { - wrote = true + var assignment []string + for _, message := range body.Messages { + if match := freeConsentAssignment.FindStringSubmatch(string(message.Content)); len(match) == 3 { + assignment = match + } + } + if len(assignment) == 3 { + command := "grep -qx FREE_TASK_FALLBACK_OK free-consent-proof.txt" + if !wrote { + wrote = true + command = "printf FREE_TASK_FALLBACK_OK > free-consent-proof.txt && " + command + } + command += " && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'holds: proof file has the requested contents'" + args, _ := json.Marshal(map[string]string{"command": command}) mu.Unlock() - args := `{"command":"printf FREE_TASK_FALLBACK_OK > free-consent-proof.txt"}` - freeConsentResponse(w, body.Model, "", map[string]any{"index": 0, "id": "free-task-write", "type": "function", "function": map[string]string{"name": "bash", "arguments": args}}, body.Stream) + freeConsentResponse(w, body.Model, "", map[string]any{"index": 0, "id": "free-task-finish-" + assignment[2], "type": "function", "function": map[string]string{"name": "bash", "arguments": string(args)}}, body.Stream) return } } @@ -270,3 +283,5 @@ func freeConsentFileExists(root string) bool { }) return found } + +var freeConsentAssignment = regexp.MustCompile(`YOUR TASK IN THE PLAN IS (t-[A-Za-z0-9_.-]+), claimed by agent ([A-Za-z0-9_.-]+)\.`) From 607bafb0fa084681524ad29a5dc32db4eb5a7bc7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:20:16 -0400 Subject: [PATCH 06/11] docs: keep logging searches focused on debug recording --- internal/manual/chat/models-and-cost.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index a564d6158e..f40f976fb3 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -883,7 +883,7 @@ the task stops on the one thing to do, which leads its line in place of `/redo s continue`, `reconnect openrouter with /connect`, or when the limit resets. What each route did is kept: a route that refused a model is left out for a week, one at its limit rests until its reset, an account out of credit is skipped until a paid call on it answers again. Free routes are -off unless you turn them on, including after a payment refusal or a low-balance reading. +off unless you enable them, including after a payment refusal or a low-balance reading. With free routes off, the task tries eligible connected alternatives or stops with `add credit on openrouter to continue`; your work stays saved. You can add credit, connect another provider, or explicitly enable free routes in `/crew`. When enabled, @@ -1070,9 +1070,9 @@ provider's policy and your account's settings there. codeaf's own usage counts c content (`docs/TELEMETRY.md` in the repository lists every field they carry). **Free routes may log prompts.** A provider's free pool of a model (an OpenRouter `…:free` -id) may log or train on what it is sent. The crew uses free routes only when you turn -them on — the `free routes` switch at the end of `/crew`'s providers list. A low balance, -remembered payment refusal, or live payment refusal never turns that switch on or +id) may log or train on what it is sent. The crew uses free routes only when you enable +them — the `free routes` switch at the end of `/crew`'s providers list. A low balance, +remembered payment refusal, or live payment refusal never enables that switch or sends a task to a free pool behind your back. Existing paid, subscription and local connections remain eligible. If none can serve the task, its work stays saved and its line explains how to continue: add credit or reconnect the provider. You can @@ -1080,7 +1080,7 @@ also explicitly enable free routes in `/crew` if their privacy tradeoff is accep An explicit choice of a named `:free` model — in `/model`, a helper row, or a crew pin — permits that named route even with the automatic switch off. It does not permit -other free fallback models. Turning the switch off also excludes free routes from +other free fallback models. Disabling automatic free routes also excludes them from saved automatic fallback ladders. When free routes are enabled, a seat tries at most three pools per task, within your allowed models and spend limits, and the crew line says `free routes in use (may log prompts)`. From c28025b369ff1ac5a0cc53e4e96b89a43436d93e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:20:50 -0400 Subject: [PATCH 07/11] Inspect completed background tasks through their actual disclosure --- internal/e2e/free_route_consent_e2e_test.go | 42 ++++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/e2e/free_route_consent_e2e_test.go b/internal/e2e/free_route_consent_e2e_test.go index 675c72a7b2..913a39f42e 100644 --- a/internal/e2e/free_route_consent_e2e_test.go +++ b/internal/e2e/free_route_consent_e2e_test.go @@ -227,6 +227,7 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing rig.lit("/task fix: the parser crashes on empty input; write free-consent-proof.txt containing FREE_TASK_FALLBACK_OK") rig.keys("Enter") deadline := time.Now().Add(45 * time.Second) + expanded := false for { mu.Lock() paidN, freeN := paidRequests, freeRequests @@ -234,8 +235,16 @@ func TestAutomaticTask402FreeFallbackRequiresConsentOnTheRealTerminal(t *testing if !enableOn402 && paidN > 0 && strings.Contains(strings.ToLower(rig.capture()), "credit") { break } - if enableOn402 && paidN > 0 && freeN > 0 && (freeConsentFileExists(home) || freeConsentFileExists(ws)) && strings.Contains(rig.capture(), say(t, "taskDoneWord")) { - break + if enableOn402 && paidN > 0 && freeN > 0 && (freeConsentFileExists(home) || freeConsentFileExists(ws)) && freeConsentDoneNote(home) { + screen := rig.capture() + if !expanded && strings.Contains(screen, "background work") && strings.Contains(screen, "ctrl+e") { + rig.keys("C-e") + expanded = true + continue + } + if strings.Contains(screen, say(t, "taskDoneWord")) { + break + } } if time.Now().After(deadline) { t.Fatalf("automatic402 acceptance incomplete: on=%t paid=%d free=%d screen=%s", enableOn402, paidN, freeN, rig.capture()) @@ -285,3 +294,32 @@ func freeConsentFileExists(root string) bool { } var freeConsentAssignment = regexp.MustCompile(`YOUR TASK IN THE PLAN IS (t-[A-Za-z0-9_.-]+), claimed by agent ([A-Za-z0-9_.-]+)\.`) + +func freeConsentDoneNote(root string) bool { + found := false + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() || entry.Name() != "transcript.jsonl" { + return nil + } + raw, _ := os.ReadFile(path) + for _, line := range strings.Split(string(raw), "\n") { + var row struct { + Note bool `json:"note"` + ReplyTags []struct { + State string `json:"state"` + } `json:"replyTags"` + } + if json.Unmarshal([]byte(line), &row) != nil || !row.Note { + continue + } + for _, tag := range row.ReplyTags { + if tag.State == "done" { + found = true + return filepath.SkipAll + } + } + } + return nil + }) + return found +} From ad28163750281cb0e06d0a9453553e36b6e14f74 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:23:08 -0400 Subject: [PATCH 08/11] Require current explicit settings for free helper consent --- internal/session/crewprivacy_test.go | 84 +++++++++++++++++++++++++++- internal/session/taskcrewprivacy.go | 41 +++++++++----- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/internal/session/crewprivacy_test.go b/internal/session/crewprivacy_test.go index 2b5b7349a0..603805f4c9 100644 --- a/internal/session/crewprivacy_test.go +++ b/internal/session/crewprivacy_test.go @@ -1,8 +1,11 @@ package session import ( + "encoding/json" "errors" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -135,8 +138,11 @@ func TestAlreadyCreatedHelperHonorsOptOutUnlessItsCurrentRowExplicitlyNamesFreeM if err := config.SetCrewFreeRoutes(dir, false); err != nil { t.Fatal(err) } + // The live resolver can retain an old automatic value; it is never + // evidence that the person explicitly selected this free route. + agent.config.RolesSource = func(key string) (string, bool) { return frozen, key == roles.TierKey(roles.TierLow) } if explicit { - agent.config.RolesSource = func(key string) (string, bool) { return frozen, key == roles.TierKey(roles.TierLow) } + writeFreeConsentProfileRows(t, dir, map[string]any{config.KeyTierLowModel: frozen}) } _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private probe task")}, ai.WithModel(frozen)) if explicit { @@ -151,3 +157,79 @@ func TestAlreadyCreatedHelperHonorsOptOutUnlessItsCurrentRowExplicitlyNamesFreeM }) } } + +func TestFrozenHelperDoesNotTreatFailedSettingsRebuildAsConsent(t *testing.T) { + const model = "thinkingmachines/inkling-small:free" + for _, current := range []string{"invalid role row", "invalid project JSON", "current explicit helper", "current explicit project helper", "current explicit role pin", "current explicit profile role pin"} { + t.Run(current, func(t *testing.T) { + stub := newCrewStub(t, http.StatusOK, map[string]int{model: http.StatusOK}) + agent, dir := crewStubAgent(t, stub) + // This is the last good v3Crew snapshot, retained when the current + // settings fail to parse. It was an automatic free choice while on. + agent.config.RolesSource = func(key string) (string, bool) { return model, key == roles.TierKey(roles.TierLow) } + if err := config.SetCrewFreeRoutes(dir, false); err != nil { + t.Fatal(err) + } + projectDir := filepath.Join(agent.config.Workspace, config.ProjectConfigDir) + if err := os.MkdirAll(projectDir, 0o700); err != nil { + t.Fatal(err) + } + project := map[string]any{} + switch current { + case "invalid role row": + project[config.KeyModelRoles] = "not-a-pair" + case "current explicit helper": + writeFreeConsentProfileRows(t, dir, map[string]any{config.KeyTierLowModel: model}) + case "current explicit project helper": + project[config.KeyTierLowModel] = model + case "current explicit role pin": + project[config.KeyModelRoles] = "title:" + model + case "current explicit profile role pin": + writeFreeConsentProfileRows(t, dir, map[string]any{config.KeyModelRoles: "title:" + model}) + } + raw, err := json.Marshal(project) + if err != nil { + t.Fatal(err) + } + if current == "invalid project JSON" { + raw = []byte(`{"model.roles":`) + } + if err := os.WriteFile(filepath.Join(projectDir, config.ProjectConfigFile), raw, 0o600); err != nil { + t.Fatal(err) + } + crew := &taskCrew{original: map[crewroute.Seat]string{crewroute.Worker: "z-ai/glm-5.3-flash"}} + _, err = (crewSeatCompleter{agent: agent, run: &beltRun{row: 1, crew: crew}}).CompleteWithMessages(t.Context(), []ai.Message{textMessage("user", "private frozen helper fixture")}, ai.WithModel(model)) + explicit := strings.HasPrefix(current, "current explicit") + if explicit { + if err != nil || len(stub.models()) != 1 { + t.Fatalf("current explicit consent refused: err=%v calls=%v", err, stub.models()) + } + } else if err == nil || len(stub.models()) != 0 || !strings.Contains(err.Error(), "free routes are off") { + t.Fatalf("stale automatic source granted consent: err=%v calls=%v", err, stub.models()) + } + }) + } +} + +func writeFreeConsentProfileRows(t *testing.T, dir string, rows map[string]any) { + t.Helper() + path := filepath.Join(dir, "config.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var profile map[string]any + if err := json.Unmarshal(raw, &profile); err != nil { + t.Fatal(err) + } + for key, value := range rows { + profile[key] = value + } + raw, err = json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/taskcrewprivacy.go b/internal/session/taskcrewprivacy.go index adb48fdae5..22a896a68b 100644 --- a/internal/session/taskcrewprivacy.go +++ b/internal/session/taskcrewprivacy.go @@ -45,25 +45,38 @@ func (c Config) freeHelperAllowed(model string) bool { if !crewroute.IsFree(model) || config.CrewFreeRoutesAt(c.ProfileDir) { return true } - if c.RolesSource != nil && c.sourceNamesFreeHelper(model) { - return true + // RolesSource may retain a previous automatic choice when a later settings + // parse fails. A resolved model is not proof of current explicit consent. + project, err := config.LoadProjectConfig(c.Workspace) + if err != nil { + return false } - for _, tier := range []string{config.ModelTierReflex, config.ModelTierLow} { - seat := config.TierSeatAt(c.ProfileDir, tier) - if seat.Source == config.SeatPinned && sameFreeModel(seat.Model, model) { + rawPins, err := project.ResolveString(c.ProfileDir, config.KeyModelRoles) + if err != nil { + return false + } + pins, err := config.ParseModelRoles(rawPins) + if err != nil { + return false + } + projectLow, hasProjectLow, err := project.String(config.KeyTierLowModel) + if err != nil { + return false + } + for _, named := range pins { + if sameFreeModel(named, model) { return true } } - return false -} - -func (c Config) sourceNamesFreeHelper(model string) bool { - keys := []string{roles.TierKey(roles.TierReflex), roles.TierKey(roles.TierLow)} - for _, role := range roles.Vocabulary() { - keys = append(keys, roles.PinKey(role)) + if hasProjectLow && sameFreeModel(projectLow, model) { + return true } - for _, key := range keys { - if named, ok := c.RolesSource(key); ok && sameFreeModel(named, model) { + for _, tier := range []string{config.ModelTierReflex, config.ModelTierLow} { + if tier == config.ModelTierLow && hasProjectLow { + continue + } + seat := config.TierSeatAt(c.ProfileDir, tier) + if seat.Source == config.SeatPinned && sameFreeModel(seat.Model, model) { return true } } From d199a55c0247157f9d27ebcc8afa5c296d8ec8bc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:25:41 -0400 Subject: [PATCH 09/11] Separate explicit helper consent reads from profile seat checks --- internal/session/taskcrewprivacy.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/session/taskcrewprivacy.go b/internal/session/taskcrewprivacy.go index 22a896a68b..4916e922bd 100644 --- a/internal/session/taskcrewprivacy.go +++ b/internal/session/taskcrewprivacy.go @@ -71,6 +71,12 @@ func (c Config) freeHelperAllowed(model string) bool { if hasProjectLow && sameFreeModel(projectLow, model) { return true } + return c.profileHelperConsent(model, hasProjectLow) +} + +// An explicit project low row replaces the profile row, including when empty. +// Reflex remains profile-owned; only pinned rows grant consent while off. +func (c Config) profileHelperConsent(model string, hasProjectLow bool) bool { for _, tier := range []string{config.ModelTierReflex, config.ModelTierLow} { if tier == config.ModelTierLow && hasProjectLow { continue From 668532a9a91f16c17421cd489628c050e7217850 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:38:39 -0400 Subject: [PATCH 10/11] test: make cross-process credit refresh respect free-route consent --- cmd/codeaf/chatv3_crew_test.go | 106 +++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/cmd/codeaf/chatv3_crew_test.go b/cmd/codeaf/chatv3_crew_test.go index adf8b5493f..ee7016180a 100644 --- a/cmd/codeaf/chatv3_crew_test.go +++ b/cmd/codeaf/chatv3_crew_test.go @@ -176,45 +176,73 @@ func TestTheCrewSourceIsSafeUnderConcurrentTurns(t *testing.T) { wait.Wait() } -// C1 on the ordinary launch road (#1439): the surface reads the balance and -// writes the record, and the ENGINE process seats the crew. A record written by -// another process never moves this process's settings generation, so the crew -// has to notice the record itself — or the engine keeps seating the paid table -// its snapshot was built on, and the reflex call on a $0 account is refused. +// The surface writes credit records while the engine seats the crew. External +// writes do not move this process's settings generation. The live source must +// notice the threshold record, but only explicit free-route opt-in may change +// its model; low means <= $0.50, not proof that the account is empty. func TestACreditRecordWrittenByAnotherProcessReseatsTheLiveCrew(t *testing.T) { - dir := t.TempDir() - t.Setenv(config.APIKeyEnv, "sk-or-v1-crewtest") - source, err := v3RolesSource(t.TempDir(), dir) - if err != nil { - t.Fatalf("v3RolesSource: %v", err) - } - reflex := roles.TierKey(roles.TierReflex) - paid, _ := source(reflex) - if paid != config.DefaultReflexModel { - t.Fatalf("a profile with no record seated reflex on %q, want the shipped %q", paid, config.DefaultReflexModel) - } - record, err := json.Marshal(map[string]any{ - "low": true, "known": true, "key": config.CreditsKeyPrint("sk-or-v1-crewtest"), "read_at": "2026-09-24T21:44:22Z", - }) - if err != nil { - t.Fatal(err) - } - // Written the way ANOTHER process writes it: straight to the file, with no - // call into this process's settings writer. - if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil { - t.Fatal(err) - } - free, _ := source(reflex) - if free == paid || free != config.TierModelAt(dir, config.ModelTierReflex) || !strings.HasSuffix(free, ":free") { - t.Fatalf("after another process recorded a low balance the live crew seated reflex on %q", free) - } - record, _ = json.Marshal(map[string]any{ - "low": false, "known": true, "key": config.CreditsKeyPrint("sk-or-v1-crewtest"), "read_at": "2026-09-24T21:50:00Z", - }) - if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil { - t.Fatal(err) - } - if back, _ := source(reflex); back != paid { - t.Fatalf("after a top-up recorded elsewhere the live crew kept reflex on %q", back) + for _, freeAllowed := range []bool{false, true} { + name := "free routes off keeps paid helpers" + if freeAllowed { + name = "explicit opt-in follows external balance" + } + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + const key = "sk-or-v1-crewtest" + t.Setenv(config.APIKeyEnv, key) + if freeAllowed { + if err := config.SetCrewFreeRoutes(dir, true); err != nil { + t.Fatal(err) + } + } + source, err := v3RolesSource(t.TempDir(), dir) + if err != nil { + t.Fatalf("v3RolesSource: %v", err) + } + reflex := roles.TierKey(roles.TierReflex) + paid, _ := source(reflex) + if paid != config.DefaultReflexModel { + t.Fatalf("a profile with no record seated reflex on %q, want %q", paid, config.DefaultReflexModel) + } + generation := config.SettingsGeneration() + writeExternalRecord := func(low bool, readAt string) { + t.Helper() + record, err := json.Marshal(map[string]any{"low": low, "known": true, "key": config.CreditsKeyPrint(key), "read_at": readAt}) + if err != nil { + t.Fatal(err) + } + // Use the external writer's file path, never this process's settings API. + if err := os.WriteFile(filepath.Join(dir, "credits.json"), record, 0o600); err != nil { + t.Fatal(err) + } + if config.SettingsGeneration() != generation { + t.Fatal("external credit record changed this process's settings generation") + } + } + writeExternalRecord(true, "2026-09-24T21:44:22Z") + if !config.CreditsLowAt(dir) { + t.Fatal("external low threshold record was not visible") + } + current, _ := source(reflex) + if freeAllowed { + if current == paid || current != config.TierModelAt(dir, config.ModelTierReflex) || !strings.HasSuffix(current, ":free") { + t.Fatalf("opted-in live crew ignored external low reading: %q", current) + } + } else { + if current != paid { + t.Fatalf("a low threshold record silently enabled free reflex: %q", current) + } + if low, _ := source(roles.TierKey(roles.TierLow)); strings.HasSuffix(low, ":free") { + t.Fatalf("a low threshold record silently enabled free small-work: %q", low) + } + } + writeExternalRecord(false, "2026-09-24T21:50:00Z") + if back, _ := source(reflex); back != paid { + t.Fatalf("after a top-up recorded elsewhere the live crew kept reflex on %q", back) + } + if config.CrewFreeRoutesAt(dir) != freeAllowed { + t.Fatal("external balance read changed the person's free-route choice") + } + }) } } From 16903b4d02306ea4cc198bdc304d82ad3ab9a40e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 21:43:19 -0400 Subject: [PATCH 11/11] docs: record free-route consent during recovery --- .../changes/unreleased/1905-free-route-consent.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/changes/unreleased/1905-free-route-consent.md diff --git a/docs/changes/unreleased/1905-free-route-consent.md b/docs/changes/unreleased/1905-free-route-consent.md new file mode 100644 index 0000000000..fe05140e48 --- /dev/null +++ b/docs/changes/unreleased/1905-free-route-consent.md @@ -0,0 +1,15 @@ +--- +kind: fixed +title: Automatic credit recovery honors free-route consent +pr: 1905 +surface: [chat, engine] +invalidates: + - "Low credit and payment refusals previously allowed automatic free routes even with the switch off. Every automatic default, recovery ladder and helper now honors opt-out." + - "A low-balance record previously blocked paid routes before an actual payment refusal. With free routes off, a small positive paid balance remains usable." + - "Resolved helper models previously counted as explicit consent. Helpers now require current explicit settings, and malformed settings fail closed." +--- + +A current explicit choice of a named free model permits that model without +enabling other automatic free fallbacks. When no allowed route remains, work +is preserved and the user is guided to add credit, connect a provider, or +explicitly enable free routes. Saved preferences and pins are not rewritten.