From 0e8ab8713479bf63f367e9c1bc500d0b3b0a23e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 21:11:49 -0500 Subject: [PATCH 1/3] Enforce the local-only rule where each auxiliary call is made (#380) Each caller checked thread.localTarget itself before skipping Jev, credit fallback, naming or a routine turn, so a new caller could leak a local thread. localOnly(thread) in run-policy.ts is now enforced at the chokepoints too: Decisions.connect gives a question naming a local thread no connection before any key is resolved; creditFallback and recentCreditFallback return nothing; nameThread asks no title model; sendUnattended refuses rather than reroutes. The callers' checks stay. One regression calls each chokepoint directly with cloud controls; disabling each of the five guards in the compiled output made it fail. Docs: local-models.md, status.md and a dated review record. Co-Authored-By: Claude Opus 5.5 --- .../main/engine/local-models/run-policy.ts | 9 +++ .../src/main/engine/routing/decisions.ts | 8 +++ apps/desktop/src/main/engine/runner.ts | 11 +++- apps/desktop/test/runner-local-models.test.ts | 43 ++++++++++++++ docs/local-models.md | 18 ++++++ .../2026-10-30-lm5-local-only-chokepoints.md | 58 +++++++++++++++++++ docs/status.md | 4 +- 7 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 docs/reviews/2026-10-30-lm5-local-only-chokepoints.md diff --git a/apps/desktop/src/main/engine/local-models/run-policy.ts b/apps/desktop/src/main/engine/local-models/run-policy.ts index 1613d087..922f70ff 100644 --- a/apps/desktop/src/main/engine/local-models/run-policy.ts +++ b/apps/desktop/src/main/engine/local-models/run-policy.ts @@ -16,6 +16,15 @@ * server could forward it; see docs/reviews/2026-10-27-lm5-destination-policy.md. */ +/** + * Whether a thread is local-only (#380): it carries a local target, valid or not. Main keeps every + * auxiliary model call and cloud fallback off for such a thread at the point that would make it (Jev, + * credit fallback, naming, unattended runs), not only at the caller, so a new caller cannot leak it. + */ +export function localOnly(thread: { localTarget?: unknown } | undefined): boolean { + return thread !== undefined && thread.localTarget !== undefined; +} + export interface ConfigLayerSource { type: string; file?: string; domain?: string; key?: string; id?: string; name?: string; dotCodexFolder?: string; profile?: string | null } export interface ConfigLayer { name: ConfigLayerSource; version: string; config: unknown; disabledReason?: string | null } export interface ConfigRead { config: Record; origins: Record; layers?: ConfigLayer[] | null } diff --git a/apps/desktop/src/main/engine/routing/decisions.ts b/apps/desktop/src/main/engine/routing/decisions.ts index 7078d77e..670558b3 100644 --- a/apps/desktop/src/main/engine/routing/decisions.ts +++ b/apps/desktop/src/main/engine/routing/decisions.ts @@ -128,12 +128,19 @@ export class Decisions { private jevDisabled: string | null = null; /** The last explicit Settings test. Status reads this without making a provider request. */ private lastTest: RoutingStatus["lastTest"]; + /** Main's local-only rule (#380): threads no question may be asked about. */ + private localOnly: (threadId: string) => boolean = () => false; constructor(private readonly o: DecisionsOptions) { this.secrets = o.secrets ?? new SecretStore(o.home, noCipher); this.receipts = new ReceiptLog(path.join(o.home, "app", "decisions.jsonl")); } + /** A question about a thread this names gets no connection: no key is resolved and nothing is sent. */ + guardThreads(localOnly: (threadId: string) => boolean): void { + this.localOnly = localOnly; + } + /** Forget the resolved key/transport and any session-level rejection (after a key or policy change). */ reset(): void { this.setupGeneration++; @@ -184,6 +191,7 @@ export class Decisions { async connect(set: QuestionSet, context: { threadId?: string; preview?: boolean } = {}): Promise { const generation = this.setupGeneration; const model = this.model(); + if (context.threadId && this.localOnly(context.threadId)) return { transport: null, model, unavailable: "Jev is never asked about a local thread." }; const s = await this.setup(); if (this.jevDisabled) return { transport: null, model, disabled: this.jevDisabled }; if (!s.transport) return { transport: null, model, unavailable: (s.unavailableReason ?? s.key.problem ?? "No Jev transport available") }; diff --git a/apps/desktop/src/main/engine/runner.ts b/apps/desktop/src/main/engine/runner.ts index 3afba15a..ee1b4d02 100644 --- a/apps/desktop/src/main/engine/runner.ts +++ b/apps/desktop/src/main/engine/runner.ts @@ -1,5 +1,6 @@ import { resolveCli } from "./cli-path.js"; import type { LocalExecution } from "./local-models/execution.js"; +import { localOnly } from "./local-models/run-policy.js"; import { RestartAdmission } from "./restart-admission.js"; import fs from "node:fs"; import path from "node:path"; @@ -156,6 +157,8 @@ export class ThreadRunner { mock: o.backends?.mock ?? new MockBackend(() => s().mock_script, o.home), }; this.router = o.router ?? new Router({ home: o.home, policy: () => s().routing, listModels: (b) => this.listModels(b), secrets: o.secrets }); + // Jev is never asked about a local thread, whichever caller asks (#380). + this.router.decisions.guardThreads(id => localOnly(o.store.thread(id))); } backend(id: BackendId): Backend { @@ -329,6 +332,8 @@ export class ThreadRunner { async sendUnattended(threadId: string, text: string): Promise<{ completed: boolean }> { const thread = this.o.store.thread(threadId); if (!thread || thread.mode !== "chat") throw new Error("An unattended turn must run in Chat, which is read-only."); + // Nobody is there to see a local failure or stop a local child: unattended local work stays unavailable, never rerouted. + if (localOnly(thread)) throw new Error("A routine can't run on a local model. Local turns need someone watching."); this.unattended.add(threadId); try { await this.send(threadId, text); @@ -965,6 +970,8 @@ export class ThreadRunner { private nameThread(threadId: string, text: string, fallback: string, backend: Backend, model: string, reply?: string): void { const current = this.o.store.thread(threadId); + // Naming is a separate model conversation: a local thread keeps its opening-message title. + if (localOnly(current)) return; if (!backend.generateTitle || this.disposing || this.deleting.has(threadId) || current?.title !== fallback || current.titleSource === "manual") return; const abort = new AbortController(); this.naming.set(threadId, abort); @@ -1215,6 +1222,8 @@ export class ThreadRunner { * and a second run could repeat it, so that turn keeps its failure card. */ private creditFallback(thread: Thread, error: string | undefined, turnItems: ThreadItem[]): string | undefined { + // A local turn never moves to a cloud model. + if (localOnly(thread)) return undefined; if (!this.o.store.settings.model_fallback || !error || !isModelOutOfCredits(error)) return undefined; if (turnItems.some((item) => item.kind === "assistant" || item.kind === "tool" || item.kind === "thinking")) return undefined; const to = creditFallbackModel(thread.backend, thread.model); @@ -1223,7 +1232,7 @@ export class ThreadRunner { /** The fallback for a model that was refused for usage credits within CREDIT_SKIP_MS, or undefined. */ private recentCreditFallback(thread: Thread): string | undefined { - if (!this.o.store.settings.model_fallback) return undefined; + if (localOnly(thread) || !this.o.store.settings.model_fallback) return undefined; const key = `${thread.backend}:${thread.model}`; const at = this.outOfCredits.get(key); if (at === undefined) return undefined; diff --git a/apps/desktop/test/runner-local-models.test.ts b/apps/desktop/test/runner-local-models.test.ts index 6b84e4fe..282db831 100644 --- a/apps/desktop/test/runner-local-models.test.ts +++ b/apps/desktop/test/runner-local-models.test.ts @@ -92,6 +92,49 @@ test("a failed local turn stays failed: no credit fallback, no cloud retry", asy } finally { await h.runner.dispose(); } }); +test("main's local-only rule holds where each auxiliary call is made, whichever caller reaches it", async () => { + const h = await setup(); + try { + h.select(); + const cloudThread = await h.runner.createThread(h.thread.projectId, { backend: "codex", model: "gpt-6-astra", auto: true }); + const decisions = h.runner.router.decisions; + // Control: a cloud thread's question gets the injected Jev transport. + assert.ok((await decisions.connect("routing", { threadId: cloudThread.id })).transport); + for (const set of ["routing", "follow_up", "approval"] as const) { + const connection = await decisions.connect(set, { threadId: h.thread.id }); + assert.equal(connection.transport, null, set); + assert.match((connection as { unavailable?: string }).unavailable ?? "", /never asked about a local thread/); + } + // The router itself, called directly rather than through the runner's own guard, sends nothing to Jev. + const local = h.store.thread(h.thread.id)!; + await h.runner.router.route({ thread: local, text: "read", items: [], project: { name: "p", branch: null } }); + await h.runner.router.followUp({ thread: local, text: "read", items: [], project: { name: "p" } }); + assert.equal(h.jevRequests.length, 0); + + // Credit fallback: a cloud Fable thread falls back to Opus; the same thread carrying a local target never does. + const internals = h.runner as unknown as { creditFallback(t: unknown, e: string, i: unknown[]): string | undefined; recentCreditFallback(t: unknown): string | undefined; + outOfCredits: Map; nameThread(id: string, text: string, fallback: string, backend: unknown, model: string): void; titleRuns: Set> }; + const fable = { ...local, backend: "claude", model: "fable", localTarget: undefined }; + assert.equal(internals.creditFallback(fable, "This model requires usage credits", []), "opus"); + assert.equal(internals.creditFallback({ ...fable, localTarget: h.target }, "This model requires usage credits", []), undefined); + internals.outOfCredits.set("claude:fable", Date.now()); + assert.equal(internals.recentCreditFallback(fable), "opus"); + assert.equal(internals.recentCreditFallback({ ...fable, localTarget: h.target }), undefined); + + // Naming: a backend that can name is still never asked to name a local thread. + const titled = h.store.thread(h.thread.id)!; + internals.nameThread(h.thread.id, "read", titled.title, { generateTitle: async () => { h.counts.titles++; return "model title"; } }, MODEL); + await Promise.all([...internals.titleRuns]); + assert.equal(h.counts.titles, 0); + assert.equal(h.store.thread(h.thread.id)!.title, titled.title); + + // A routine's unattended turn never runs on a local target, and is not rerouted to a cloud one. + h.runner.updateThread(h.thread.id, { mode: "chat" }); + await assert.rejects(h.runner.sendUnattended(h.thread.id, "read"), /routine can't run on a local model/); + assert.deepEqual({ cloud: h.counts.cloud, local: h.counts.local, jev: h.jevRequests.length }, { cloud: 0, local: 0, jev: 0 }); + } finally { await h.runner.dispose(); } +}); + test("a forged, stale or unloadable persisted target never reaches a cloud backend", async () => { const h = await setup(); try { diff --git a/docs/local-models.md b/docs/local-models.md index f2bdc0ac..d1d6bbb0 100644 --- a/docs/local-models.md +++ b/docs/local-models.md @@ -129,6 +129,24 @@ redirect. No Codex setting constrains this. A loopback server that redirects can wherever it points, as it could by forwarding. Evidence and the receipts are in the [LM-5 slice 1 review](reviews/2026-10-27-lm5-destination-policy.md). +## LM-5 slice 2: the local-only rule at each auxiliary chokepoint + +Status: on `main`, no UI, local execution still unadmitted (#380). Before this slice each caller checked +`thread.localTarget` itself: the runner skipped Auto, follow-ups, naming and credit fallback, and the approval +gate skipped Jev. A new caller could forget. Now `localOnly(thread)` in `engine/local-models/run-policy.ts` +(true for any persisted local target, valid or not) is also enforced where each call is made: + +- **Jev.** `Decisions.connect` returns no connection for a question naming a local thread, before any key is + resolved or CLI probed, so routing, follow-up and approval questions all stop there. Settings' Try it names + no thread and is unaffected. +- **Credit fallback.** Both the after-failure fallback and the recent-refusal skip return nothing for a local + thread, whatever its backend and model claim. +- **Naming.** A local thread is never sent to a title model, even through a backend that can name. +- **Unattended turns.** `sendUnattended`, the routine path, refuses a local thread: nobody is there to see a + local failure or stop its child, and the turn is not rerouted to a cloud target. + +The callers' own checks stay in place. Evidence: [LM-5 local-only chokepoints](reviews/2026-10-30-lm5-local-only-chokepoints.md). + ## Accepted boundary and follow-up runtime availability Val accepted narrow Gate A and separately approved the AGENTS metadata boundary in this task on 2026-10-24. The [acceptance and runtime-availability receipt](reviews/2026-10-24-lm1-acceptance.md) records the exact producer SHA, approval scope and authorized installed-model inventory. The accepted producer and Strata evidence may be consumed by LM-2/LM-3; acceptance does not qualify Plan, fresh-process resume, other runtimes, offline operation or UI. diff --git a/docs/reviews/2026-10-30-lm5-local-only-chokepoints.md b/docs/reviews/2026-10-30-lm5-local-only-chokepoints.md new file mode 100644 index 00000000..aa8a2b98 --- /dev/null +++ b/docs/reviews/2026-10-30-lm5-local-only-chokepoints.md @@ -0,0 +1,58 @@ +# LM-5 local-only chokepoints (#380), 2026-10-30 + +Issue #380 remains open. This slice moves the no-cloud-fallback rule for local threads from each caller to the +point where each auxiliary call is made. It is cut from `main` at `f51ef7e`. It does not admit product local +execution: `main` still constructs no `LocalExecution`. It also qualifies no native runtime as offline. + +## What changed + +- **Predicate.** `localOnly(thread)` in `engine/local-models/run-policy.ts` is true for any persisted local + target, valid or not. +- **Jev.** `Decisions.guardThreads` lets the runner register that predicate. `Decisions.connect` then returns + `{ transport: null }` for any question naming a local thread, and returns before key resolution or CLI + detection. This covers routing, follow-up and approval. +- **Credit fallback.** `creditFallback` and `recentCreditFallback` return nothing for a local thread. +- **Naming.** `nameThread` returns before any title model is asked. +- **Unattended turns.** `sendUnattended` refuses a local thread with "A routine can't run on a local model". + It neither runs nor reroutes the turn. + +The callers' existing guards are unchanged. + +## Verification + +New regression: `runner-local-models.test.ts`, "main's local-only rule holds where each auxiliary call is made, +whichever caller reaches it". It calls each chokepoint directly, bypassing the runner's caller checks: + +| Chokepoint | Control (cloud) | Local thread | +| --- | --- | --- | +| `Decisions.connect` (`routing`) | transport returned | no transport, 0 Jev requests | +| `Decisions.connect` (`follow_up`, `approval`) | not run | no transport, 0 Jev requests | +| `Router.route` / `Router.followUp` called directly | not run | 0 Jev requests | +| `creditFallback` (Fable, "requires usage credits") | `opus` | none | +| `recentCreditFallback` (Fable refused within the hour) | `opus` | none | +| `nameThread` with a backend that can name | not run | 0 title calls, title unchanged | +| `sendUnattended` in Chat | not run | rejected; 0 local, 0 cloud, 0 Jev | + +**Negative controls.** Each guard was disabled in turn in the compiled `dist` output, and the regression failed +each time: + +- Decisions guard: `AssertionError: routing`. +- `creditFallback`: strict-equal failure. +- `recentCreditFallback`: strict-equal failure. +- Naming: strict-equal failure. +- Unattended: `Missing expected rejection`. + +The original files were then restored, verified byte for byte with `cmp`, and the case passed again. + +**Results:** + +- Focused suites (`runner-local-models`, `local-run-policy`, `routing`): 56/56. +- Full-check results are recorded in the PR. + +## Still open on #380 + +- the native network-denied offline canary (AC2), which needs Val's authorization and an installed local coding + model; +- runtime effective-context evidence (AC6); +- OOM, overflow and restart behaviour against a real runtime (AC4); +- product admission of `LocalExecution`, which is Val's decision. diff --git a/docs/status.md b/docs/status.md index 57be5545..1b3028b5 100644 --- a/docs/status.md +++ b/docs/status.md @@ -248,7 +248,9 @@ Desktop CI memory diagnostics landed through #458 after #456’s cold transcript - **Notes history identity repair.** Repeated history toggles could duplicate revision rows or leave history visible after closing because the history and editor siblings shared a React key. The isolated `notes-history-identity` repair gives them distinct identities; three regression repetitions failed before the fix and passed afterward. Independent review found no blockers. Build and full unit/E2E checks passed (2,191 unit passed, 23 skipped; 470 desktop passed, seven skipped; seven site passed). Protected delivery landed in #429 (`7a01df2`); its successful merge-queue run passed 473 desktop tests with four skips and seven site tests. See [repair evidence](reviews/2026-10-28-notes-history-identity.md). -- **LM-5 effective subagent admission.** A reproduced policy gap accepted enabled native subagent flags despite disabled launch settings. The repair requires both effective values to be false; ten regressions failed before the fix, and 26 focused policy/execution tests now pass, including refusal before a native thread opens. A configuration-only CLI 0.166.0 probe confirms the explicit false report shape. Build and full unit validation passed (2,254 passes, 26 skips); independent review found no blocking findings. Protected delivery remains pending; native qualification stays separate. [Evidence](reviews/2026-10-30-lm5-subagent-policy.md). +- **LM-5 effective subagent admission.** A reproduced policy gap accepted enabled native subagent flags despite disabled launch settings. The repair requires both effective values to be false; ten regressions failed before the fix, and 26 focused policy/execution tests now pass, including refusal before a native thread opens. A configuration-only CLI 0.166.0 probe confirms the explicit false report shape. Build and full unit validation passed (2,254 passes, 26 skips); independent review found no blocking findings. Protected delivery landed through #448; native qualification stays separate. [Evidence](reviews/2026-10-30-lm5-subagent-policy.md). + +- **LM-5 local-only rule at each auxiliary chokepoint (#380).** `localOnly(thread)` is now enforced where each auxiliary call is made, not only by its callers. A Jev question naming a local thread gets no connection before any key is resolved; credit fallback, thread naming and unattended (routine) turns refuse a local thread, and the routine turn is not rerouted. One regression calls each chokepoint directly, with cloud controls; disabling each of the five guards made it fail. Product admission of `LocalExecution`, the native offline canary, runtime context evidence and real-runtime failure handling stay open. [Evidence](reviews/2026-10-30-lm5-local-only-chokepoints.md). - **LM-2 credential repair, included in v0.4.0.** PR #418 delivered the credential-copy and stale-discovery fixes from closed #401, together with isolation that prevents fake child PIDs from reaching OS signals, at `6c9000f`. Build, full unit tests, typechecks and the Electron IPC journey are recorded in the [reconciliation evidence](reviews/2026-10-26-lm2-credential-repair.md). This source delivery is separate from local-runtime qualification and product admission. From ea202627d71beaccc7e6a2e4eb048d8bbaf656c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 21:32:45 -0500 Subject: [PATCH 2/3] test: give the stub router a decisions guard seam (#380) Co-Authored-By: Claude Opus 5.5 --- apps/desktop/test/runner-routing-ceiling.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/test/runner-routing-ceiling.test.ts b/apps/desktop/test/runner-routing-ceiling.test.ts index daffabaf..b8b7d6f2 100644 --- a/apps/desktop/test/runner-routing-ceiling.test.ts +++ b/apps/desktop/test/runner-routing-ceiling.test.ts @@ -113,6 +113,7 @@ test("a blocked router decision cannot switch backends or discard a saved CLI se runTurn: async () => { runs++; return { status: "completed" }; }, }; const router = { + decisions: { guardThreads: () => {} }, route: async () => ({ item: { id: "blocked-route", kind: "route", backend: "claude", model: "opus", effort: "high", fast: false, From 6fbec39d7b8cbed71f493961bf183e6d20e22639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Val=20Alexander=20=F0=9F=91=91?= Date: Fri, 30 Oct 2026 21:33:12 -0500 Subject: [PATCH 3/3] docs: name #461 in the LM-5 status entry (#380) Co-Authored-By: Claude Opus 5.5 --- docs/status.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/status.md b/docs/status.md index 1b3028b5..db9ece3d 100644 --- a/docs/status.md +++ b/docs/status.md @@ -250,7 +250,7 @@ Desktop CI memory diagnostics landed through #458 after #456’s cold transcript - **LM-5 effective subagent admission.** A reproduced policy gap accepted enabled native subagent flags despite disabled launch settings. The repair requires both effective values to be false; ten regressions failed before the fix, and 26 focused policy/execution tests now pass, including refusal before a native thread opens. A configuration-only CLI 0.166.0 probe confirms the explicit false report shape. Build and full unit validation passed (2,254 passes, 26 skips); independent review found no blocking findings. Protected delivery landed through #448; native qualification stays separate. [Evidence](reviews/2026-10-30-lm5-subagent-policy.md). -- **LM-5 local-only rule at each auxiliary chokepoint (#380).** `localOnly(thread)` is now enforced where each auxiliary call is made, not only by its callers. A Jev question naming a local thread gets no connection before any key is resolved; credit fallback, thread naming and unattended (routine) turns refuse a local thread, and the routine turn is not rerouted. One regression calls each chokepoint directly, with cloud controls; disabling each of the five guards made it fail. Product admission of `LocalExecution`, the native offline canary, runtime context evidence and real-runtime failure handling stay open. [Evidence](reviews/2026-10-30-lm5-local-only-chokepoints.md). +- **LM-5 local-only rule at each auxiliary chokepoint (#380).** `localOnly(thread)` is now enforced where each auxiliary call is made, not only by its callers. A Jev question naming a local thread gets no connection before any key is resolved; credit fallback, thread naming and unattended (routine) turns refuse a local thread, and the routine turn is not rerouted. One regression calls each chokepoint directly, with cloud controls; disabling each of the five guards made it fail. Product admission of `LocalExecution`, the native offline canary, runtime context evidence and real-runtime failure handling stay open. Delivered through #461. [Evidence](reviews/2026-10-30-lm5-local-only-chokepoints.md). - **LM-2 credential repair, included in v0.4.0.** PR #418 delivered the credential-copy and stale-discovery fixes from closed #401, together with isolation that prevents fake child PIDs from reaching OS signals, at `6c9000f`. Build, full unit tests, typechecks and the Electron IPC journey are recorded in the [reconciliation evidence](reviews/2026-10-26-lm2-credential-repair.md). This source delivery is separate from local-runtime qualification and product admission.